什么是 call 和 staticcall?合约互相调用时,谁能修改状态

call 和 staticcall 都返回成功标记与原始数据。本文讲清执行上下文、ETH 转账、错误处理、只读约束、返回值和重入风险。

什么是 call 和 staticcall?合约互相调用时,谁能修改状态

合约 A 想问合约 B 一个价格,可以用普通接口函数,也可以把函数名和参数编码成一串 bytes,再执行 staticcall。如果 A 还想转 ETH、调用未知插件或自己处理失败,就可能看到 call。两者都很底层,一行代码背后藏着编码、执行上下文和错误处理。

最简化地说,call 允许目标执行普通外部调用,staticcall 则把整段调用置于静态环境,禁止状态修改。它们都返回“是否成功”和原始返回数据,不会自动替开发者理解业务结果。

普通接口调用先帮你做了什么

当代码写 token.balanceOf(user),编译器依据接口检查参数和返回类型,生成函数选择器与 ABI 编码,并按预期类型解码结果。如果外部调用失败,错误通常会向上传播。这是已知合约交互的首选方式,可读性和类型安全都更好。

低级 call 接受一段原始 bytes

address(target).call(data) 不知道 data 代表哪个函数。开发者可以用 abi.encodeWithSelector、abi.encodeCall 或其他编码方式准备 calldata。执行后得到 (bool success, bytes memory returnData),需要自行检查和解码。

bytes memory data = abi.encodeWithSignature("setValue(uint256)", 42);
(bool ok, bytes memory result) = target.call(data);
require(ok, "external call failed");

函数名拼错、参数类型不匹配、目标回退函数接收了调用,或目标根本没有代码,都可能产生与开发者直觉不同的结果。能编译只说明 bytes 构造合法,不说明业务调用正确。

Solidity 低级 call 返回 success 布尔值和原始 returnData
使用 call 后,编码、目标检查、失败传播和返回值解释都由调用者负责。

call 在谁的上下文里执行

目标代码使用目标合约自己的存储和余额;在目标看来,msg.sender 通常是发起 call 的合约,msg.value 是本次附带的 ETH。调用方的存储不会被目标直接当作自己的存储,但目标可以在执行过程中回调调用方,形成重入路径。

如何随 call 发送 ETH

可以写 target.call{value: amount}(data)。目标必须有匹配的 payable 函数,或由 receive/fallback 接收。调用方要先更新关键记账、检查 success,并考虑接收方拒绝、耗尽 Gas 或回调。发送成功不等于对方按预期业务入账。

success 为 true 不代表业务成功

success 只表示 EVM 调用没有以异常或 revert 结束。目标可能返回 false 表示业务失败,也可能通过 fallback 吞掉未知函数;低级调用一个没有代码的普通地址也可能成功并返回空数据。因此还要检查目标、返回长度,并按明确 ABI 解码业务值。

结果可能含义调用方动作
success=falserevert、异常或 Gas 不足传播或按设计处理错误
true + 有返回数据目标正常返回或返回业务失败值按已知 ABI 解码并验证
true + 空数据无返回值、fallback 或目标无代码依据预期决定是否接受
false + revert data可能包含 Error、Panic 或自定义错误谨慎解析并保留原始原因

为什么必须检查返回值

忽略 success 会让外部操作失败后,本地逻辑继续执行。例如转账失败但订单被标记已付款,或插件初始化失败却记录为已启用。Solidity 编译器会警告未使用的低级调用返回值,但真正的处理策略仍需业务设计。

错误是传播还是吞掉

如果外部操作是整体交易不可缺少的一步,通常应在失败时回退并尽可能传播 revert data。如果它只是可选通知,可以记录事件后继续。但“继续”必须是明确的降级策略,不能因为处理错误麻烦就把 false 忽略。

重要原则:低级调用给你的是控制权,不是安全保证。每多一分灵活,就多一分验证责任。

staticcall 增加了只读约束

Solidity 官方文档说明,staticcall 与 call 类似,但如果被调用过程尝试以任何方式修改状态,就会失败。它同样接收 bytes,并返回 success 与 returnData;可以指定 Gas,但不能像 call 那样附带 value。

bytes memory data = abi.encodeWithSignature("price()" );
(bool ok, bytes memory raw) = oracle.staticcall(data);
require(ok && raw.length == 32, "bad price response");
uint256 price = abi.decode(raw, (uint256));

“禁止修改”会传到更深的调用

静态执行不是只看入口函数有没有写 storage。目标调用的其他合约也处在静态限制中;如果深层逻辑写存储、发事件或执行其他状态变更操作,整个静态调用会异常,外层低级 staticcall 收到 false。读取存储和计算返回值则允许。

Solidity staticcall 在整个调用链中禁止状态修改
staticcall 提供 EVM 级只读边界,但目标身份与返回语义仍需验证。

view 和 staticcall 有什么区别

view 是 Solidity 函数声明,编译器会限制函数源码中的状态修改;外部调用 view 函数时通常使用 STATICCALL。低级 staticcall 则是调用者主动施加的 EVM 执行约束,可用于动态目标。但一个名字叫 view 或 price 的函数并不自动保证返回值真实、及时或未被操纵。

staticcall 也不是“无风险读取”

目标可以返回恶意构造的数据、消耗大量 Gas、故意 revert,或根据调用者和区块环境返回不同结果。调用方仍要限制 Gas、验证返回长度和范围,并考虑预言机操纵。静态限制只阻止状态写入,不证明数据正确。

还要警惕只读重入

staticcall 本身不能改状态,但它可能在另一笔正在执行的复杂调用中读取到尚未完成更新的中间状态。如果协议把这个暂时不一致的价格或份额暴露给其他合约,就可能造成所谓只读重入风险。检查顺序、锁和外部可观察值仍然重要。

call 和 delegatecall 不要混淆

call 在目标自己的存储中运行;delegatecall 借用目标代码,却使用调用方的存储、余额和上下文,因此是代理模式与许多严重存储风险的来源。仅仅想调用另一个合约功能时,不应把 delegatecall 当作更省事的 call。

什么时候确实需要低级调用

  • 目标接口在运行时动态确定,无法提前声明完整类型。
  • 需要转发任意 calldata,例如代理、路由器或插件系统。
  • 要精确处理 revert data、Gas 或兼容非标准返回值。
  • 需要向地址发送 ETH 并处理 receive/fallback。
  • 要在动态读取场景中施加 staticcall 只读边界。

什么时候不该用

若目标和函数在编译时已知,优先声明 Interface 并做普通类型调用。为了少写几行接口而使用字符串编码,会失去拼写、参数和返回类型检查,也让审计者更难追踪真实调用。通用框架需要灵活性,普通业务合约往往不需要。

低级调用审查清单

  • 为什么不能使用明确 Interface 的高层调用?
  • 目标地址来自哪里,是否允许用户任意控制?
  • calldata 是否使用安全编码并经过测试?
  • success、空返回和业务 false 是否分别处理?
  • 解码前是否检查返回长度和类型范围?
  • 外部调用前是否完成关键状态更新并防重入?
  • Gas 限制、恶意返回数据和拒绝服务是否考虑?
  • staticcall 的深层调用是否可能尝试写状态?

官方资料

call、delegatecall 与 staticcall 的参数、返回值和警告见 Solidity:Members of Addresses;外部调用交出控制权后的重入风险见 Solidity:Security Considerations。

call 让合约以原始数据发起一次可修改状态的外部执行,staticcall 在相似通道上增加只读限制。真正的安全边界还包括目标身份、编码、返回值、重入和失败策略;少检查其中任何一项,灵活性都可能变成漏洞入口。


本文为链上指南原创科普内容,不构成任何投资建议。低级调用会扩大合约攻击面,开发与交互前请核对验证源码、目标地址、代理实现、测试结果和独立审计。