How EIP2535 Diamonds Reduces Gas Costs for Smart Contract Systems
A diamond has a fixed gas cost for any amount of cross contract interaction under its control.
Gas costs for users increase when a single deployed smart contract grows and becomes two deployed smart contracts that need to communicate. When more functionality is needed then more contracts are deployed and made to interact. The more smart contracts that need to communicate or interact, the more gas for users.
The reason for increased gas costs when contracts interact is because one contract calling a function on another contract — known as an external function call — is expensive. At the time of this writing a typical external function call costs a little more than 2600 gas.
Why not make one big smart contract with all functionality and deploy it? That would remove all the gas created by contracts calling external functions on other contracts. One problem with this is that there is a limit to how large a single contract can get. The code of a smart contract is compiled into a mass of data called bytecode and the bytecode is what is deployed and stored on a blockchain. The maximum size of a smart contract’s bytecode is 24.5KB. So that limits how much functionality can exists in a smart contract.
So it seems common for a developer to develop a smart contract and hit the limit, and so make another smart contract that communicates with the first one. And perhaps this repeats, deploying more contracts and creating more and more contract interaction that users need to pay for.
Also keep in mind that when one contract calls into another contract a permission system is sometimes required. You don’t want anyone or anything calling certain functions on a smart contract, only approved contracts. Permission systems require reading state variables which is expensive. This adds to the gas cost of calling external functions.
How to get out of multi-contract gas costs?
Is there a possible way out of a lot of external function calls? YES A general purpose light-weight smart contract framework exists for implementing smart contract systems. It handles many things like the 24.5KB bytecode size limit, fine-grained upgrades with options of full or partial immutability, standardization and transparency of upgrades, how to systematically extend an existing deployed smart contract system in an organized way, automated visualization of your contract system with a user interface and more. And it can help you reduce the amount of external function calls between contracts, thereby reducing gas costs.
This smart contract framework is EIP2535 Diamonds, also called the Diamond Standard by some people. How does it work?
I admit it takes a bit of understanding. But so does any software framework. At its core a diamond is a proxy smart contract that utilizes the code from any number of other smart contracts. An introduction is here: Introduction to EIP-2535 Diamonds.
One thing to know is that calling an external function on a diamond causes it to read one state variable and retrieve code from another contract and that costs gas. Calling an external function on a diamond basically costs 2 external function calls and 1 state read. Note that any proxy contract pattern that supports upgrades has this gas cost.
The runtime gas cost of calling an external function on a single regular smart contract is less than on a diamond. But we aren’t talking about a single smart contract. We are talking about a larger project that requires a system of smart contracts that need to call each other to work. How does a diamond beat that?
The simple, and probably opaque answer is that a diamond can convert external function calls to internal function calls. Internal function calls are dirt cheap, nothing compared to external function calls, and internal functions don’t require permission systems (unlike external functions) which further reduces gas costs.
To illustrate how a diamond can save gas in a smart contract system here is a simple, contrived, made up example:
Let’s say that we implemented and deployed an ERC721 contract called CryptoKitties and we included our own custom function `attachHat(uint256 _kittyId, uint256 _hatId) external`.
And we implemented and deployed an ERC1155 smart contract called KittyHats.
And we implemented and deployed a contract called Marketplace that the project uses to list and sell KittyHats and CryptoKitties.
And we implemented a Guild contract.
In our example these four contracts can’t be implemented in a single contract because of the max size contract limit, and could be unwieldy code-wise if all of it did fit in one contract without using a good technique to organize the code. And if it was all in one contract an upgrade would require redeploying the entire thing.
Calling the `attachHat` function on CryptoKitties will add a specific ERC1155 hat to a specific ERC721 kitty.
Here are the external function calls that are made when `attachHat` is called:
`attachHat` is called on the CryptoKitties contract. That is the first external function call.
The `attachHat` function makes an external function call to the Marketplace contract to make sure that the specific hat is not listed for sale. Because attaching hats to kitties that are for sale is not allowed. That is the second external function call.
The `attachHat` function calls an external function called `transferToKitty` on the KittyHats contract to transfer ownership of the specific hat to the CryptoKitties contract address. That’s the third external function call.
The `transferToKitty` reads a state variable to check that only the CryptoKitties contract is calling it, because we don’t want anyone transferring hats with this function. The `transferToKitty` function only allows hats to be transferred to kitties that are part of a guild. So the `transferToKitty` function makes an external function call to the Guild contract to find out if the kitty is part of a guild. That’s the fourth external function call and a state read.
So in the above example there 4 four external function calls and one state read that is needed with it.
Now If It Was A Diamond
Let’s look at the external function calls if this was a diamond instead, and lets look at the makeup of this diamond. Also here is a link to a source code example as a diamond: Source Code Example
Similar to before an ERC721 CryptoKitties contract, an ERC1155 KittyHats contract, a Marketplace contract and a Guild contract are implemented and deployed.
A diamond proxy contract is deployed. I call this the “diamond” for short.
The four contracts (CryptoKitties, KittyHats, Marketplace, Guild) are added to the diamond. This means there is one Ethereum address (the diamond address) with the external functions of all four contracts. If you wanted to, you could add more contracts to the diamond. How many? As many as you want, there is no limit. However function names plus parameter types are unique in a diamond. A diamond can’t have two functions with the same name and parameter types. In this case the diamond implements ERC721 for kitties and ERC1155 for hats and it works because ERC1155 functions are designed not to conflict with ERC721.
Let’s look at the external function calls required when calling `attachHat`on the diamond:
`attachHat` is called on the diamond. This causes 2 external function calls and one state read. Because that is the fixed cost of all external function calls on a diamond.
That’s it. The rest of the function calls between CryptoKitties, KittyHats, Marketplace and Guild were converted to internal function calls, which cost very little gas.
So that is 4 external calls and one state read versus 2 external calls and one state read.
But what if there was more going on and with regular contracts 5 or 6 or 10 external function calls were required by a single function? With a diamond all those external function calls would be gone if they came from and to contracts that could be added to the diamond. So a diamond has a fixed gas cost for any amount of cross contract interaction under its control.
See the example source code for this diamond: Gist Source Code
How to Convert External Function Calls to Internal Function Calls with a Diamond?
There are a couple ways.
My favorite way is to write internal functions in Solidity libraries, and import those libraries in contracts that will be added to a diamond.
It is important to understand that internal functions in Solidity libraries work very differently than external functions in Solidity libraries. A Solidity library with external functions is deployed separately from any contracts that use it. And contracts make external function calls to Solidity libraries that have external functions. Internal functions in Solidity libraries are not deployed separately, they are added to the bytecode of the contracts that use them.
Functionality can be written once in internal functions of Solidity libraries and imported into any number of contracts that are added to diamonds. Only the actual internal functions used in a particular contract are added to the contract’s bytecode. This makes it possible to write large Solidity libraries without bloating the size of contracts that import them.
The other way to share internal functions among contracts used in diamonds is to write contracts that contain only internal functions and inherit them in contracts that are added to diamonds. The SolidState Solidity smart contract library uses this approach.
This all works because contracts added to a diamond read and write to the diamond proxy’s contract storage — not to their own.
Sharing internal functions between contracts does duplicate the same bytecode among different contracts, which will cost gas to deploy. However it is usually better to optimize for run-time gas costs rather than deployment gas cost.
Also, it should be known that contracts can be written in a way that they are reusable after deployment. They can be reused on-chain by different diamonds.
In passing I’ll mention that contracts used in diamonds are called `facets` to distinguish them from regular or other kinds of contracts.
If you would like to see the source code for a diamond in production holding millions of dollars of assets that shares internal functions between facets, check out the Aavegotchi diamond and/or the Beanstalk diamond.
The Second Way to Save Gas With an EIP2535 Diamond
Because EIP2535 Diamonds beats the contract max size limit and any number of external functions can be added to a diamond, it makes it more possible to add special use case or gas-optimizing functions for particular use cases. A great example is adding ERC721 batch transfer functions to a diamond to reduce gas for transferring many NFTs in a single transaction.
Third Way to Save Gas With an EIP2535 Diamond
Setting the Solidity optimizer at a high runs setting decreases the gas cost to call external functions but increases the bytecode size of a contract.
In larger contracts getting near the 24.5kb max contract size limit it is not possible to use a high runs setting because that will cause the contract to go over the size limit. But with a diamond a large contract can be broken up into multiple small contracts and these contracts can use a high runs setting (for example 2000) to further reduce gas on external function calls.