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

Conversation

0xrusowsky
Copy link
Contributor

@0xrusowsky 0xrusowsky commented Jun 19, 2025

Motivation

  • Continue expanding the supported lints supported by forge-lint
  • Inspired by this ghostty PR and the prompts that Mitchell Hashimoto shared, we decided to showcase how to leverage LLMs and agentic —tools such as claude code— in our codebase, to encourage security researchers with limited rust knowledge to contribute.

Solution

Add 2 new lints to warn about unchecked:

  • low-level calls
  • ERC20 transfers

Setup:

The development of this PR was done by running Claude code and giving it access to both solar (so that it could check the AST impls when necessary) and foundry:

claude/
├── solar/
└── foundry/

IMPORTANT: link @foundry/docs/dev/lintrules.md in the prompts, so that claude is aware of the implementation and testing guidelines for forge-lint

Used Prompts:

Each individual prompt is separated by a blank line, to showcase the conversation with Claude code.

i want to extend forge-lint by adding support for new lints, as so far only the lints in @foundry/crates/lint/src/sol/ are supported.
let's implement a new lint for unchecked-transfer-erc20 which should validate whether ERC20 calls to transfer(address to, uint256 amount) and transferFrom(address from, address to, uint256 amount) check the return value. to do so, i think that the lint pass should use fn check_item_function to check all contract fns and flag calls that don't have its output assigned to a variable.
to understand how to add new lints, you can check the implementation guidelines defined in @foundry/docs/dev/lintrules.md.
for AST-related context, you can check @solar/crates/solar/ast/src/ast.

at this point we had a working lint! but it was quite ugly, so i made a follow-up prompt to improve it:

that's great! fn visit_stmt is too complex, you don't manually need to handle all cases. Instead
if the stmt is not an expression, simply do self.walk_stmt(stmt).
also, we don't need to cache whether we find unchecked calls or not.
finally can we improve the docs of @foundry/crates/lint/src/sol/high/unchecked_transfer_erc20.rs so that people can easily follow its implementation? don't make them excessively verbose, but expand the current docs

here we had a working lint for unchecked ERC20 transfers!
however, i wanted to push it a little bit more and also asked for a new lint that would do the same for low-level calls

excellent. can we add a similar lint rule that ensures that low-level calls check the success of the call?

it correctly implemented the logic to identify low-level calls.
although, as expected, it only checked for calls without variable assignments. Since low-level calls return a tuple, it is also necessary to handle the case where users do something with the returned bytes but not with the success boolean.

UncheckedCall is almost there, however, we need to ensure that the user at least uses (bool success, ) = target.call(""). note that checks should work regardless of the var names, and independently if the second var is used or not.

at this point we had a working implementation, which i tweaked a little bit manually. Finally i asked it to refactor the code and merge everything into a single file to keep "unchecked call" lints together:

great. let's refactor the code into a single file unchecked_calls.rs that contains different lints (UNCHECKED_CALL, UNCHECKED_TRANSFER_ERC20, etc).

finally i did a style-based refactor for code readability and consistency (and also had to run clippy and fmt)

@grandizzy grandizzy marked this pull request as ready for review June 19, 2025 12:27
@0xrusowsky 0xrusowsky changed the title feat(forge-lint): unchecked calls feat(forge-lint): [claude] unchecked calls Jun 19, 2025
Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

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

nice, I think that's really great. Left couple of comments, pls check

}
}

/// 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

Use cleaner pattern matching and `solar_interface::kw` keywords instead of
string literals. Move function signatures to doc comments.
@0xrusowsky
Copy link
Contributor Author

prompt to address @grandizzy's feedback:

can you incorporate the suggestions in the PR review [feat(forge-lint): [claude] unchecked calls](https://github.com/foundry-rs/foundry/pull/10810). I want you to address all the raised
comments except the one regarding potential false positives (this one we will ignore for now, as it
is more complex).
note the you can use the github CLI via gh

write a short commit msg

yash-atreya
yash-atreya previously approved these changes Jun 27, 2025
Copy link
Member

@yash-atreya yash-atreya left a comment

Choose a reason for hiding this comment

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

lgtm!

Re: false positive #10810 (comment)

We can live with it for now and address it in a followup.

DaniPopes
DaniPopes previously approved these changes Jun 27, 2025
@DaniPopes DaniPopes enabled auto-merge (squash) June 27, 2025 12:57
@0xrusowsky 0xrusowsky dismissed stale reviews from yash-atreya and DaniPopes via 4f4d770 June 27, 2025 13:36
@0xrusowsky 0xrusowsky requested a review from grandizzy June 27, 2025 13:36
@0xrusowsky 0xrusowsky dismissed grandizzy’s stale review June 27, 2025 13:38

approved by Yash and Dani (pending to be improved)
users can now disable false positives thanks to inline config

@DaniPopes
Copy link
Member

note that the default commit description when merging prs here is the aggregate of all the commits, so you should add the prompts to the commits themselves

@0xrusowsky
Copy link
Contributor Author

note that the default commit description when merging prs here is the aggregate of all the commits, so you should add the prompts to the commits themselves

can i manually squash and use the PR description as the summary description of the squashed commit? shouldn't that work?

@DaniPopes
Copy link
Member

sure, just noting that the prompts would not be currently included had i not overridden the message by pasting the description in

@0xrusowsky
Copy link
Contributor Author

sure, just noting that the prompts would not be currently included had i not overridden the message by pasting the description in

makes sense, thanks for the heads up

@0xrusowsky 0xrusowsky disabled auto-merge June 27, 2025 15:36
Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

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

lgtm

@0xrusowsky 0xrusowsky merged commit 39898f4 into master Jun 27, 2025
42 of 44 checks passed
@0xrusowsky 0xrusowsky deleted the lint/unchecked-calls branch June 27, 2025 16:35
@github-project-automation github-project-automation bot moved this to Done in Foundry Jun 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

4 participants