What’s one of the web3 proponents’ biggest talking point? Composability. Write software once and it’s reusable by anyone. No need to repeat the same code. No wasted effort.
Yet, most of the NFT contracts don’t follow this pattern. Most contracts redeploy the same damn OpenZeppelin contracts every single time.
Sure, they are not writing everything from scratch. But they are using off-chain composability. While we need more on-chain composability.
Off-chain vs on-chain composability
Off-chain is when you’re composing contracts off-chain and redeploying.
Take as an example the CryptoCoven contract. They cloned OpenZeppelin(OZ)’s ERC-721 to their local machine, extended it, and redeployed the whole thing.
On-chain is when you call an already deployed contract. What if OZ’s contract was deployed once and everyone just called the contract by its address on-chain?
Most NFT contracts today use off-chain composability. What’s the problem with off-chain?
- Cost of redeploying - you need to redeploy the same OZ code every time
- Cost of reauditing - auditors need to reaudit the same OZ code every time to make sure that it wasn’t modified in a subtle and malicious way (for example by sneaking in a bug deep inside the contract inheritance tree - people are lazy and won’t read the same OZ code every time).
*** Here is an example of a subtle and malicious bug embedded deep inside the inheritance tree - tweet (TL;DR - onlyOwner
was modified slightly so that even if the contract ownership is renounced via setting the owner to zero-address, money can still be withdrawn from the contract by a non-owner).
We need to switch to more on-chain composability. On-chain composability saves gas, and cost of reaudit, and also adds the ability for wallets to display a checkmark next to the contracts that use a trusted on-chain OZ code. It also improves the readability of the contract as you can just mentally abstract away all the contract calls to trusted sources.
The path to true on-chain composability
How do we achieve this?
Well, there should be an already deployed contract that does the NFT contract logic. And all the NFT contracts (client contracts) should just call it. No need to redeploy the same logic time and time again. Kinda like Solana NFTs if you know how they are implemented.
We can easily extract out the NFT logic such as minting, burning, and transfers into a shared contract. But where do we store the state such as the mapping from tokenId
to owner? And should we use something like delegatecalls/clones/proxies/diamonds?
I made the following design decisions:
Shared state
The state will be shared among all client NFT contracts. I could have made each client contract manage its own state and used delegatecalls
but that seemed more complex.
For the sake of the prototype, I decided that a shared state will be enough. The disadvantage of this method is that the namespace will be crowded, though.
But it should be possible to extend the ideas in this article to a delegatecall
approach. If you implement the delegatecall
version of the shared contracts, please let me know, and I will include your implementation here.
No advanced Solidity patterns
I also decided to use a very simple pattern and avoid things like clones, proxies, and diamonds.
I wanted the initial prototype to be a pretty simple but on-chain composable contract.
Most NFT contracts are just:
- minting/burning/transfers functionality
- storing ownership/balances/approval info
- managing last minted
tokenId
- managing sale state
- configuring max supply
- allowlists
- setting
tokenUri
- and emitting
Events
Our prototype will cover the first 2 functionalities. We can’t really abstract away the rest of the functionality easily because they need too much customization. We can leave those things to the client contract. They are pretty straightforward and don’t require much code.
ERC-721C (a prototype)
I extracted all the logic from OZ’s ERC-721 into a shared contract that can be used by many clients.
I called this contract ERC-721C (yet another 721X 😅). C stands for “composable” or “common”.
*** Since I could not find a way to isolate namespaces, I had to add another layer of nesting for the mappings, so reading/accessing them gets a little awkward.
Here is how a typical client would use it:
The full code repo is on Github.
The caveat of the entire approach is that you add many wrappers. But it’s still more efficient than deploying the same OZ contracts again and again, as we will see in the next section.
This code is proof of concept and has not been fully optimized or tested. It needs more work to be production ready so please don’t use it for serious projects.
ERC-721C vs OZ’s ERC-721 in performance
I took well-known contracts like CryptoCoven and InvisibleFriends and modified them as if they had used ERC721C
instead of OZ’s ERC721. I then compared deploy cost, and the cost of transactions like mint
, transfer
, and approve
against the baseline. Here are the results:
As you can see, ERC721C
saves you around 8% of deployment cost but it comes with significant drawbacks to the transaction cost.
The increase in transactions cost comes from the overhead of calling another contract. It also comes from the triply-nested mapping lookup overhead. It is usually better to optimize for run-time gas costs rather than deployment gas costs.
But we should take into account the savings of auditing NFT contracts. With ERC721C
, the bulk of the code will have to be audited only once, and all clients will benefit from this single audit.
ERC721C
is also just a plain copy-paste from OZ’s ERC721 (except for making the architecture shared). It does not have any optimizations specific to this shared architecture. In v2 of ERC721C
, I am planning to add some optimizations, might also try using diamonds (although diamonds will likely not be helpful because their efficiency kicks in in more complex architectures when more than 2 contracts are talking to each other). I might also try using delegatecalls. Let me know (in the comments) if you have additional ideas.
Conclusions
As you can see ERC721C
saves around 8% in deployment cost and a few grand on audit costs per NFT project. But this comes at a significant run-time cost to the minters.
This was my attempt at removing the duplicate code from all NFT contracts. True on-chain composability is the holy grail of web3 and I think today we are not fully going in that direction.
I initially thought that the increased runtime costs would be negligible but turns out external contract calls are very expensive. I will keep iterating on this approach. Hopefully, I can get it to 0 additional run-time overhead. Feel free to contribute to the repo or let me know your thoughts (in the comments).