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