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

相关推荐

  • 引用类型详解:数组、结构体、映射、字符串

    引用类型详解:数组、结构体、映射、字符串 学习目标 掌握 Solidity 中四种引用类型(数组、结构体、映射、字符串/变长字节数组)的定义和使用方法。 前置知识 已学习值类型(整型、布尔、地址、定长字节数组等)。 数组(Array) storage 中的数组 Solidity 中数组分为两种: 静态数组 T[K]:长度固定,编译时确定 动态数组 T[]:长…

  • 函数定义与访问控制

    函数定义与访问控制 学习目标 掌握 Solidity 函数定义、可见性修饰符、交易属性、modifier 和构造函数。 函数定义 一般形式 function fname([参数]) [可见性][交易属性][modifier...] returns(返回值) { ... } 函数签名:fname([参数]) —— 唯一标识一个函数 返回值:returns(返回…

  • Web3 概述与愿景

    Web3 概述与愿景 学习目标 理解 Web1、Web2、Web3 的演进历程与核心区别 掌握 Web3 的核心理念:去中心化、数据确权、用户主权 了解 Web3 带来的创新机会与全新商业模式 熟悉 Web3 开发者的学习路线图 前置知识 基本的互联网使用经验 对软件开发有初步了解(非必须,但有助于理解技术部分) 一、Web1 → Web2 → Web3 的…

    Web3与WASM 19小时前
    600
  • 继承多态与库合约

    继承多态与库合约 学习目标 掌握 Solidity 继承机制与多态 理解 C3 线性化算法 掌握库合约(library)的定义和使用 继承基础 继承定义 使用 is 关键字 继承的实现方式是代码拷贝:部署后变成一个合约 可见性与继承 private:子合约不可见,但不能定义同名成员 internal:子合约可见 public:完全可见 event 和 mod…

    Web3与WASM 20小时前
    400
  • 存储位置与拷贝机制:storage、memory、calldata

    存储位置与拷贝机制:storage、memory、calldata 学习目标 理解 EVM 中三种数据存储位置的特点,以及引用类型在不同存储位置之间赋值时的拷贝规则。 前置知识 已学习值类型和引用类型(数组、结构体、映射、字符串)。 三种存储位置 storage —— 持久化存储 类似数据库,数据永久保存在区块链上 成员变量(状态变量)默认存储在 stora…

简体中文 繁体中文 English