Solidity 中的 call 调用

call 是一种底层函数调用方式,用于与其他合约进行交互, 分为 3 种:

  • call
  • staticcall
  • delegatecall

call

可以用来调用另一个合约的函数,或直接发送以太币, 它的语法如下:

(bool success, bytes memory returnData) = address(target).call{value: msg.value, gas: gasAmount}(data);

使用示例

  1. 调用其他合约函数

pragma solidity ^0.8.0;

// 目标合约
contract TargetContract {
    uint256 public value;

    function setValue(uint256 _value) external payable {
        value = _value;
    }
}

// 调用方合约
contract CallerContract {
    function callSetValue(address target, uint256 _value) public returns (bool) {
        // abi.encodeWithSignature("setValue(uint256)", _value) 会生成目标合约的函数签名和参数数据。
        // target.call(...) 会调用 TargetContract 中的 setValue 函数
        (bool success, ) = target.call(abi.encodeWithSignature("setValue(uint256)", _value));
        return success;
    }
}

// 在某些情况下, 目标合约的 ABI 可能是未知的
// 用户可以动态传入 data(即函数签名和参数的 ABI 编码), 这样就可以调用目标合约的任意函数,而不需要明确的接口定义
contract CallerContract {
    function callUnknownFunction(address target, bytes memory data) public returns (bool, bytes memory) {
        (bool success, bytes memory returnData) = target.call(data);
        return (success, returnData);
    }
}
  1. 发送以太币
pragma solidity ^0.8.0;

// 接收方合约
contract TargetContract {
    uint256 public balance;

    receive() external payable {
        balance += msg.value;
    }
}

// 调用方合约
pragma solidity ^0.8.0;

contract CallerContract {
    function sendEther(address payable target) public payable returns (bool) {
        // target.call{value: msg.value}("") 会向目标合约发送以太币。
        // msg.value 是通过交易传入的以太币数量
        (bool success, ) = target.call{value: msg.value}("");
        return success;
    }
}

底层实现

call 的 EVM 指令流程如下:

1. 初始化调用:

  • 执行 CALL 指令。
  • 将调用的参数(包括 to 地址、value、gas、calldata 等)压入 EVM 的堆栈。
  • 创建新的执行上下文,分配给目标合约

2. 切换上下文:

  • 当前 EVM 上下文切换到目标合约。
  • 使用目标合约的代码和存储。
  • 在执行目标合约逻辑时,msg.sender 和 msg.value 保持调用合约传递的值。

3. 完成调用:

  • 执行完成后,返回执行结果和返回数据。
  • 如果调用失败,调用者合约需要手动处理错误(success = false)

注意事项

  1. call 不会抛出异常,即使调用失败也不会回滚。因此,调用者需要检查返回的 success 值,并根据需要手动处理错误
(bool success, bytes memory data) = target.call(data);
require(success, "Low-level call failed");
  1. Reentrancy 攻击 由于 call 是低级调用,当目标合约执行其逻辑时,可能会调用当前合约的代码,导致 reentrancy(重入攻击), 建议使用 checks-effects-interactions 模式,先修改状态,再执行外部调用,或者使用 Solidity 0.8.0+ 的 reentrancy guard

  2. Gas 限制 call 提供了一个 gas 限制参数。如果不指定,目标合约继承调用者剩余的 Gas。如果目标合约消耗了太多的 Gas,可能导致调用失败


staticcall

(bool success, bytes memory returnData) = target.staticcall(data);

静态调用,用于执行只读操作,调用的目标合约代码不能修改状态,也不能发送以太币

底层实现

staticcall 的 EVM 指令流程如下:

1. 初始化调用:

  • 执行 STATICCALL 指令。
  • 将调用参数(目标地址、calldata 等)压入 EVM 堆栈。
  • 初始化只读上下文,目标合约无法更改状态

2. 执行静态逻辑:

  • 当前 EVM 上下文切换到目标合约。
  • 执行的代码仅允许查询变量或返回数据。

3. 完成调用:

  • 执行完成后,返回调用结果和返回数据。
  • 如果调用尝试修改状态,staticcall 会直接失败

注意事项

  1. 状态修改限制:staticcall 不能调用会修改状态的函数,否则调用会失败。
  2. staticcall 的 Gas 消耗通常较少,因为它只执行只读操作

delegatecall

(bool success, bytes memory returnData) = target.delegatecall(data);
  • call 类似, 区别在于一个合约对目标智能合约进行 delegatecall 时,会在自己的环境中执行目标合约的逻辑 (相当于把目标合约的代码复制到当前合约中执行)
  • delegatecall 是可升级合约架构(如 透明代理模式 和 UUPS 模式)的核心

底层实现

delegatecall 的 EVM 指令流程如下:

1. 初始化调用:

  • 执行 DELEGATECALL 指令。
  • 将调用参数(目标地址、gas、calldata 等)压入 EVM 堆栈。
  • 不发送 value,只传递执行逻辑。
  • 分配执行上下文,但使用调用合约的存储

2. 切换逻辑上下文:

  • 当前 EVM 上下文切换到目标合约的代码逻辑。
  • 所有的存储变量、msg.sender 和 msg.value 都保持为调用者合约的值。

3. 完成调用:

  • 执行完成后,返回调用结果和返回数据。
  • 如果调用失败,success 为 false

注意事项

  1. 存储与上下文继承:目标合约的存储变量不会被修改,所有存储操作都应用在调用合约(当前合约)中。
  2. 如果目标合约的存储布局与代理合约不匹配,可能导致意外的存储冲突。
  3. 安全性:delegatecall 执行目标合约逻辑,但使用调用者的上下文。如果目标合约中有恶意代码,可能导致调用者的存储被破坏

总结

特性 call delegatecall staticcall
调用上下文 使用目标合约的存储和上下文 使用调用者合约的存储和上下文 使用目标合约的存储和上下文
状态修改 可以修改 可以修改 不允许(只读操作)
Gas 消耗 依赖目标合约代码 依赖目标合约代码 较低,只执行只读逻辑
用途 调用函数、发送以太币 实现可升级合约,调用共享逻辑 安全查询、静态调用
返回值 成功布尔值和返回数据 成功布尔值和返回数据 成功布尔值和返回数据
风险 易受重入攻击 如果存储布局不匹配,可能引发存储冲突 安全,无状态修改

注: 从底层实现来看, 三种方式分别对应三个不同的 EVM 指令,其中最主要的区别也是在初始化上下文:

  • call 是创建新的执行上下文,分配给目标合约
  • staticcall 是初始化只读上下文,目标合约无法更改状态
  • delegatecall 是分配执行上下文,但使用调用合约自己本身的存储