>/D_
Published on

Assembly in Ethereum Smart Contracts

Authors
  • avatar
    Name
    Frank
    Twitter

Understanding Assembly in Ethereum Smart Contracts

In this article, we'll explore how assembly language works in Ethereum smart contract development. We'll use the ERC-1167 minimal proxy contract as an example to illustrate key concepts. Some of the bullet points and examples are kept as they are because they do a great job of explaining the concepts.

Introduction to Assembly in Ethereum

Assembly language in Ethereum provides a low-level programming approach, offering more control and efficiency than high-level Solidity code. While it's more complex and harder to read, it allows developers to write optimized code that can reduce gas costs and enable functionalities not possible in Solidity alone.

The ERC-1167 Minimal Proxy Contract

An excellent example of using assembly is building the ERC-1167 minimal proxy contract. This contract is used to deploy minimal proxies (also known as clones) that delegate calls to an implementation contract, saving gas and deployment costs.

The binary for the minimal proxy contract is:

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

The runtime code (the code executed when the contract receives a transaction) is:

363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

In this code, the bytes at indices 10-29 (inclusive) must be replaced by the 20-byte address of the implementation contract.

How the Minimal Proxy Works

  • Delegate Calls: The proxy contract takes all calls and delegates them to another contract (the implementation contract).
  • Result Handling: It returns the result if the delegated call is successful or reverts the transaction if not.
  • Storage Usage: The storage slots of the proxy contract are used, meaning that any state changes (like balances in an ERC-20 contract) affect the proxy's storage, not the implementation's.

This mechanism allows for multiple proxies to share the same implementation code, saving space and deployment costs.

Key Concepts in Ethereum Assembly

1. Assembly Language

Assembly language in Ethereum (often referred to as Yul or inline assembly in Solidity) allows direct interaction with the Ethereum Virtual Machine (EVM) at the opcode level. It provides:

  • Control and Efficiency: More direct control over low-level operations, enabling optimizations.
  • Complexity: Increased complexity and potential for errors, requiring careful handling.

2. Stack

  • Definition: A Last-In-First-Out (LIFO) data structure used for storing temporary data.
  • Operations: Data is pushed onto the stack and popped off as needed.
  • Limitations: The EVM stack has a limited size (1024 items), so efficient stack management is crucial.

3. Memory

  • Definition: A linear and expandable storage area for temporary data during contract execution.
  • Characteristics:
    • Read and write access to any location.
    • Data is cleared after the contract execution ends.
  • Usage: Ideal for complex data manipulation within a function call.

4. Storage

  • Definition: Permanent storage that persists across transactions and contract executions.
  • Characteristics:
    • Each storage slot is 32 bytes.
    • Writing to storage is expensive in terms of gas.
  • Usage: Used for state variables that need to persist between function calls.

Memory vs. Storage

  • Memory:
    • Temporary data storage during function execution.
    • Cheaper gas costs.
    • Cleared after execution ends.
  • Storage:
    • Persistent data storage.
    • More expensive gas costs.
    • Retains data between transactions.

How Assembly Works in the EVM

In the EVM, your program's bytes correspond to opcodes or instructions. Each instruction may require arguments and may return a value. Here's how it generally works:

  1. Preparing the Stack:

    • Push the required arguments onto the stack in reverse order (due to LIFO nature).
    • For an instruction that takes three arguments, push the third argument first, then the second, then the first.
  2. Executing Instructions:

    • Call the instruction, which pops the arguments off the stack.
    • The instruction performs its operation, possibly updating memory or storage.
    • Any return values may be pushed onto the stack or affect memory/storage.

Example: Using CALLDATACOPY

Let's walk through an example where we want to copy the transaction's calldata:

  1. Objective: Copy the entire calldata to memory starting at position 0.

  2. Instruction: CALLDATACOPY (opcode 0x37), which requires three arguments:

    • Destination memory offset.
    • Source calldata offset.
    • Length of data to copy.
  3. Preparing the Stack:

    • Push Length: Use CALLDATASIZE (opcode 0x36) to get the size of the calldata and push it onto the stack.
    • Push Source Offset: Push 0 onto the stack (starting from the beginning of calldata).
    • Push Destination Offset: Push 0 onto the stack (starting at the beginning of memory).
  4. Stack Operations:

    // Push CALLDATASIZE onto the stack
    36    // CALLDATASIZE
    // Push 0 onto the stack
    3d    // RETURNDATASIZE (used here to push 0)
    // Push 0 onto the stack
    3d    // RETURNDATASIZE
    // Call CALLDATACOPY
    37    // CALLDATACOPY
    
  5. Execution:

    • CALLDATACOPY pops the three values off the stack and copies the calldata to memory.

Understanding Stack and Memory Changes

  • Stack Before CALLDATACOPY:

    |   Length (CALLDATASIZE)   |
    | Source Offset (0)         |
    | Destination Offset (0)    |
    
  • Stack After CALLDATACOPY:

    • The three arguments are popped off, and the stack returns to its previous state.
  • Memory After CALLDATACOPY:

    • Memory from position 0 now contains the calldata.

Control Flow with DELEGATECALL and JUMPI

DELEGATECALL

  • Purpose: Calls another contract's code while preserving the current contract's context (storage, caller, etc.).
  • Return Value: Pushes a success flag (1 for success, 0 for failure) onto the stack.

Using the Success Flag with JUMPI

  • JUMPI: Conditional jump instruction that requires two stack items:

    • Destination address to jump to.
    • Condition (non-zero value means jump).
  • Process:

    1. Perform DELEGATECALL: The success flag is pushed onto the stack.
    2. Prepare Destination:
      • Push the byte offset of the JUMPDEST label onto the stack.
    3. Execute JUMPI:
      • If the success flag is non-zero, execution jumps to the specified destination.

Example Code Snippet

5a    // GAS
f4    // DELEGATECALL
602b  // PUSH1 0x2b (destination offset)
57    // JUMPI
fd    // REVERT
5b    // JUMPDEST
  • Explanation:
    • 5a f4: Execute DELEGATECALL with provided gas and parameters.
    • 602b 57: If the call was successful, jump to offset 0x2b.
    • fd: If not, revert the transaction.
    • 5b: JUMPDEST label at offset 0x2b where execution continues after a successful call.

Why DELEGATECALL Returns Success on the Stack

The DELEGATECALL opcode pushes the return value "success" onto the stack because:

  • Stack-Based Architecture: The EVM is designed to use the stack for temporary data and control flow decisions.
  • Efficiency: Using the stack is more gas-efficient than writing to memory or storage.
  • Immediate Use: The success flag is often needed right after the call for conditional operations like JUMPI.

Representing Storage Operations

While the EVM stack and memory are temporary during execution, storage persists beyond function calls. In assembly:

  • Reading from Storage: Use the SLOAD opcode to load a value from a storage slot onto the stack.
  • Writing to Storage: Use the SSTORE opcode to write a value from the stack to a storage slot.

Example: Writing to Storage

  1. Push Value and Slot:

    • Push the value to store onto the stack.
    • Push the storage slot index onto the stack.
  2. Execute SSTORE:

    • SSTORE pops both the value and the slot index from the stack and writes the value to the specified storage slot.

Gas Considerations

  • Storage Writes: Expensive in terms of gas, so should be used judiciously.
  • Storage Reads: Cheaper than writes but more expensive than memory operations.

Conclusion

Understanding assembly in Ethereum smart contracts allows developers to write highly optimized code and unlock functionalities not available in high-level languages like Solidity. By grasping how the EVM handles the stack, memory, and storage, and how instructions manipulate these components, developers can:

  • Optimize Gas Costs: Reduce unnecessary operations to save gas.
  • Implement Advanced Features: Create contracts like minimal proxies for efficient code reuse.
  • Enhance Control Flow: Use low-level instructions for precise execution control.

While assembly provides power and flexibility, it comes with increased complexity and potential for errors. It's essential to thoroughly test and audit assembly code to ensure security and correctness.

My shorthand notes were the source material for this article produced by generative AI.