forge_lint/sol/high/
unchecked_calls.rs

1use super::{UncheckedCall, UncheckedTransferERC20};
2use crate::{
3    linter::{EarlyLintPass, LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast::{Expr, ExprKind, ItemFunction, Stmt, StmtKind, visit::Visit},
8    interface::kw,
9    sema::hir::{self},
10};
11use std::ops::ControlFlow;
12
13declare_forge_lint!(
14    UNCHECKED_CALL,
15    Severity::High,
16    "unchecked-call",
17    "Low-level calls should check the success return value"
18);
19
20declare_forge_lint!(
21    ERC20_UNCHECKED_TRANSFER,
22    Severity::High,
23    "erc20-unchecked-transfer",
24    "ERC20 'transfer' and 'transferFrom' calls should check the return value"
25);
26
27// -- ERC20 UNCKECKED TRANSFERS -------------------------------------------------------------------
28
29/// Checks that calls to functions with the same signature as the ERC20 transfer methods, and which
30/// return a boolean are not ignored.
31///
32/// WARN: can issue false positives, as it doesn't check that the contract being called sticks to
33/// the full ERC20 specification.
34impl<'hir> LateLintPass<'hir> for UncheckedTransferERC20 {
35    fn check_stmt(
36        &mut self,
37        ctx: &LintContext,
38        hir: &'hir hir::Hir<'hir>,
39        stmt: &'hir hir::Stmt<'hir>,
40    ) {
41        // Only expression statements can contain unchecked transfers.
42        if let hir::StmtKind::Expr(expr) = &stmt.kind
43            && is_erc20_transfer_call(hir, expr)
44        {
45            ctx.emit(&ERC20_UNCHECKED_TRANSFER, expr.span);
46        }
47    }
48}
49
50/// Checks if an expression is an ERC20 `transfer` or `transferFrom` call.
51/// * `function transfer(address to, uint256 amount) external returns bool;`
52/// * `function transferFrom(address from, address to, uint256 amount) external returns bool;`
53///
54/// Validates the method name, the params (count + types), and the returns (count + types).
55fn is_erc20_transfer_call(hir: &hir::Hir<'_>, expr: &hir::Expr<'_>) -> bool {
56    let is_type = |var_id: hir::VariableId, type_str: &str| {
57        matches!(
58            &hir.variable(var_id).ty.kind,
59            hir::TypeKind::Elementary(ty) if ty.to_abi_str() == type_str
60        )
61    };
62
63    // Ensure the expression is a call to a contract member function.
64    let hir::ExprKind::Call(
65        hir::Expr { kind: hir::ExprKind::Member(contract_expr, func_ident), .. },
66        hir::CallArgs { kind: hir::CallArgsKind::Unnamed(args), .. },
67        ..,
68    ) = &expr.kind
69    else {
70        return false;
71    };
72
73    // Determine the expected ERC20 signature from the call
74    let (expected_params, expected_returns): (&[&str], &[&str]) = match func_ident.as_str() {
75        "transferFrom" if args.len() == 3 => (&["address", "address", "uint256"], &["bool"]),
76        "transfer" if args.len() == 2 => (&["address", "uint256"], &["bool"]),
77        _ => return false,
78    };
79
80    let Some(cid) = (match &contract_expr.kind {
81        // Call to pre-instantiated contract variable
82        hir::ExprKind::Ident([hir::Res::Item(hir::ItemId::Variable(id)), ..]) => {
83            if let hir::TypeKind::Custom(hir::ItemId::Contract(cid)) = hir.variable(*id).ty.kind {
84                Some(cid)
85            } else {
86                None
87            }
88        }
89        // Call to address wrapped by the contract interface
90        hir::ExprKind::Call(
91            hir::Expr {
92                kind: hir::ExprKind::Ident([hir::Res::Item(hir::ItemId::Contract(cid))]),
93                ..
94            },
95            ..,
96        ) => Some(*cid),
97        _ => None,
98    }) else {
99        return false;
100    };
101
102    // Try to find a function in the contract that matches the expected signature.
103    hir.contract_item_ids(cid).any(|item| {
104        let Some(fid) = item.as_function() else { return false };
105        let func = hir.function(fid);
106        func.name.is_some_and(|name| name.as_str() == func_ident.as_str())
107            && func.kind.is_function()
108            && func.mutates_state()
109            && func.parameters.len() == expected_params.len()
110            && func.returns.len() == expected_returns.len()
111            && func.parameters.iter().zip(expected_params).all(|(id, &ty)| is_type(*id, ty))
112            && func.returns.iter().zip(expected_returns).all(|(id, &ty)| is_type(*id, ty))
113    })
114}
115
116// -- UNCKECKED LOW-LEVEL CALLS -------------------------------------------------------------------
117
118impl<'ast> EarlyLintPass<'ast> for UncheckedCall {
119    fn check_item_function(&mut self, ctx: &LintContext, func: &'ast ItemFunction<'ast>) {
120        if let Some(body) = &func.body {
121            let mut checker = UncheckedCallChecker { ctx };
122            let _ = checker.visit_block(body);
123        }
124    }
125}
126
127/// Visitor that detects unchecked low-level calls within function bodies.
128///
129/// Similar to unchecked transfers, unchecked calls appear as standalone expression
130/// statements. When the success value is checked (in require, if, etc.), the call
131/// is part of a larger expression and won't be flagged.
132struct UncheckedCallChecker<'a, 's> {
133    ctx: &'a LintContext<'s, 'a>,
134}
135
136impl<'ast> Visit<'ast> for UncheckedCallChecker<'_, '_> {
137    type BreakValue = ();
138
139    fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
140        match &stmt.kind {
141            // Check standalone expression statements: `target.call(data);`
142            StmtKind::Expr(expr) => {
143                if is_low_level_call(expr) {
144                    self.ctx.emit(&UNCHECKED_CALL, expr.span);
145                } else if let ExprKind::Assign(lhs, _, rhs) = &expr.kind {
146                    // Check assignments to existing vars: `(, existingVar) = target.call(data);`
147                    if is_low_level_call(rhs) && is_unchecked_tuple_assignment(lhs) {
148                        self.ctx.emit(&UNCHECKED_CALL, expr.span);
149                    }
150                }
151            }
152            // Check multi-variable declarations: `(bool success, ) = target.call(data);`
153            StmtKind::DeclMulti(vars, expr) => {
154                if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) {
155                    self.ctx.emit(&UNCHECKED_CALL, stmt.span);
156                }
157            }
158            _ => {}
159        }
160        self.walk_stmt(stmt)
161    }
162}
163
164/// Checks if an expression is a low-level call that should be checked.
165///
166/// Detects patterns like:
167/// - `target.call(...)`
168/// - `target.delegatecall(...)`
169/// - `target.staticcall(...)`
170/// - `target.call{value: x}(...)`
171fn is_low_level_call(expr: &Expr<'_>) -> bool {
172    if let ExprKind::Call(call_expr, _args) = &expr.kind {
173        // Check the callee expression
174        let callee = match &call_expr.kind {
175            // Handle call options like {value: x}
176            ExprKind::CallOptions(inner_expr, _) => inner_expr,
177            // Direct call without options
178            _ => call_expr,
179        };
180
181        if let ExprKind::Member(_, member) = &callee.kind {
182            // Check for low-level call methods
183            return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall);
184        }
185    }
186    false
187}
188
189/// Checks if a tuple assignment doesn't properly check the success value.
190///
191/// Returns true if the first variable (success) is None: `(, bytes memory data) =
192/// target.call(...)`
193fn is_unchecked_tuple_assignment(expr: &Expr<'_>) -> bool {
194    if let ExprKind::Tuple(elements) = &expr.kind {
195        elements.first().is_none_or(|e| e.is_none())
196    } else {
197        false
198    }
199}