Audits beyond code: optimizing gas

When we perform audits, we also include gas optimization techniques that can make your deployment and usage of the smart contract cheaper long term. This doesn’t matter much in ICOs where the contract is only used that one time, but can matter a whole lot in long-running dapps, contracts that are run repeatedly and maybe by different contracts, and in decentralized exchanges.

Here are some useful Gas optimization tips, techniques and explanations.

But first - start by learning about gas here - the post will cover what it is, how to estimate costs, and more.

Remove extra code

It’s an obvious tip, but bears repeating. There’s so much extra unused code in various contracts that we simply must say this: before deploying, check if you have any unused functions. This includes third party code. If your SafeMath include is there only to provide you with the uint256.add function and you never sub, mul or div, then remove those from the contract. You don’t need another library in there if you’re only using 5% of it.

Unless the third party functionality you’re looking for is really complex (at which point it will probably be deployed live somewhere for you to reuse), it’s generally worth it to grab only the functions you need from those libraries and paste them into your code.

Function order matters on function calls

If you have an expensive function f() and a cheap function g(), and if the execution of either of those triggers a condition, make sure the cheaper one is executed first. Why? See this logic table:

ConditionOperand AOperand BResult
OR111
OR101
OR011
OR000

This is the common logic table for the OR operation. If either of the operands is true, then the result is true. Most programming language execution environments will, in such cases, stop evaluating the logic construct as soon as one condition is true.

So, if f is more expensive than g, then:

if (g() || f()) { ... }

is cheaper than

if (f() || g()) { ... }

but only if f has more chance of being true than g. Once the condition sees f is true, it won’t even try to run g.

Keep the order of functions in mind when writing such conditions, and order them according to both cost and probability.

Avoid loops

In general, you should avoid loops. Yes, the EVM is Turing complete and that means it can run loops and execute arbitrarily complex programs, but this doesn’t mean it should.

If avoiding loops is not possible, try to avoid unbounded loops, i.e. loops which you do not know the upper limit of iterations for. If the program doesn’t know how many loops a loop is likely to do, then it cannot estimate the gas cost when running a function and it cannot provide good usability for users.

Worse than that, an unbounded loop can run out of gas: if you have thousands of users you want to do an operation on, your function might run out of gas before it can complete everything and that means all the changes will be reverted. You’ll lose gas and gain nothing. Build hard limits into loops, or avoid them entirely: in the case of processing many users, grab the users on the client-side instead and then perform the operation on fixed-size batches of them.

Memory vs Storage

Only write to storage when you’re finished with your operations, and when using a variable more than once in a function make sure you read it from memory.

This post explains it clearly:

The SLOAD opcode which reads a data word from storage costs 200 gas whereas the MSTORE and MLOAD opcodes which write and read from memory cost only 3 gas each.

uint256 public num = 50;

function readStorage() public {
    if (num == 40 || num <= 10 && num > 100) {
        // execution cost: 885 gas
    }
}

function readMemory() public {
    uint256 mnum = num;
    if (mnum == 40 || mnum <= 10 && mnum > 100) {
        // execution cost: 605 gas
    }
}

This can quickly build up in loops or complex contracts!

Type size and fixed-size arrays

Use fixed size arrays whenever possible (see below).

Also, use 32 bytes / 256 bits whenever possible, because that’s the most optimized storage type. For example, when using a small number, storing it in uint8 is not cheaper than storing it into uint256! As this article demonstrates:

/*
    SSTORE opcode which writes a data word to storage costs 20000 gas + some amount depending on the type.
    SLOAD opcode which reads a data word from storage costs 200 gas + some amount depending on the type.

    Intuition would tell us gas optimizations can be acheived by using smaller data types.
    However this is only the case inside of structs.
    Large types should always be used unless struct packing is possible.
*/

uint256 public integer256;

function write() public {
    integer256 = 1;         // 20430 gas
}

function read() public returns (uint256) {
    return integer256;      // 656 gas
}


uint128 public integer128;

function write() public {
    integer128 = 1;         // 20648 gas
}

function read() public returns (uint128) {
    return integer128;      // 671 gas
}

This is also why the SafeMath library from OpenZeppelin defaults to uint256 and doesn’t even bother with other types.

Strings

Use bytes32 instead of string for small strings like usernames. As per the docs, it is possible to use an array of bytes as byte[], but it is wasting a lot of space, 31 bytes every element, to be exact, when passing in calls. It is better to use bytes. As a rule of thumb, use bytes for arbitrary-length raw byte data and string for arbitrary-length string (UTF–8) data. If you can limit the length to a certain number of bytes (like on usernames), always use one of bytes1 to bytes32 because they are much cheaper.

Read more about this here.

Struct packing

A struct in Solidity is a format of data which contains multiple elements of possibly different types. For example:

struct User {
  uint256 id;
  string username;
}

But structs can compress data and store it in a “packed” format, on the condition that the data is stored in multiples of 32 bytes, as documented in this post.

Observe:

/*
    Structs allow to combine data types into single data words of 32 bytes.

    Things to keep in mind:
    1. Putting a 32 bytes type in a struct is more expensive than keeping it outside.
    2. Packing works in multiples of 32 bytes therefore putting a type smaller than 32 bytes alone in a struct does not cause any saving
    3. It's possible to pack numbers and strings together.
*/

struct Struct {
    uint256 num;
}
Struct public s;
function write() public {
    s = Struct(1);              // 20497 gas
}
function read() public returns (uint256) {
    return s.num;               // 656 gas
}

struct Struct {
    uint128 num1;
    uint128 num2;
}
Struct public s;
function write() public {
    s = Struct(1,2);            // 20775 gas (instead of 40000+)
}
function read() public returns (uint128,uint128) {
    return (s.num1,s.num2) ;    // 730 gas
}

struct Struct {
    uint128 num1;
    bytes16 byte1;
    uint128 num2;
    bytes16 byte2;
}
Struct public s;
function write() public {
    s = Struct(1,'2',3,'4');                 // 41044 gas (instead of 80000+)
}
function read() public returns (uint128,bytes16,uint128,bytes16) {
    return (s.num1,s.byte1,s.num1,s.byte2);  // 1014 gas
}

This is a little harder to predict and plan for, but as you can see it can yield truly significant gains when used right.

Optimization and Optimizer Runs

The solidity compiler comes with an optimizer built in. This optimizer chews up your code and returns an optimized version which is, to a degree, explained in the docs.

You run it like so:

solc --optimize --bin sourceFile.sol

or with more / fewer runs like so:

solc --optimize --runs=250 --bin sourceFile.sol

Runs are NOT “how many times it’s run through the optimizer”. Runs are how many runs you expect the code to be used for.

By default, the optimizer will optimize the contract for 200 runs. If you want to optimize for initial contract deployment and get the smallest output, set it to --runs=1. If you expect many transactions and don’t care for higher deployment cost and output size, set --runs to a high number.

Refunds (Gas token)

When an expensive contract is deployed, it takes up a lot of space on the blockchain. Freeing it up by destroying it generates a gas refund, but not in the way of giving you actual gas or ether, but in generating a discount for your next deployment.

For example, say deploying contract A costs X gas, and A has a destroy function. Then say deploying B costs Y gas and you would like it to cost less. By doing the following: destroy(A) && deploy(B) you end up paying approximately Y-X/2 in gas - the destruction of A provides a discount for the deployment of B.

This is the method that GasToken uses to “tokenize” gas. In effect, it lets you deploy a mock contract when gas is cheap so that you have “gas tokens” (i.e. a discount) to use for when gas is expensive. While not an optimization technique per se, this is a pretty decent hack which lets you get away with sending transactions in times of high gas costs: popular ICOs, swarms of people running to use a single app, and so on.

Assembly and Opcode cost

Don’t forget that you can also write assembly in Solidity itself, at which point a reference of opcode costs might come in handy.

This list documents all the possible operations in the EVM and how much they cost. That way, instead of writing Solidity and relying on the compiler to interpret it, you can just write assembly directly using these codes - it’ll end up cheaper but is much harder to write, so be careful!

Learn how to write it here.

Got any other gas optimization tips? Let us know!