Skip to main content

forge_lint/sol/high/
arbitrary_send_erc20.rs

1use super::ArbitrarySendErc20;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            arg_for_param, branch_always_exits, expr_is_address, is_address_like_cast,
8            is_address_self, is_address_type, is_elementary, is_msg_sender, is_require_or_assert,
9            loop_update, modifier_prefix, receiver_contract_id, state_lhs_vars, tuple_elems,
10            underlying_var,
11        },
12    },
13};
14use solar::{
15    ast::{BinOpKind, StateMutability, UnOpKind, Visibility},
16    interface::{Span, Symbol, data_structures::Never},
17    sema::{
18        Gcx,
19        hir::{
20            self, CallArgs, ContractId, ContractKind, Expr, ExprKind, FunctionId, FunctionKind,
21            Hir, ItemId, LoopSource, Modifier, Stmt, StmtKind, TypeKind, VariableId, Visit,
22        },
23    },
24};
25use std::{
26    cell::RefCell,
27    collections::{HashMap, HashSet},
28    hash::Hash,
29    ops::ControlFlow,
30    rc::Rc,
31};
32
33declare_forge_lint!(
34    ARBITRARY_SEND_ERC20,
35    Severity::High,
36    "arbitrary-send-erc20",
37    "`transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`"
38);
39
40declare_forge_lint!(
41    ARBITRARY_SEND_ERC20_PERMIT,
42    Severity::High,
43    "arbitrary-send-erc20-permit",
44    "`transferFrom` uses an arbitrary `from` after `permit`"
45);
46
47/// Recursion budget for `_msgSender()`-style helper chains.
48const HELPER_DEPTH: u8 = 3;
49
50impl<'gcx> LateLintPass<'gcx> for ArbitrarySendErc20 {
51    fn check_function(
52        &mut self,
53        ctx: &LintContext,
54        gcx: Gcx<'gcx>,
55        func: &'gcx hir::Function<'gcx>,
56    ) {
57        // Library functions forward `from` from their caller; the call site is flagged instead.
58        if matches!(func.state_mutability, StateMutability::Pure | StateMutability::View)
59            || func.is_constructor()
60            || func.contract.is_some_and(|cid| gcx.hir.contract(cid).kind == ContractKind::Library)
61        {
62            return;
63        }
64        let Some(body) = func.body else { return };
65        // A modifier prefix that always exits makes the body unreachable.
66        if func.modifiers.iter().any(|m| {
67            m.id.as_function()
68                .and_then(|fid| modifier_prefix(&gcx.hir, fid))
69                .is_some_and(|p| p.iter().any(|s| branch_always_exits(gcx, s)))
70        }) {
71            return;
72        }
73        let mut a = Analyzer::new(gcx);
74        if let Some(cid) = func.contract {
75            a.seed_immutable_facts(cid);
76        }
77        a.seed_callsite_facts(func);
78        for m in func.modifiers {
79            a.hoist_modifier_facts(m);
80        }
81        a.visit_stmts(body.stmts);
82        for (span, lint) in a.hits {
83            ctx.emit(lint, span);
84        }
85    }
86}
87
88/// Identifier correlating permit and sink token receivers: `token` or `cfg.token`.
89#[derive(Clone, Copy, PartialEq, Eq, Hash)]
90enum TokenKey {
91    Var(VariableId),
92    Field(VariableId, Symbol),
93}
94
95impl TokenKey {
96    fn touches(self, v: VariableId) -> bool {
97        match self {
98            Self::Var(x) | Self::Field(x, _) => x == v,
99        }
100    }
101}
102
103/// An EIP-2612 permit with `spender == address(this)` seen earlier on the current path.
104#[derive(Clone, Copy, PartialEq, Eq, Hash)]
105struct PermitRecord {
106    token: TokenKey,
107    owner: VariableId,
108}
109
110/// Outstanding EIP-3156 repayment licensed by a prior `onFlashLoan` call.
111#[derive(Clone, Copy, PartialEq, Eq, Hash)]
112struct PendingRepayment {
113    receiver: VariableId,
114    token: VariableId,
115    amount: VariableId,
116    fee: VariableId,
117}
118
119/// An ERC20 `transferFrom`-shaped sink.
120struct Sink<'gcx> {
121    from: &'gcx Expr<'gcx>,
122    to: &'gcx Expr<'gcx>,
123    amount: &'gcx Expr<'gcx>,
124    token: Option<TokenKey>,
125}
126
127/// Facts about an assignment's RHS, captured before any write.
128#[derive(Clone, Copy, Default)]
129struct Rhs {
130    safe: bool,
131    is_self: bool,
132    alias: Option<VariableId>,
133    sum: Option<(VariableId, VariableId)>,
134}
135
136/// Path-sensitive facts.
137#[derive(Clone, Default)]
138struct State {
139    /// Locals and `immutable`/`constant` state proven equal to `msg.sender` or `address(this)`.
140    /// Mutable storage may be rewritten between the check and the sink.
141    safe_vars: HashSet<VariableId>,
142    /// Subset of `safe_vars` proven equal to `address(this)`; recognises permit spenders.
143    self_vars: HashSet<VariableId>,
144    /// Permits seen on this path, keyed by canonical token / owner.
145    permits: HashSet<PermitRecord>,
146    /// Pending flash-loan repayments; each `onFlashLoan` call licenses one consumption.
147    repayments: HashMap<PendingRepayment, u32>,
148    /// `x = y` records `x -> canonical(y)`.
149    aliases: HashMap<VariableId, VariableId>,
150    /// `x = a + b` records `x -> (a, b)`, matched against flash-repayment sums.
151    sum_of: HashMap<VariableId, (VariableId, VariableId)>,
152}
153
154impl State {
155    fn meet(&self, other: &Self) -> Self {
156        Self {
157            safe_vars: self.safe_vars.intersection(&other.safe_vars).copied().collect(),
158            self_vars: self.self_vars.intersection(&other.self_vars).copied().collect(),
159            permits: self.permits.intersection(&other.permits).copied().collect(),
160            repayments: self
161                .repayments
162                .iter()
163                .filter_map(|(k, a)| other.repayments.get(k).map(|b| (*k, *a.min(b))))
164                .collect(),
165            aliases: common_entries(&self.aliases, &other.aliases),
166            sum_of: common_entries(&self.sum_of, &other.sum_of),
167        }
168    }
169}
170
171fn common_entries<K: Eq + Hash + Copy, V: PartialEq + Copy>(
172    a: &HashMap<K, V>,
173    b: &HashMap<K, V>,
174) -> HashMap<K, V> {
175    a.iter().filter(|(k, v)| b.get(k) == Some(v)).map(|(k, v)| (*k, *v)).collect()
176}
177
178struct Analyzer<'gcx> {
179    gcx: Gcx<'gcx>,
180    /// Gates the `using ... for address` sink form on a Solady-shaped library being present.
181    state: State,
182    /// States at `break`/`continue` of each enclosing loop, innermost last.
183    loop_exits: Vec<Vec<State>>,
184    /// Repayment counts as they stood at the entry of each enclosing loop's body, innermost
185    /// last. A repayment can only be consumed inside a loop if its count exceeds this floor,
186    /// i.e. it was freshly minted since that loop was entered - a repayment already pending
187    /// beforehand must not license a sink the single-pass body walk merely happens to contain,
188    /// since at runtime that same sink may re-execute every iteration against the one license.
189    /// Unlike `State`, this is never snapshotted or restored around `if`/`try` branches - see
190    /// `invalidate`'s comment on why it must never be mutated in place mid-analysis.
191    loop_repayment_floors: Vec<HashMap<PendingRepayment, u32>>,
192    /// Every variable written on any path.
193    written: HashSet<VariableId>,
194    hits: Vec<(Span, &'static SolLint)>,
195}
196
197impl<'gcx> Analyzer<'gcx> {
198    fn new(gcx: Gcx<'gcx>) -> Self {
199        Self {
200            gcx,
201            state: State::default(),
202            loop_exits: Vec::new(),
203            loop_repayment_floors: Vec::new(),
204            written: HashSet::new(),
205            hits: Vec::new(),
206        }
207    }
208
209    /// Seeds facts about `immutable`/`constant` state of `cid` from declaration initializers and
210    /// the constructor body.
211    fn seed_immutable_facts(&mut self, cid: ContractId) {
212        for v in self.gcx.hir.contract(cid).variables() {
213            let var = self.gcx.hir.variable(v);
214            if (var.is_immutable() || var.is_constant())
215                && let Some(init) = var.initializer
216            {
217                if self.is_safe(init) {
218                    self.state.safe_vars.insert(v);
219                }
220                if self.is_self_expr(init) {
221                    self.state.self_vars.insert(v);
222                }
223            }
224        }
225        if let Some(ctor) = self.gcx.hir.contract(cid).ctor
226            && let Some(body) = self.gcx.hir.function(ctor).body
227        {
228            let mut a = Self::new(self.gcx);
229            a.visit_stmts(body.stmts);
230            let is_state = |v: &&VariableId| self.gcx.hir.variable(**v).kind.is_state();
231            self.state.safe_vars.extend(a.state.safe_vars.iter().filter(is_state));
232            self.state.self_vars.extend(a.state.self_vars.iter().filter(is_state));
233        }
234    }
235
236    /// Seeds parameters of an internal function or modifier that every invocation site in the
237    /// compilation unit passes a safe argument for.
238    fn seed_callsite_facts(&mut self, func: &'gcx hir::Function<'gcx>) {
239        if !is_internal_only(func) {
240            return;
241        }
242        let index = callsite_index(self.gcx);
243        let Some((fid, _)) =
244            self.gcx.hir.functions_enumerated().find(|(_, f)| std::ptr::eq(*f, func))
245        else {
246            return;
247        };
248        let Some(Some(facts)) = index.get(&fid) else { return };
249        for (&param, &(safe, is_self)) in func.parameters.iter().zip(facts) {
250            if safe {
251                self.state.safe_vars.insert(param);
252            }
253            if is_self {
254                self.state.self_vars.insert(param);
255            }
256        }
257    }
258
259    /// Hoists `require(param == msg.sender | address(this))` guards from the prefix of modifier
260    /// `m` onto the caller's argument variables.
261    fn hoist_modifier_facts(&mut self, m: &'gcx Modifier<'gcx>) {
262        let Some(fid) = m.id.as_function() else { return };
263        let Some(prefix) = modifier_prefix(&self.gcx.hir, fid) else { return };
264        let modifier = self.gcx.hir.function(fid);
265        let mut a = Self::new(self.gcx);
266        for stmt in prefix {
267            a.stmt(stmt);
268        }
269        for &param in modifier.parameters {
270            // A fact about a rewritten parameter says nothing about the caller's variable.
271            if !a.written.contains(&param)
272                && let Some(caller) = arg_for_param(self.gcx, fid, param, &m.args)
273                    .and_then(|expr| underlying_var(self.gcx, expr))
274                && self.is_safe_target(caller)
275            {
276                if a.state.safe_vars.contains(&param) {
277                    self.state.safe_vars.insert(caller);
278                }
279                if a.state.self_vars.contains(&param) {
280                    self.state.self_vars.insert(caller);
281                }
282            }
283        }
284    }
285
286    /// `msg.sender`, `address(this)` or a tracked-safe variable.
287    fn is_safe(&self, expr: &Expr<'_>) -> bool {
288        origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.safe_vars, |gcx, e| {
289            is_msg_sender(gcx, e) || is_address_self(gcx, e)
290        })
291    }
292
293    /// `address(this)` or a tracked self alias.
294    fn is_self_expr(&self, expr: &Expr<'_>) -> bool {
295        origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.self_vars, is_address_self)
296    }
297
298    fn is_safe_target(&self, v: VariableId) -> bool {
299        let var = self.gcx.hir.variable(v);
300        !var.kind.is_state() || var.is_immutable() || var.is_constant()
301    }
302
303    /// Follows the alias chain to its root; bounded to guard against cycles.
304    fn canonical(&self, v: VariableId) -> VariableId {
305        let mut cur = v;
306        for _ in 0..8 {
307            match self.state.aliases.get(&cur) {
308                Some(next) if *next != cur => cur = *next,
309                _ => break,
310            }
311        }
312        cur
313    }
314
315    fn canonical_key(&self, key: TokenKey) -> TokenKey {
316        match key {
317            TokenKey::Var(v) => TokenKey::Var(self.canonical(v)),
318            TokenKey::Field(v, name) => TokenKey::Field(self.canonical(v), name),
319        }
320    }
321
322    /// Drops every fact about `v`.
323    fn invalidate(&mut self, v: VariableId) {
324        let s = &mut self.state;
325        s.safe_vars.remove(&v);
326        s.self_vars.remove(&v);
327        s.aliases.retain(|k, dst| *k != v && *dst != v);
328        s.sum_of.retain(|k, (a, b)| *k != v && *a != v && *b != v);
329        s.permits.retain(|p| !p.token.touches(v) && p.owner != v);
330        s.repayments.retain(|r, _| ![r.receiver, r.token, r.amount, r.fee].contains(&v));
331        // Deliberately NOT purged here: `loop_repayment_floors` is shared, mutate-in-place
332        // analyzer state, not part of `State` - unlike everything above, it isn't snapshotted
333        // and restored around `if`/`try` branches. Purging a floor entry here would leak across
334        // sibling branches (e.g. a `then` that reassigns `v` would permanently drop the floor
335        // before the `else` branch, sharing the same loop, is ever analyzed), turning a real
336        // repeated-pull vulnerability into a false negative. Reassigning a repayment's own key
337        // variable mid-loop and re-minting is a rare enough shape that the resulting false
338        // positive (see `badFlashLoanReassignThenRemintInLoop`) is the safer trade-off.
339    }
340
341    fn eval_rhs(&self, rhs: Option<&Expr<'_>>) -> Rhs {
342        let Some(rhs) = rhs else { return Rhs::default() };
343        Rhs {
344            safe: self.is_safe(rhs),
345            is_self: self.is_self_expr(rhs),
346            alias: underlying_var(self.gcx, rhs).map(|v| self.canonical(v)),
347            sum: sum_operands(self.gcx, rhs),
348        }
349    }
350
351    fn assign_var(&mut self, target: VariableId, rhs: Rhs) {
352        self.written.insert(target);
353        self.invalidate(target);
354        if !self.is_safe_target(target) {
355            return;
356        }
357        if rhs.safe {
358            self.state.safe_vars.insert(target);
359        }
360        if rhs.is_self {
361            self.state.self_vars.insert(target);
362        }
363        if let Some(alias) = rhs.alias
364            && alias != target
365        {
366            self.state.aliases.insert(target, alias);
367        }
368        if let Some(sum) = rhs.sum {
369            self.state.sum_of.insert(target, sum);
370        }
371    }
372
373    /// Handles single and tuple LHS; `rhs == None` is an unknown value (`delete`).
374    fn assign_lhs(&mut self, lhs: &Expr<'_>, rhs: Option<&Expr<'_>>) {
375        // Writing `cfg.token` drops permits keyed on that field.
376        if let ExprKind::Member(base, ident) = &lhs.peel_parens().kind
377            && let Some(base) = underlying_var(self.gcx, base)
378        {
379            let key = TokenKey::Field(self.canonical(base), ident.name);
380            self.state.permits.retain(|p| p.token != key);
381        }
382        if let Some(elems) = tuple_elems(lhs) {
383            let rhs = rhs.and_then(tuple_elems);
384            // Evaluate every slot before writing any, so `(x, y) = (y, x)` stays consistent.
385            let slots: Vec<_> = elems
386                .iter()
387                .enumerate()
388                .map(|(i, l)| (*l, self.eval_rhs(rhs.and_then(|r| r.get(i).copied().flatten()))))
389                .collect();
390            for (lhs, rhs) in slots {
391                if let Some(v) = lhs.and_then(|expr| underlying_var(self.gcx, expr)) {
392                    self.assign_var(v, rhs);
393                }
394            }
395        } else if let Some(v) = underlying_var(self.gcx, lhs) {
396            let rhs = self.eval_rhs(rhs);
397            self.assign_var(v, rhs);
398        }
399    }
400
401    /// Records variables proven safe by `pred` (`!pred` when `negate`).
402    fn add_facts(&mut self, pred: &Expr<'_>, negate: bool) {
403        match &pred.peel_parens().kind {
404            ExprKind::Binary(lhs, op, rhs) => {
405                let (eq, and, or) = if negate {
406                    (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And)
407                } else {
408                    (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or)
409                };
410                if op.kind == and {
411                    self.add_facts(lhs, negate);
412                    self.add_facts(rhs, negate);
413                } else if op.kind == or {
414                    // Only facts established by both disjuncts hold.
415                    let before = self.state.clone();
416                    self.add_facts(lhs, negate);
417                    let after_lhs = std::mem::replace(&mut self.state, before);
418                    self.add_facts(rhs, negate);
419                    self.state = after_lhs.meet(&self.state);
420                } else if op.kind == eq {
421                    for (a, b) in [(lhs, rhs), (rhs, lhs)] {
422                        if let Some(v) = underlying_var(self.gcx, b)
423                            && self.is_safe_target(v)
424                        {
425                            if self.is_safe(a) {
426                                self.state.safe_vars.insert(v);
427                            }
428                            if self.is_self_expr(a) {
429                                self.state.self_vars.insert(v);
430                            }
431                        }
432                    }
433                }
434            }
435            ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => {
436                self.add_facts(inner, !negate);
437            }
438            _ => {}
439        }
440    }
441
442    /// EIP-2612 `token.permit(owner, <self>, ...)` or the OpenZeppelin-style wrapper
443    /// `Lib.safePermit(token, owner, <self>, ...)`.
444    fn match_permit_call(&self, expr: &Expr<'gcx>) -> Option<PermitRecord> {
445        let ExprKind::Call(callee, ..) = &expr.kind else { return None };
446        let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
447        let (token, owner, spender) = match ident.name.as_str() {
448            "permit" => {
449                let a = canonical_args(self.gcx, expr, 7)?;
450                (*recv, a[0], a[1])
451            }
452            "safePermit"
453                if receiver_contract_id(self.gcx, recv).is_some_and(|cid| {
454                    self.gcx.hir.contract(cid).kind == ContractKind::Library
455                }) =>
456            {
457                let a = canonical_args(self.gcx, expr, 8)?;
458                (a[0], a[1], a[2])
459            }
460            _ => return None,
461        };
462        if !self.is_self_expr(spender) {
463            return None;
464        }
465        Some(PermitRecord {
466            token: self.canonical_key(token_key(self.gcx, token)?),
467            owner: self.canonical(underlying_var(self.gcx, owner)?),
468        })
469    }
470
471    fn permit_covers(&self, sink: &Sink<'_>) -> bool {
472        let (Some(token), Some(owner)) = (sink.token, underlying_var(self.gcx, sink.from)) else {
473            return false;
474        };
475        self.state.permits.contains(&PermitRecord {
476            token: self.canonical_key(token),
477            owner: self.canonical(owner),
478        })
479    }
480
481    /// `expr` is `amount + fee` (either order), or a local bound to that sum.
482    fn amount_matches(&self, expr: &Expr<'_>, amount: VariableId, fee: VariableId) -> bool {
483        let sum = sum_operands(self.gcx, expr).or_else(|| {
484            underlying_var(self.gcx, expr).and_then(|v| self.state.sum_of.get(&v).copied())
485        });
486        matches!(sum, Some(pair) if pair == (amount, fee) || pair == (fee, amount))
487    }
488
489    /// Consumes one pending repayment matched by a sink pulling `amount + fee` from the flash-loan
490    /// receiver back to `address(this)`.
491    fn consume_repayment(&mut self, sink: &Sink<'_>) -> bool {
492        let (Some(from), Some(TokenKey::Var(token))) =
493            (underlying_var(self.gcx, sink.from), sink.token)
494        else {
495            return false;
496        };
497        if !self.is_self_expr(sink.to) {
498            return false;
499        }
500        let candidates: Vec<PendingRepayment> = self
501            .state
502            .repayments
503            .keys()
504            .copied()
505            .filter(|r| {
506                r.receiver == from
507                    && r.token == token
508                    && self.amount_matches(sink.amount, r.amount, r.fee)
509            })
510            .collect();
511        // `amount + fee` and `fee + amount` both match, so an outer (pre-loop) repayment and an
512        // in-loop fresh one can both structurally match the same sink. Prefer one this loop
513        // hasn't already spent its entry-floor allowance on, rather than an arbitrary HashMap
514        // iteration order picking the stale one and rejecting a genuinely fresh license.
515        let floor = self.loop_repayment_floors.last();
516        let is_fresh = |r: &PendingRepayment, repayments: &HashMap<PendingRepayment, u32>| {
517            let count = repayments.get(r).copied().unwrap_or(0);
518            let floor = floor.and_then(|f| f.get(r)).copied().unwrap_or(0);
519            count > floor
520        };
521        let Some(rep) = candidates.into_iter().find(|r| is_fresh(r, &self.state.repayments)) else {
522            return false;
523        };
524        match self.state.repayments.get_mut(&rep) {
525            Some(count) if *count > 1 => *count -= 1,
526            _ => {
527                self.state.repayments.remove(&rep);
528            }
529        }
530        true
531    }
532
533    /// Visits `stmts` up to the first that cannot fall through; returns whether the end is
534    /// reachable.
535    fn visit_stmts(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> bool {
536        stmts.iter().all(|s| self.stmt(s))
537    }
538
539    /// Visits `stmt`, returning whether control can fall through it.
540    fn stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool {
541        match &stmt.kind {
542            StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => return self.visit_stmts(b.stmts),
543            StmtKind::Break | StmtKind::Continue => {
544                let state = self.state.clone();
545                if let Some(exits) = self.loop_exits.last_mut() {
546                    exits.push(state);
547                }
548                return false;
549            }
550            StmtKind::If(cond, then, else_) => {
551                let _ = self.visit_expr(cond);
552                let before = self.state.clone();
553                self.add_facts(cond, false);
554                let then_falls = self.stmt(then);
555                let after_then = std::mem::replace(&mut self.state, before);
556                self.add_facts(cond, true);
557                let else_falls = else_.is_none_or(|e| self.stmt(e));
558                match (then_falls, else_falls) {
559                    (true, true) => self.state = after_then.meet(&self.state),
560                    (true, false) => self.state = after_then,
561                    _ => {}
562                }
563                return then_falls || else_falls;
564            }
565            StmtKind::Loop(block, source) => {
566                // Only facts holding on every exit survive. `for`/`while` bodies may not run at
567                // all; `do-while` bodies run at least once.
568                let baseline = (!matches!(source, LoopSource::DoWhile)).then(|| self.state.clone());
569                self.loop_exits.push(baseline.into_iter().collect());
570                // The body is walked as a single static pass, but a real loop may run it many
571                // times. Record the repayment counts as they stand on entry so `consume_repayment`
572                // can refuse to spend a repayment that was already pending beforehand - such a
573                // license must not cover a sink the single-pass body walk merely happens to
574                // contain, since at runtime that sink may re-execute every iteration against the
575                // one license. A repayment minted (and consumed) inside this same body pass is
576                // unaffected, since its count exceeds the floor recorded here; repayments the body
577                // never touches at all pass through the eventual `meet` unchanged, so code after
578                // the loop still sees them.
579                //
580                // This has no notion of a loop shape that provably runs its body at most once
581                // (`do { .. } while (false)`, `while (cond) { ..; break; }`), so those flag a
582                // pull they can't actually repeat. Accepted: a narrow false positive on an
583                // otherwise-safe idiom, traded for never missing a real repeated-pull drain.
584                self.loop_repayment_floors.push(self.state.repayments.clone());
585                if self.visit_stmts(block.stmts)
586                    && loop_update(*source).is_none_or(|update| self.stmt(update))
587                {
588                    let state = self.state.clone();
589                    self.loop_exits.last_mut().expect("pushed above").push(state);
590                }
591                self.loop_repayment_floors.pop().expect("pushed above");
592                let exits = self.loop_exits.pop().expect("pushed above");
593                let falls = !exits.is_empty();
594                if let Some(joined) = exits.into_iter().reduce(|a, b| a.meet(&b)) {
595                    self.state = joined;
596                }
597                return falls;
598            }
599            StmtKind::Try(t) => {
600                // Only the success clause sees the effects of the tried call.
601                let before = self.state.clone();
602                let _ = self.visit_expr(&t.expr);
603                let after_call = self.state.clone();
604                let mut joined = None::<State>;
605                for (i, clause) in t.clauses.iter().enumerate() {
606                    self.state = if i == 0 { after_call.clone() } else { before.clone() };
607                    if self.visit_stmts(clause.block.stmts) {
608                        joined = Some(
609                            joined.map_or_else(|| self.state.clone(), |j| j.meet(&self.state)),
610                        );
611                    }
612                }
613                let falls = joined.is_some();
614                self.state = joined.unwrap_or(after_call);
615                return falls;
616            }
617            StmtKind::DeclSingle(vid) => {
618                if let Some(init) = self.gcx.hir.variable(*vid).initializer {
619                    let rhs = self.eval_rhs(Some(init));
620                    self.assign_var(*vid, rhs);
621                }
622            }
623            StmtKind::DeclMulti(vars, init) => {
624                for (vid, rhs) in vars.iter().zip(tuple_elems(init).into_iter().flatten()) {
625                    if let (Some(vid), Some(rhs)) = (vid, rhs) {
626                        let rhs = self.eval_rhs(Some(rhs));
627                        self.assign_var(*vid, rhs);
628                    }
629                }
630            }
631            _ => {}
632        }
633        let _ = self.walk_stmt(stmt);
634        !branch_always_exits(self.gcx, stmt)
635    }
636}
637
638impl<'gcx> Visit<'gcx> for Analyzer<'gcx> {
639    type BreakValue = Never;
640
641    fn hir(&self) -> &'gcx Hir<'gcx> {
642        &self.gcx.hir
643    }
644
645    fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<Never> {
646        self.stmt(stmt);
647        ControlFlow::Continue(())
648    }
649
650    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
651        match &expr.kind {
652            // `rhs` may not execute: its facts and writes survive only if they also hold without
653            // it, while `lhs` facts flow into `rhs`. Sinks in `rhs` are still reported.
654            ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => {
655                let _ = self.visit_expr(lhs);
656                let skipped = self.state.clone();
657                self.add_facts(lhs, op.kind == BinOpKind::Or);
658                let _ = self.visit_expr(rhs);
659                self.state = skipped.meet(&self.state);
660            }
661            ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => {
662                // Sinks inside the predicate run before the guard takes effect.
663                let _ = self.walk_expr(expr);
664                if let Some(cond) = args.exprs().next() {
665                    self.add_facts(cond, false);
666                }
667            }
668            ExprKind::Call(callee, ..) => {
669                if let Some(rep) = match_flash_loan_call(self.gcx, expr) {
670                    *self.state.repayments.entry(rep).or_insert(0) += 1;
671                } else if let Some(permit) = self.match_permit_call(expr) {
672                    self.state.permits.insert(permit);
673                } else if let Some(sink) = match_sink(self.gcx, expr)
674                    && !self.is_safe(sink.from)
675                    && !self.consume_repayment(&sink)
676                {
677                    // A prior permit does not make the sink safe: a non-permit token with a
678                    // fallback (e.g. WETH) silently accepts the permit.
679                    let lint = if self.permit_covers(&sink) {
680                        &ARBITRARY_SEND_ERC20_PERMIT
681                    } else {
682                        &ARBITRARY_SEND_ERC20
683                    };
684                    self.hits.push((expr.span, lint));
685                }
686                // Arguments are evaluated before the callee runs: walk them first, then drop facts
687                // about state the callee writes.
688                let _ = self.walk_expr(expr);
689                if let Some(fid) = self.gcx.resolved_function(callee)
690                    && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
691                {
692                    for v in state_writes(self.gcx, fid) {
693                        self.invalidate(v);
694                    }
695                }
696            }
697            ExprKind::Assign(lhs, _, rhs) => {
698                self.assign_lhs(lhs, Some(rhs));
699                let _ = self.walk_expr(expr);
700            }
701            ExprKind::Delete(target) => {
702                self.assign_lhs(target, None);
703                let _ = self.walk_expr(expr);
704            }
705            _ => {
706                let _ = self.walk_expr(expr);
707            }
708        }
709        ControlFlow::Continue(())
710    }
711}
712
713/// True when `expr` is `base(..)` or a variable in `vars`, through parens, `payable(..)`, casts,
714/// ternaries whose both arms qualify and no-arg helpers whose body returns such an expression.
715fn origin_matches(
716    gcx: Gcx<'_>,
717    expr: &Expr<'_>,
718    depth: u8,
719    vars: &HashSet<VariableId>,
720    base: fn(Gcx<'_>, &Expr<'_>) -> bool,
721) -> bool {
722    let expr = expr.peel_parens();
723    match &expr.kind {
724        ExprKind::Payable(inner) => return origin_matches(gcx, inner, depth, vars, base),
725        ExprKind::Call(callee, args) if is_address_like_cast(gcx, callee) => {
726            return args.exprs().next().is_some_and(|e| origin_matches(gcx, e, depth, vars, base));
727        }
728        _ => {}
729    }
730    base(gcx, expr)
731        || match &expr.kind {
732            ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(|v| vars.contains(&v)),
733            ExprKind::Ternary(_, t, f) => {
734                origin_matches(gcx, t, depth, vars, base)
735                    && origin_matches(gcx, f, depth, vars, base)
736            }
737            ExprKind::Call(callee, args) if depth > 0 && args.exprs().next().is_none() => gcx
738                .resolved_function(callee)
739                .filter(|_| matches!(callee.peel_parens().kind, ExprKind::Ident(_)))
740                .is_some_and(|fid| {
741                    let f = gcx.hir.function(fid);
742                    f.parameters.is_empty()
743                        && matches!(f.body.map(|b| b.stmts), Some([stmt])
744                            if matches!(&stmt.kind, StmtKind::Return(Some(e))
745                                if origin_matches(gcx, e, depth - 1, vars, base)))
746                }),
747            _ => false,
748        }
749}
750
751/// `a + b` with both operands variables.
752fn sum_operands(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<(VariableId, VariableId)> {
753    match &expr.peel_parens().kind {
754        ExprKind::Binary(lhs, op, rhs) if op.kind == BinOpKind::Add => {
755            underlying_var(gcx, lhs).zip(underlying_var(gcx, rhs))
756        }
757        _ => None,
758    }
759}
760
761/// `token` or `cfg.token` receiver key, through casts and `payable(..)`.
762fn token_key(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<TokenKey> {
763    if let Some(v) = underlying_var(gcx, expr) {
764        return Some(TokenKey::Var(v));
765    }
766    match &expr.peel_parens().kind {
767        ExprKind::Member(base, ident) => {
768            Some(TokenKey::Field(underlying_var(gcx, base)?, ident.name))
769        }
770        _ => None,
771    }
772}
773
774/// Call arguments in declaration order, with the expected arity.
775fn canonical_args<'gcx>(
776    gcx: Gcx<'gcx>,
777    expr: &Expr<'gcx>,
778    arity: usize,
779) -> Option<Vec<&'gcx Expr<'gcx>>> {
780    let ExprKind::Call(_, args) = &expr.kind else { return None };
781    if args.len() != arity {
782        return None;
783    }
784    (0..arity).map(|index| gcx.call_arg(expr, index)).collect()
785}
786
787/// EIP-3156 `receiver.onFlashLoan(initiator, token, amount, fee, data)` on a receiver type
788/// declaring the exact signature. Literal arguments yield `None`.
789fn match_flash_loan_call<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> Option<PendingRepayment> {
790    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
791    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
792    if ident.name.as_str() != "onFlashLoan" {
793        return None;
794    }
795    let a = canonical_args(gcx, expr, 5)?;
796    let cid = receiver_contract_id(gcx, recv)?;
797    if !interface_has_function(
798        gcx,
799        cid,
800        "onFlashLoan(address,address,uint256,uint256,bytes)",
801        &["address", "address", "uint256", "uint256", "bytes"],
802        &["bytes32"],
803    ) {
804        return None;
805    }
806    Some(PendingRepayment {
807        receiver: underlying_var(gcx, recv)?,
808        token: underlying_var(gcx, a[1])?,
809        amount: underlying_var(gcx, a[2])?,
810        fee: underlying_var(gcx, a[3])?,
811    })
812}
813
814/// `recv.transferFrom(from, to, amt)` / `recv.safeTransferFrom(from, to, amt)` on a contract
815/// declaring ERC20's `transferFrom(address,address,uint256) returns (bool)` (ERC721's same-named
816/// overload is excluded), `addr.safeTransferFrom(..)` via `using SafeTransferLib for address`,
817/// or the library form `Lib.safeTransferFrom(token, from, to, amt)`.
818fn match_sink<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option<Sink<'gcx>> {
819    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
820    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
821    let name = ident.name.as_str();
822    if matches!(name, "transferFrom" | "safeTransferFrom")
823        && let Some(a) = canonical_args(gcx, expr, 3)
824    {
825        let erc20 = receiver_contract_id(gcx, recv).is_some_and(|cid| has_transfer_from(gcx, cid));
826        let attached = gcx.resolved_call(expr).is_some_and(|resolved| {
827            resolved.attached
828                && resolved
829                    .res
830                    .as_function()
831                    .and_then(|fid| gcx.hir.function(fid).contract)
832                    .is_some_and(|cid| library_has_safe_transfer_from(gcx, cid))
833        });
834        if erc20 || (name == "safeTransferFrom" && attached && expr_is_address(gcx, recv)) {
835            return Some(Sink { from: a[0], to: a[1], amount: a[2], token: token_key(gcx, recv) });
836        }
837    }
838    if name == "safeTransferFrom"
839        && let Some(a) = canonical_args(gcx, expr, 4)
840        && let Some(cid) = receiver_contract_id(gcx, recv)
841        && gcx.hir.contract(cid).kind == ContractKind::Library
842        && library_has_safe_transfer_from(gcx, cid)
843    {
844        return Some(Sink { from: a[1], to: a[2], amount: a[3], token: token_key(gcx, a[0]) });
845    }
846    None
847}
848
849/// State variables written by `fid` or by the internal functions it calls (one level deep).
850fn state_writes<'gcx>(gcx: Gcx<'gcx>, fid: FunctionId) -> HashSet<VariableId> {
851    let mut w = StateWrites { gcx, out: HashSet::new(), callees: Vec::new() };
852    w.scan(fid);
853    for callee in std::mem::take(&mut w.callees) {
854        w.scan(callee);
855    }
856    w.out
857}
858
859struct StateWrites<'gcx> {
860    gcx: Gcx<'gcx>,
861    out: HashSet<VariableId>,
862    callees: Vec<FunctionId>,
863}
864
865impl StateWrites<'_> {
866    fn scan(&mut self, fid: FunctionId) {
867        if let Some(body) = self.gcx.hir.function(fid).body {
868            for stmt in body.stmts {
869                let _ = self.visit_stmt(stmt);
870            }
871        }
872    }
873}
874
875impl<'gcx> Visit<'gcx> for StateWrites<'gcx> {
876    type BreakValue = Never;
877
878    fn hir(&self) -> &'gcx Hir<'gcx> {
879        &self.gcx.hir
880    }
881
882    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
883        match &expr.kind {
884            ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => {
885                self.out.extend(state_lhs_vars(self.gcx, lhs));
886            }
887            ExprKind::Call(callee, ..)
888                if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) =>
889            {
890                self.callees.extend(self.gcx.resolved_function(callee));
891            }
892            _ => {}
893        }
894        self.walk_expr(expr)
895    }
896}
897
898/// Internal functions and modifiers are only reachable from the compilation unit, so their
899/// parameters can be proven safe from the invocation sites seen there.
900const fn is_internal_only(f: &hir::Function<'_>) -> bool {
901    !f.parameters.is_empty()
902        && (matches!(f.kind, FunctionKind::Modifier)
903            || (f.kind.is_function()
904                && matches!(f.visibility, Visibility::Private | Visibility::Internal)))
905}
906
907/// Per internal function, whether every call site passes a statically safe / self argument for
908/// each parameter; `None` when some call site could not be matched to the parameters.
909type CallsiteFacts = HashMap<FunctionId, Option<Vec<(bool, bool)>>>;
910
911thread_local! {
912    static CALLSITE_INDEX: RefCell<Option<(usize, Rc<CallsiteFacts>)>> = const { RefCell::new(None) };
913}
914
915/// The call-site index of `hir`, built once per compilation unit.
916fn callsite_index<'gcx>(gcx: Gcx<'gcx>) -> Rc<CallsiteFacts> {
917    let key = std::ptr::from_ref(&gcx.hir) as usize;
918    CALLSITE_INDEX.with(|cell| {
919        let mut slot = cell.borrow_mut();
920        if let Some((cached_key, index)) = &*slot
921            && *cached_key == key
922        {
923            return index.clone();
924        }
925        let mut c = CallsiteCollector { gcx, out: HashMap::new() };
926        for (_, func) in gcx.hir.functions_enumerated() {
927            for m in func.modifiers {
928                if let ItemId::Function(fid) = m.id {
929                    c.record(fid, &m.args);
930                }
931            }
932            for stmt in func.body.map_or(&[][..], |b| b.stmts) {
933                let _ = c.visit_stmt(stmt);
934            }
935        }
936        let index = Rc::new(c.out);
937        *slot = Some((key, index.clone()));
938        index
939    })
940}
941
942struct CallsiteCollector<'gcx> {
943    gcx: Gcx<'gcx>,
944    out: CallsiteFacts,
945}
946
947impl<'gcx> CallsiteCollector<'gcx> {
948    fn record(&mut self, fid: FunctionId, args: &'gcx CallArgs<'gcx>) {
949        let f = self.gcx.hir.function(fid);
950        if !is_internal_only(f) {
951            return;
952        }
953        let call_args =
954            f.parameters.iter().map(|&p| arg_for_param(self.gcx, fid, p, args)).collect();
955        let entry =
956            self.out.entry(fid).or_insert_with(|| Some(vec![(true, true); f.parameters.len()]));
957        let (Some(facts), Some(call_args)) = (entry.as_mut(), call_args) else {
958            *entry = None;
959            return;
960        };
961        let none = HashSet::new();
962        for ((safe, is_self), arg) in facts.iter_mut().zip::<Vec<_>>(call_args) {
963            *safe &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, |gcx, e| {
964                is_msg_sender(gcx, e) || is_address_self(gcx, e)
965            });
966            *is_self &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, is_address_self);
967        }
968    }
969}
970
971impl<'gcx> Visit<'gcx> for CallsiteCollector<'gcx> {
972    type BreakValue = Never;
973
974    fn hir(&self) -> &'gcx Hir<'gcx> {
975        &self.gcx.hir
976    }
977
978    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
979        if let ExprKind::Call(callee, args) = &expr.kind
980            && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
981            && let Some(fid) = self.gcx.resolved_function(callee)
982        {
983            self.record(fid, args);
984        }
985        self.walk_expr(expr)
986    }
987}
988
989/// ERC20's `transferFrom(address,address,uint256) returns (bool)`.
990fn has_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool {
991    interface_has_function(
992        gcx,
993        cid,
994        "transferFrom(address,address,uint256)",
995        &["address", "address", "uint256"],
996        &["bool"],
997    )
998}
999
1000fn interface_has_function(
1001    gcx: Gcx<'_>,
1002    cid: ContractId,
1003    signature: &str,
1004    params: &[&str],
1005    returns: &[&str],
1006) -> bool {
1007    gcx.interface_functions(cid).all().iter().any(|function| {
1008        let f = gcx.hir.function(function.id);
1009        gcx.item_signature(function.id.into()) == signature
1010            && f.parameters.len() == params.len()
1011            && f.returns.len() == returns.len()
1012            && f.parameters.iter().zip(params).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi))
1013            && f.returns.iter().zip(returns).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi))
1014    })
1015}
1016
1017/// 4-arg `safeTransferFrom(token, address, address, uint256)` where `token` is `address` (Solady)
1018/// or an ERC20 contract type (OpenZeppelin `SafeERC20`); ERC721/1155 helpers are excluded since
1019/// their `transferFrom` has no return value.
1020fn library_has_safe_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool {
1021    let hir = &gcx.hir;
1022    hir.contract(cid).functions().any(|fid| {
1023        let f = hir.function(fid);
1024        let [token, from, to, amount] = f.parameters else { return false };
1025        let token_ok = match hir.variable(*token).ty.kind {
1026            TypeKind::Custom(ItemId::Contract(token_cid)) => has_transfer_from(gcx, token_cid),
1027            _ => is_address_type(hir, *token),
1028        };
1029        f.name.is_some_and(|n| n.name.as_str() == "safeTransferFrom")
1030            && token_ok
1031            && is_address_type(hir, *from)
1032            && is_address_type(hir, *to)
1033            && is_elementary(hir, *amount, "uint256")
1034    })
1035}