Delegatecall and Proxy Pattern
Learning Objectives
- Understand how delegatecall works
- Master Storage Layout rules
- Master Proxy Pattern and contract upgrades
- Understand Unstructured Storage
delegatecall Principle
What is delegatecall
- Syntax:
address.delegatecall(bytes calldata) - Core concept: A contract uses another contract's code to access its own member variables
- Analogy: Contract A borrows and executes Contract B's function internally, and these codes access Contract A's members.
- From the context: It appears cross-contract, but in essence, it's not.
call vs delegatecall Comparison
| Call Method | msg.sender | State Variables Operated | Ether Used |
|---|---|---|---|
| Normal call | Caller (B) | Target Contract (C) | Target Contract (C) |
| delegatecall | Original Caller (A) | Caller Contract (B) | Caller Contract (B) |
call Scenario (A calls B, B calls C)
msg.sender = B
msg.value = 50 (passed to C)
Code executes in C, operates C's state variables, uses C's Ether
delegatecall Scenario (A calls B, B delegatecalls C)
msg.sender = A (original caller maintained)
msg.value = 100 (original value maintained)
C's code executes, but operates B's state variables, uses B's Ether
Key Difference: delegatecall does not change msg.sender, and when executing the target contract's code, it operates on the calling contract's storage.
Basic Example
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.2 <0.9.0;
contract B {
// 注意:storage layout 必须与 A 合约兼容
uint public num;
address public sender;
uint public value;
function setVars(uint _num) public payable {
num = _num;
sender = msg.sender;
value = msg.value;
}
}
contract A {
uint public num;
address public sender;
uint public value;
function setVars(address _contract, uint _num) public payable {
(bool success, bytes memory data) = _contract.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
require(success, "delegatecall failed");
}
}
After calling A.setVars(), it is A's member variables that are changed, B is unaffected. This is the meaning of "borrowing code".
Storage Layout
Understanding delegatecall requires understanding storage layout, because the compiled machine code of a contract accesses data by position (not by variable name).
Basic Concepts
- EVM's storage is a huge key-value store, where the key is a 256-bit slot number, and the value is 256-bit (32-byte) data.
- State variables are allocated slots sequentially according to their declaration order.
- Compiled bytecode accesses data via slot numbers, completely disregarding variable names.
Stacking Rules for Value Types
- Value types are stored according to the number of bytes required.
2. The first element is stored starting from the low-order bits of the slot.
3. If the current slot does not have enough remaining space, a new slot is started.
4. Structs and arrays always start a new slot, and subsequent data also starts a new slot.
Storage Rules for Dynamic Types
- Dynamic arrays: Occupy one slot in the stacked layout (storing the array length), and the actual data storage location is calculated via
keccak256(slot position). - Mapping: Occupies one slot in the stacked layout (left empty), and the value location for each key is calculated via
keccak256(keccak256(key), slot position).
Layout in Inheritance
- Stacked sequentially according to the C3 linearization result.
- Data from parent and child contracts can coexist in the same slot.
Storage Layout Example
// SPDX-License-Identifier: MITpragma solidity >=0.8.2 <0.9.0;contract StorageLayout { uint public num; // slot 0 address public sender; // slot 1 Person person; // slot 2, 3, 4 bool[20] success; // slot 5 uint public value; // slot 6 struct Person { uint256 num; address sender; bool[12] success; uint256 value; }}
numoccupies slot 0 (uint256 = 32 bytes, exactly one slot);senderoccupies slot 1 (address = 20 bytes); thePersonstruct starts from slot 2, with internal members arranged sequentially; the fixed-size arraybool[20], where each bool takes 1 byte, 20 bools can be packed into one slot; finally,valueis in slot 6.
delegatecall and Layout Compatibility
- The member variable storage layouts of A and B must be compatible.
- The design of B contract's member variables is like a "training ground", while the actual combat happens in A contract.
- The training ground must simulate the layout of the actual combat field.
What happens if they are incompatible? If the layouts of A and B are inconsistent, delegatecall executing B's code will write data to the wrong slot, leading to state variables being overwritten or incorrect values being read. This is the most common source of delegatecall bugs.
Proxy Pattern
Why the Proxy Pattern is Needed
- Once a smart contract is deployed, its code cannot be changed.
- However, business logic may need upgrades or bug fixes.
- The Proxy Pattern separates data storage and business logic to achieve "upgradability".
Working Mechanism
- Caller calls Proxy's
setX().
2. Proxy's setX() does not exist, triggering fallback().
3. The fallback function uses delegatecall to call the Logic contract, passing calldata.
4. Logic's setX() is executed, but it accesses Proxy's member variables.
Solving the Upgrade Problem
- Proxy is responsible for data storage, Logic is responsible for logic processing.
- Upgrade = Proxy switches the logic address to the new logic contract.
- Data remains in the Proxy and is not lost due to upgrades.
Basic Proxy Pattern Implementation
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.2 <0.9.0;
contract Logic {
address public logic; // Placeholder, aligned with Proxy's layout
uint256 public count;
function inc() external {
count += 1;
}
}
interface LogicInterface {
function inc() external;
}
contract Proxy {
address public logic;
uint256 public count;
constructor(address _logic) {
logic = _logic;
}
fallback() external {
(bool ok, bytes memory res) = logic.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
// Upgrade: switch logic contract
function upgradeTo(address newVersion) external {
logic = newVersion;
}
}
The Logic contract must have an
address public logicplaceholder to ensurecountis in slot 1, consistent with Proxy's layout. If Logic does not have this placeholder,count += 1would modify Proxy's slot 0 (i.e., thelogicaddress), leading to contract corruption.
Complete Version Upgrade Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
interface ProxyInterface {
function inc() external;
}
contract Proxy {
address public implementation;
uint public x;
function setImplementation(address _imp) external {
implementation = _imp;
}
function _delegate(address _imp) internal virtual {
(bool suc, bytes memory data) = _imp.delegatecall(msg.data);
if (!suc) revert("failed!");
}
fallback() external payable {
_delegate(implementation);
}
}
contract V1 {
address public implementation; // Placeholder
uint public x;
function inc() external {
x += 1;
}
}
contract V2 {
address public implementation; // Placeholder
uint public x;
function inc() external {
x += 1;
}
function dec() external {
x -= 1;
}
}
V1 only has inc(), V2 adds dec(). By switching with setImplementation, Proxy's x data remains unchanged, but the available functionality is upgraded.
Upgrade Process:
- Deploy V1, deploy Proxy (setImplementation points to V1)
2. Call inc() via Proxy, x becomes 1
3. Deploy V2, call setImplementation pointing to V2
4. Now you can call inc() and dec() via Proxy, and the value of x is still 1
Problems with the Basic Proxy Pattern
Although the above implementation works, it has obvious flaws:
- Layout Coupling: Each Logic contract needs to declare placeholder variables (e.g.,
address public implementation), which is tightly coupled with the Proxy's layout. - Not Generic: Different Proxies may have different numbers of administrative variables, requiring Logic to customize placeholders for each Proxy.
- View Function Issue: The basic version of
_delegatecannot correctly return the delegatecall's return value, so public view functions called via Proxy cannot get the correct result.
These problems led to the emergence of the Unstructured Storage solution.
Unstructured Storage
Problem Review
- In the basic proxy pattern, Proxy and Logic must have the same layout (including placeholder variables).
- This couples Proxy and Logic, making it less generic.
Solution Idea
- Leave the low-order storage of the Proxy blank, to be entirely operated by Logic.
- Proxy's own control variables (such as the implementation address) avoid low-order slots by specifying special storage slots.
- Use
sloadandsstoreassembly instructions to directly operate specified slots.
Selection of Special Slots
bytes32 private constant implementationPosition = keccak256("org.zeppelinos.proxy.implementation");
- The result of
keccak256is an extremely large number, far beyond the normal slot range used by variables (slot 0, 1, 2...). - It is almost impossible to conflict with the normal storage slots of the Logic contract.
- This is the solution adopted by OpenZeppelin's EIP-1967 standard.
sload and sstore Assembly Instructions
// Read the value of a specified slot
assembly {
impl := sload(position)
}
// Write a value to a specified slot
assembly {
sstore(position, newLogic)
}
sload(slot): Loads 256-bit data from the specified slot.sstore(slot, value): Writes 256-bit data to the specified slot.- Bypasses Solidity's variable declaration mechanism to directly manipulate underlying storage.
Assembly Version of _delegate Function
The basic version of _delegate uses Solidity's delegatecall, which cannot correctly forward return values. The assembly version solves this problem:
function _delegate(address _logic) internal virtual {
assembly {
// 将 calldata 复制到内存位置 0
calldatacopy(0, 0, calldatasize())
// 执行 delegatecall
// gas() - 转发所有剩余 gas
// _logic - 目标合约地址
// 0 - 输入数据的内存起始位置
// calldatasize() - 输入数据的长度
// 0 - 输出数据的内存起始位置(先设为 0)
// 0 - 输出数据的长度(先设为 0,之后用 returndatasize 获取)
let result := delegatecall(gas(), _logic, 0, calldatasize(), 0, 0)
// 将返回数据复制到内存位置 0
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
// delegatecall 失败,revert 并返回错误信息
revert(0, returndatasize())
}
default {
// delegatecall 成功,返回数据
return(0, returndatasize())
}
}
}
Why is assembly needed?
- Solidity's delegatecall returns
(bool, bytes memory), but a fallback function cannot pass bytes as a return value to an external caller. - The assembly
returninstruction directly writes data to the caller's return buffer, perfectly forwarding the return value. - This way, public view functions (like
x()) can correctly return values when called via Proxy.
Complete Unstructured Proxy Implementation
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.2 <0.9.0;
contract UnstructuredProxy {
bytes32 private constant logicPosition = keccak256("org.zeppelinos.proxy.implementation");
function upgradeTo(address newLogic) public {
setLogic(newLogic);
}
function logic() public view returns (address impl) {
bytes32 position = logicPosition;
assembly {
impl := sload(position)
}
}
function setLogic(address newLogic) internal {
bytes32 position = logicPosition;
assembly {
sstore(position, newLogic)
}
}
function _delegate(address _logic) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _logic, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
fallback() external payable {
_delegate(logic());
}
}
Complete Unstructured Proxy Pattern (including Logic Contracts)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
interface ProxyInterface {
function inc() external;
function x() external view returns(uint);
}
contract Proxy {
bytes32 private constant implementationPosition = keccak256("org.zeppelinos.proxy.implementation");
function upgradeTo(address newImplementation) public {
setImplementation(newImplementation);
}
function implementation() public view returns(address impl) {
bytes32 position = implementationPosition;
assembly {
impl := sload(position)
}
}
function setImplementation(address newImplementation) internal {
bytes32 position = implementationPosition;
assembly {
sstore(position, newImplementation)
}
}
function _delegate(address _imp) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _imp, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
fallback() external payable {
_delegate(implementation());
}
}
// Logic contract no longer needs placeholders!
contract V1 {
uint public x;
function inc() external {
x += 1;
}
}
contract V2 {
uint public x;
function inc() external {
x += 1;
}
function dec() external {
x -= 1;
}
}
Compared to the basic proxy pattern, V1 and V2 no longer require the
address public implementationplaceholder. Proxy's administrative variables are stored in very high slot positions, avoiding conflicts with Logic's normal slots (0, 1, 2...). This is the core value of unstructured storage.
In-depth Exploration of delegatecall
Considerations in the Call Chain
Important conclusions drawn from experiments:
- A contract called by delegatecall cannot call/delegatecall itself again: The code called by delegatecall cannot embed calls to itself, delegatecalls to itself, or calls via
this.
2. A delegatecall can call/delegatecall other contracts: When a cross-contract call occurs within a delegatecall, the delegate semantics are broken, and the called contract executes normal call semantics.
3. Context Subject Rule: A contract only becomes the context subject when it is called by a non-delegatecall; otherwise, it belongs to the delegatecall's caller.
Three-Contract Call Chain Example (A delegatecalls B, B calls C)
// SPDX-License-Identifier: MITpragma solidity ^0.8.13;contract C { uint public num; address public sender; uint public value; bytes public cdata; function setVars(uint _num) public payable { num = _num; sender = msg.sender; value = msg.value; cdata = msg.data; }}contract B { uint public num; address public sender; uint public value; bytes public cdata; function setVarsByCall(address c, uint _num) public payable { num = _num; sender = msg.sender; value = msg.value; cdata = msg.data; (bool suc, ) = c.call(abi.encodeWithSignature("setVars(uint256)", _num)); require(suc, "B call C failed"); }}contract A { uint public num; address public sender; uint public value; bytes public cdata; function setVarsByDelegateCall(address b, address c, uint _num) public payable { (bool success, ) = b.delegatecall( abi.encodeWithSignature("setVarsByCall(address,uint256)", c, _num) ); require(success, "A delegatecall B failed"); }}Result: A's member variables are modified by B's code (delegatecall semantics), and C's member variables are modified by a normal call (delegate semantics are broken).
Call Chain Result Analysis
Assume the EOA address is 0xUser, and A, B, C are deployed at different addresses:
Contract A (modified by delegatecall):
- num = the passed _num
- sender = 0xUser (original caller, delegatecall preserves msg.sender)
- value = sent ETH (delegatecall preserves msg.value)
Contract B (not modified):
- All state variables remain unchanged, B merely "lent out" its code.
Contract C (modified by call):
- num = the passed _num
- sender = A's address (not 0xUser, because call breaks delegate semantics, A is the context subject)
- value = 0 (call did not attach ETH)
Summary
Core Points of delegatecall
- Code Borrowing: Executes the target contract's code, but operates on the calling contract's storage.
2. Context Preservation: msg.sender and msg.value remain unchanged.
3. Layout Must Be Compatible: The storage layout of the calling contract and the target contract must match.
Evolution of Proxy Pattern
| Stage | Solution | Features | Drawbacks |
|---|---|---|---|
| Basic Version | Placeholder variables | Simple and intuitive | Logic needs to declare placeholders, severe coupling |
| Unstructured | keccak256 special slot | Logic needs no placeholders, decoupled | Requires inline assembly |
| EIP-1967 | Standardized slot | Industry standard, tool compatible | Implemented by OpenZeppelin |
Security Considerations
- Layout Incompatibility: The most common bug; when upgrading, the new Logic must maintain a layout compatible with the old Logic (only appending is allowed, existing variable order and types cannot be modified).
- Initialization Issues: The Logic contract's constructor will not be executed by the Proxy; an
initialize()function is needed instead. - Function Selector Conflicts: Proxy's own functions (e.g.,
upgradeTo) may conflict with Logic's function selectors. - Access Control:
upgradeTomust have permission checks, otherwise anyone could replace the logic contract.
主题测试文章,只做测试使用。发布者:Walker,转转请注明出处:https://walker-learn.xyz/archives/7498
