DEV Community

Cover image for Yul Dark Arts: Understanding the EVM from first principles
Rafael Abuawad
Rafael Abuawad

Posted on Originally published at x.com

Yul Dark Arts: Understanding the EVM from first principles

In this article, I want to walk you through my experience learning Yul to better understand how the EVM manages memory and how you can use this to your advantage, whether it is for learning more about the EVM or for trying to squeeze extra performance from a smart contract.

What is Yul?

Yul is a type-ish, low-level intermediate representation where EVM opcodes are represented as simple function calls. The idea is to give the developer more control over memory management.

Yul is highly flexible. Since you can use it directly inside Solidity, it has high levels of interoperability with Solidity, and since you are managing the low-level opcodes, it is faster (if you know what you are doing).

Remember, Yul is like an EVM dialect, not a fully fledged programming language. This means that every data type is represented with u256, which includes strings, bools, enums, etc. You are going to do everything manually: storage management, memory management, event emissions, manual abi.encode and abi.encodePacked, and so on.

How do you use Yul?

The easiest way to use Yul is inside a Solidity smart contract, inside an assembly block.

Yul is extremely interesting as a programming language, but it adds a lot of mental overhead. You need to understand this before moving forward. I do not recommend you code all your smart contracts in Yul. The main reason is that you need to pay extreme attention to memory, how to move and manage it. Solidity and Vyper already have tons of abstractions to achieve the same task.

But I would recommend you use Solady if you are coding smart contracts in Solidity.

For basic stuff, Yul, it can be straightforward, as we are going to see shortly. But as soon as the function at hand does more than just reading and modifying a few storage slots, it can get really tricky.

Thinking in terms of slots

In Yul, you stop thinking in variables and start thinking in slots. That shift mattered a lot for me. It changed how I looked at storage, and especially mappings, when writing other smart contracts using Vyper.

Everything in contract storage is just a key-value pair: a slot holds a 32-byte word. You keep a mental map of those slots (and of memory, which is temporary and cheaper). Yes, it is that simple.

Pattern A: Reading a single key

To interact with a single value using Yul, we only need two opcodes, one to read and another to write. These opcodes are sload and sstore respectively. This pattern is the simplest one. You just need a storage slot and a value, and that is it.

For example, let's say we want to read the totalSupply of a super-optimized ERC20 (Solady-style namespaced slot). We would do this:

uint256 private constant _TOTAL_SUPPLY_SLOT = 0x05345cdf77eb68f44c;

function totalSupply() external view returns (uint256 result) {
    assembly {
        result := sload(_TOTAL_SUPPLY_SLOT)
    }
}
Enter fullscreen mode Exit fullscreen mode

Writing is the same idea, just sstore:

function _setTotalSupply(uint256 newSupply) internal {
    assembly {
        sstore(_TOTAL_SUPPLY_SLOT, newSupply)
    }
}
Enter fullscreen mode Exit fullscreen mode

Or, if you are minting and need to bump supply:

function _increaseTotalSupply(uint256 amount) internal {
    assembly {
        let before := sload(_TOTAL_SUPPLY_SLOT)
        sstore(_TOTAL_SUPPLY_SLOT, add(before, amount))
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern B: Reading a mapping

Inside the EVM bytecode, there is no mapping per se. What there is is a pointer, a hashed key that points to a value. And mappings are no different. So, how do we calculate and use the concept of a mapping in Yul? Certainly, it is the most used data structure in all of EVM development, and the answer is actually really simple.

Instead of storing a slot, we store a slot seed that, in conjunction with the other key in the mapping, will give you the actual slot where the mapping value lives. So, for example, we want to calculate the balanceOf of a given user. We can do the following:

uint256 private constant _BALANCE_SLOT_SEED = 0x87a211a2;

function balanceOf(address owner) external view returns (uint256 result) {
    assembly {
        mstore(0x0c, _BALANCE_SLOT_SEED)
        mstore(0x00, owner)
        let balanceSlot := keccak256(0x0c, 0x20)
        result := sload(balanceSlot)
    }
}
Enter fullscreen mode Exit fullscreen mode

Writing a balance is the same as math; then sstore:

function _setBalance(address owner, uint256 amount) internal {
    assembly {
        mstore(0x0c, _BALANCE_SLOT_SEED)
        mstore(0x00, owner)
        let balanceSlot := keccak256(0x0c, 0x20)
        sstore(balanceSlot, amount)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why mstore(0x0c, seed) works

This is the part that confused me the most at first, so let's slow down.

mstore is big-endian. When you write a 32-byte word at some offset, the most significant byte lands at that offset, and the least significant byte lands 31 bytes later. An address is only 20 bytes, so when you mstore(0x00, owner) the address does not sit at 0x00. It sits at mem[0x0c .. 0x1f], after 12 bytes of zero padding.

The Solady overlap trick uses that:

  • mstore(0x0c, _BALANCE_SLOT_SEED) puts the 4-byte seed at the end of the word starting at 0x0c, which means the seed lands at mem[0x28 .. 0x2b].
  • mstore(0x00, owner) puts the address at mem[0x0c .. 0x1f].
  • keccak256(0x0c, 0x20) hashes exactly 32 bytes: [ address 20B | pad 8B | seed 4B ].

Oversimplified explanation of memory

That is why you start the hash at 0x0c, not at 0x00. You skip the left padding of the address word and pull the seed into the same 32-byte preimage.

You will also see the packed form, which builds the same preimage with one mstore:

let owner_ := shl(96, owner)
mstore(0x0c, or(owner_, _BALANCE_SLOT_SEED))
let balanceSlot := keccak256(0x0c, 0x20)
Enter fullscreen mode Exit fullscreen mode

Same hash input. Fewer stores. This is not the same thing as cleaning an address (more on that later).

Pattern C: Reading a mapping of a mapping

This pattern is also used a lot, in places like allowances:

uint256 private constant _ALLOWANCE_SLOT_SEED = 0x7f5e9f20;

function allowance(address owner, address spender) external view returns (uint256 result) {
    assembly {
        mstore(0x20, spender)
        mstore(0x0c, _ALLOWANCE_SLOT_SEED)
        mstore(0x00, owner)
        let allowanceSlot := keccak256(0x0c, 0x34)
        result := sload(allowanceSlot)
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the hash length is 0x34 (52 bytes), not 0x20. You are hashing owner + seed + spender layout across more than one word. Writing is again the same slot, then sstore(allowanceSlot, amount).

The seed pattern

This is pretty self-explanatory. We use a seed in conjunction with the other values we want to use as a key, then hash them together to generate the actual key we want to use to store the specific value. Solady uses namespaced seeds like _BALANCE_SLOT_SEED instead of compiler slots 0, 1, 2... so inherited contracts do not collide on storage.

Managing memory

In libraries like Solady, you are going to see a lot of use of scratch space. Slots 0x00 up to 0x3f are scratch memory, and you can essentially do whatever you want with them. You are mostly going to use memory to store values to calculate given slots, or to store (in memory) cleaned-up addresses. That is exactly why Patterns B and C hash in scratch: no free-memory allocation, just trash the first 64 bytes, hash, sload/sstore, done.

The free memory pointer lives at 0x40. If you need real allocated memory (dynamic returns, bigger ABI payloads, building structs in memory), you read it first with mload(0x40), write from that pointer, then bump it so the next allocation does not overwrite you.
The zero slot lives at 0x60. It is not recommended to write the zero slot, but if you ever need to do so, you have to set this slot back to 0. Solidity assumes that word stays zero.

So the mental map is:

  • 0x00 to 0x3f: scratch, safe to trash for hashing and temps (in carefully scoped assembly)
  • 0x40: free memory pointer
  • 0x60: zero slot, put it back if you touch it

Footgun: cleaning addresses vs packing them

Two patterns look similar and are not the same.

Cleaning forces the upper 12 bytes of an address word to zero before you compare it or emit it as a topic:

owner := shr(96, shl(96, owner))
Enter fullscreen mode Exit fullscreen mode

If you skip this and some upper bits are dirty, eq can fail against a clean address, or you can emit a weird topic that indexers will not match.

Packing shifts the address left and ors it with a seed so one mstore builds the balance-slot preimage:

mstore(0x0c, or(shl(96, from), _BALANCE_SLOT_SEED))
Enter fullscreen mode Exit fullscreen mode

Cleaning is about the correctness of the address as an address. Packing is about building a storage key cheaply. Do not unify them in your head. I did. It bites.

Events

Since in Yul you are using plain EVM opcodes as functions, we need to compute the event signature and pass the parameters accordingly. If you don't know how events work under the hood, there is an amazing guide by RareSkills explaining how events work: Solidity Events by RareSkills.

For this article, we need to understand one thing: events can have up to 4 indexed topics, and topics are, in and of themselves, the data that the event is logging. The name of the event (its signature hash) is also considered a topic. An event can have a maximum of 3 indexed topics plus the signature topic. Essentially, we have to call the correct opcode. The opcodes to emit events are log0 through log4.

Emitting a Transfer event in Solidity

event Transfer(address indexed from, address indexed to, uint256 amount);

// Inside a function.
emit Transfer(from, to, amount);
Enter fullscreen mode Exit fullscreen mode

Emitting a Transfer event in Solidity + Yul

uint256 private constant _TRANSFER_EVENT_SIGNATURE =
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;

function _emitTransfer(address from, address to, uint256 amount) internal {
    assembly {
        mstore(0x20, amount)
        log3(0x20, 0x20, _TRANSFER_EVENT_SIGNATURE, from, to)
    }
}
Enter fullscreen mode Exit fullscreen mode

log3 means: signature topic + 2 indexed topics (from, to), and amount lives in the non-indexed data payload. Same fingerprint idea as seeds: compute the event hash once, never keccak the string at runtime.

To compute the event signature, you can do the following operation in chisel or cast:

`keccak256(bytes("Transfer(address,address,uint256)"))`
Enter fullscreen mode Exit fullscreen mode

Custom errors

Custom errors in Yul follow a similar idea to events. You precompute the error selector (the first 4 bytes of keccak256("ErrorName()")), store it in memory, then revert with the right offset and size.

// InsufficientBalance() selector = 0xf4d678b8
function _revertInsufficientBalance() internal pure {
    assembly {
        mstore(0x00, 0xf4d678b8)
        revert(0x1c, 0x04)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why revert(0x1c, 0x04)? Because mstore is big-endian again. A 4-byte selector written as a small integer lands at the end of the 32-byte word (mem[0x1c .. 0x1f]). Offset 0x1c with length 0x04 returns exactly those four bytes. No ABI fluff.

If the error has arguments, you mstore them after the selector and increase the revert size accordingly. Same mental model: fingerprint once, then speak in opcodes.

Precompute

Something I learned is that many values can be precomputed using chisel or cast to save on compute. Solady does this a lot to squeeze the last drop of efficiency, making Solady's ERC20, for example, one of the cheapest ERC20s to interact with.
Selectors, event signatures, slot seeds, bitmasks: if it never changes at runtime, do not compute them at runtime.

# event topic0
cast keccak "Transfer(address,address,uint256)"

# error selector (first 4 bytes)
cast sig "InsufficientBalance()"
Enter fullscreen mode Exit fullscreen mode

Paste the result as a uint256 private constant and you are done.

Final thoughts

Just to be clear, I don't think you should be writing Yul for smart contracts as your default. The whole point of this article is also the reason not to live there: you have to keep a mental map of how memory works, slots, scratch, free memory pointer, and dirty bits.

That overhead is real.

I have a lot of respect for the people behind many of the top protocols and libraries that squeeze the last drop of performance from their smart contracts using Yul, such as Solady and OpenSea, just to name a few. Learning to read that code is still worth it.
If you want to learn from a true expert, I recommend you watch this video.

In my opinion, Solidity should improve the language; they are targeting that with Core Solidity, and I hope it goes well. The generated opcodes should be as efficient as possible, so developers are not forced into assembly for performance. Vyper has this right, and the resulting opcode is really good and well optimized. The high-level language does more of the hard work for you.

Still, I don't think hand-rolled Yul is the way forward for writing regular smart contracts. That doesn't mean you can't become an expert in Yul, and Understanding Yul is a really good way to understand the EVM.

Top comments (0)