Delegatecall 与代理模式
学习目标
- 理解 delegatecall 的工作原理
- 掌握 Storage Layout(存储布局)规则
- 掌握代理模式与合约升级
- 了解非结构化存储(Unstructured Storage)
delegatecall 原理
什么是 delegatecall
- 语法:
address.delegatecall(bytes calldata) - 核心概念:一个合约用别人的代码访问自己的成员变量
- 类比:A 合约把 B 合约的函数借用、搬运到自己内部来执行,这些代码访问的是 A 合约的成员
- 从上下文来看:形式上跨合约,实质上没有跨合约
call vs delegatecall 对比
| 调用方式 | msg.sender | 操作的状态变量 | 使用的 Ether |
|---|---|---|---|
| 普通 call | 调用者(B) | 目标合约(C) | 目标合约(C) |
| delegatecall | 原始调用者(A) | 调用者合约(B) | 调用者合约(B) |
call 场景(A 调用 B,B 调用 C)
msg.sender = B
msg.value = 50(传递给 C 的)
执行代码在 C 中,操作 C 的状态变量,使用 C 的 Ether
delegatecall 场景(A 调用 B,B delegatecall C)
msg.sender = A(保持原始调用者)
msg.value = 100(保持原始值)
执行 C 的代码,但操作 B 的状态变量,使用 B 的 Ether
关键区别:delegatecall 不更改 msg.sender,执行目标合约代码时操作的是调用合约的存储。
基础示例
// 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");
}
}
调用 A.setVars() 后,改变的是 A 的成员变量,B 不受影响。这就是"借用代码"的含义。
Storage Layout(存储布局)
理解 delegatecall 的前提是理解存储布局,因为合约编译后的机器码用位置(而非变量名)来访问数据。
基本概念
- EVM 的存储是一个巨大的 key-value 存储,key 是 256 位的 slot 编号,value 是 256 位(32 字节)的数据
- 状态变量按声明顺序依次分配 slot
- 编译后的字节码通过 slot 编号访问数据,完全不关心变量名
值类型的堆叠规则
- 值类型按所需字节数存储
- 第一个元素从 slot 的低位开始存放
- 如果当前 slot 剩余空间不够,新起一个 slot
- 结构体和数组总是新起一个 slot,后续数据也新起一个 slot
动态类型的存储规则
- 动态数组:在堆叠布局中占一个 slot(存数组长度),实际数据通过
keccak256(slot位置)计算存储位置 - mapping:在堆叠布局中占一个 slot(空着),每个 key 的 value 位置通过
keccak256(keccak256(key), slot位置)计算
继承中的 Layout
- 按 C3 线性化结果依次堆叠
- 父子合约的数据可以共存于同一个 slot
Storage Layout 示例
// SPDX-License-Identifier: MIT
pragma 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;
}
}
num占 slot 0(uint256 = 32 字节,刚好一个 slot);sender占 slot 1(address = 20 字节);Person结构体从 slot 2 开始,内部成员依次排列;定长数组bool[20]每个 bool 占 1 字节,20 个 bool 可以打包进一个 slot;最后value在 slot 6。
delegatecall 与 Layout 兼容性
- A 和 B 的成员变量存储布局必须兼容
- B 合约的成员变量设计相当于"训练场",实战发生在 A 合约
- 训练场必须模拟实战场的布局
不兼容会怎样? 如果 A 和 B 的 layout 不一致,delegatecall 执行 B 的代码时会把数据写入错误的 slot,导致状态变量被覆盖或读取到错误的值。这是 delegatecall 最常见的 bug 来源。
代理模式(Proxy Pattern)
为什么需要代理模式
- 智能合约一旦部署,代码不可更改
- 但业务逻辑可能需要升级、修复 bug
- 代理模式将数据存储和业务逻辑分离,实现"可升级"
工作机制
- 调用者调用 Proxy 的
setX() - Proxy 的
setX()不存在,触发fallback() - fallback 使用 delegatecall 调用 Logic 合约,传递 calldata
- Logic 的
setX()被执行,但访问的是 Proxy 的成员变量
解决升级问题
- Proxy 负责数据存储,Logic 负责逻辑处理
- 升级 = Proxy 将 logic 地址切换到新的逻辑合约
- 数据保留在 Proxy 中,不会因为升级而丢失
基础代理模式实现
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.2 <0.9.0;
contract Logic {
address public logic; // 占位符,与 Proxy 的 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");
}
// 升级:切换逻辑合约
function upgradeTo(address newVersion) external {
logic = newVersion;
}
}
Logic 合约中必须有
address public logic占位符,确保count在 slot 1,与 Proxy 的 layout 一致。如果 Logic 没有这个占位符,count += 1会修改 Proxy 的 slot 0(即logic地址),导致合约损坏。
完整的带版本升级示例
// 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; // 占位符
uint public x;
function inc() external {
x += 1;
}
}
contract V2 {
address public implementation; // 占位符
uint public x;
function inc() external {
x += 1;
}
function dec() external {
x -= 1;
}
}
V1 只有 inc(),V2 增加了 dec()。通过 setImplementation 切换,Proxy 的 x 数据保持不变,但可用的功能升级了。
升级流程:
- 部署 V1,部署 Proxy(setImplementation 指向 V1)
- 通过 Proxy 调用 inc(),x 变为 1
- 部署 V2,调用 setImplementation 指向 V2
- 现在通过 Proxy 可以调用 inc() 和 dec(),x 的值依然是 1
基础代理模式的问题
虽然上面的实现可以工作,但存在明显缺陷:
- Layout 耦合:每个 Logic 合约都需要声明占位符变量(如
address public implementation),与 Proxy 的 layout 强绑定 - 不通用:不同的 Proxy 可能有不同数量的管理变量,Logic 需要针对每个 Proxy 定制占位符
- view 函数问题:基础版本的
_delegate无法正确返回 delegatecall 的返回值,public view 函数通过 Proxy 调用时无法获得正确结果
这些问题催生了非结构化存储方案。
非结构化存储(Unstructured Storage)
问题回顾
- 基础代理模式中,Proxy 和 Logic 必须有相同的布局(包含占位符变量)
- 这让 Proxy 和 Logic 耦合,不够通用
解决思想
- Proxy 的低位 storage 留空白,完全由 Logic 操作
- Proxy 自己的控制变量(如 implementation 地址)通过指定特殊的 storage slot 避开低位
- 使用
sload和sstore汇编指令直接操作指定 slot
特殊 slot 的选择
bytes32 private constant implementationPosition = keccak256("org.zeppelinos.proxy.implementation");
keccak256的结果是一个极大的数字,远超正常变量使用的 slot 范围(slot 0, 1, 2...)- 几乎不可能与 Logic 合约的正常 storage slot 冲突
- 这是 OpenZeppelin 的 EIP-1967 标准采用的方案
sload 与 sstore 汇编指令
// 读取指定 slot 的值
assembly {
impl := sload(position)
}
// 写入值到指定 slot
assembly {
sstore(position, newLogic)
}
sload(slot): 从指定 slot 加载 256 位数据sstore(slot, value): 将 256 位数据写入指定 slot- 绕过 Solidity 的变量声明机制,直接操作底层存储
汇编版 _delegate 函数
基础版本的 _delegate 使用 Solidity 的 delegatecall,无法正确转发返回值。汇编版本解决了这个问题:
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())
}
}
}
为什么需要汇编?
- Solidity 的 delegatecall 返回
(bool, bytes memory),但 fallback 函数无法将 bytes 作为返回值传递给外部调用者 - 汇编的
return指令直接将数据写入调用者的返回缓冲区,完美转发返回值 - 这样 public view 函数(如
x())通过 Proxy 调用时就能正确返回值了
非结构化代理完整实现
// 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());
}
}
完整的非结构化代理模式(含 Logic 合约)
// 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 V1 {
uint public x;
function inc() external {
x += 1;
}
}
contract V2 {
uint public x;
function inc() external {
x += 1;
}
function dec() external {
x -= 1;
}
}
对比基础代理模式,V1 和 V2 不再需要
address public implementation占位符。Proxy 的管理变量存储在极高的 slot 位置,与 Logic 的正常 slot(0, 1, 2...)不冲突。这就是非结构化存储的核心价值。
delegatecall 深入探索
调用链中的注意事项
通过实验得出的重要结论:
- delegatecall 调用的合约不能再 call/delegatecall 自己:被 delegatecall 调用的代码中不能嵌入调用自身的 call、delegatecall、或通过 this 调用
- delegatecall 中可以 call/delegatecall 其他合约:当 delegatecall 中出现合约间的 call 时,delegate 语义被切断,被 call 的合约执行正常的 call 语义
- 上下文主体规则:一个合约只有被非 delegatecall 调用时,才会成为上下文主体;否则它从属于 delegatecall 的调用者
三合约调用链示例(A delegatecall B, B call C)
// SPDX-License-Identifier: MIT
pragma 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");
}
}
结果:A 的成员变量被 B 的代码修改(delegatecall 语义),C 的成员变量被正常的 call 修改(delegate 语义被切断)。
调用链结果分析
假设 EOA 地址为 0xUser,A、B、C 分别部署在不同地址:
A 合约(被 delegatecall 修改):
- num = 传入的 _num
- sender = 0xUser(原始调用者,delegatecall 保持 msg.sender)
- value = 发送的 ETH(delegatecall 保持 msg.value)
B 合约(未被修改):
- 所有状态变量保持不变,B 只是"借出"了代码
C 合约(被 call 修改):
- num = 传入的 _num
- sender = A 的地址(不是 0xUser,因为 call 切断了 delegate 语义,A 是上下文主体)
- value = 0(call 没有附带 ETH)
总结
delegatecall 核心要点
- 借用代码:执行目标合约的代码,但操作调用合约的存储
- 保持上下文:msg.sender 和 msg.value 不变
- Layout 必须兼容:调用合约和目标合约的 storage layout 必须匹配
代理模式演进
| 阶段 | 方案 | 特点 | 缺陷 |
|---|---|---|---|
| 基础版 | 占位符变量 | 简单直观 | Logic 需要声明占位符,耦合严重 |
| 非结构化 | keccak256 特殊 slot | Logic 无需占位符,解耦 | 需要内联汇编 |
| EIP-1967 | 标准化 slot | 行业标准,工具兼容 | OpenZeppelin 实现 |
安全注意事项
- Layout 不兼容:最常见的 bug,升级时新 Logic 必须保持与旧 Logic 兼容的 layout(只能追加,不能修改已有变量的顺序和类型)
- 初始化问题:Logic 合约的 constructor 不会被 Proxy 执行,需要用
initialize()函数代替 - 函数选择器冲突:Proxy 自身的函数(如
upgradeTo)可能与 Logic 的函数选择器冲突 - 权限控制:
upgradeTo必须做权限校验,否则任何人都能替换逻辑合约
主题测试文章,只做测试使用。发布者:Walker,转转请注明出处:https://walker-learn.xyz/archives/7498
