- Published on
Assembly in Ethereum Smart Contracts
- Authors
- Name
- Frank
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:
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.
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:
Objective: Copy the entire calldata to memory starting at position 0.
Instruction:
CALLDATACOPY
(opcode0x37
), which requires three arguments:- Destination memory offset.
- Source calldata offset.
- Length of data to copy.
Preparing the Stack:
- Push Length: Use
CALLDATASIZE
(opcode0x36
) 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).
- Push Length: Use
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
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.
- Memory from position
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:
- Perform DELEGATECALL: The success flag is pushed onto the stack.
- Prepare Destination:
- Push the byte offset of the
JUMPDEST
label onto the stack.
- Push the byte offset of the
- 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
: ExecuteDELEGATECALL
with provided gas and parameters.602b 57
: If the call was successful, jump to offset0x2b
.fd
: If not, revert the transaction.5b
:JUMPDEST
label at offset0x2b
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
Push Value and Slot:
- Push the value to store onto the stack.
- Push the storage slot index onto the stack.
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.