Unlock the Power of Inline Assembly:
a Smart Contract Developer's Guide.

Time Square

Time Square (1903 Vs. 2020)

Yul is a low-level language that can be used in-line in Solidity via an assembly block, as a standalone language, and as a compilation target. Currently, the default dialect of Yul is the EVM dialect, so to harness this power, you must first gain a deep understanding of how the EVM works and second master the abstraction of standards Solidity imposed.

Since the EVM is a stack-based virtual machine, it operates by a set of instructions that can be categorized to:

1- Stack Instructions

2- Arithmetic Instructions

3- Comparison Instructions

4- Bitwise Instructions

5- Memory Instructions

6- Read Context Instructions

7- Write Context Instructions

You can find a list of all opcodes used in Yul here.

Note: please note that we will toggle between EVM instructions and Solidity layout a lot in this article.

Master Solidity Layout for Efficient Assembly Coding

As per Solidity documentation, there are five standard layouts that every developer must be aware of. The crucial aspects of layouts are:

1- Storage Layout

Storage is persistent between function calls, writing to and reading from the storage is the most expensive in terms of gas.

Contract storage is simply a key mapping to a value, it maps a 32-byte key which represents the position of a variable in storage to a 32-byte value at that given position sstore(key, value)

1โ€“1. Layout of Statically-Sized Variables in Storage:

      
        contract FixedSizeVariables {
          uint256 private value1;                  // value1 = 1 in slot 0
          uint256[2] private value2;               // value2[0] = 2 & value2[1] = 3 in slot 1 & 2
          uint128 private value3;                  // value3 = 4 in slot 3
          uint128 private value4;                  // value4 = 5 in slot 3
          uint8 private value5;                    // value5 = 6 in slot 4
          uint8 private value6;                    // value6 = 7 in slot 4
        }
        
        // Storage Layout:
        // 0x00:  0x0000000000000000000000000000000000000000000000000000000000000001
        // 0x001: 0x0000000000000000000000000000000000000000000000000000000000000002
        // 0x002: 0x0000000000000000000000000000000000000000000000000000000000000003
        // 0x003: 0x0000000000000000000000000000000500000000000000000000000000000004
        // 0x004: 0x0000000000000000000000000000000000000000000000000000000000000706
      
    

Let's assume the value of each variable as stated above in the comments:

1โ€“2. Layout of Dynamically-Sized Variables in Storage:

Using reserved slots doesn't work for dynamically-sized arrays and mapping because there is no way of knowing how many slots to reserve, instead:

        
          contract DyanmicSizeVariables {
            mapping(address => uint256) private _balances;    // account -> balance slot 0
            uint256[] private _values;                    // slot 1
            string private _name;                        // slot 2
          }
  
          // Storage Layout:
          // 0x00: 0x0000000000000000000000000000000000000000000000000000000000000000
          // 0x01: 0x0000000000000000000000000000000000000000000000000000000000000002
          // 0x02: 0x4a65726f6d650000000000000000000000000000000000000000000000000012
          
          // mapping elements:
          // 0x3ddcac31351e0705625963ec259851464733fec321375bc6bada6a59752ea7c4: 0x00000000000000000000000000000000000000000000000000000000000004b0
          // 0xbabeeff9e42c6a75123df37ff2f874914fb38fdf5076178f847844476f22232a: 0x0000000000000000000000000000000000000000000000000000000000000171
          
          // array elements [50, 60]:
          // 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6: 0x0000000000000000000000000000000000000000000000000000000000000032
          // 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf7: 0x000000000000000000000000000000000000000000000000000000000000003c

Mapping in Slot 0 :

0x00: 0x0000000000000000000000000000000000000000000000000000000000000000
//Please note that address has to be all lowercased
          000000000000000000000000266626bc2bb7c645ce958cc731e2c34705e8d87
        
      
        0000000000000000000000000000000000000000000000000000000000000000
      
        000000000000000000000000266626bc2bb7c645ce958cc731e2c34705e8d870000000000000000000000000000000000000000000000000000000000000000
      
        3ddcac31351e0705625963ec259851464733fec321375bc6bada6a59752ea7c4
      
          00000000000000000000000000000000000000000000000000000000000004b0
        
          
            // address of the second account is
            0x266626bc2bb7c645cc958cc731e2c34705e7f87
            // pad address to 32 bytes without hexadecimal
            000000000000000000000000266626bc2bb7c645cc958cc731e2c34705e7f87
            // index of mapping slot which is slot 0
            0000000000000000000000000000000000000000000000000000000000000000
            // concatenate key to the slot index
            000000000000000000000000266626bc2bb7c645cc958cc731e2c34705e7f870000000000000000000000000000000000000000000000000000000000000000
            // keccak256 of the concatenation is:
            babeeff9e42c6a75123df37ff2f874914fb38fdf5076178f847844476f22232a
            // balance of the address is 369 to bytes32
            0000000000000000000000000000000000000000000000000000000000000171
          
        

Array in Slot 1:

            
              // declared in slot 1 with 2 elements in length 
              0x01: 0x0000000000000000000000000000000000000000000000000000000000000002
            
          

Keccak256(0000000000000000000000000000000000000000000000000000000000000001) = b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

so this the where the array element of index 0 will be stored, now it's time to store the element itself which has the value 50 as follows:

0000000000000000000000000000000000000000000000000000000000000032

storage location of the first element with index 0 was:

b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

storage location of the second element with index 1 will increment the hash to be: b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf7

and bytes32 representation of 60 is:

000000000000000000000000000000000000000000000000000000000000003c

String in Slot 2:

            4a65726f6d650000000000000000000000000000000000000000000000000000
          

then multiplying its length of 6 characters by 2 which equals 12 that is added at the most right side as displayed in slot 2 and add hexadecimal.

0x4a65726f6d650000000000000000000000000000000000000000000000000012

1โ€“3. Layout of Inherited State Variables in Storage:

contract First {
      uint256 private x;    // x = 0
  }
  
  contract Second {
  
      uint256 private y;    // y = 1
  }
  
  contract Third is First, Second {
    
      uint256 private z;    // z = 2
  }
  
  // storage Layout
  // 0x00: 0x0000000000000000000000000000000000000000000000000000000000000000
  // 0x01: 0x0000000000000000000000000000000000000000000000000000000000000001
  // 0x02 : 0x0000000000000000000000000000000000000000000000000000000000000002

2- Errors Layout

Solidity has a set of predefined errors but starting from v0.8.4 it allowed developers to define custom errors by name and argument type. A general rule is that errors are stored by the first 4 bytes of the hashing the error and any error data if any.

// bytes4(keccak256('InsufficientBalance(uint256,uint256)')    
  bytes32 constant insufficientBalanceSelector = 0xcf47918100000000000000000000000000000000000000000000000000000000;
  
  // bytes4(keccak256('UnauthorizedCaller()')
  bytes32 constant unauthorizedCallerSelector = 0x5c427cd900000000000000000000000000000000000000000000000000000000;
  
  error InsufficientBalanceSelector(uint256 available, uint256 required);
  error UnauthorizedCaller();
  
  function transfer(address to, uint256 amount) public pure {
      assembly {
        if eq(caller(), to) {
        mstore(0x00, unauthorizedCallerSelector)
        revert(0x00, 0x04)
        }
  
        let callerBalance :=  sload(keccak256(mload(0x40), 0x40))
        if lt(callerBalance, amount) {
        mstore(0x00, insufficientBalanceSelector)
        revert(0x00, 0x04)
        }
      }
  }

3- Memory Layout

While reading from and writing to memory is cheaper than storage, you still have to consider cost carefully when writing to memory as it's cost quadratically; you can read more about gas in this guide.

Reading from memory is limited to a width of 256 bits, while writing can be either 8 bits or 256 bits wide, in the case of writing Solidity reserved 4 slots as follows:

The 64 bytes scratch spaces are used for hashing methods and shouldn't be touched or written to. When coding in inline assembly, writing to memory should always start after the free pointer, and that's why we load from memory the first 2 slots as reserved mload(0x40).

Worth to note that variables are stored differently in memory than in storage:

Example of how variables are stored differently in memory:

      uint8[4] public ids;

In storage: the above array occupies 1 slot (8 *4 = 32 bytes)

In memory: the same array occupies 4 slots ( 4 * 32 = 128 bytes)

        struct Person {
    uint256 amount;
    uint256 id;
    uint8 rank;
    uint8 deposit;
  }

In storage: 2 slots for uint256 each and 1 slot for uint8 combined

In memory: 1 slot for each variable, 4 slots in total.

4- Calldata Layout

As per the ABI standards, the calldata is the first four bytes of the Keccak-256 hash of the signature of the function; it's the function name with the parenthesizes list of parameter types and the return type of a function is not part of this signature.

Parameter types are split by a single comma - no spaces are used and each argument is padded to 32 bytes. If an argument is of dynamic size, the 32-byte slot will be a pointer to the dynamic value.

Solidity supports all the types with the exception of tuples, on the other hand, some Solidity types are not supported by the ABI but are represented with alternative types as follows:

How to Encode Different Argument Types and Hash the Function Selector

function baz(uint32 x, bool y) public pure returns (bool r) { 
      r = x > 32 || y; 
  }
function bar(bytes3[2] memory) public pure {}
function sam(bytes memory, bool, uint[] memory) public pure {}

If we wanted to call sam with the arguments "dave", false, and [1,2,3]

5- Events Layout

As per the ABI standards, events are stored in the logs entries which include the contract's address, series of topics, and some arbitrary binary data. Note that the address of the contract is provided internally and needs no manual encoding.

An event has a name and a series of event parameters; indexed parameters are called topics and non-indexed parameters are called the data.

An event can have up to four topics, the first topic is the keccak256 hash of the event signature, and the rest is based on actual event parameters.

Non-indexed parameters or arbitrary data are stored in memory and then passed to the log instructions a pointer to the start of the data and the length of the data.

    event Transfer(address indexed sender, address indexed receiver, uint256 amount);
  
  function transfer(address to, uint256 amount) public returns(bool) {
    _transfer(msg.sender, to, amount);
    emit Transfer(msg.sender, to, amount)
  }

That's the transfer function from ERC20 in Solidity, to code the event in inline assembly as follows:

event Transfer(address indexed sender, address indexed receiver, uint256 amount);
  
  function transfer(address to, uint256 amount) public returns(bool) {
  
    // hash of the event name
    bytes32 transferHash = keccak256("Transfer(address,address,uint256)")
    // amount is non indexed so will be stored in memory
    mstore(0x00, amount)
    // event has 3 parameters
    // `0x00` is the memory pointer
    // `0x20` the 32 bytes length of amount
    log3(0x00, 0x20, transferHash, caller(), receiver)
  }
  

Now let's overview everything we learned so far in the access storage contract, link to the source code in GitHub:


1- Constructor

remember we are reading from memory mloadand writing to storage sstore, so memory and storage layout standards are applied here.

we applied the memory layout when we loaded the length, the string pointer, and the actual data nameData.

we applied the storage layout of a string with 31 or less length which is packed in one slot and the right-most byte is the length multiplied by 2.

2- Read Function getCause()

3- Write Function donate()

4- Read Function totalDonations()

we're loading what is in slot 1 sload(0x01) , storing it in memory, and returning the 32 bytes from memory at index zero. The same goes for the next read function totalDonors.

5- Read Function owner()

which is the deployer address but since it is a compile-time variable, we can't use assembly code to read it.

6- Read Function getAllDonors()

it reads from storage all addresses in the array _donors - outside the assembly code, we initiate 2 local variables to copy the array and its length. - sload(0x03) we load from slot 3 _totalDonors as it truly reflects how many addresses are in the array _donors in slot 2 since we prevent duplications. - mload(0x40) we allocate memory for the local array allDonors| - mstore(allDonors, arrayLength) set the local array and its length in memory, I didn't forget about the free memory pointer which I'm going to offset down the line in the code but according to Solc docs:

Scratch space can be used between statements (i.e. within inline assembly). The zero slot is used as the initial value for dynamic memory arrays and should never be written to.

7- Read Function donorAmout()

pardon me for the typo I didn't notice it until I was testing the contract.