forge_lint/sol/high/
unchecked_calls.rs

1use super::{UncheckedCall, UncheckedTransferERC20};
2use crate::{
3    linter::{EarlyLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar_ast::{Expr, ExprKind, ItemFunction, Stmt, StmtKind, visit::Visit};
7use solar_interface::kw;
8use std::ops::ControlFlow;
9
10declare_forge_lint!(
11    UNCHECKED_CALL,
12    Severity::High,
13    "unchecked-call",
14    "Low-level calls should check the success return value"
15);
16
17declare_forge_lint!(
18    ERC20_UNCHECKED_TRANSFER,
19    Severity::High,
20    "erc20-unchecked-transfer",
21    "ERC20 'transfer' and 'transferFrom' calls should check the return value"
22);
23
24// -- ERC20 UNCKECKED TRANSFERS -------------------------------------------------------------------
25
26/// WARN: can issue false positives. It does not check that the contract being called is an ERC20.
27/// TODO: re-implement using `LateLintPass` so that it can't issue false positives.
28impl<'ast> EarlyLintPass<'ast> for UncheckedTransferERC20 {
29    fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) {
30        if let Some(body) = &func.body {
31            let mut checker = UncheckedTransferERC20Checker { ctx };
32            let _ = checker.visit_block(body);
33        }
34    }
35}
36
37/// Visitor that detects unchecked ERC20 transfer calls within function bodies.
38///
39/// Unchecked transfers appear as standalone expression statements.
40/// When a transfer's return value is used (in require, assignment, etc.), it's part
41/// of a larger expression and won't be flagged.
42struct UncheckedTransferERC20Checker<'a, 's> {
43    ctx: &'a LintContext<'s>,
44}
45
46impl<'ast> Visit<'ast> for UncheckedTransferERC20Checker<'_, '_> {
47    type BreakValue = ();
48
49    fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
50        // Only expression statements can contain unchecked transfers.
51        if let StmtKind::Expr(expr) = &stmt.kind
52            && is_erc20_transfer_call(expr)
53        {
54            self.ctx.emit(&ERC20_UNCHECKED_TRANSFER, expr.span);
55        }
56        self.walk_stmt(stmt)
57    }
58}
59
60/// Checks if an expression is an ERC20 `transfer` or `transferFrom` call.
61/// `function ERC20.transfer(to, amount)`
62/// `function ERC20.transferFrom(from, to, amount)`
63///
64/// Validates both the method name and argument count to avoid false positives
65/// from other functions that happen to be named "transfer".
66fn is_erc20_transfer_call(expr: &Expr<'_>) -> bool {
67    if let ExprKind::Call(call_expr, args) = &expr.kind {
68        // Must be a member access pattern: `token.transfer(...)`
69        if let ExprKind::Member(_, member) = &call_expr.kind {
70            return (args.len() == 2 && member.as_str() == "transfer")
71                || (args.len() == 3 && member.as_str() == "transferFrom");
72        }
73    }
74    false
75}
76
77// -- UNCKECKED LOW-LEVEL CALLS -------------------------------------------------------------------
78
79impl<'ast> EarlyLintPass<'ast> for UncheckedCall {
80    fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) {
81        if let Some(body) = &func.body {
82            let mut checker = UncheckedCallChecker { ctx };
83            let _ = checker.visit_block(body);
84        }
85    }
86}
87
88/// Visitor that detects unchecked low-level calls within function bodies.
89///
90/// Similar to unchecked transfers, unchecked calls appear as standalone expression
91/// statements. When the success value is checked (in require, if, etc.), the call
92/// is part of a larger expression and won't be flagged.
93struct UncheckedCallChecker<'a, 's> {
94    ctx: &'a LintContext<'s>,
95}
96
97impl<'ast> Visit<'ast> for UncheckedCallChecker<'_, '_> {
98    type BreakValue = ();
99
100    fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
101        match &stmt.kind {
102            // Check standalone expression statements: `target.call(data);`
103            StmtKind::Expr(expr) => {
104                if is_low_level_call(expr) {
105                    self.ctx.emit(&UNCHECKED_CALL, expr.span);
106                } else if let ExprKind::Assign(lhs, _, rhs) = &expr.kind {
107                    // Check assignments to existing vars: `(, existingVar) = target.call(data);`
108                    if is_low_level_call(rhs) && is_unchecked_tuple_assignment(lhs) {
109                        self.ctx.emit(&UNCHECKED_CALL, expr.span);
110                    }
111                }
112            }
113            // Check multi-variable declarations: `(bool success, ) = target.call(data);`
114            StmtKind::DeclMulti(vars, expr) => {
115                if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) {
116                    self.ctx.emit(&UNCHECKED_CALL, stmt.span);
117                }
118            }
119            _ => {}
120        }
121        self.walk_stmt(stmt)
122    }
123}
124
125/// Checks if an expression is a low-level call that should be checked.
126///
127/// Detects patterns like:
128/// - `target.call(...)`
129/// - `target.delegatecall(...)`
130/// - `target.staticcall(...)`
131/// - `target.call{value: x}(...)`
132fn is_low_level_call(expr: &Expr<'_>) -> bool {
133    if let ExprKind::Call(call_expr, _args) = &expr.kind {
134        // Check the callee expression
135        let callee = match &call_expr.kind {
136            // Handle call options like {value: x}
137            ExprKind::CallOptions(inner_expr, _) => inner_expr,
138            // Direct call without options
139            _ => call_expr,
140        };
141
142        if let ExprKind::Member(_, member) = &callee.kind {
143            // Check for low-level call methods
144            return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall);
145        }
146    }
147    false
148}
149
150/// Checks if a tuple assignment doesn't properly check the success value.
151///
152/// Returns true if the first variable (success) is None: `(, bytes memory data) =
153/// target.call(...)`
154fn is_unchecked_tuple_assignment(expr: &Expr<'_>) -> bool {
155    if let ExprKind::Tuple(elements) = &expr.kind {
156        elements.first().is_none_or(|e| e.is_none())
157    } else {
158        false
159    }
160}