Solidity 102 #1: Keeping Gas Cost under Control
By: Sorawit Suriyakarn
Tags: Solidity, Smart Contracts, Ethereum, Solidity 102, Blockchain

Welcome to Band Protocol’s Solidity 102. In this introductory article, we discuss important points you should be aware when writing performant smart contracts in Solidity. While Solidity’s syntax looks somewhat similar to that of JavaScript or C++, its EVM runtime is totally different. Knowing the limitation of EVM and the techniques to workaround those constraints will help you write better Solidity. The article will focus on high level ideas, and we will cover concrete implementations in the subsequent articles of the series.

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.

If createPoll function is called often, one may consider removing memo from 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 Good and Bad struct below.

Example implementation of Good and Bad struct member ordering.

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.

`doBad` function call costs 60709 Gas for its execution

`doGood` function call costs 40493 Gas for its execution

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.

Left: Looping through a list on-chain costs O(n) gas, which scales linearly as the list grows. Right: Computing location off-chain and verifying the value on-chain costs constant amount of gas regardless of the list’s size.

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.

Left: Invoking one `Distribute` action costs gas proportional to number of receivers in one transaction, which can fail given a large enough number of users. Right: All transactions (1 `Add` and 4 `Claim`) do not have costs that scale with the number of users.

Conclusion

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!