call 与 delegatecall

一、为什么要用底层调用

我们知道,在一个合约中调用另一个合约的接口,通常使用contractName(address).functionName() 来进行调用,比如:

contract MyContract {
    function add(address _counter) public {
        // 调用 Counter 合约的方法
        Counter(_counter).increment();
    }
}

但是,有时我们在编写合约时,还不知道要调用的目标合约的接口,甚至是目标合约还没有创建。这时就无法用上面的方法进行调用。

这个问题该如何解决呢?

你也许知道很多编程语言(如Java和Go)有反射的概念,反射允许在运行时动态地调用函数或方法。地址的底层调用和反射非常类似。

使用address底层调用功能,是在运行时动态地决定调用目标合约和函数, 因此在编译时,可以不知道具体要调用的函数或方法。

二、底层调用

address类型还有3个底层的成员函数:

<address>.call(bytes memory abiEncodeData) returns (bool, bytes memory)

<address>.delegatecall(bytes memory abiEncodeData) returns (bool, bytes memory)

<address>.staticcall(bytes memory abiEncodeData) returns (bool, bytes memory)
  • 其中,call 是常规调用,delegatecall 为委托调用,staticcall 是静态调用(不修改合约状态, 相当于调用 view 方法)。

这三个函数都可以用于与目标合约<address>交互,三个函数均接受 abi 编码数据作为参数(abiEncodeData)来调用对应的函数。

这里我们使用底层方法调用一下《手把手教你部署智能合约》中的合约:

contract CallTest {
    function makeCallGet(address _counter) public view returns (uint) {
        // staticcall调用
        bytes memory payload = abi.encodeWithSignature("get()");
        (bool success, bytes memory returnData) = address(_counter).staticcall(payload);

        // 判断一下
        require(success, "Call to target contract failed.");

        // 将returnData解析成指定类型(e.g. uint)
        (uint res) = abi.decode(returnData, (uint));
        
        return res;
    }

    function makeCallCount(address _counter) public {
        // call调用
        bytes memory payload = abi.encodeWithSignature("count()");
        (bool success, ) = address(_counter).call(payload);

        // 判断一下
        require(success, "Call to target contract failed.");
    }
}

// https://testnet.routescan.io/address/0xcF10C1b7DA166987a1D9bB81C072C339cb7205fd

使用底层方法调用合约函数时, 当被调用的函数发生异常时(revert),异常不会冒泡到调用者(即不会回退), 而是返回布尔值 false。因此在使用所有这些低级函数时,一定要记得检查返回值

三、call 与 delegatecall

常规调用 call 与 委托调用 delegatecall 的区别是什么呢?

image

  1. 执行上下文:当使用call函数时,被调用的函数在目标合约的上下文中执行,这意味着它有自己的thismsg.sender。而delegatecall函数则在调用合约(当前合约)的上下文中执行被调用的函数。—— 相当于将函数代码 pull 到当前合约中执行

  2. 状态存储call函数在执行时不会改变调用合约的状态,它只会改变被调用合约的状态。而delegatecall函数则可以改变调用合约的状态,因为它在调用合约的上下文中执行。

  3. 用途call函数通常用于调用其他合约的函数,而delegatecall函数允许一个合约借用另一个合约的代码,在自己的上下文中执行,常用于实现可升级合约和库函数。

四、可升级合约的方案和实践

在区块链中,智能合约一旦部署就无法更改。

然而,业务需求、bug修复、功能优化等往往需要我们更新合约的代码逻辑。这种情况下,可升级合约模式应运而生,允许我们在保留合约状态的同时更新合约逻辑。

image

4.1 数据分离模式

数据分离模式就是使用一个 Logic 合约负责实现所有业务逻辑,另一个 Storage 合约存储数据。Logic 合约再通过正常的 call(而非 delegatecall)去调用 Storage 合约中定义的 Setter/Getter 函数 来读写数据。

image

这种模式的设计目标通常是为了简化升级流程,保证数据不丢失,但它也有很多缺点。

  1. 交互地址改变:这种模式并没有解决可升级合约_最核心_的问题——地址持久性。当 Logic 合约需要升级时,你必须部署一个新的 Logic 合约 V2,结果用户的交互地址改变了,你需要更新所有前端、其他依赖合约,并告知用户使用新的 V2 地址。

  2. Gas消耗大:外部 call 调用比 delegatecall 消耗更高的 Gas,并且比直接在 Logic 合约存储状态消耗高得多。这使得用户的交互成本明显增加。

因此,这种模式在主流可升级合约设计中很少被采用。

4.2 代理模式

代理模式是通过在fallback函数中进行delegatecall实现的。

在代理模式中,用户实际上是与代理合约(Proxy)进行交互,数据是存储在代理合约中,然后代理合约通过 delegatecall 调用实际的逻辑合约(实现业务逻辑)。

image

当升级合约时,只需要更新Proxy合约中的 Logic 地址,指向一个新的逻辑合约,而不影响Proxy合约维护的数据,也不须要求用户更新交互地址。

4.2.1 透明代理(Transparent Proxy)

在上述的基础代理模式中,需注意确保只有可信的地址(如合约的拥有者或管理员)才可以更新逻辑合约地址。这就延伸出了透明代理。

在透明代理模式中,它区分了管理员地址和普通用户:

  • 管理员地址:有权限升级合约,但无法与Logic合约交互。

  • 普通用户:可以与Logic合约交互,但无法升级合约。

这种模式防止了普通用户误调用管理功能,从而降低错误风险。

简单透明代理合约例子:

// 透明代理模式
contract TransparentProxy {
    address public logicAddress; // 逻辑合约地址
    address public adminAddress; // 管理员地址
    // 其他状态变量

    constructor(address logic) {
        logicAddress = logic;
        adminAddress = msg.sender;
    }

    // 只能是管理员才能调用以升级合约
    function upgrade(address newLogic) public {
        require(msg.sender == adminAddress, "Only admin can upgrade"); 
        logicAddress = newLogic;
    }

    // 只有普通用户才能调用逻辑合约
    fallback() external payable {
        require(msg.sender != adminAddress, "Admin not allowed"); 
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), logicAddress, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
}

4.2.2 UUPS代理

UUPS(Universal Upgradeable Proxy Standard,通用升级代理标准)模式是透明代理模式的优化,它将upgrade函数不再放在 Proxy 合约中,而是放在 Logic 合约中。

简单 UUPS 代理合约例子:

// 逻辑合约
contract UUPSLogic {
    address public logicAddress; // 防止存储冲突
    address public adminAddress; // 防止存储冲突

    function upgrade(address newLogic) public {
        require(msg.sender == adminAddress, "Only admin can upgrade");
        logicAddress = newLogic;
    }
    
    // 其他function
}

// 代理合约
contract UUPSProxy {
    address public logicAddress; // 逻辑合约地址
    address public adminAddress; // 管理员地址

    constructor(address logic) {
        logicAddress = logic;
        adminAddress = msg.sender;
    }

    fallback() external payable {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), logicAddress, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        } 
    }
}

在UUPS代理模式中,升级函数upgrade()位于逻辑合约中,管理员在需要升级时进行delegatecall,该函数即可更新代理合约中存储的逻辑合同地址。

UUPS代理合约中不再有升级函数,fallback()函数也不需要再检查调用者是否是管理员,可以节省gas费。OpenZeppelin官方建议转向UUPS代理。

4.2.3 钻石代理(Diamond Proxy, EIP-2535)

image

4.2.4 存储冲突

为什么会产生存储冲突(Storage Collision)?

  1. delegatecall 的工作方式:

    1. 执行代码: 执行的是被调用合约(Implementation合约/ Logic合约)的代码。

    2. 使用上下文: 使用的是调用合约(Proxy Contract / 代理合约)的存储空间msg.sendermsg.value 等环境信息。

  2. Solidity 存储机制:

    1. Solidity 合约的状态变量(State Variables)是按顺序存储在合约的存储槽(Storage Slots)中的,从槽位 0 开始,依次是槽位 1、槽位 2...

    2. 一个变量存储在哪个槽位,完全取决于它在合约代码中的声明顺序和类型

  3. 冲突的产生:

    1. 当 Proxy 合约使用 delegatecall 调用 Logic 合约的函数并尝试修改状态时,Logic 合约的代码会按照它自己的变量声明顺序来计算要写入的存储槽位。

    2. 然而,实际写入的却是 Proxy 合约的存储。

    3. 如果 Proxy 合约和 Logic 合约的状态变量声明顺序和类型不完全匹配(即它们的存储布局不一样),那么 Logic 合约本想修改的变量 A,可能会错误地写入到 Proxy 合约的变量 B 所在的存储槽位,导致数据覆盖,这就是存储冲突

示例说明

假设有两个合约:代理合约 (Proxy)逻辑合约 (Logic)

代理合约 (Proxy) 的存储布局:

  • Slot 0: address admin

  • Slot 1: uint256 count

逻辑合约 V1 (Logic V1) 的存储布局:

  • Slot 0: address admin

  • Slot 1: uint256 count

此时,Proxy 和 Logic V1 在 Slot 0 和 Slot 1 的变量类型和顺序都匹配,因此不会有冲突。

逻辑合约 V2 (Logic V2) 的存储布局(升级后):

  • Slot 0: address admin

  • Slot 1: uint256 newFeatureFlag 👈 新增变量

  • Slot 2: uint256 count

当 Proxy 使用 delegatecall 执行 Logic V2 的代码,尝试修改 count (Logic V2 的 Slot 2) 时:

  • Logic V2 的代码认为 count 在 Slot 2。

  • 它会尝试写入 Proxy 的 Slot 2。

  • 但 Proxy 自身只声明了 Slot 0 和 Slot 1,Slot 2 可能是空的,或者更糟的是,如果 Proxy 在 Slot 2 存储了其他重要数据,那么 count 的值就会覆盖这个重要数据。

如何避免存储冲突?

在设计可升级合约(Proxy Pattern)时,避免存储冲突是至关重要的:

  1. 保持存储布局一致: 确保代理合约和所有逻辑合约(V1, V2, V3...)的前导状态变量必须在完全相同的位置。

  2. 使用 __gap 变量: OpenZeppelin 等标准升级方案会在代理合约和逻辑合约的末尾使用一个填充数组(如 uint256[50] __gap;),以确保未来的升级中如果逻辑合约新增了状态变量,它们会填充到这个 __gap 区域,而不会影响到代理合约已有的存储槽位。

  3. 使用非结构化存储(Unstructured Storage): 在代理合约中,将 logicAddress 和 adminAddress 存储在一个极高位使用特定哈希计算的存储槽中,以最大程度地避免与其他逻辑变量发生意外冲突。

最佳实践

EIP-1967 提出了一种标准化的方法来存储关键信息,如 Logic 合约的地址,到固定且已知的存储位置。这主要包括两个方面:

  • 逻辑合约地址(implementation address):存储在 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) 槽位。

  • 管理员地址(admin address):存储在 bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) 槽位

contract ProxyWithoutStorageCollision {
    bytes32 private constant logicContractSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
    bytes32 private constant adminSlot = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);

    constructor(address _logic, address _admin) {
        // 设置逻辑合约槽位的值
        bytes32 slot = logicContractSlot;
        assembly {
            sstore(slot, _logic)
        }
        
        // 设置管理员槽位的值
        slot = adminSlot;
        assembly {
            sstore(slot, _admin)
        }
    }

    fallback() external payable {
        // 取出逻辑合约槽位的值
        bytes32 slot = logicContractSlot;
        address logic;
        assembly {
            logic := sload(slot)
        }
        // delegatecall调用逻辑合约的函数
        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()) }
        }
    }
}
posted @ 2025-11-22 20:10  songlee  阅读(35)  评论(0)    收藏  举报