Solidity 底层 call 详解:与接口调用的区别

理解 .call 的返回值、失败不自动回滚、调用空地址的陷阱与 call/staticcall/delegatecall 区别

6 分钟阅读
Solidity 底层 call 详解:与接口调用的区别

Solidity 底层 call 详解:与接口调用的区别

目录


一、两种合约间调用方式

Solidity 里 A 合约调用 B 合约有两条路:

  1. 高层调用(接口方式)B(addr).foo()——通过接口或合约类型;
  2. 底层调用(call 方式)addr.call(abi.encodeWithSignature("foo()"))

两者底层用的是同一个 EVM CALL 操作码,但编译器为它们生成的字节码不同,对失败的处理也不同。

二、核心区别:失败如何处理

2.1 高层接口调用

通过接口调用时,编译器自动检查 CALL 返回的布尔值。一旦失败(返回 false),编译器自动插入 revert 代码让整笔交易回滚(除非你用 try/catch 包住)。

function callByInterface(address _address) public {
    Called called = Called(_address);
    called.ops();  // 如果 ops() 失败,自动 revert
}

2.2 底层 call 调用

底层 call 返回布尔成功值,但失败时不自动回滚。你必须手动检查并自己决定是否 revert。

function callByCall(address _address) public returns (bool success) {
    (success, ) = _address.call(abi.encodeWithSignature("ops()"));
    // 失败只返回 false,不会自动 revert
}

加上回滚逻辑:

function callByCall(address _address) public returns (bool success) {
    (success, ) = _address.call(abi.encodeWithSignature("ops()"));
    if (!success) {
        revert("Something went wrong");
    }
}

⚠️ 忘记检查 success 是常见安全 bug:调用悄悄失败了,你的合约却以为成功了,继续执行下去。

三、调用不存在的地址

这是底层 call 最隐蔽的陷阱。

高层接口调用:执行 CALL 之前,编译器生成的字节码会用 EXTCODESIZE 检查目标地址有没有合约代码。如果代码大小为 0(没合约),在调用前就 revert

底层 call完全不做任何前置检查,直接执行 CALL。调用一个空地址时,CALL 操作码会成功返回(不触发 REVERT),返回 true 和空的返回数据。

原因:

一次执行会回滚,只有当它遇到 REVERT 操作码、Gas 耗尽、或做了被禁止的事(如除以 0)。而调用空地址时,以上情况一个都不会发生。

所以 addr.call(...) 对空地址返回 success = truedata = 空——你以为调用成功了,其实啥也没执行。这在调用可能不存在的合约(如刚被自毁的合约)时极其危险。

四、返回值结构

底层 call 永远返回一个元组:

(bool success, bytes memory data) = addr.call(payload);
  • success:CALL 是否成功;
  • data:被调函数的返回数据(ABI 编码的字节),用 abi.decode(data, (类型)) 还原。

五、异常的冒泡规则

Solidity 文档原文:

子调用中发生异常会”向上冒泡”(自动重新抛出)。例外是 send 和底层函数 call、delegatecall、staticcall:它们在异常时返回 false 作为第一个返回值,而不是冒泡。

也就是说:普通接口调用失败会自动把异常往上传播(连锁回滚);而底层四件套(send/call/delegatecall/staticcall)失败只是返回 false,要不要回滚由你决定。

六、call / staticcall / delegatecall 全景

操作码谁的存储msg.sender能否改状态典型用途
CALL被调合约的存储变成调用者(本合约)普通跨合约调用、转 ETH
STATICCALL被调合约变成本合约不能(只读)view/pure 调用,防重入
DELEGATECALL调用者自己的存储保持原始 sender代理合约、库

记忆要点:call 改对方的家,delegatecall 让对方改你的家,staticcall 只能看不能动。下一篇 delegatecall 会深入讲第三种。

七、发送 ETH 与 Gas 转发

底层 call 可以同时带 ETH 和数据:

(bool ok, ) = addr.call{value: 1 ether, gas: 50000}(abi.encodeWithSignature("deposit()"));
  • {value: x}:附带发送 x wei;
  • {gas: g}:限制转发的 Gas;
  • 不写 gas 则转发当前剩余 Gas 的 63/64(EIP-150 规定,保留 1/64 给本合约善后)。

转账 ETH 的推荐写法就是用 call{value:}(而非已过时的 transfer/send,它们固定 2300 gas 在某些合约会失败),但务必检查 success 并防重入。

八、总结

  • 接口调用失败自动 revert + 自动 EXTCODESIZE 检查;底层 call 都不做,要你手动;
  • 底层 call 调用空地址返回 true 是最大陷阱;
  • call/delegatecall/staticcall/send 失败返回 false 而非冒泡;
  • 三种 call 的区别在于”用谁的存储、msg.sender 是谁、能否改状态”;
  • 转 ETH 用 call{value:} 并检查返回值 + 防重入。

九、动手练习项目:通用调用转发器 CallForwarder

项目目标

实现一个能转发任意低层调用的合约,亲手处理 success 检查、空地址陷阱、ETH 转发,部署到 Sepolia 体会接口调用与底层 call 的行为差异。

合约要求

1. Target.sol(被调合约)

  • uint256 public counter;
  • bump() external returns (uint256):counter++ 并返回新值;
  • boom() external:直接 revert("boom")
  • deposit() external payable:接收 ETH。

2. CallForwarder.sol

  • forward(address target, bytes calldata data) external returns (bool success, bytes memory ret):执行 target.call(data)自动 revert,原样返回;
  • forwardOrRevert(address target, bytes calldata data) external returns (bytes memory):执行后若 !success 则 revert(用 assembly 把被调方的 revert reason 原样冒泡出来——进阶);
  • forwardWithValue(address target, bytes calldata data) external payable:带 {value: msg.value} 转发,检查 success;
  • safeCall(address target, bytes calldata data):调用前先用 target.code.length == 0 检查是否空地址,空则 revert NoCode()——亲手补上底层 call 缺失的 EXTCODESIZE 检查。

测试要求(Foundry)

  1. test_ForwardSuccess:forward 调用 bump(),success 为 true,解码返回值正确;
  2. test_ForwardFailure_NoAutoRevert:forward 调用 boom(),success 为 false 但交易不回滚,证明底层 call 不自动 revert;
  3. test_ForwardOrRevert_BubblesReason:forwardOrRevert 调用 boom,断言 revert 且 reason 是 “boom”;
  4. test_EmptyAddressTrap:forward 调用一个 EOA/空地址,断言 success == true、data 为空——亲眼验证空地址陷阱;
  5. test_SafeCall_RevertsOnEmpty:safeCall 同一空地址,断言 revert NoCode
  6. test_ForwardWithValue:转 1 ether 调 deposit,目标合约余额增加。

Sepolia 部署与验证步骤

  1. 部署 Target 和 CallForwarder;
  2. 用 cast 构造 bump() 的 calldata(cast calldata "bump()"),调 forward
  3. 故意传一个随机空地址给 forward,观察返回 success=true(陷阱),再用 safeCall 观察 revert;
  4. 转一点 Sepolia ETH 调 forwardWithValue 给 Target.deposit。

进阶挑战(可选)

  • 在 forwardOrRevert 里用 assembly returndatacopy + revert(ptr, size) 把被调方的原始 revert 数据完整冒泡(包括自定义错误);
  • 写注释回答:如果用 staticcall 转发,调用一个会修改状态的函数会怎样?为什么?

💬 评论