Skip to content

feat(forge-lint): [claude] unchecked calls #10810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/lint/src/sol/high/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
use crate::sol::{EarlyLintPass, SolLint};

mod incorrect_shift;
mod unchecked_calls;

use incorrect_shift::INCORRECT_SHIFT;
use unchecked_calls::{ERC20_UNCHECKED_TRANSFER, UNCHECKED_CALL};

register_lints!((IncorrectShift, (INCORRECT_SHIFT)));
register_lints!(
(IncorrectShift, (INCORRECT_SHIFT)),
(UncheckedCall, (UNCHECKED_CALL)),
(UncheckedTransferERC20, (ERC20_UNCHECKED_TRANSFER))
);
159 changes: 159 additions & 0 deletions crates/lint/src/sol/high/unchecked_calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use super::{UncheckedCall, UncheckedTransferERC20};
use crate::{
linter::{EarlyLintPass, LintContext},
sol::{Severity, SolLint},
};
use solar_ast::{visit::Visit, Expr, ExprKind, ItemFunction, Stmt, StmtKind};
use solar_interface::kw;
use std::ops::ControlFlow;

declare_forge_lint!(
UNCHECKED_CALL,
Severity::High,
"unchecked-call",
"Low-level calls should check the success return value"
);

declare_forge_lint!(
ERC20_UNCHECKED_TRANSFER,
Severity::High,
"erc20-unchecked-transfer",
"ERC20 'transfer' and 'transferFrom' calls should check the return value"
);

// -- ERC20 UNCKECKED TRANSFERS -------------------------------------------------------------------

/// WARN: can issue false positives. It does not check that the contract being called is an ERC20.
impl<'ast> EarlyLintPass<'ast> for UncheckedTransferERC20 {
fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) {
if let Some(body) = &func.body {
let mut checker = UncheckedTransferERC20Checker { ctx };
let _ = checker.visit_block(body);
}
}
}

/// Visitor that detects unchecked ERC20 transfer calls within function bodies.
///
/// Unchecked transfers appear as standalone expression statements.
/// When a transfer's return value is used (in require, assignment, etc.), it's part
/// of a larger expression and won't be flagged.
struct UncheckedTransferERC20Checker<'a, 's> {
ctx: &'a LintContext<'s>,
}

impl<'ast> Visit<'ast> for UncheckedTransferERC20Checker<'_, '_> {
type BreakValue = ();

fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
// Only expression statements can contain unchecked transfers.
if let StmtKind::Expr(expr) = &stmt.kind {
if is_erc20_transfer_call(expr) {
self.ctx.emit(&ERC20_UNCHECKED_TRANSFER, expr.span);
}
}
self.walk_stmt(stmt)
}
}

/// Checks if an expression is an ERC20 `transfer` or `transferFrom` call.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I suggest moving function names in fn description and simplify check from match to bellow

/// Checks if an expression is an ERC20 `transfer` or `transferFrom` call.
/// `function ERC20.transfer(to, amount)`
//  `function ERC20.transferFrom(from, to, amount)`
///
/// Validates both the method name and argument count to avoid false positives
/// from other functions that happen to be named "transfer".
fn is_erc20_transfer_call(expr: &Expr<'_>) -> bool {
    if let ExprKind::Call(call_expr, args) = &expr.kind {
        // Must be a member access pattern: `token.transfer(...)`
        if let ExprKind::Member(_, member) = &call_expr.kind {
            let method_name = member.as_str();
            return (args.len() == 2 && method_name == "transfer") ||
                (args.len() == 3 && method_name == "transferFrom")
        }
    }
    false
}

Copy link
Collaborator

@grandizzy grandizzy Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, couldn't this give false warnings in case of a non ERC20 contract (Proxy) with transfer() or transferFrom() fns, e.g.

contract Proxy {
    function transfer(address to, uint256 amount) public {
        require(IERC20(token).transfer(to, amount), "Transfer failed");
    }
}

contract MyContract {
    Proxy public proxy;

    function refill() public {
        // this one fails lint
    	proxy.transfer(address(1), 1);
    }
}

so maybe we should check that fn returns a bool? (even so it can flag false warnings but could be more robust)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm that's true.

this check is more tricky, let me think alternatives do we have

Copy link
Member

@yash-atreya yash-atreya Jun 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xrusowsky I think we can live with this, now that we have #10776. We can add a in docs regarding this false positive and address it in a followup?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is pragmatic short-term solution if we want to move forward and support new lints. also it will keep the PR simple as an example on how to use LLMs effectively.

i think that once we merge #10662 we should be able to use the new fn post_source_unit to figure out if it really is an ERC20 or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

later today i will link a PR with the docs, explaining that this lint can issue false positives

/// `function ERC20.transfer(to, amount)`
/// `function ERC20.transferFrom(from, to, amount)`
///
/// Validates both the method name and argument count to avoid false positives
/// from other functions that happen to be named "transfer".
fn is_erc20_transfer_call(expr: &Expr<'_>) -> bool {
if let ExprKind::Call(call_expr, args) = &expr.kind {
// Must be a member access pattern: `token.transfer(...)`
if let ExprKind::Member(_, member) = &call_expr.kind {
return (args.len() == 2 && member.as_str() == "transfer") ||
(args.len() == 3 && member.as_str() == "transferFrom")
}
}
false
}

// -- UNCKECKED LOW-LEVEL CALLS -------------------------------------------------------------------

impl<'ast> EarlyLintPass<'ast> for UncheckedCall {
fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) {
if let Some(body) = &func.body {
let mut checker = UncheckedCallChecker { ctx };
let _ = checker.visit_block(body);
}
}
}

/// Visitor that detects unchecked low-level calls within function bodies.
///
/// Similar to unchecked transfers, unchecked calls appear as standalone expression
/// statements. When the success value is checked (in require, if, etc.), the call
/// is part of a larger expression and won't be flagged.
struct UncheckedCallChecker<'a, 's> {
ctx: &'a LintContext<'s>,
}

impl<'ast> Visit<'ast> for UncheckedCallChecker<'_, '_> {
type BreakValue = ();

fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
match &stmt.kind {
// Check standalone expression statements: `target.call(data);`
StmtKind::Expr(expr) => {
if is_low_level_call(expr) {
self.ctx.emit(&UNCHECKED_CALL, expr.span);
} else if let ExprKind::Assign(lhs, _, rhs) = &expr.kind {
// Check assignments to existing vars: `(, existingVar) = target.call(data);`
if is_low_level_call(rhs) && is_unchecked_tuple_assignment(lhs) {
self.ctx.emit(&UNCHECKED_CALL, expr.span);
}
}
}
// Check multi-variable declarations: `(bool success, ) = target.call(data);`
StmtKind::DeclMulti(vars, expr) => {
if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) {
self.ctx.emit(&UNCHECKED_CALL, stmt.span);
}
}
_ => {}
}
self.walk_stmt(stmt)
}
}

/// Checks if an expression is a low-level call that should be checked.
///
/// Detects patterns like:
/// - `target.call(...)`
/// - `target.delegatecall(...)`
/// - `target.staticcall(...)`
/// - `target.call{value: x}(...)`
fn is_low_level_call(expr: &Expr<'_>) -> bool {
if let ExprKind::Call(call_expr, _args) = &expr.kind {
// Check the callee expression
let callee = match &call_expr.kind {
// Handle call options like {value: x}
ExprKind::CallOptions(inner_expr, _) => inner_expr,
// Direct call without options
_ => call_expr,
};

if let ExprKind::Member(_, member) = &callee.kind {
// Check for low-level call methods
return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall)
}
}
false
}

/// Checks if a tuple assignment doesn't properly check the success value.
///
/// Returns true if the first variable (success) is None: `(, bytes memory data) =
/// target.call(...)`
fn is_unchecked_tuple_assignment(expr: &Expr<'_>) -> bool {
if let ExprKind::Tuple(elements) = &expr.kind {
elements.first().is_none_or(|e| e.is_none())
} else {
false
}
}
88 changes: 88 additions & 0 deletions crates/lint/testdata/UncheckedCall.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract UncheckedCall {

// SHOULD PASS: Properly checked low-level calls
function checkedCallWithTuple(address target, bytes memory data) public {
(bool success, bytes memory result) = target.call(data);
require(success, "Call failed");
emit CallResult(success, result);
}

function checkedCallWithIfStatement(address target, bytes memory data) public {
(bool success, ) = target.call(data);
if (!success) {
revert("Call failed");
}
}

function checkedDelegateCall(address target, bytes memory data) public returns (bool) {
(bool success, ) = target.delegatecall(data);
return success;
}

function checkedStaticCall(address target, bytes memory data) public view returns (bytes memory) {
(bool success, bytes memory result) = target.staticcall(data);
require(success, "Static call failed");
return result;
}

function checkedCallInRequire(address target) public {
(bool success, ) = target.call("");
require(success, "Call must succeed");
}

function checkedCallWithAssert(address target) public {
(bool success, ) = target.call("");
assert(success);
}

// Edge case: pre-existing variable assignment
bool success;
function checkWithExistingVar(address target) public {
(bool, ) = target.call("");
(bool, existingData) = target.call("");
}

// Edge case: send and transfer are not low-level calls (they automatically revert on failure)
function sendEther(address payable target) public {
target.transfer(1 ether); // Should not trigger
bool sent = target.send(1 ether); // Should not trigger
require(sent, "Send failed");
}


// SHOULD FAIL: Unchecked low-level calls
function uncheckedCall(address target, bytes memory data) public {
target.call(data); //~WARN: Low-level calls should check the success return value
}

function uncheckedCallWithValue(address payable target, uint256 value) public {
target.call{value: value}(""); //~WARN: Low-level calls should check the success return value
}

function uncheckedDelegateCall(address target, bytes memory data) public {
target.delegatecall(data); //~WARN: Low-level calls should check the success return value
}

function uncheckedStaticCall(address target, bytes memory data) public {
target.staticcall(data); //~WARN: Low-level calls should check the success return value
}

function multipleUncheckedCalls(address target1, address target2) public {
target1.call(""); //~WARN: Low-level calls should check the success return value
target2.delegatecall(""); //~WARN: Low-level calls should check the success return value
}

function ignoredReturnWithPartialTuple(address target) public {
(, bytes memory data) = target.call(""); //~WARN: Low-level calls should check the success return value
// Only capturing data, not checking success
}

bytes memory existingData;
function ignoredReturnExistingVar(address target) public {
(, existingData) = target.call(""); //~WARN: Low-level calls should check the success return value
}

}
64 changes: 64 additions & 0 deletions crates/lint/testdata/UncheckedCall.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
58 | target.call(data);
| -----------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
62 | target.call{value: value}("");
| -----------------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
66 | target.delegatecall(data);
| -------------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
70 | target.staticcall(data);
| -----------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
74 | target1.call("");
| ----------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
75 | target2.delegatecall("");
| ------------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
79 | (, bytes memory data) = target.call("");
| ----------------------------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

warning[unchecked-call]: Low-level calls should check the success return value
--> ROOT/testdata/UncheckedCall.sol:LL:CC
|
85 | (, existingData) = target.call("");
| ----------------------------------
|
= help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call

Loading