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
