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    /// Every variable written on any path.
185    written: HashSet<VariableId>,
186    hits: Vec<(Span, &'static SolLint)>,
187}
188
189impl<'gcx> Analyzer<'gcx> {
190    fn new(gcx: Gcx<'gcx>) -> Self {
191        Self {
192            gcx,
193            state: State::default(),
194            loop_exits: Vec::new(),
195            written: HashSet::new(),
196            hits: Vec::new(),
197        }
198    }
199
200    /// Seeds facts about `immutable`/`constant` state of `cid` from declaration initializers and
201    /// the constructor body.
202    fn seed_immutable_facts(&mut self, cid: ContractId) {
203        for v in self.gcx.hir.contract(cid).variables() {
204            let var = self.gcx.hir.variable(v);
205            if (var.is_immutable() || var.is_constant())
206                && let Some(init) = var.initializer
207            {
208                if self.is_safe(init) {
209                    self.state.safe_vars.insert(v);
210                }
211                if self.is_self_expr(init) {
212                    self.state.self_vars.insert(v);
213                }
214            }
215        }
216        if let Some(ctor) = self.gcx.hir.contract(cid).ctor
217            && let Some(body) = self.gcx.hir.function(ctor).body
218        {
219            let mut a = Self::new(self.gcx);
220            a.visit_stmts(body.stmts);
221            let is_state = |v: &&VariableId| self.gcx.hir.variable(**v).kind.is_state();
222            self.state.safe_vars.extend(a.state.safe_vars.iter().filter(is_state));
223            self.state.self_vars.extend(a.state.self_vars.iter().filter(is_state));
224        }
225    }
226
227    /// Seeds parameters of an internal function or modifier that every invocation site in the
228    /// compilation unit passes a safe argument for.
229    fn seed_callsite_facts(&mut self, func: &'gcx hir::Function<'gcx>) {
230        if !is_internal_only(func) {
231            return;
232        }
233        let index = callsite_index(self.gcx);
234        let Some((fid, _)) =
235            self.gcx.hir.functions_enumerated().find(|(_, f)| std::ptr::eq(*f, func))
236        else {
237            return;
238        };
239        let Some(Some(facts)) = index.get(&fid) else { return };
240        for (&param, &(safe, is_self)) in func.parameters.iter().zip(facts) {
241            if safe {
242                self.state.safe_vars.insert(param);
243            }
244            if is_self {
245                self.state.self_vars.insert(param);
246            }
247        }
248    }
249
250    /// Hoists `require(param == msg.sender | address(this))` guards from the prefix of modifier
251    /// `m` onto the caller's argument variables.
252    fn hoist_modifier_facts(&mut self, m: &'gcx Modifier<'gcx>) {
253        let Some(fid) = m.id.as_function() else { return };
254        let Some(prefix) = modifier_prefix(&self.gcx.hir, fid) else { return };
255        let modifier = self.gcx.hir.function(fid);
256        let mut a = Self::new(self.gcx);
257        for stmt in prefix {
258            a.stmt(stmt);
259        }
260        for &param in modifier.parameters {
261            // A fact about a rewritten parameter says nothing about the caller's variable.
262            if !a.written.contains(&param)
263                && let Some(caller) = arg_for_param(self.gcx, fid, param, &m.args)
264                    .and_then(|expr| underlying_var(self.gcx, expr))
265                && self.is_safe_target(caller)
266            {
267                if a.state.safe_vars.contains(&param) {
268                    self.state.safe_vars.insert(caller);
269                }
270                if a.state.self_vars.contains(&param) {
271                    self.state.self_vars.insert(caller);
272                }
273            }
274        }
275    }
276
277    /// `msg.sender`, `address(this)` or a tracked-safe variable.
278    fn is_safe(&self, expr: &Expr<'_>) -> bool {
279        origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.safe_vars, |gcx, e| {
280            is_msg_sender(gcx, e) || is_address_self(gcx, e)
281        })
282    }
283
284    /// `address(this)` or a tracked self alias.
285    fn is_self_expr(&self, expr: &Expr<'_>) -> bool {
286        origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.self_vars, is_address_self)
287    }
288
289    fn is_safe_target(&self, v: VariableId) -> bool {
290        let var = self.gcx.hir.variable(v);
291        !var.kind.is_state() || var.is_immutable() || var.is_constant()
292    }
293
294    /// Follows the alias chain to its root; bounded to guard against cycles.
295    fn canonical(&self, v: VariableId) -> VariableId {
296        let mut cur = v;
297        for _ in 0..8 {
298            match self.state.aliases.get(&cur) {
299                Some(next) if *next != cur => cur = *next,
300                _ => break,
301            }
302        }
303        cur
304    }
305
306    fn canonical_key(&self, key: TokenKey) -> TokenKey {
307        match key {
308            TokenKey::Var(v) => TokenKey::Var(self.canonical(v)),
309            TokenKey::Field(v, name) => TokenKey::Field(self.canonical(v), name),
310        }
311    }
312
313    /// Drops every fact about `v`.
314    fn invalidate(&mut self, v: VariableId) {
315        let s = &mut self.state;
316        s.safe_vars.remove(&v);
317        s.self_vars.remove(&v);
318        s.aliases.retain(|k, dst| *k != v && *dst != v);
319        s.sum_of.retain(|k, (a, b)| *k != v && *a != v && *b != v);
320        s.permits.retain(|p| !p.token.touches(v) && p.owner != v);
321        s.repayments.retain(|r, _| ![r.receiver, r.token, r.amount, r.fee].contains(&v));
322    }
323
324    fn eval_rhs(&self, rhs: Option<&Expr<'_>>) -> Rhs {
325        let Some(rhs) = rhs else { return Rhs::default() };
326        Rhs {
327            safe: self.is_safe(rhs),
328            is_self: self.is_self_expr(rhs),
329            alias: underlying_var(self.gcx, rhs).map(|v| self.canonical(v)),
330            sum: sum_operands(self.gcx, rhs),
331        }
332    }
333
334    fn assign_var(&mut self, target: VariableId, rhs: Rhs) {
335        self.written.insert(target);
336        self.invalidate(target);
337        if !self.is_safe_target(target) {
338            return;
339        }
340        if rhs.safe {
341            self.state.safe_vars.insert(target);
342        }
343        if rhs.is_self {
344            self.state.self_vars.insert(target);
345        }
346        if let Some(alias) = rhs.alias
347            && alias != target
348        {
349            self.state.aliases.insert(target, alias);
350        }
351        if let Some(sum) = rhs.sum {
352            self.state.sum_of.insert(target, sum);
353        }
354    }
355
356    /// Handles single and tuple LHS; `rhs == None` is an unknown value (`delete`).
357    fn assign_lhs(&mut self, lhs: &Expr<'_>, rhs: Option<&Expr<'_>>) {
358        // Writing `cfg.token` drops permits keyed on that field.
359        if let ExprKind::Member(base, ident) = &lhs.peel_parens().kind
360            && let Some(base) = underlying_var(self.gcx, base)
361        {
362            let key = TokenKey::Field(self.canonical(base), ident.name);
363            self.state.permits.retain(|p| p.token != key);
364        }
365        if let Some(elems) = tuple_elems(lhs) {
366            let rhs = rhs.and_then(tuple_elems);
367            // Evaluate every slot before writing any, so `(x, y) = (y, x)` stays consistent.
368            let slots: Vec<_> = elems
369                .iter()
370                .enumerate()
371                .map(|(i, l)| (*l, self.eval_rhs(rhs.and_then(|r| r.get(i).copied().flatten()))))
372                .collect();
373            for (lhs, rhs) in slots {
374                if let Some(v) = lhs.and_then(|expr| underlying_var(self.gcx, expr)) {
375                    self.assign_var(v, rhs);
376                }
377            }
378        } else if let Some(v) = underlying_var(self.gcx, lhs) {
379            let rhs = self.eval_rhs(rhs);
380            self.assign_var(v, rhs);
381        }
382    }
383
384    /// Records variables proven safe by `pred` (`!pred` when `negate`).
385    fn add_facts(&mut self, pred: &Expr<'_>, negate: bool) {
386        match &pred.peel_parens().kind {
387            ExprKind::Binary(lhs, op, rhs) => {
388                let (eq, and, or) = if negate {
389                    (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And)
390                } else {
391                    (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or)
392                };
393                if op.kind == and {
394                    self.add_facts(lhs, negate);
395                    self.add_facts(rhs, negate);
396                } else if op.kind == or {
397                    // Only facts established by both disjuncts hold.
398                    let before = self.state.clone();
399                    self.add_facts(lhs, negate);
400                    let after_lhs = std::mem::replace(&mut self.state, before);
401                    self.add_facts(rhs, negate);
402                    self.state = after_lhs.meet(&self.state);
403                } else if op.kind == eq {
404                    for (a, b) in [(lhs, rhs), (rhs, lhs)] {
405                        if let Some(v) = underlying_var(self.gcx, b)
406                            && self.is_safe_target(v)
407                        {
408                            if self.is_safe(a) {
409                                self.state.safe_vars.insert(v);
410                            }
411                            if self.is_self_expr(a) {
412                                self.state.self_vars.insert(v);
413                            }
414                        }
415                    }
416                }
417            }
418            ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => {
419                self.add_facts(inner, !negate);
420            }
421            _ => {}
422        }
423    }
424
425    /// EIP-2612 `token.permit(owner, <self>, ...)` or the OpenZeppelin-style wrapper
426    /// `Lib.safePermit(token, owner, <self>, ...)`.
427    fn match_permit_call(&self, expr: &Expr<'gcx>) -> Option<PermitRecord> {
428        let ExprKind::Call(callee, ..) = &expr.kind else { return None };
429        let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
430        let (token, owner, spender) = match ident.name.as_str() {
431            "permit" => {
432                let a = canonical_args(self.gcx, expr, 7)?;
433                (*recv, a[0], a[1])
434            }
435            "safePermit"
436                if receiver_contract_id(self.gcx, recv).is_some_and(|cid| {
437                    self.gcx.hir.contract(cid).kind == ContractKind::Library
438                }) =>
439            {
440                let a = canonical_args(self.gcx, expr, 8)?;
441                (a[0], a[1], a[2])
442            }
443            _ => return None,
444        };
445        if !self.is_self_expr(spender) {
446            return None;
447        }
448        Some(PermitRecord {
449            token: self.canonical_key(token_key(self.gcx, token)?),
450            owner: self.canonical(underlying_var(self.gcx, owner)?),
451        })
452    }
453
454    fn permit_covers(&self, sink: &Sink<'_>) -> bool {
455        let (Some(token), Some(owner)) = (sink.token, underlying_var(self.gcx, sink.from)) else {
456            return false;
457        };
458        self.state.permits.contains(&PermitRecord {
459            token: self.canonical_key(token),
460            owner: self.canonical(owner),
461        })
462    }
463
464    /// `expr` is `amount + fee` (either order), or a local bound to that sum.
465    fn amount_matches(&self, expr: &Expr<'_>, amount: VariableId, fee: VariableId) -> bool {
466        let sum = sum_operands(self.gcx, expr).or_else(|| {
467            underlying_var(self.gcx, expr).and_then(|v| self.state.sum_of.get(&v).copied())
468        });
469        matches!(sum, Some(pair) if pair == (amount, fee) || pair == (fee, amount))
470    }
471
472    /// Consumes one pending repayment matched by a sink pulling `amount + fee` from the flash-loan
473    /// receiver back to `address(this)`.
474    fn consume_repayment(&mut self, sink: &Sink<'_>) -> bool {
475        let (Some(from), Some(TokenKey::Var(token))) =
476            (underlying_var(self.gcx, sink.from), sink.token)
477        else {
478            return false;
479        };
480        if !self.is_self_expr(sink.to) {
481            return false;
482        }
483        let Some(rep) = self.state.repayments.keys().copied().find(|r| {
484            r.receiver == from
485                && r.token == token
486                && self.amount_matches(sink.amount, r.amount, r.fee)
487        }) else {
488            return false;
489        };
490        match self.state.repayments.get_mut(&rep) {
491            Some(count) if *count > 1 => *count -= 1,
492            _ => {
493                self.state.repayments.remove(&rep);
494            }
495        }
496        true
497    }
498
499    /// Visits `stmts` up to the first that cannot fall through; returns whether the end is
500    /// reachable.
501    fn visit_stmts(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> bool {
502        stmts.iter().all(|s| self.stmt(s))
503    }
504
505    /// Visits `stmt`, returning whether control can fall through it.
506    fn stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool {
507        match &stmt.kind {
508            StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => return self.visit_stmts(b.stmts),
509            StmtKind::Break | StmtKind::Continue => {
510                let state = self.state.clone();
511                if let Some(exits) = self.loop_exits.last_mut() {
512                    exits.push(state);
513                }
514                return false;
515            }
516            StmtKind::If(cond, then, else_) => {
517                let _ = self.visit_expr(cond);
518                let before = self.state.clone();
519                self.add_facts(cond, false);
520                let then_falls = self.stmt(then);
521                let after_then = std::mem::replace(&mut self.state, before);
522                self.add_facts(cond, true);
523                let else_falls = else_.is_none_or(|e| self.stmt(e));
524                match (then_falls, else_falls) {
525                    (true, true) => self.state = after_then.meet(&self.state),
526                    (true, false) => self.state = after_then,
527                    _ => {}
528                }
529                return then_falls || else_falls;
530            }
531            StmtKind::Loop(block, source) => {
532                // Only facts holding on every exit survive. `for`/`while` bodies may not run at
533                // all; `do-while` bodies run at least once.
534                let baseline = (!matches!(source, LoopSource::DoWhile)).then(|| self.state.clone());
535                self.loop_exits.push(baseline.into_iter().collect());
536                if self.visit_stmts(block.stmts)
537                    && loop_update(*source).is_none_or(|update| self.stmt(update))
538                {
539                    let state = self.state.clone();
540                    self.loop_exits.last_mut().expect("pushed above").push(state);
541                }
542                let exits = self.loop_exits.pop().expect("pushed above");
543                let falls = !exits.is_empty();
544                if let Some(joined) = exits.into_iter().reduce(|a, b| a.meet(&b)) {
545                    self.state = joined;
546                }
547                return falls;
548            }
549            StmtKind::Try(t) => {
550                // Only the success clause sees the effects of the tried call.
551                let before = self.state.clone();
552                let _ = self.visit_expr(&t.expr);
553                let after_call = self.state.clone();
554                let mut joined = None::<State>;
555                for (i, clause) in t.clauses.iter().enumerate() {
556                    self.state = if i == 0 { after_call.clone() } else { before.clone() };
557                    if self.visit_stmts(clause.block.stmts) {
558                        joined = Some(
559                            joined.map_or_else(|| self.state.clone(), |j| j.meet(&self.state)),
560                        );
561                    }
562                }
563                let falls = joined.is_some();
564                self.state = joined.unwrap_or(after_call);
565                return falls;
566            }
567            StmtKind::DeclSingle(vid) => {
568                if let Some(init) = self.gcx.hir.variable(*vid).initializer {
569                    let rhs = self.eval_rhs(Some(init));
570                    self.assign_var(*vid, rhs);
571                }
572            }
573            StmtKind::DeclMulti(vars, init) => {
574                for (vid, rhs) in vars.iter().zip(tuple_elems(init).into_iter().flatten()) {
575                    if let (Some(vid), Some(rhs)) = (vid, rhs) {
576                        let rhs = self.eval_rhs(Some(rhs));
577                        self.assign_var(*vid, rhs);
578                    }
579                }
580            }
581            _ => {}
582        }
583        let _ = self.walk_stmt(stmt);
584        !branch_always_exits(self.gcx, stmt)
585    }
586}
587
588impl<'gcx> Visit<'gcx> for Analyzer<'gcx> {
589    type BreakValue = Never;
590
591    fn hir(&self) -> &'gcx Hir<'gcx> {
592        &self.gcx.hir
593    }
594
595    fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<Never> {
596        self.stmt(stmt);
597        ControlFlow::Continue(())
598    }
599
600    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
601        match &expr.kind {
602            // `rhs` may not execute: its facts and writes survive only if they also hold without
603            // it, while `lhs` facts flow into `rhs`. Sinks in `rhs` are still reported.
604            ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => {
605                let _ = self.visit_expr(lhs);
606                let skipped = self.state.clone();
607                self.add_facts(lhs, op.kind == BinOpKind::Or);
608                let _ = self.visit_expr(rhs);
609                self.state = skipped.meet(&self.state);
610            }
611            ExprKind::Call(callee, args, _) if is_require_or_assert(self.gcx, callee) => {
612                // Sinks inside the predicate run before the guard takes effect.
613                let _ = self.walk_expr(expr);
614                if let Some(cond) = args.exprs().next() {
615                    self.add_facts(cond, false);
616                }
617            }
618            ExprKind::Call(callee, ..) => {
619                if let Some(rep) = match_flash_loan_call(self.gcx, expr) {
620                    *self.state.repayments.entry(rep).or_insert(0) += 1;
621                } else if let Some(permit) = self.match_permit_call(expr) {
622                    self.state.permits.insert(permit);
623                } else if let Some(sink) = match_sink(self.gcx, expr)
624                    && !self.is_safe(sink.from)
625                    && !self.consume_repayment(&sink)
626                {
627                    // A prior permit does not make the sink safe: a non-permit token with a
628                    // fallback (e.g. WETH) silently accepts the permit.
629                    let lint = if self.permit_covers(&sink) {
630                        &ARBITRARY_SEND_ERC20_PERMIT
631                    } else {
632                        &ARBITRARY_SEND_ERC20
633                    };
634                    self.hits.push((expr.span, lint));
635                }
636                // Arguments are evaluated before the callee runs: walk them first, then drop facts
637                // about state the callee writes.
638                let _ = self.walk_expr(expr);
639                if let Some(fid) = self.gcx.resolved_function(callee)
640                    && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
641                {
642                    for v in state_writes(self.gcx, fid) {
643                        self.invalidate(v);
644                    }
645                }
646            }
647            ExprKind::Assign(lhs, _, rhs) => {
648                self.assign_lhs(lhs, Some(rhs));
649                let _ = self.walk_expr(expr);
650            }
651            ExprKind::Delete(target) => {
652                self.assign_lhs(target, None);
653                let _ = self.walk_expr(expr);
654            }
655            _ => {
656                let _ = self.walk_expr(expr);
657            }
658        }
659        ControlFlow::Continue(())
660    }
661}
662
663/// True when `expr` is `base(..)` or a variable in `vars`, through parens, `payable(..)`, casts,
664/// ternaries whose both arms qualify and no-arg helpers whose body returns such an expression.
665fn origin_matches(
666    gcx: Gcx<'_>,
667    expr: &Expr<'_>,
668    depth: u8,
669    vars: &HashSet<VariableId>,
670    base: fn(Gcx<'_>, &Expr<'_>) -> bool,
671) -> bool {
672    let expr = expr.peel_parens();
673    match &expr.kind {
674        ExprKind::Payable(inner) => return origin_matches(gcx, inner, depth, vars, base),
675        ExprKind::Call(callee, args, _) if is_address_like_cast(gcx, callee) => {
676            return args.exprs().next().is_some_and(|e| origin_matches(gcx, e, depth, vars, base));
677        }
678        _ => {}
679    }
680    base(gcx, expr)
681        || match &expr.kind {
682            ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(|v| vars.contains(&v)),
683            ExprKind::Ternary(_, t, f) => {
684                origin_matches(gcx, t, depth, vars, base)
685                    && origin_matches(gcx, f, depth, vars, base)
686            }
687            ExprKind::Call(callee, args, _) if depth > 0 && args.exprs().next().is_none() => gcx
688                .resolved_function(callee)
689                .filter(|_| matches!(callee.peel_parens().kind, ExprKind::Ident(_)))
690                .is_some_and(|fid| {
691                    let f = gcx.hir.function(fid);
692                    f.parameters.is_empty()
693                        && matches!(f.body.map(|b| b.stmts), Some([stmt])
694                            if matches!(&stmt.kind, StmtKind::Return(Some(e))
695                                if origin_matches(gcx, e, depth - 1, vars, base)))
696                }),
697            _ => false,
698        }
699}
700
701/// `a + b` with both operands variables.
702fn sum_operands(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<(VariableId, VariableId)> {
703    match &expr.peel_parens().kind {
704        ExprKind::Binary(lhs, op, rhs) if op.kind == BinOpKind::Add => {
705            underlying_var(gcx, lhs).zip(underlying_var(gcx, rhs))
706        }
707        _ => None,
708    }
709}
710
711/// `token` or `cfg.token` receiver key, through casts and `payable(..)`.
712fn token_key(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<TokenKey> {
713    if let Some(v) = underlying_var(gcx, expr) {
714        return Some(TokenKey::Var(v));
715    }
716    match &expr.peel_parens().kind {
717        ExprKind::Member(base, ident) => {
718            Some(TokenKey::Field(underlying_var(gcx, base)?, ident.name))
719        }
720        _ => None,
721    }
722}
723
724/// Call arguments in declaration order, with the expected arity.
725fn canonical_args<'gcx>(
726    gcx: Gcx<'gcx>,
727    expr: &Expr<'gcx>,
728    arity: usize,
729) -> Option<Vec<&'gcx Expr<'gcx>>> {
730    let ExprKind::Call(_, args, _) = &expr.kind else { return None };
731    if args.len() != arity {
732        return None;
733    }
734    (0..arity).map(|index| gcx.call_arg(expr, index)).collect()
735}
736
737/// EIP-3156 `receiver.onFlashLoan(initiator, token, amount, fee, data)` on a receiver type
738/// declaring the exact signature. Literal arguments yield `None`.
739fn match_flash_loan_call<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> Option<PendingRepayment> {
740    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
741    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
742    if ident.name.as_str() != "onFlashLoan" {
743        return None;
744    }
745    let a = canonical_args(gcx, expr, 5)?;
746    let cid = receiver_contract_id(gcx, recv)?;
747    if !interface_has_function(
748        gcx,
749        cid,
750        "onFlashLoan(address,address,uint256,uint256,bytes)",
751        &["address", "address", "uint256", "uint256", "bytes"],
752        &["bytes32"],
753    ) {
754        return None;
755    }
756    Some(PendingRepayment {
757        receiver: underlying_var(gcx, recv)?,
758        token: underlying_var(gcx, a[1])?,
759        amount: underlying_var(gcx, a[2])?,
760        fee: underlying_var(gcx, a[3])?,
761    })
762}
763
764/// `recv.transferFrom(from, to, amt)` / `recv.safeTransferFrom(from, to, amt)` on a contract
765/// declaring ERC20's `transferFrom(address,address,uint256) returns (bool)` (ERC721's same-named
766/// overload is excluded), `addr.safeTransferFrom(..)` via `using SafeTransferLib for address`,
767/// or the library form `Lib.safeTransferFrom(token, from, to, amt)`.
768fn match_sink<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option<Sink<'gcx>> {
769    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
770    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
771    let name = ident.name.as_str();
772    if matches!(name, "transferFrom" | "safeTransferFrom")
773        && let Some(a) = canonical_args(gcx, expr, 3)
774    {
775        let erc20 = receiver_contract_id(gcx, recv).is_some_and(|cid| has_transfer_from(gcx, cid));
776        let attached = gcx.resolved_call(expr).is_some_and(|resolved| {
777            resolved.attached
778                && resolved
779                    .res
780                    .as_function()
781                    .and_then(|fid| gcx.hir.function(fid).contract)
782                    .is_some_and(|cid| library_has_safe_transfer_from(gcx, cid))
783        });
784        if erc20 || (name == "safeTransferFrom" && attached && expr_is_address(gcx, recv)) {
785            return Some(Sink { from: a[0], to: a[1], amount: a[2], token: token_key(gcx, recv) });
786        }
787    }
788    if name == "safeTransferFrom"
789        && let Some(a) = canonical_args(gcx, expr, 4)
790        && let Some(cid) = receiver_contract_id(gcx, recv)
791        && gcx.hir.contract(cid).kind == ContractKind::Library
792        && library_has_safe_transfer_from(gcx, cid)
793    {
794        return Some(Sink { from: a[1], to: a[2], amount: a[3], token: token_key(gcx, a[0]) });
795    }
796    None
797}
798
799/// State variables written by `fid` or by the internal functions it calls (one level deep).
800fn state_writes<'gcx>(gcx: Gcx<'gcx>, fid: FunctionId) -> HashSet<VariableId> {
801    let mut w = StateWrites { gcx, out: HashSet::new(), callees: Vec::new() };
802    w.scan(fid);
803    for callee in std::mem::take(&mut w.callees) {
804        w.scan(callee);
805    }
806    w.out
807}
808
809struct StateWrites<'gcx> {
810    gcx: Gcx<'gcx>,
811    out: HashSet<VariableId>,
812    callees: Vec<FunctionId>,
813}
814
815impl StateWrites<'_> {
816    fn scan(&mut self, fid: FunctionId) {
817        if let Some(body) = self.gcx.hir.function(fid).body {
818            for stmt in body.stmts {
819                let _ = self.visit_stmt(stmt);
820            }
821        }
822    }
823}
824
825impl<'gcx> Visit<'gcx> for StateWrites<'gcx> {
826    type BreakValue = Never;
827
828    fn hir(&self) -> &'gcx Hir<'gcx> {
829        &self.gcx.hir
830    }
831
832    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
833        match &expr.kind {
834            ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => {
835                self.out.extend(state_lhs_vars(self.gcx, lhs));
836            }
837            ExprKind::Call(callee, ..)
838                if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) =>
839            {
840                self.callees.extend(self.gcx.resolved_function(callee));
841            }
842            _ => {}
843        }
844        self.walk_expr(expr)
845    }
846}
847
848/// Internal functions and modifiers are only reachable from the compilation unit, so their
849/// parameters can be proven safe from the invocation sites seen there.
850const fn is_internal_only(f: &hir::Function<'_>) -> bool {
851    !f.parameters.is_empty()
852        && (matches!(f.kind, FunctionKind::Modifier)
853            || (f.kind.is_function()
854                && matches!(f.visibility, Visibility::Private | Visibility::Internal)))
855}
856
857/// Per internal function, whether every call site passes a statically safe / self argument for
858/// each parameter; `None` when some call site could not be matched to the parameters.
859type CallsiteFacts = HashMap<FunctionId, Option<Vec<(bool, bool)>>>;
860
861thread_local! {
862    static CALLSITE_INDEX: RefCell<Option<(usize, Rc<CallsiteFacts>)>> = const { RefCell::new(None) };
863}
864
865/// The call-site index of `hir`, built once per compilation unit.
866fn callsite_index<'gcx>(gcx: Gcx<'gcx>) -> Rc<CallsiteFacts> {
867    let key = std::ptr::from_ref(&gcx.hir) as usize;
868    CALLSITE_INDEX.with(|cell| {
869        let mut slot = cell.borrow_mut();
870        if let Some((cached_key, index)) = &*slot
871            && *cached_key == key
872        {
873            return index.clone();
874        }
875        let mut c = CallsiteCollector { gcx, out: HashMap::new() };
876        for (_, func) in gcx.hir.functions_enumerated() {
877            for m in func.modifiers {
878                if let ItemId::Function(fid) = m.id {
879                    c.record(fid, &m.args);
880                }
881            }
882            for stmt in func.body.map_or(&[][..], |b| b.stmts) {
883                let _ = c.visit_stmt(stmt);
884            }
885        }
886        let index = Rc::new(c.out);
887        *slot = Some((key, index.clone()));
888        index
889    })
890}
891
892struct CallsiteCollector<'gcx> {
893    gcx: Gcx<'gcx>,
894    out: CallsiteFacts,
895}
896
897impl<'gcx> CallsiteCollector<'gcx> {
898    fn record(&mut self, fid: FunctionId, args: &'gcx CallArgs<'gcx>) {
899        let f = self.gcx.hir.function(fid);
900        if !is_internal_only(f) {
901            return;
902        }
903        let call_args =
904            f.parameters.iter().map(|&p| arg_for_param(self.gcx, fid, p, args)).collect();
905        let entry =
906            self.out.entry(fid).or_insert_with(|| Some(vec![(true, true); f.parameters.len()]));
907        let (Some(facts), Some(call_args)) = (entry.as_mut(), call_args) else {
908            *entry = None;
909            return;
910        };
911        let none = HashSet::new();
912        for ((safe, is_self), arg) in facts.iter_mut().zip::<Vec<_>>(call_args) {
913            *safe &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, |gcx, e| {
914                is_msg_sender(gcx, e) || is_address_self(gcx, e)
915            });
916            *is_self &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, is_address_self);
917        }
918    }
919}
920
921impl<'gcx> Visit<'gcx> for CallsiteCollector<'gcx> {
922    type BreakValue = Never;
923
924    fn hir(&self) -> &'gcx Hir<'gcx> {
925        &self.gcx.hir
926    }
927
928    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
929        if let ExprKind::Call(callee, args, _) = &expr.kind
930            && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
931            && let Some(fid) = self.gcx.resolved_function(callee)
932        {
933            self.record(fid, args);
934        }
935        self.walk_expr(expr)
936    }
937}
938
939/// ERC20's `transferFrom(address,address,uint256) returns (bool)`.
940fn has_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool {
941    interface_has_function(
942        gcx,
943        cid,
944        "transferFrom(address,address,uint256)",
945        &["address", "address", "uint256"],
946        &["bool"],
947    )
948}
949
950fn interface_has_function(
951    gcx: Gcx<'_>,
952    cid: ContractId,
953    signature: &str,
954    params: &[&str],
955    returns: &[&str],
956) -> bool {
957    gcx.interface_functions(cid).all().iter().any(|function| {
958        let f = gcx.hir.function(function.id);
959        gcx.item_signature(function.id.into()) == signature
960            && f.parameters.len() == params.len()
961            && f.returns.len() == returns.len()
962            && f.parameters.iter().zip(params).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi))
963            && f.returns.iter().zip(returns).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi))
964    })
965}
966
967/// 4-arg `safeTransferFrom(token, address, address, uint256)` where `token` is `address` (Solady)
968/// or an ERC20 contract type (OpenZeppelin `SafeERC20`); ERC721/1155 helpers are excluded since
969/// their `transferFrom` has no return value.
970fn library_has_safe_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool {
971    let hir = &gcx.hir;
972    hir.contract(cid).functions().any(|fid| {
973        let f = hir.function(fid);
974        let [token, from, to, amount] = f.parameters else { return false };
975        let token_ok = match hir.variable(*token).ty.kind {
976            TypeKind::Custom(ItemId::Contract(token_cid)) => has_transfer_from(gcx, token_cid),
977            _ => is_address_type(hir, *token),
978        };
979        f.name.is_some_and(|n| n.name.as_str() == "safeTransferFrom")
980            && token_ok
981            && is_address_type(hir, *from)
982            && is_address_type(hir, *to)
983            && is_elementary(hir, *amount, "uint256")
984    })
985}