Skip to content

feat(lint): detect external calls in loops#14778

Open
figtracer wants to merge 5 commits into
foundry-rs:masterfrom
figtracer:feat/lint-unprotected-selfdestruct
Open

feat(lint): detect external calls in loops#14778
figtracer wants to merge 5 commits into
foundry-rs:masterfrom
figtracer:feat/lint-unprotected-selfdestruct

Conversation

@figtracer
Copy link
Copy Markdown
Collaborator

@figtracer figtracer commented May 15, 2026

Summary

  • add the low-severity calls-loop lint
  • detect external calls reachable from loops, including low-level calls, ETH send/transfer, contract/interface calls, this.foo(), and internal helpers called from a loop
  • avoid flagging library-style member calls on non-contract values
  • add lint documentation and UI coverage for flagged and ignored cases

@figtracer figtracer force-pushed the feat/lint-unprotected-selfdestruct branch from ad3d193 to d1394c2 Compare May 15, 2026 14:26
@figtracer figtracer changed the title feat(lint): detect unprotected selfdestruct feat(lint): detect permit signature collisions May 15, 2026
@figtracer figtracer force-pushed the feat/lint-unprotected-selfdestruct branch from d1394c2 to fa25ecd Compare May 15, 2026 14:41
@figtracer figtracer changed the title feat(lint): detect permit signature collisions feat(lint): detect external calls in loops May 15, 2026
Comment thread crates/lint/src/sol/low/calls_loop.rs Outdated
res.as_variable().is_some_and(|var_id| {
matches!(
hir.variable(var_id).ty.kind,
TypeKind::Elementary(ty) if ty.to_abi_str() == "address"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ElementaryType::Address(_) pattern match is cleaner imo than to_abi_str() == "address"

)
}

fn is_contract_like(hir: &Hir<'_>, expr: &Expr<'_>) -> bool {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems that receivers derived from returned values, mappings, or struct fields are missed: getRecipient().foo(), m[addr].foo(), s.target.foo()

Copy link
Copy Markdown
Member

@mattsse mattsse left a comment

Choose a reason for hiding this comment

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

I found two false-negative cases worth addressing before this lands:

  1. crates/lint/src/sol/low/calls_loop.rs ignores StmtKind::Placeholder, so calls executed through a modifier placeholder inside a loop are missed. For example, modifier twice() { for (...) { _; } } applied to function f() external twice { receiver.ping(0); } executes the external call in a loop, but the modifier pass sees only _ at loop depth > 0 and the function pass sees the call at loop depth 0. The reentrancy lint expands modifier placeholders; this lint likely needs similar handling.

  2. is_internal_callable skips pure internal/private helpers, but pure helpers can still make external pure interface calls. I verified solc 0.8.30 accepts an internal pure helper that calls IReceiver.ping(...) external pure; if that helper is called from a loop, this lint won’t descend into it. This should either descend into pure helpers too or add coverage showing a deliberately narrower behavior.

Copy link
Copy Markdown
Member

@mattsse mattsse left a comment

Choose a reason for hiding this comment

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

I found a couple of remaining edge cases that should be addressed before this lands.

Comment thread crates/lint/src/sol/low/calls_loop.rs Outdated

fn is_contract_like(hir: &Hir<'_>, expr: &Expr<'_>) -> bool {
match &expr.peel_parens().kind {
ExprKind::Call(callee, _, _)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Explicit interface casts still appear to be missed here. is_contract_like only treats cast callees shaped as ExprKind::Type(...), but the existing ERC20 unchecked-transfer matcher handles interface casts as ExprKind::Ident([Res::Item(ItemId::Contract(_))]); IReceiver(target).ping(i) inside a loop would not pass this branch and will be skipped. Can we add that HIR shape plus a regression test? This is in the documented contract/interface-call scope.

Comment thread crates/lint/src/sol/low/calls_loop.rs Outdated
fn is_external_call(hir: &Hir<'_>, callee: &Expr<'_>) -> bool {
let ExprKind::Member(base, member) = &callee.peel_parens().kind else { return false };

if matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low-level call names are emitted solely by member name here. Solidity allows library extension methods named call, delegatecall, or staticcall on non-address values; box.call() in a loop would be reported as an external low-level call even though the docs say library-style member calls on non-contract values are ignored. Please gate these names on an address-like base or add coverage for the intended behavior.

@p5hzehxa

This comment was marked as spam.

@p5hzehxa

This comment was marked as spam.

@p5hzehxa

This comment was marked as spam.

@p5hzehxa

This comment was marked as spam.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants