Solidity 底层 call 详解:与接口调用的区别
目录
- 一、两种合约间调用方式
- 二、核心区别:失败如何处理
- 三、调用不存在的地址
- 四、返回值结构
- 五、异常的冒泡规则
- 六、call / staticcall / delegatecall 全景
- 七、发送 ETH 与 Gas 转发
- 八、总结
- 九、动手练习项目:通用调用转发器 CallForwarder
一、两种合约间调用方式
Solidity 里 A 合约调用 B 合约有两条路:
- 高层调用(接口方式):
B(addr).foo()——通过接口或合约类型; - 底层调用(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 = true、data = 空——你以为调用成功了,其实啥也没执行。这在调用可能不存在的合约(如刚被自毁的合约)时极其危险。
四、返回值结构
底层 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检查是否空地址,空则 revertNoCode()——亲手补上底层 call 缺失的 EXTCODESIZE 检查。
测试要求(Foundry)
test_ForwardSuccess:forward 调用bump(),success 为 true,解码返回值正确;test_ForwardFailure_NoAutoRevert:forward 调用boom(),success 为 false 但交易不回滚,证明底层 call 不自动 revert;test_ForwardOrRevert_BubblesReason:forwardOrRevert 调用 boom,断言 revert 且 reason 是 “boom”;test_EmptyAddressTrap:forward 调用一个 EOA/空地址,断言 success == true、data 为空——亲眼验证空地址陷阱;test_SafeCall_RevertsOnEmpty:safeCall 同一空地址,断言 revertNoCode;test_ForwardWithValue:转 1 ether 调 deposit,目标合约余额增加。
Sepolia 部署与验证步骤
- 部署 Target 和 CallForwarder;
- 用 cast 构造
bump()的 calldata(cast calldata "bump()"),调forward; - 故意传一个随机空地址给
forward,观察返回 success=true(陷阱),再用safeCall观察 revert; - 转一点 Sepolia ETH 调
forwardWithValue给 Target.deposit。
进阶挑战(可选)
- 在 forwardOrRevert 里用 assembly
returndatacopy+revert(ptr, size)把被调方的原始 revert 数据完整冒泡(包括自定义错误); - 写注释回答:如果用 staticcall 转发,调用一个会修改状态的函数会怎样?为什么?