Avoiding out-of-gas error in large Ethereum smart contracts

Kirill Bulgakov
Daox
Published in
7 min readMay 3, 2018

Nowadays, more and more apps and instruments utilize blockchain technology and Ethereum smart contracts. The more complex the business logic behind the smart contract is, the more gas it takes to deploy it to the network. This is due to the fact that when the size of the source code increases, the size of the contract’s bytecode increases as well. Uploading a large amount of bytecode requires a lot of gas. At this time, there is a limitation of about 8 million gas limit per block on the Ethereum blockchain. It means that the total amount of gas for all the transactions in a block including contract deploy cannot exceed this number.

This article will describe several methods that could be used to place large smart contracts on the Ethereum blockchain.

What is a bytecode

Smart contracts cannot function without the Ethereum Virtual Machine. EVM is a runtime environment for executing smart contracts based on Ethereum. The Virtual Machine does not work directly with the Solidity code, it works with the bytecode. The bytecode is a set of instructions for the Virtual Machine, that has strict technical specification described in the Ethereum Yellow Paper . Any smart contract written in Solidity (or any other language for the EVM) is first compiled to the bytecode that will be executed by the Virtual Machine later on.

Let us take a look at a small contract and its bytecode:

contract Example {
mapping(uint => bool) public map;

function setValue(uint key, bool value) {
map[key] = value;
}
}
Bytecode of the Example contract

As you might notice, a large amount of bytecode is generated even for a couple lines of a simple code. An issue stated above (block gas limit exceeded) can take place when your contracts become more complex or start to use other contracts.

Let us have a look at some methods that could be of help here.

Libraries

Code reuse through libraries

A Library is a special type of contract with some limitations. For example, a library cannot receive Ether and does not have its own storage.

Despite some limitations, a library can be used to reduce the bytecode size of the main contract. A library is uploaded to the Ethereum network just once, and then it can be used by several contracts that require this functionality. Thus, you can move various functions to the library in order to reuse the code efficiently. In so doing, you reduce error probability as well as reduce the size of bytecode in the contract that calls the code. This result achieves due to call low-level function which will be described below.

Using signatures to call functions

Low-level function: call

In addition to common language structures, Solidity supports low-level commands as well. One of such commands is call that is used to send message calls to other contracts. An advantage of this approach is that a smart contract does not have to store the bytecode of the called function. This approach can prove to be especially relevant if the bytecode of the called method is too big. As a result, the compiled smart contract will not have any extra opcodes, that in turn will reduce its size. However, please notice that it is not possible to get the return value of the function when the call command is used.

The syntax of the call function looks as follows:

address.call(bytes4(keccak256(<function signature>)), arguments);

In other words, the first argument should be the first 4 bytes of the hash of the called function signature. It is crucial to consider the following when writing the function signature:

  1. For any size of the uint type (uint8, uint16, etc.) it is necessary to use uint256 in the signature;
  2. When being listed, the arguments should be separated by commas, but with no spaces!
  3. Transmission of dynamically sized types, for instance strings or arrays, will not function correctly;

For example, if a contract has the function function test(uint a, uint b), the correct call of such a function will look as follows: address.call(bytes4(keccak256(“test(uint256,uint256)”)), a, b);

In case the test executing was successful, you will get true, but if an error occurred you will get false. So don’t forget to use require for handling error purposes.

Using interfaces

The same result can be achieved by means of interfaces.

An Interface is a special type of contract, limited to what the Contract ABI can represent. Put it in other way, it is possible to describe the function signature in the interface, but not the implementation. Using interfaces is convenient for a couple of reasons:

  1. The code readability improves, as compared to the explicit usage of call;
  2. You can handle the return value;

The use of an interface can be shown with the following example:

interface ICallee {
function makeCall(uint val) returns(uint);
}

contract Caller {
ICallee public callee;

function call() {
callee.makeCall(42);
}
}

The Caller contract knows the interface of the ICallee contract, that is why its bytecode will have hash of signature of the makeCall function: …16637e839a3c602a…

So, it becomes possible to use a function from another contract without storing its bytecode in the called contract.

Code decomposition

Code decomposition through libraries

Code reuse is not the sole purpose of a library. For instance, in Daox smart contracts ecosystem CrowdsaleDAO Factory uses the library to split bytecode into two contracts. To avoid uploading the bytecode of the CrowdsaleDAO contract along with the bytecode of the CrowdsaleDAO Factory contract we moved the process of its creation into a separate light-weighted DAODeployer library, which is uploaded independently from the factory:

contract CrowdsaleDAOFactory is DAOFactoryInterface {
function createCrowdsaleDAO(string _name, string _description) public {
address dao = DAODeployer.deployCrowdsaleDAO(_name, _description);
DAODeployer.transferOwnership(dao, msg.sender);
}
}

library DAODeployer {
function deployCrowdsaleDAO(string _name, string _description)
returns(CrowdsaleDAO dao) {
dao = new CrowdsaleDAO(_name, _description);
}

function transferOwnership(address _dao, address _newOwner) {
CrowdsaleDAO(_dao).transferOwnership(_newOwner);
}
}

The following two images show how effective this method is:

The difference between two methods is more than 5 times

The factory on the left does not use the DAODeployer library, while the one on the right does. Thus, a slight increase in the functionality of the factory or CrowdsaleDAO will make the block gas limit sufficient for uploading the factory to the network without any additional optimization.

Decomposing the code into contracts

Solidity supports inheritance with the is keyword. As Solidity documentation says: “When a contract inherits from multiple contracts, only a single contract is created on the blockchain, and the code from all the base contracts is copied into the created contract.” Consequently, if parent contracts have large bytecode, the result contract is very probable to exceed gas limit of the block. In order to avoid a problem one can decompose the code into several contracts, and use delegatecall after that.

delegatecall is another low-level method that is very similar to call.

Its main difference from call is that when using delegatecall the state of the storage gets fully copied from the calling contract into the called contract. At the same time, the msg.sender and msg.value variables do not change. This method can be used when reusing the code that implies change of the storage. Let us consider how effective this method is on the following example:

The ecosystem of Daox smart contracts has several types of voting, each with almost identical functionality. When using inheritance through the is keyword, the bytecode size gets too large, that is why this option is not suitable.

In order to reduce the amount of opcodes of the contract it was decided to use delegatecall. As can be seen from the diagram above (fig. 1), three types of voting use a common codebase through a delegate call to the Voting contract. Please notice, that all three voting types are inherited from the VotingFields parent contract. This allows for having the same feature set, which in turn ensures the correct operation of such pseudo-inheritance.

Summary

Solidity has several architectural solutions for excessive block gas limit.

  • If you call a small number of functions from the external contract, interfaces or call() will work for you, as there is no need to store the bytecode of other unused functions;
  • When several contracts use one functionality that does not change the storage, libraries would be an ideal option;
  • If an access to the storage variables is needed, you can resort to the code decomposition, using delegatecall();

Please note that this list is not complete, but these turned out to be the most effective optimization.

--

--