An Introduction To Advanced Solidity Optimization Using Assembly (Yul)

While the pool of Ethereum developers, is still small, more and more developers are slowly joining the ecosystem. Many are migrating from more traditional dev roles and are not familiar with the intricacies of the EVM. Pair this with the high cost of gas fees, and it is extremely easy to write inefficient smart contracts that could cost users millions of dollars in gas fees.

In this article, we will be looking at how to optimize your Solidity smart contracts using the Yul language. Yul is a low-level language that is used to compile Solidity smart contracts to bytecode. It is a low-level language that is similar to assembly, but with a few extra features that make it easier to write code that compiles to bytecode.

We will be exploring the following topics in this article:

  1. Read And Write Storage
  2. Arithmetic Operations
  3. Conditionals
  4. Loops
  5. Arrays in Memory and Calldata
  6. Function Calls (Call and StaticCall)

Before we start, I must emphasize that having a well designed system writte in plain Solidity is always better than having a poorly designed system written in Yul. Yul is a tool that should be used to optimize your Solidity smart contracts, not to write them from scratch.

Pre-Read

Before reading the following optimizations, it would be best to familiarize yourself with a little bit of basics of the EVM. This article includes basic Yul OPCODES and an overview of the different data locations in Solidity. https://betterprogramming.pub/all-about-solidity-data-locations-part-i-storage-e50604bfc1ad

1. Writing To Storage

The first thing we will be looking at is how to read and write in values using Yul. The first thing to keep in mind is that reading in data depends on the type of data you are reading in. For example, reading storage values is different from reading memory values. For now, we will be looking at how to read in values from storage and memory.

1.1 Writing Primitives To Storage

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract StorageReadAndWritePrimitives {
    uint256 public ourUint;
    bool public ourBool;
    address public ourAddress;
    bytes32 public ourBytes32;

    function storeUint(uint256 _ourUint) external {
        assembly{
            //Store _ourUint in storage slot 0 or ourUint.slot
            sstore(ourUint.slot, _ourUint)
        }
    }

    //This Can Be Any Primitive Type
    function readUintFromStorage() external view returns(uint256) {
        assembly{
            //Read in the value from storage slot 0 or ourUint.slot
            mstore(0x0, sload(ourUint.slot))
            //Note: The return(pos,size) returns what's stored in mem from (mem[pos], mem[pos+size])
            return(0x0,0x20) //return the first 32 bytes of memory
        }
    }
    function storeBool(bool _ourBool) external {
        assembly{
            //Store _ourBool in storage slot 1 or ourBool.slot
            sstore(ourBool.slot, _ourBool)
        }
    }
    function storeAddress(address _ourAddress) external {
        assembly{
            //Store _ourAddress in storage slot 2 or ourAddress.slot
            sstore(ourAddress.slot, _ourAddress)
        }
    }
    function storeBytes32(bytes32 _ourBytes32) external {
        assembly{
            //Store _ourBytes32 in storage slot 3 or ourBytes32.slot
            sstore(ourBytes32.slot, _ourBytes32)
        }
    }
    
}

1.2 Reading & Writing From and To Mapping Storage

Storage slots for a mapping are calculated by hashing the key and the storage slot of the mapping.

For example, If we have a mapping(uint=>uint) public ourMapping; and we want to store a value in the mapping, we would do the following:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


contract StorageWriteStructs {
    mapping(uint256 => uint256) public ourMapping;
   
   function storeInMapping(uint256 key,uint256 value) external {
    assembly {
        //Cache Our Key In Memory -
        mstore(0x00, key)
        //Cache the storage sload of ourMapping in memory
        mstore(0x20, ourMapping.slot)

        //Calculate the hash of our key and mapping pair
        let slot := keccak256(0x00, 0x40)


        //Store the value in the mapping
        sstore(slot, value)
    }
   }

   function getValueFromMappingIndex(uint key) external view returns(uint){
    assembly{
        mstore(0x0,key)
        mstore(0x20,ourMapping.slot)
        //Calculate the hash of our key and mapping pair
        let slot := keccak256(0x0,0x40)
        mstore(0x0,sload(slot))
        return(0x0,0x20)
    }
   }
}

1.3 Writing To Array Storage

To preface this, we must understand that dynamically sized arrays are different than statically sized arrays. A static sized array is something like uint[5] public ourStaticArray; A dynamic sized array is something like uint[] public OurDynamicArray;

Static Sized Arrays

For a static sized array of size x, since the size is known at compile time, we reserve x slots in storage for the array. For example, if we have a static array of size 5, we reserve 5 slots in storage. The first slot holds the 0 index and the last slot holds the 4 index.

Dynamic Arrays

Dynamic arrays operate more similarly to mappings, with one caveat. The first slot in storage holds the length of the array and then we start storing the values in the array at slot keccak256(ourArray.slot). This is because the length of the array is not known at compile time. See examples below.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract  StorageReadAndWriteArrays {
    uint256[] public ourDynamicArray;
    uint256[5] public ourStaticArray;
    function storeInDynamicArray(uint256 index, uint256 value) external {
        assembly {
         mstore(0x0,ourDynamicArray.slot)
         let arrStartSlot := keccak256(0x0,0x20)
         sstore(
             add(arrStartSlot,index),
             value
         )

         let len := sload(ourDynamicArray.slot) 
         if iszero(lt(index,len)) {
             sstore(ourDynamicArray.slot,add(index,1))
         }
        }
    }

    function getLengthOfDynamicArray() external view returns(uint) {
        assembly{
            mstore(0x00,sload(ourDynamicArray.slot))
            return(0x0,0x20)
        }
    }
    function getValueFromDynamicArray(uint index) external view returns(uint256) {
        assembly {
            mstore(0x0,ourDynamicArray.slot)
            let arrayZeroSlot := keccak256(0x0,0x20)
            let desiredArrayIndexSlot := add(arrayZeroSlot,index)
            mstore(0x0,sload(desiredArrayIndexSlot)) //override memory to hold value
            return(0x0,0x20)
        }
    }

    function getFromStaticArray(uint index) external view returns(uint) {
        assembly{

            let slot := ourStaticArray.slot
            mstore(0x0,sload(add(slot,index)))
            return(0x0,0x20)
        }
    }
    function storeInStaticArray(uint index,uint value) external {
        assembly{
            let slot := add(ourStaticArray.slot,index)
            sstore(slot,value)
        }
    }
}

2 Basic Arithmetic Operators

Here we take a look at basic arithmetic operators such as add, sub, mul, div, mod, and exp. We also look at bitwise operators such as and, or, xor, not, shl, and shr.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BasicArithmetic {
    //a+b
    function add(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := add(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //a-b
    function sub(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := sub(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //a*b
    function mul(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := mul(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //a/b
    function div(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := div(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //a%b
    function mod(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := mod(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //a to the power of b
    function exp(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := exp(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //bitwise a and b
    function and(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := and(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //bitwise a or b
    function or(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := or(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //bitwise a xor b
    function xor(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            let result := xor(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //bitwise not a
    function not(uint256 a) external pure returns(uint256) {
        assembly {
            let result := not(a)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //shift y left by x bits
    function shl(uint256 x, uint256 y) external pure returns(uint256) {
        assembly {
            let result := shl(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
    //shift y right by x bits
    function shr(uint256 x, uint256 y) external pure returns(uint256) {
        assembly {
            let result := shr(a,b)
            mstore(0x0,result)
            return(0x0,0x20)
        }
    }
}

3 Conditionals

Here we take a look at conditionals such as lt, gt, eq, and iszero. We also look at if statements.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Conditionals {
    //a<b
    function lt(uint256 a, uint256 b) external pure returns(bool) {
        assembly {
            let result := lt(a,b)
            mstore(0x0,result)
            return(0x0,0x20) //return 0x01 if true, 0x00 if false
        }
    }
    //a>b 
    function gt(uint256 a, uint256 b) external pure returns(bool) {
        assembly {
            let result := gt(a,b)
            mstore(0x0,result)
            return(0x0,0x20) //return 0x01 if true, 0x00 if false
        }
    }
    //a==b
    function eq(uint256 a, uint256 b) external pure returns(bool) {
        assembly {
            let result := eq(a,b)
            mstore(0x0,result)
            return(0x0,0x20) //return 0x01 if true, 0x00 if false
        }
    }

    function iszero(uint256 a) external pure returns(bool) {
        assembly {
            let result := iszero(a)
            mstore(0x0,result)
            return(0x0,0x20) //return 0x01 if true, 0x00 if false
        }
    }

    //if a<b return a else return b
    function min(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            //if a<b return a else return b
            if lt(a,b) {
                mstore(0x0,a)
                return(0x0,0x20)
            }
            mstore(0x0,b)
            return(0x0,0x20)

        }
    }
    //if a>b return a else return b
    function max(uint256 a, uint256 b) external pure returns(uint256) {
        assembly {
            if gt(a,b) {
                mstore(0x0,a)
                return(0x0,0x20)
            }
            mstore(0x0,b)
            return(0x0,0x20)
        }
    }
}

4. For loops

Here we take a look at for loops and how to use them in assembly.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ForLoops {
uint[5] public myStaticArray = [1,2,3,4,5];

function sumOfMyStaticArray() external view returns(uint) {
    
    assembly {
        let sum := 0
        for {let i:=0} lt(i,5) {i:=add(i,1)} {
            sum := add(sum, sload(myStaticArray.slot + i))
        }
        mstore(0x0,sum)
        return(0x0,0x20)
        }
    }
    
}

5. Arrays In Memory And Calldata

Here we take a look at how to access arrays in memory and calldata. First, let's understand the difference between memory and calldata.

Memory is a temporary storage area that is used to store data during the execution of a function. Calldata is a temporary IMMUTABLE storage area that is used to store data during the execution of a function. calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory.

5.1 Arrays In Memory

Arrays in memory are stored in a contiguous block of memory. The first 32 bytes of the array stores the length of the array. The next 32 bytes stores the pointer to the first element of the array. The next 32 bytes stores the pointer to the second element of the array. The next 32 bytes stores the pointer to the third element of the array and so on..

Let's take a look at a quick example.


function getLengthOfMemoryArray(uint[] memory myArray) external pure returns(uint) {
    assembly {
        let length := mload(myArray)
        mstore(0x0,length)
        return(0x0,0x20)
    }
}

function getValueOfMemoryArrayAtIndex(uint[] memory myArray, uint index) external pure returns(uint) {
    assembly {
        //Let Original Position Of Array = myArray
        let originalPositionInMemory := myArray
        //Load Length Of Array
        let len := mload(originalPositionInMemory)
        //Make sure we're not out of bounds
        if iszero(gt(len,index)) {
            revert(0,0)
        }
        //positionOfIndex = originalPosition +  ((index +1) * 32)
        let positionOfIndex := add(originalPositionInMemory,
        mul(add(index,1),
        0x20))
        let value := mload(positionOfIndex)
        mstore(0x0,value)
        return(0x0,0x20)
    }
}

5.2 Arrays In Calldata

Arrays in calldata are stored in a contiguous block of memory. The length of the array can be accessed by using array.length in Yul. The Calldata is a read-only byte-addressable space where the data parameter of a transaction or call is held. Unlike the Stack, to use this data you have to specify an exact byte offset and number of bytes you want to read. The opcodes related to Calldata provided by EVM are:

• CALLDATASIZE returns the size of transaction data

• CALLDATALOAD imports 32 bytes of transaction data onto the stack

• CALLDATACOPY copies transaction data of a certain number of bytes to memory

function getIndexFromCalldataArray(uint[] calldata calldataArray,uint indexToGrab) external pure returns(uint){
        assembly{
            //Same logic as mload, but the array is stored in the calldata, so we use calldata load
            //We need to make sure to call arr.offset to get the correct data
            //Note we can also use calldataArray.length to get the length. We will see this below
            let val := calldataload(add(calldataArray.offset,mul(indexToGrab,0x20)))
            mstore(0x0,val)
            return(0x0,0x20)
        }
    }

function getSumCalldataAssembly(uint[] calldata myArray) external pure returns(uint sum){
        assembly{
            //We can use the .length property on calldata arrays
            let len := myArray.length


            /*
                 for(uint i; i<myArray.length;++i){
                    sum += myArray[i];
                        }
                    return sum;

            */
            // declare a var, boolean expression, iterative callback
           for {let i := 0} lt(i,len) { i := add(i,1)} {
               // sum += myArray[i]
               sum := add(sum,calldataload(add(myArray.offset,mul(i,0x20))))
           }
        }

    }

If you've made it this far, congratulations. Here's where we get into some fun stuff.

6. Making Smart Function Contract Calls

In this section we're going to learn about how to make smart contract function calls from within assembly. The first thing we get familiar with is the function selector. The function selector is a 4 byte hash of the function signature. The function selector is used to identify which function to call.

We're also going to look into two important OPCODES: call and staticcall.

  1. call is used to make a function call to another contract. It takes in 7 parameters:

    1. gas
    2. address of the contract
    3. value to send (in wei)
    4. pointer in memory to the start of the input
    5. length of the input
    6. pointer to the start of the output (aka where to write to in memory)
    7. length of the output
  2. staticcall is used to make a function call to another contract. I t takes in 6 parameters:

    1. gas
    2. address of the contract
    3. pointer in memory to the start of the input
    4. length of the input
    5. pointer to the start of the output (aka where to write to in memory)
    6. length of the output

When should you use call vs staticcall? call is used to make a function call to another contract that can modify state. staticcall is used to make a function call to another contract that cannot modify state.

For example, you should use staticcall when you want to call an external view or pure function on another contract. You should use call when you want to call an external function that can modify state on another contract. For example, if the function you're calling takes in a uint and increments it by 1 in storage, you should use call.

Let's take a look at a quick example of how to make a function call to another contract.

contract Callee {
    uint private myNumber;

    function incrementNumber() external {
        ++myNumber;
    }
    function setNumber(uint number) external {
        myNumber = number;
    }
    function getNumber() external view returns(uint){
        return myNumber;
    }
}
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Caller{
bytes4 constant public INCREMENT_NUMBER_SELECTOR = bytes4(keccak256("incrementNumber()")); //0x273ea3e3
bytes4 constant public SET_NUMBER_SELECTOR = bytes4(keccak256("setNumber(uint256)")); //0x3fb5c1cb
bytes4 constant public GET_NUMBER_SELECTOR = bytes4(keccak256("getNumber()")); //0xf2c9ecd8
address private calleeAddress = 0x2F8895b08D8F226b19895d46154faB7096fB2593; //For demonstration

function incrementFromCaller() external {

    assembly{
        //We need to make sure to pass in the correct gas
        //We need to store the function selector
        mstore(0x0,0x273ea3e3) //INCREMENT_NUMBER_SELECTOR
        let cachedAddress := sload(calleeAddress.slot)

        //We start memory from pointer 0x1c we read the first 4 bytes as the function selector
        let success := call(gas(),cachedAddress,0,0x1c,0x4,0x00,0x00)
        //success = 1 if the call was successful'
        //success = 0 if the call was unsuccessful
        if iszero(success) {
            revert(0,0)
            }

        }

    }
function getNumberFromCaller() external view returns(uint) {
    assembly{
        //We need to make sure to pass in the correct gas
        //We need to store the function selector
        mstore(0x0,0xf2c9ecd8) //GET_NUMBER_SELECTOR
        let cachedAddress := sload(calleeAddress.slot)

        //We start memory from pointer 0x1c we read the first 4 bytes as the function selector
        let success := staticcall(gas(),cachedAddress,0x1c,0x4,0x00,0x20)

        /*
            It's very important to notice the difference in this staticcall.
            We don't pass in the value parameter. This is because staticcall
            can't modify state within the Callee. This also makes it cheaper.

            Notice the last two parameters (0x0,0x20).
            This means, if staticcall is successful,
            we should have the return value stored in memory at pointer 0x0
            This means it's stored in the 32 byte slot between (0x0,0x20)
        */

        if iszero(success) {
            revert(0,0)
            }
        //We need to read the return value from memory
        return(0x0,0x20)
        }
    }


function setNumberFromCaller(uint newVal) external {
    assembly{
        //We need to make sure to pass in the correct gas
        //We need to store the function selector
        mstore(0x0,0x3fb5c1cb) //SET_NUMBER_SELECTOR
        mstore(0x20,newVal)

        //We start memory from pointer 0x1c we read the first 4 bytes as the function selector
        //length of calldata is 0x4 (size of func selector) + 0x20 since we're only passing in one parameter
        let cachedAddress := sload(calleeAddress.slot)
        let success := call(gas(),cachedAddress,0,0x1c,0x24,0x00,0x00)


        /*



        */
        //success = 1 if the call was successful'
        //success = 0 if the call was unsuccessful
        if iszero(success) {
            revert(0,0)
            }

        }

    }
}