Foundry 静态检查规则 unchecked-call:杜绝被忽略的低层调用返回值
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
低层调用(low-level call)是 Solidity 开发中最危险的模式之一:被调用方失败时调用不会 revert,而是静默返回false。Foundry 内置的forge lint静态检查器通过unchecked-call(High 严重级别)规则,在编译期定位所有丢弃了成功标志的低层调用,帮助开发者在部署前拦截这类高危隐患。本文以 crates/lint/docs/unchecked-call.md 为主线,结合其源码实现(crates/lint/src/sol/high/unchecked_calls.rs)与测试用例(crates/lint/testdata/UncheckedCall.sol),讲清规则判定边界、运行方式与修复方案。
规则概览:ID、严重级别与检测目标
unchecked-call是 Foundry 内置 Solidity 检查器forge lint提供的一条 High 级别规则,其元信息定义如下:
| 属性 | 值 |
|---|---|
| 规则 ID | unchecked-call |
| 严重级别(Severity) | High |
| 检测目标 | 低层调用返回的bool成功标志被丢弃 |
| 告警消息 | low-level call does not check the success return value |
在源码中,该规则通过declare_forge_lint!宏注册,见 crates/lint/src/sol/high/unchecked_calls.rs:
declare_forge_lint!( UNCHECKED_CALL, Severity::High, "unchecked-call", "low-level call does not check the success return value" );它与erc20-unchecked-transfer(同样为 High 级别的 ERC20 返回值检查规则)一同在 crates/lint/src/sol/high/mod.rs 中注册:
unchecked_calls: (UncheckedCall, early, (UNCHECKED_CALL)), (UncheckedTransferERC20, late, (ERC20_UNCHECKED_TRANSFER));其中UncheckedCall注册为EarlyLintPass(在 HIR 之前基于 AST 直接判定),而UncheckedTransferERC20是 LateLintPass,两者侧重点不同:前者只管低层调用的成功标志,后者负责 ERC20transfer/transferFrom的返回值。
规则触发条件(What it does)
规则在以下两种情况下告警:
- 低层调用的返回值整体被丢弃——调用以独立表达式语句形式出现,例如
target.call(data); - 只解构保留了
bytes memory返回数据——例如(, bytes memory ret) = target.call(data);,只取第二个元素而放弃第一个bool success
这里的"低层调用"特指 Solidity 的三种 address 成员方法:
addr.call(...)addr.delegatecall(...)addr.staticcall(...)
均支持{value: x}/{gas: g}选项形式,判定函数位于 crates/lint/src/sol/analysis/exprs.rs:
/// AST-level: `target.call(...)`, `.delegatecall(...)`, `.staticcall(...)`, with or without /// `{value: x}` options. pub const fn is_low_level_call(expr: &ast::Expr<'_>) -> bool { if let ast::ExprKind::Call(call_expr, _) = &expr.kind { let callee = match &call_expr.kind { ast::ExprKind::CallOptions(inner, _) => inner, _ => call_expr, }; if let ast::ExprKind::Member(_, member) = &callee.kind { return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall); } } false }注意transfer和send不在其列——二者虽然也是低层交互,但失败时会自动 revert(transfer)或返回值本身就是bool(send),不会被此规则误报(详见下文"判定边界")。
为什么危险:静默失败与状态污染
文档明确给出了这条规则存在的原因:
Low-level calls donotrevert when the callee fails; they silently return
false. Ignoring the success flag means a failed call is indistinguishable from a successful one, leading to bugs where state is updated on the assumption that an external interaction succeeded.
与普通的外部函数调用(如接口调用IToken(token).transfer(...))不同,低层调用把错误处理完全交给调用方:被调用合约 revert、目标地址没有代码、gas 耗尽等任何失败情形,都只表现为返回(false, bytes memory),不会向上传播异常。
因此,一旦忽略这个bool,程序就无法区分"调用成功"与"调用失败",常见的连锁后果包括:
- 在
call之后无条件更新状态变量,把"从未发生的外部操作"当作"已生效"记账,造成账实不符; - 依赖回调结果(如
delegatecall写入的存储、staticcall读取的返回值)继续执行后续逻辑,基于错误数据做决策; - 资金转移场景下,ETH 没有真正转出,但代码继续执行"转账后"的收尾逻辑。
一句话概括:丢弃成功标志 = 对失败完全无感。这正是unchecked-call被定为 High 严重级别的原因。
违规示例与推荐修复
原文档给出的违规写法:
target.call(data); // success ignored (, bytes memory ret) = target.call(data); // only payload kept第一行完全忽略了返回值;第二行虽然解构了元组,但只保留了bytes载荷,真正关键的成功标志bool被,直接跳过。
推荐的修复方式是把成功标志显式解构出来并强制检查:
(bool ok, ) = target.call(data); require(ok, "call failed");进一步地,还可以利用 Solidity 的require短路特性把检查写得更紧凑,并配合自定义错误节省 gas:
(bool ok, bytes memory ret) = target.call{value: amount}(data); if (!ok) revert CallFailed(); // 之后才可安全使用 ret 或更新状态修复的核心原则是:在使用调用结果(返回值、回调副作用、后续状态写入)之前,必须让bool success参与一次显式检查(require、assert、if (!ok) revert或直接return success)。
判定边界:什么会报、什么不会报
规则的实现是严格的 AST 模式匹配。从 crates/lint/src/sol/high/unchecked_calls.rs 可以精确还原判定逻辑:
impl<'ast> EarlyLintPass<'ast> for UncheckedCall { fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { let span = match &stmt.kind { // `target.call(data);` 和 `(, existingVar) = target.call(data);` StmtKind::Expr(expr) if is_low_level_call(expr) || matches!(&expr.kind, ExprKind::Assign(lhs, _, rhs) if is_low_level_call(rhs) && matches!(&lhs.kind, ExprKind::Tuple(elements) if elements.first().is_none_or(|e| e.is_none()))) => { expr.span } // `(, bytes memory data) = target.call(data);` StmtKind::DeclMulti(vars, expr) if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) => { stmt.span } _ => return, }; ctx.emit(&UNCHECKED_CALL, span); } }据此可以总结出完整的判定边界:
| 代码形态 | 是否告警 | 原因 |
|---|---|---|
target.call(data); | ✅ 告警 | 表达式语句,返回值整体丢弃 |
target.call{value: v}(""); | ✅ 告警 | 带{value}选项同样匹配is_low_level_call |
target.delegatecall(data); | ✅ 告警 | 同属低层调用集合 |
target.staticcall(data); | ✅ 告警 | 同属低层调用集合 |
(, bytes memory d) = target.call(""); | ✅ 告警 | DeclMulti首元素为None,只保留载荷 |
(, existingVar) = target.call(""); | ✅ 告警 | 赋值到既有变量的元组,首元素同样被丢弃 |
(bool ok, ) = target.call(data); | ❌ 不告警 | 成功标志被解构,且后续被检查 |
(bool ok, ) = target.call(data); require(ok, ...); | ❌ 不告警 | require/assert/if (!ok) revert均为合法检查 |
target.transfer(1 ether);/target.send(1 ether); | ❌ 不告警 | transfer自动 revert、send返回bool,二者都不是"静默失败"的低层调用 |
(bool ok, bytes memory d) = target.call(data);随后return d;(未用 ok) | 不告警 | 规则只判定"成功标志是否被解构出来",不做数据流分析——解构但未使用不会报 |
最后一行是需要开发者注意的边界:该规则是语法级(Early/基于 AST)而非数据流级检查,它只保证bool被解构出来,不追踪它后续是否真的参与了分支判断。这是它的设计取舍,也意味着完全依赖单条规则并不能覆盖所有"解构了却不检查"的情况,建议与代码评审、forge test的测试覆盖配合使用。
测试用例 crates/lint/testdata/UncheckedCall.sol 完整覆盖了上述边界:checkedCallWithTuple、checkedCallWithIfStatement、checkedDelegateCall、checkedStaticCall、checkedCallInRequire、checkedCallWithAssert、checkWithExistingVar、sendEther全部标记为 SHOULD PASS,而 8 个 SHOULD FAIL 用例(含uncheckedCallWithValue、multipleUncheckedCalls、ignoredReturnWithPartialTuple、ignoredReturnExistingVar)分别对应//~WARN:断言,期望输出见 crates/lint/testdata/UncheckedCall.stderr。
告警输出格式
命中规则时,forge lint输出如下(摘自测试基线 crates/lint/testdata/UncheckedCall.stderr):
warning[unchecked-call]: low-level call does not check the success return value ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC │ LL │ target.call(data); │ ━━━━━━━━━━━━━━━━━ │ ╰ help: https://getfoundry.sh/forge/linting/unchecked-call输出包含规则 ID、告警消息、精确到列的位置信息与源代码行高亮。该规则属于Severity::High,对应终端输出为红色加粗(Severity::color实现见 crates/config/src/lint.rs),方便在大量告警中快速定位高危项。
在项目中启用与运行
unchecked-call属于默认启用的 High 级别规则,无需任何额外配置即可工作。项目默认的 lint 配置(见 crates/config/src/lint.rs)为:
lint_on_build: true:构建时自动执行 lint(可通过配置关闭)severity: [High, Med, Low]:默认启用高、中、低三个级别,unchecked-call处于 High,天然在列exclude_lints: []:不排除任何规则
因此常规的forge build就会报告unchecked-call告警;也可以使用独立命令针对性检查:
# 只运行 unchecked-call 单条规则 forge lint --only-lint unchecked-call # 指定文件/目录(覆盖 ignore 配置) forge lint src/Contract.sol # 按严重级别过滤 forge lint --severity high # 输出 rustc 兼容的 JSON 格式(配合 CI 解析) forge lint --format-json--only-lint与--severity的优先级逻辑见 crates/forge/src/cmd/lint.rs:显式传入--only-lint时会绕过严重级别过滤,只运行指定的规则 ID。另外需要注意,lint 依赖 Solar 编译器,仅支持 Solidity >= 0.8.0的源码(见 crates/forge/src/cmd/lint.rs)。
foundry.toml 配置
在foundry.toml的[lint]段中可以持久化配置:
[lint] # 只运行高、中、低三个级别(默认值) severity = ["high", "med", "low"] # 排除指定规则(按 ID),例如想保留 unchecked-call 而排除其他 exclude_lints = ["mixed-case-function"] # 忽略的 glob 路径 ignore = ["test/**", "script/**"] # 构建时是否自动 lint,默认 true lint_on_build = trueLinterConfig的完整字段定义见 crates/config/src/lint.rs。此外,根据 crates/lint/README.md,位于 test 与 script 目录下的文件默认会被排除在所有 lint 之外(unsafe-cheatcode与environment-read-across-mutation两条除外),即测试辅助代码中的低层调用不会触发本规则,生产源码则始终接受检查。
行内抑制
对于确认为有意为之的调用,可以在语句前添加行内抑制注释(该语法同样适用于所有 lint 规则):
// forge-lint: disable-next-line(unchecked-call) (bool ok, ) = target.call(data); // ok 在本函数末尾统一校验如果某条抑制注释没有实际命中任何告警,可配合--report-unused-suppressions将其暴露出来:
forge lint --report-unused-suppressions使用说明见 crates/forge/src/cmd/lint.rs。
实现要点小结
- 规则定位:
unchecked-call是forge lint内置的 High 级别静态检查规则,ID 为unchecked-call,注册为 EarlyLintPass,基于 AST 语法匹配,见 crates/lint/src/sol/high/unchecked_calls.rs。 - 覆盖范围:
call、delegatecall、staticcall三种低层调用(含{value}/{gas}选项),覆盖"独立表达式丢弃返回值"与"元组解构只取bytes"两类形态。 - 修复范式:
(bool ok, ) = target.call(data);后立即require(ok)/assert(ok)/if (!ok) revert,或直接return ok。 - 验证途径:仓库测试基线 crates/lint/testdata/UncheckedCall.sol 与 crates/lint/testdata/UncheckedCall.stderr 给出了 8 个命中与 8 个放行用例,是理解判定边界的最佳参照。
将unchecked-call纳入日常开发与 CI 流水线,能以接近零成本的方式消除一类极易被忽视却后果严重的合约缺陷——任何低层调用,都应该给失败一个"说得出口"的去处。
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考