Delegatecall 與代理模式

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 編號訪問數據,完全不關心變量名

值類型的堆疊規則

  1. 值類型按所需字節數存儲
  2. 第一個元素從 slot 的低位開始存放
  3. 如果當前 slot 剩餘空間不夠,新起一個 slot
  4. 結構體和數組總是新起一個 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
  • 代理模式將數據存儲業務邏輯分離,實現"可升級"

工作機制

  1. 調用者調用 Proxy 的 setX()
  2. Proxy 的 setX() 不存在,觸發 fallback()
  3. fallback 使用 delegatecall 調用 Logic 合約,傳遞 calldata
  4. Logic 的 setX() 被執行,但訪問的是 Proxy 的成員變量

delegatecall 代理模式

解決升級問題

  • 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 數據保持不變,但可用的功能升級了。

升級流程:

  1. 部署 V1,部署 Proxy(setImplementation 指向 V1)
  2. 通過 Proxy 調用 inc(),x 變為 1
  3. 部署 V2,調用 setImplementation 指向 V2
  4. 現在通過 Proxy 可以調用 inc() 和 dec(),x 的值依然是 1

基礎代理模式的問題

雖然上面的實現可以工作,但存在明顯缺陷:

  1. Layout 耦合:每個 Logic 合約都需要聲明佔位符變量(如 address public implementation),與 Proxy 的 layout 強綁定
  2. 不通用:不同的 Proxy 可能有不同數量的管理變量,Logic 需要針對每個 Proxy 定制佔位符
  3. view 函數問題:基礎版本的 _delegate 無法正確返回 delegatecall 的返回值,public view 函數通過 Proxy 調用時無法獲得正確結果

這些問題催生了非結構化存儲方案。


非結構化存儲(Unstructured Storage)

問題回顧

  • 基礎代理模式中,Proxy 和 Logic 必須有相同的佈局(包含佔位符變量)
  • 這讓 Proxy 和 Logic 耦合,不夠通用

解決思想

  • Proxy 的低位 storage 留空白,完全由 Logic 操作
  • Proxy 自己的控制變量(如 implementation 地址)通過指定特殊的 storage slot 避開低位
  • 使用 sloadsstore 匯編指令直接操作指定 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 深入探索

調用鏈中的注意事項

通過實驗得出的重要結論:

  1. delegatecall 調用的合約不能再 call/delegatecall 自己:被 delegatecall 調用的代碼中不能嵌入調用自身的 call、delegatecall、或通過 this 調用
  2. delegatecall 中可以 call/delegatecall 其他合約:當 delegatecall 中出現合約間的 call 時,delegate 語義被切斷,被 call 的合約執行正常的 call 語義
  3. 上下文主體規則:一個合約只有被非 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 核心要點

  1. 借用代碼:執行目標合約的代碼,但操作調用合約的存儲
  2. 保持上下文:msg.sender 和 msg.value 不變
  3. 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

(0)
Walker的頭像Walker
上一篇 5天前
下一篇 2025年11月24日 01:00

相關推薦

  • Gas機制與轉賬設計

    Gas機制與轉賬設計 學習目標 理解區塊鏈的經濟模型與激勵機制 掌握 Gas、Gas Price、Gas Fee 的概念與關係 理解 Ether 單位與轉換 掌握合約轉賬設計(receive / fallback / payable) 區塊鏈的經濟系統 為甚麼需要經濟模型? 計算與存儲資源是稀缺的:每個節點都要執行和存儲所有交易 共識和 trustless …

    20小時前
    000
  • 函數定義與訪問控制

    函數定義與訪問控制 學習目標 掌握 Solidity 函數定義、可見性修飾符、交易屬性、modifier 和構造函數。 函數定義 一般形式 function fname([參數]) [可見性][交易屬性][modifier...] returns(返回值) { ... } 函數簽名:fname([參數]) —— 唯一標識一個函數 返回值:returns(返回…

    Web3與WASM 23小時前
    100
  • 合約交互與 ABI

    合約交互與 ABI 學習目標 掌握合約間調用方式、接口定義、ABI 數據結構、Web3.js 訪問合約的方法。 合約間調用基礎 EOA(外部賬號)發起調用,可能觸發合約間的調用鏈 調用者必須持有被調用合約的地址 方式一:同文件內直接調用 當兩個合約在同一個文件中時,可以直接通過合約類型和地址進行調用: // SPDX-License-Identifier: …

    22小時前
    000
  • Web3 概述與願景

    Web3 概述與願景 學習目標 理解 Web1、Web2、Web3 的演進歷程與核心區別 掌握 Web3 的核心理念:去中心化、數據確權、用戶主權 瞭解 Web3 帶來的創新機會與全新商業模式 熟悉 Web3 開發者的學習路線圖 前置知識 基本的互聯網使用經驗 對軟件開發有初步瞭解(非必須,但有助於理解技術部分) 一、Web1 → Web2 → Web3 的…

    Web3與WASM 17小時前
    100
  • Solidity 入門與開發環境

    Solidity 入門與開發環境 學習目標 理解智能合約的本質與核心特性 掌握合約在以太坊上的運行原理(Transaction + EVM) 認識 Solidity 語言特點與開發工具鏈 編寫並部署第一個智能合約 前置知識 瞭解區塊鏈基本概念(區塊、交易、共識) 瞭解以太坊賬戶模型(EOA 與合約賬戶) 基本編程經驗(任意語言均可) 一、智能合約的根本性質 …

    1天前
    900
簡體中文 繁體中文 English