Skip to main content

forge_lint/sol/high/
unchecked_calls.rs

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