Delegatecall 深度解析:借用代码、共享存储
目录
- 一、delegatecall 是什么
- 二、与 call 的关键区别
- 三、存储插槽冲突风险(核心)
- 四、上下文的保留:msg.sender / msg.value / address(this)
- 五、immutable 与 constant 陷阱
- 六、msg.data 的行为
- 七、链式 delegatecall
- 八、CODESIZE 例外
- 九、Yul 底层实现与 Gas 转发
- 十、安全隐患
- 十一、代理模式:delegatecall 的正当用途
- 十二、总结
- 十三、动手练习项目:可升级计数器代理 MiniProxy
一、delegatecall 是什么
delegatecall 是 EVM 操作码(0xF4)。它执行目标合约的代码,但用的是调用者自己的上下文(存储、余额、地址)。
心智模型:实现合约把自己的字节码”借”给调用者,调用者像执行自己的代码一样运行这段借来的逻辑。这就是可升级合约的全部魔法所在。
二、与 call 的关键区别
| 方面 | CALL | DELEGATECALL |
|---|---|---|
| 改谁的存储 | 被调合约的存储 | 调用者的存储 |
| msg.sender | 变成调用者 | 保持原始发送者 |
| msg.value | 被调上下文可见 | 调用者上下文可见 |
| address(this) | 被调合约地址 | 调用者地址 |
一句话:delegatecall 执行的是别人的代码,操作的却是自己的一切。
三、存储插槽冲突风险(核心)
这是 delegatecall 头号危险。
发起 delegatecall 的合约必须极其小心地预测自己的哪个存储插槽会被修改。
因为目标代码按”插槽编号”读写,而它读写的是调用者的插槽。
3.1 错误示范
// 被调合约
contract Called {
uint public number; // 插槽 0
function increment() public { number++; }
}
// 调用者(危险!)
contract Caller {
address public calledAddress; // 插槽 0
uint public myNumber; // 插槽 1
function callIncrement() public {
calledAddress.delegatecall(abi.encodeWithSignature("increment()"));
}
}
increment() 想给”插槽 0 的 number” 加 1,但在 delegatecall 下它加的是 Caller 的插槽 0——也就是 calledAddress!结果把合约地址变量改成了 1,逻辑彻底崩坏。
3.2 修正版
让布局对齐——把要被修改的变量放到匹配的插槽:
contract Caller {
uint public myNumber; // 插槽 0 ← 对齐 Called 的 number
address public calledAddress; // 插槽 1
function callIncrement() public {
calledAddress.delegatecall(abi.encodeWithSignature("increment()"));
}
}
现在 increment 改的是插槽 0 的 myNumber,符合预期。
3.3 变量名不重要,插槽才重要
变量叫什么名字无所谓,根本的是它在哪个插槽。
Caller 里叫 myNumber、Called 里叫 number,名字不同没关系——只要都在插槽 0,delegatecall 就能正确协作。这也是为什么所有代理框架都强调”升级时不能改变量声明顺序、只能在末尾追加”。
四、上下文的保留:msg.sender / msg.value / address(this)
contract Called {
function getInfo() public payable returns (address, uint, address) {
return (msg.sender, msg.value, address(this));
}
}
contract Caller {
function getDelegatedInfo(address _called) public payable
returns (address, uint, address) {
(, bytes memory data) = _called.delegatecall(abi.encodeWithSignature("getInfo()"));
return abi.decode(data, (address, uint, address));
}
}
返回的三个值全是 Caller 的上下文:
msg.sender= 最初发起交易的人(不是 Caller);msg.value= 原交易带的 ETH;address(this)= Caller 的地址(不是 Called)。
五、immutable 与 constant 陷阱
一个非常隐蔽的坑:
contract Caller {
uint256 private immutable a = 3;
function getValueDelegate(address called) public view returns (uint256) {
(, bytes memory data) = called.delegatecall(abi.encodeWithSignature("getValue()"));
return abi.decode(data, (uint256));
}
}
contract Called {
uint256 private immutable a = 2;
function getValue() public pure returns (uint256) { return a; }
}
返回 2,不是 3!因为 immutable/constant 变量硬编码在字节码里,不在存储插槽。delegatecall 执行的是 Called 的字节码,里面写死的 a 就是 2,与 Caller 的存储上下文无关。
记住:delegatecall 共享的是存储,不是字节码里的常量。
六、msg.data 的行为
contract Called {
function returnMsgData() public pure returns (bytes memory) { return msg.data; }
}
通过 delegatecall 调用 returnMsgData 时,里面读到的 msg.data 是它自己被调用时的 calldata(即选择器 0x0b1c837f),而不是原始交易的 input data。每一层调用的 msg.data 是该层的 calldata。
七、链式 delegatecall
A delegatecall B,B 再 delegatecall C——整个过程上下文始终是 A 的:
contract Caller {
address calledFirst = 0xF273...01cb;
function delegateCallToFirst() public {
calledFirst.delegatecall(abi.encodeWithSignature("logSender()"));
}
}
contract CalledFirst {
event SenderAtCalledFirst(address sender);
address constant calledLast = 0x1d14...B8BD;
function logSender() public {
emit SenderAtCalledFirst(msg.sender);
calledLast.delegatecall(abi.encodeWithSignature("logSender()"));
}
}
contract CalledLast {
event SenderAtCalledLast(address sender);
function logSender() public { emit SenderAtCalledLast(msg.sender); }
}
两个事件记录的 msg.sender 完全相同——都是原始交易发送者。因为”delegatecall 只是借字节码”,上下文层层传递不变。(如果 CalledFirst 改用普通 call 调 CalledLast,最后那个事件就会显示 CalledFirst 的地址。)
八、CODESIZE 例外
有一个操作码打破”借字节码”模型:CODESIZE 返回被调合约的字节码大小,而不是调用者的。这个细节在某些”检测代码大小”的绕过攻击里会被利用,需要留意。
九、Yul 底层实现与 Gas 转发
DELEGATECALL 操作码取 6 个栈参数:
delegatecall(gas, address, argsOffset, argsSize, retOffset, retSize)
Yul 示例:
assembly {
mstore(0x00, 0x34ee2172) // 函数选择器
let result := delegatecall(
gas(), target_address,
0x1c, // 参数偏移(跳过选择器前的 28 字节)
4, // 参数大小
0, 0x20 // 返回偏移、返回大小
)
data := mload(0)
}
Gas 转发限制:EIP-150 后只转发可用 Gas 的 63/64,保留 1/64。用 gas() 也不能保证 100% 转发。
十、安全隐患
delegatecall 到不可信目标风险极高:
- 存储破坏:插槽布局不对齐会悄无声息地改坏状态;
- 逻辑劫持:目标代码以调用者的权限执行——它能动用调用者的全部余额和权限;
- selfdestruct 灾难:如果目标代码含 selfdestruct,会摧毁的是调用者(代理)合约——历史上 Parity 多签钱包就是这样被冻结了数亿美元;
- msg.sender 无法区分:实现合约分不清是原始用户还是中间合约在调用。
铁律:只 delegatecall 你完全信任、且存储布局严格对齐的合约。
十一、代理模式:delegatecall 的正当用途
delegatecall 最主要的合法用途就是可升级代理:
// 代理合约(存数据)
contract Proxy {
uint public price; // 插槽 0
address public implementation; // 插槽 1
function setDiscountPrice(uint rate) public {
implementation.delegatecall(abi.encodeWithSignature("applyDiscount(uint256)", rate));
}
}
// 逻辑合约(可替换)
contract ImplementationV1 {
function applyDiscount(uint rate) public {
// price 在调用者上下文是插槽 0,这里改的是 Proxy 的 price
}
}
升级时只需把 implementation 换成新逻辑合约地址,存储(price 等)原封不动保留——用户数据不丢,代码却换新了。后续几篇(ERC-1967、Transparent、UUPS、Beacon)都是在解决”如何安全地管理 implementation 地址和存储布局”这个问题。
十二、总结
- delegatecall = 执行别人的字节码 + 用自己的存储/sender/余额/地址;
- 头号风险是存储插槽冲突,必须对齐布局(看插槽不看名字);
- immutable/constant 在字节码里,delegatecall 取的是目标的硬编码值;
- 上下文层层保留,msg.sender 一路不变;CODESIZE 是例外;
- 只 delegatecall 可信且布局对齐的目标,警惕 selfdestruct。
十三、动手练习项目:可升级计数器代理 MiniProxy
项目目标
亲手实现一个最小可升级代理:Proxy 用 delegatecall 转发到逻辑合约,逻辑合约改的是 Proxy 的存储;制造一次存储冲突 bug 再修复它;最后把逻辑从 V1 升级到 V2,验证存储保留。部署到 Sepolia。
合约要求
1. LogicV1.sol
// 存储布局:插槽 0 = count
contract LogicV1 {
uint256 public count; // 插槽 0
function increment() external { count++; }
}
2. LogicV2.sol(升级版,必须保持布局兼容)
contract LogicV2 {
uint256 public count; // 插槽 0(不变!)
uint256 public lastCaller;// 插槽 1(末尾追加)
function increment() external { count += 2; lastCaller = uint256(uint160(msg.sender)); }
}
3. MiniProxy.sol
- 存储布局严格对齐逻辑合约:
uint256 public count;(插槽 0)、address public implementation;(插槽 1)——注意这里故意暴露第三节的冲突隐患,让你在测试里发现并思考; - 更稳妥的做法(推荐实现):把 implementation 存到 ERC-1967 风格的哈希插槽(下一篇内容预习),用汇编 sload/sstore 读写,彻底避开冲突;
upgradeTo(address newImpl):仅 admin 可调,更新 implementation;fallback() external payable:用 assembly 把 calldata 原样 delegatecall 到 implementation,并冒泡返回值/revert(标准代理 fallback 模板)。
测试要求(Foundry)
test_DelegatecallUsesProxyStorage:通过 Proxy 调 increment,断言 Proxy 的 count 变了、LogicV1 自身的 count 始终为 0;test_StorageCollisionBug:故意把 Proxy 的 implementation 放到插槽 0,复现”increment 改坏了 implementation 地址”,断言之后调用失败——亲手制造并观察 bug;test_FixedLayout:用对齐布局(或哈希插槽)版本,验证 increment 正常且 implementation 不被破坏;test_Upgrade_PreservesStorage:count 增到 5 后 upgradeTo(LogicV2),再 increment,断言 count 变成 7(保留 5 + V2 的 +2)、lastCaller 被记录;test_MsgSenderPreserved:在 LogicV2 里记录的 lastCaller == 最初的 EOA,不是 Proxy 地址;test_OnlyAdminUpgrade:非 admin 调 upgradeTo 应 revert。
Sepolia 部署与验证步骤
- 部署 LogicV1、MiniProxy(构造传 LogicV1 地址 + admin);
- 用 Proxy 地址 + LogicV1 的 ABI 在 Etherscan 上调
increment()几次,读count; - 部署 LogicV2,调
upgradeTo(LogicV2),再 increment,确认 count 在原值基础上 +2、且数据未丢; - (如果用了 ERC-1967 插槽)用
cast storage <proxy> 0x360894...382bbc读出 implementation 地址验证。
进阶挑战(可选)
- 给逻辑合约加一个
destroy()含 selfdestruct,在测试网(用一次性合约)观察 delegatecall selfdestruct 摧毁的是谁,理解 Parity 事故; - 写注释回答:为什么代理的 fallback 必须用 assembly 而不能用
implementation.delegatecall(msg.data)的高层写法返回?(提示:返回任意长度数据 + 冒泡 revert)