This is part 1/N of Band Protocol’s “Solidity 102” series. We explore and discuss data structures and implementation techniques for writing efficient Solidity code under Ethereum’s unique EVM cost model. Readers should be familiar with coding in Solidity and how EVM works in general.
Interaction with Persistent Storage
See Ethereum Yellow Paper Appendix G for full reference of EVM opcode cost.
Persistent storage on opcode (
SSTORE) is extremely expensive. The current cost per one 32-byte word is 20,000 Gas (~ 5 cents at 10 Gwei gas price and $250 per ETH) for the first time a particular slot is set, and 5,000 Gas for each subsequent modification. While the cost is "constant" in the theoretical sense of complexity, it is more than a thousand times the cost of arithmetic or memory operations, which mostly take less than 10 Gas per operation. Combining with the fact that Gas limit of one whole block (as of June 2019) is ~8,000,000 Gas, developers should design their smart contracts to minimize amount of storage slots needed. Note that the impact of using storage unnecessarily will get with the upcoming(?) state rent upgrade. Fortunately there are a few methods that can help mitigate the problem.
Don’t Store Unnecessary Data
This may sound obvious, but is very well worth mentioning. When writing smart contracts, you should store only what’s necessary for transaction validation. Data such as transaction memo or long description not related to contract logic may not need to be kept in contract storage. Consider the following PollContract smart contract, which user can create a poll, which can be automatically executed when reaching a certain threshold.
createPoll function is called often, one may consider removing
Poll struct it does not directly impact the contract's logic. Since the event containing memo is already emitted, only the hash value (32 bytes) of
memo may be stored for quick verifications afterward. The tradeoff between gas cost and contract simplicity should be carefully considered by developers.
Additionally, throughout Band Protocol’s Solidity lesson, we will cover various data structure implementations, such as linked-lists, iterable maps, Merkle trees, etc., that are specifically designed to reduce the amount of data needed to store on the Ethereum long-term storage.
Pack Multiple Small Variables into Single Word
EVM operates under 32-byte word memory model. Multiple variables that are smaller than 32 bytes can be packed into a single storage slot to minimize the number of
SSTORE opcodes. Although Solidity automatically tries to pack small elementary types into the same slot if possible, naive struct member ordering may prevent the compiler to do that. Consider
Bad struct below.
Using solc 0.5.9+commit.e560f70d with optimization enabled, the first
doBad() function call consumes ~60,000 Gas for its execution while
doGood() only consumes ~40,000 Gas. Notice one word difference (20,000 Gas) because
Good struct packs two uint128 into a single word.
Only Store Merkle Root as the State
A more extreme approach to mitigate state bloat is to store just a 32-byte Merkle Root on the blockchain. A transaction’s caller is responsible to provide appropriate values and proofs for any data that the transaction needs to use during its execution. Smart contracts can verify that the proof is correct, but do not need to store any of that information persistently on-chain — only one 32-byte root is required to be kept and updated.
Potentially Unbounded Iterations
Being a Turing complete language, Solidity allows potentially unbounded loops to be executed. For instance, a function that does something for every user can consume prohibitively huge amount of gas if the set of users has no apparent size limit. Avoiding unbounded loop will make gas cost more manageable. Here are some tricks you can utilize to improve your smart contracts.
Off-Chain Computation (Hint)
Naive implementations of common data structures like sorted list require an iteration through the whole collection to find the proper location when adding an element to the list to make sure it is still sorted. A more efficient approach would be to have the contract require an off-chain computation to provide it the exact location of the element to add. On-chain computation only needs to verify that all the invariants are preserved (i.e. that the added value ranks between its adjacent elements), which can prevent the cost from growing linearly with the total size of the data structure. See B9lab’s article for a more thorough list of examples.
Use Withdrawal Patterns
Instead of looping though and performing action on every single address under one transaction, a smart contract can keep a mapping whether each user has perform that action. Each user is responsible for sending a transaction to initiate the action, while the smart contract only verifies that no duplicated actions from the same user get executed. With this scheme, each transaction’s cost stays constant in runtime complexity and does grow with the total number of users. This eliminates the possibility of going beyond gas limit in one transaction. However, it’s important to note that the total overall number of gas cost will be more than just doing everything in one transaction.
In this article, we cover a few of Solidity programming patterns that can lead to expensive transaction fees, or worse, un-executable smart contract due to insufficient block gas limit. This is by no means an exhaustive list, but it should give you an idea of how to optimize you contracts. In the next articles, we will start to get our hands dirty and implement some real smart contracts or libraries with Solidity. Stay tuned!
Band Protocol is a platform for decentralized data governance. We are a team of engineers who look forward to the future where smart contracts can connect to real-world data efficiently without trusted parties. If you are a passionate developer and want to contribute to Band Protocol, please reach out to us at email@example.com.