Skip to main content

forge_lint/sol/high/
arbitrary_send_erc20.rs

1use super::ArbitrarySendErc20;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast,
8    interface::{Span, data_structures::Never, kw, sym},
9    sema::{
10        Gcx,
11        hir::{
12            self, ContractKind, ElementaryType, ExprKind, ItemId, LoopSource, Res, StmtKind,
13            TypeKind, Visit,
14        },
15        ty::{Ty, TyKind},
16    },
17};
18use std::{
19    cell::RefCell,
20    collections::{HashMap, HashSet},
21    ops::ControlFlow,
22    rc::Rc,
23};
24
25declare_forge_lint!(
26    ARBITRARY_SEND_ERC20,
27    Severity::High,
28    "arbitrary-send-erc20",
29    "`transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`"
30);
31
32declare_forge_lint!(
33    ARBITRARY_SEND_ERC20_PERMIT,
34    Severity::High,
35    "arbitrary-send-erc20-permit",
36    "`transferFrom` uses an arbitrary `from` after `permit`; a non-permit token (e.g. WETH) with a fallback can silently accept the permit and let anyone drain previously-approved tokens"
37);
38
39impl<'hir> LateLintPass<'hir> for ArbitrarySendErc20 {
40    fn check_function(
41        &mut self,
42        ctx: &LintContext,
43        gcx: Gcx<'hir>,
44        hir: &'hir hir::Hir<'hir>,
45        func: &'hir hir::Function<'hir>,
46    ) {
47        if !func.kind.is_function()
48            || matches!(
49                func.state_mutability,
50                ast::StateMutability::Pure | ast::StateMutability::View
51            )
52        {
53            return;
54        }
55        // Library functions typically forward `from` from their caller; flag at the call
56        // site (in user contracts) instead, where the trust boundary actually lives.
57        if func.contract.is_some_and(|cid| hir.contract(cid).kind == ContractKind::Library) {
58            return;
59        }
60        let Some(body) = func.body else { return };
61
62        let has_solady_lib = has_solady_safe_transfer_lib(hir);
63        // Skip when a modifier's prefix definitely exits before `_;`.
64        if func.modifiers.iter().any(|m| modifier_prefix_always_exits(hir, m)) {
65            return;
66        }
67        let mut a = Analyzer::new(gcx, hir, has_solady_lib);
68        // Seed `self_vars` / `safe_vars` with immutable/constant state vars proven
69        // equal to `address(this)` / `msg.sender` by their declaration initializer
70        // or the contract's constructor.
71        if let Some(cid) = func.contract {
72            seed_immutable_facts(gcx, hir, has_solady_lib, cid, &mut a);
73        }
74        seed_internal_callsite_facts(gcx, hir, has_solady_lib, func, &mut a);
75        for m in func.modifiers {
76            collect_modifier_safety(
77                gcx,
78                hir,
79                has_solady_lib,
80                m,
81                &mut a.safe_vars,
82                &mut a.self_vars,
83            );
84        }
85        for stmt in body.stmts {
86            let _ = a.visit_stmt(stmt);
87            // Skip dead code after a definite exit.
88            if branch_always_exits(stmt) {
89                break;
90            }
91        }
92        for (span, lint) in a.hits {
93            ctx.emit(lint, span);
94        }
95    }
96}
97
98/// `(token, owner)` of an EIP-2612 permit recorded earlier on the current path with
99/// `spender == address(this)`.
100#[derive(Clone, Copy, PartialEq, Eq, Hash)]
101struct PermitRecord {
102    token: TokenKey,
103    owner: hir::VariableId,
104}
105
106/// Identifier used to correlate permit / sink token receivers. `Field` lets
107/// `cfg.token.permit(...)` and `cfg.token.transferFrom(...)` match (FN-5).
108#[derive(Clone, Copy, PartialEq, Eq, Hash)]
109enum TokenKey {
110    Var(hir::VariableId),
111    Field(hir::VariableId, solar::interface::Symbol),
112}
113
114impl TokenKey {
115    fn touches(&self, v: hir::VariableId) -> bool {
116        match self {
117            Self::Var(x) | Self::Field(x, _) => *x == v,
118        }
119    }
120}
121
122/// RHS facts captured before any write so tuple-swap assignments stay consistent.
123#[derive(Clone, Copy, Default)]
124struct AssignRhs {
125    is_safe: bool,
126    is_self: bool,
127    alias: Option<hir::VariableId>,
128    sum: Option<(hir::VariableId, hir::VariableId)>,
129}
130
131/// Outstanding EIP-3156 repayment licensed by a prior `onFlashLoan` call.
132#[derive(Clone, Copy, PartialEq, Eq, Hash)]
133struct PendingRepayment {
134    receiver: hir::VariableId,
135    token: hir::VariableId,
136    amount: hir::VariableId,
137    fee: hir::VariableId,
138}
139
140struct Analyzer<'hir> {
141    gcx: Gcx<'hir>,
142    hir: &'hir hir::Hir<'hir>,
143    /// Variables proven safe (equal to `msg.sender` or `address(this)`) on this path.
144    /// Function-locals and `immutable`/`constant` state vars only — mutable storage may be
145    /// rewritten between the check and the sink.
146    safe_vars: HashSet<hir::VariableId>,
147    /// Locals proven equal to `address(this)`. Subset of `safe_vars`, used to recognise
148    /// the permit `spender` arg via an alias.
149    self_vars: HashSet<hir::VariableId>,
150    /// Permits seen earlier on this path. Killed on token/owner reassignment.
151    permits: HashSet<PermitRecord>,
152    /// Pending flash-loan repayments, as a multiset: repeated `onFlashLoan` calls each
153    /// license one consumption.
154    repayments: HashMap<PendingRepayment, u32>,
155    /// `x = y` records `x -> canonical(y)`; killed on writes to either side.
156    aliases: HashMap<hir::VariableId, hir::VariableId>,
157    /// `x = a + b` records `x -> (a, b)`; matches flash-repayment sums.
158    sum_of: HashMap<hir::VariableId, (hir::VariableId, hir::VariableId)>,
159    /// Gates the `using ... for address` sink branch on a `SafeTransferLib` being present.
160    has_solady_lib: bool,
161    hits: Vec<(Span, &'static SolLint)>,
162    /// Vars written by an assignment / `delete`. Used to drop unsound modifier-param
163    /// hoists when the modifier rewrote the param.
164    written: HashSet<hir::VariableId>,
165}
166
167#[derive(Clone)]
168struct FlowState {
169    safe_vars: HashSet<hir::VariableId>,
170    self_vars: HashSet<hir::VariableId>,
171    permits: HashSet<PermitRecord>,
172    repayments: HashMap<PendingRepayment, u32>,
173    aliases: HashMap<hir::VariableId, hir::VariableId>,
174    sum_of: HashMap<hir::VariableId, (hir::VariableId, hir::VariableId)>,
175}
176
177#[derive(Default)]
178struct ProjectIndex {
179    function_ids_by_ptr: HashMap<usize, hir::FunctionId>,
180    internal_callsites: HashMap<hir::FunctionId, ParamCallsiteFacts>,
181}
182
183struct ParamCallsiteFacts {
184    seen: Vec<bool>,
185    all_safe: Vec<bool>,
186    all_self: Vec<bool>,
187    unknown: bool,
188}
189
190impl ParamCallsiteFacts {
191    fn new(len: usize) -> Self {
192        Self {
193            seen: vec![false; len],
194            all_safe: vec![true; len],
195            all_self: vec![true; len],
196            unknown: false,
197        }
198    }
199}
200
201impl FlowState {
202    fn empty() -> Self {
203        Self {
204            safe_vars: HashSet::new(),
205            self_vars: HashSet::new(),
206            permits: HashSet::new(),
207            repayments: HashMap::new(),
208            aliases: HashMap::new(),
209            sum_of: HashMap::new(),
210        }
211    }
212
213    fn intersection(a: &Self, b: &Self) -> Self {
214        Self {
215            safe_vars: a.safe_vars.intersection(&b.safe_vars).copied().collect(),
216            self_vars: a.self_vars.intersection(&b.self_vars).copied().collect(),
217            permits: a.permits.intersection(&b.permits).copied().collect(),
218            // Multiset intersection: min per key.
219            repayments: a
220                .repayments
221                .iter()
222                .filter_map(|(k, va)| b.repayments.get(k).map(|vb| (*k, *va.min(vb))))
223                .collect(),
224            aliases: a
225                .aliases
226                .iter()
227                .filter_map(|(k, v)| (b.aliases.get(k) == Some(v)).then_some((*k, *v)))
228                .collect(),
229            sum_of: a
230                .sum_of
231                .iter()
232                .filter_map(|(k, v)| (b.sum_of.get(k) == Some(v)).then_some((*k, *v)))
233                .collect(),
234        }
235    }
236
237    fn intersection_all(mut states: impl Iterator<Item = Self>) -> Self {
238        let mut out = states.next().unwrap_or_else(Self::empty);
239        for state in states {
240            out = Self::intersection(&out, &state);
241        }
242        out
243    }
244}
245
246/// Recursion budget for `_msgSender()`-style helper chains.
247const HELPER_DEPTH: u8 = 3;
248
249impl<'hir> Analyzer<'hir> {
250    fn new(gcx: Gcx<'hir>, hir: &'hir hir::Hir<'hir>, has_solady_lib: bool) -> Self {
251        Self {
252            gcx,
253            hir,
254            safe_vars: HashSet::new(),
255            self_vars: HashSet::new(),
256            permits: HashSet::new(),
257            repayments: HashMap::new(),
258            aliases: HashMap::new(),
259            sum_of: HashMap::new(),
260            has_solady_lib,
261            hits: Vec::new(),
262            written: HashSet::new(),
263        }
264    }
265
266    fn snapshot(&self) -> FlowState {
267        FlowState {
268            safe_vars: self.safe_vars.clone(),
269            self_vars: self.self_vars.clone(),
270            permits: self.permits.clone(),
271            repayments: self.repayments.clone(),
272            aliases: self.aliases.clone(),
273            sum_of: self.sum_of.clone(),
274        }
275    }
276
277    fn restore(&mut self, state: FlowState) {
278        self.safe_vars = state.safe_vars;
279        self.self_vars = state.self_vars;
280        self.permits = state.permits;
281        self.repayments = state.repayments;
282        self.aliases = state.aliases;
283        self.sum_of = state.sum_of;
284    }
285
286    /// Follows the alias chain to its root. Bounded to guard against cycles.
287    fn canonical(&self, v: hir::VariableId) -> hir::VariableId {
288        let mut cur = v;
289        for _ in 0..8 {
290            match self.aliases.get(&cur).copied() {
291                Some(next) if next != cur => cur = next,
292                _ => break,
293            }
294        }
295        cur
296    }
297
298    fn is_safe(&self, expr: &hir::Expr<'_>) -> bool {
299        self.is_safe_inner(expr, HELPER_DEPTH)
300    }
301
302    fn is_safe_inner(&self, expr: &hir::Expr<'_>, depth: u8) -> bool {
303        match &expr.peel_parens().kind {
304            ExprKind::Member(base, ident) if ident.name == sym::sender => {
305                is_builtin(base, sym::msg)
306            }
307            ExprKind::Ident(_) if is_builtin(expr, sym::this) => true,
308            ExprKind::Ident(reses) => reses.iter().any(
309                |r| matches!(r, Res::Item(ItemId::Variable(vid)) if self.safe_vars.contains(vid)),
310            ),
311            ExprKind::Call(callee, args, _) if is_address_cast(callee) => {
312                args.exprs().next().is_some_and(|e| self.is_safe_inner(e, depth))
313            }
314            ExprKind::Payable(inner) => self.is_safe_inner(inner, depth),
315            ExprKind::Ternary(_, t, f) => {
316                self.is_safe_inner(t, depth) && self.is_safe_inner(f, depth)
317            }
318            // No-arg helper whose body is `return X;` with `X` statically safe (e.g. `_msgSender`).
319            ExprKind::Call(callee, args, _)
320                if depth > 0
321                    && args.exprs().next().is_none()
322                    && self.callee_returns_safe(callee, depth - 1) =>
323            {
324                true
325            }
326            _ => false,
327        }
328    }
329
330    fn callee_returns_safe(&self, callee: &hir::Expr<'_>, depth: u8) -> bool {
331        let ExprKind::Ident(reses) = &callee.peel_parens().kind else { return false };
332        reses.iter().any(|r| match r {
333            Res::Item(ItemId::Function(fid)) => {
334                let f = self.hir.function(*fid);
335                let Some(body) = f.body else { return false };
336                f.parameters.is_empty()
337                    && body.stmts.len() == 1
338                    && matches!(
339                        &body.stmts[0].kind,
340                        StmtKind::Return(Some(e)) if self.is_safe_inner(e, depth)
341                    )
342            }
343            _ => false,
344        })
345    }
346
347    /// Variant of `assign_one_flags` for declarations: skips permit/repayment kills
348    /// since a freshly declared variable has no prior facts.
349    fn assign(&mut self, target: hir::VariableId, rhs: &hir::Expr<'_>) {
350        let eval = self.eval_rhs(Some(rhs));
351        self.written.insert(target);
352        if eval.is_safe {
353            self.safe_vars.insert(target);
354        } else {
355            self.safe_vars.remove(&target);
356        }
357        if eval.is_self {
358            self.self_vars.insert(target);
359        } else {
360            self.self_vars.remove(&target);
361        }
362        if let Some(alias) = eval.alias
363            && alias != target
364        {
365            self.aliases.insert(target, alias);
366        }
367        if let Some(sum) = eval.sum {
368            self.sum_of.insert(target, sum);
369        }
370    }
371
372    /// `true` when `expr` resolves to `address(this)`: bare form, a local alias,
373    /// `payable(...)`/cast wraps, a ternary whose both branches are self, or a no-arg
374    /// helper returning a statically-self expression.
375    fn is_self_expr(&self, expr: &hir::Expr<'_>) -> bool {
376        self.is_self_expr_inner(expr, HELPER_DEPTH)
377    }
378
379    fn is_self_expr_inner(&self, expr: &hir::Expr<'_>, depth: u8) -> bool {
380        let expr = expr.peel_parens();
381        if is_address_self(expr) {
382            return true;
383        }
384        match &expr.kind {
385            ExprKind::Ident(reses) => reses.iter().any(
386                |r| matches!(r, Res::Item(ItemId::Variable(vid)) if self.self_vars.contains(vid)),
387            ),
388            ExprKind::Payable(inner) => self.is_self_expr_inner(inner, depth),
389            ExprKind::Call(callee, args, _) if is_address_cast(callee) => {
390                args.exprs().next().is_some_and(|e| self.is_self_expr_inner(e, depth))
391            }
392            ExprKind::Ternary(_, t, f) => {
393                self.is_self_expr_inner(t, depth) && self.is_self_expr_inner(f, depth)
394            }
395            ExprKind::Call(callee, args, _)
396                if depth > 0
397                    && args.exprs().next().is_none()
398                    && self.callee_returns_self(callee, depth - 1) =>
399            {
400                true
401            }
402            _ => false,
403        }
404    }
405
406    fn callee_returns_self(&self, callee: &hir::Expr<'_>, depth: u8) -> bool {
407        let ExprKind::Ident(reses) = &callee.peel_parens().kind else { return false };
408        reses.iter().any(|r| match r {
409            Res::Item(ItemId::Function(fid)) => {
410                let f = self.hir.function(*fid);
411                let Some(body) = f.body else { return false };
412                f.parameters.is_empty()
413                    && body.stmts.len() == 1
414                    && matches!(
415                        &body.stmts[0].kind,
416                        StmtKind::Return(Some(e)) if self.is_self_expr_inner(e, depth)
417                    )
418            }
419            _ => false,
420        })
421    }
422
423    /// Matches EIP-2612 `token.permit(owner, <self>, value, deadline, v, r, s)`, plus
424    /// the library form `Lib.safePermit(token, owner, <self>, value, deadline, v, r, s)`
425    /// (OpenZeppelin-style wrapper that delegates to `token.permit`).
426    fn match_permit_call(&self, expr: &'hir hir::Expr<'hir>) -> Option<PermitRecord> {
427        let ExprKind::Call(callee, args, _) = &expr.kind else { return None };
428        let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
429        let name = ident.name.as_str();
430
431        if name == "permit"
432            && let Some(canonical) = canonical_args(
433                args.kind,
434                &[&["owner"], &["spender"], &["value"], &["deadline"], &["v"], &["r"], &["s"]],
435            )
436            && self.is_self_expr(canonical[1])
437        {
438            return Some(PermitRecord {
439                token: self.canonical_key(token_key(recv)?),
440                owner: self.canonical(underlying_var(canonical[0])?),
441            });
442        }
443
444        if name == "safePermit"
445            && let Some(canonical) = canonical_args(
446                args.kind,
447                &[
448                    &["token"],
449                    &["owner"],
450                    &["spender"],
451                    &["value"],
452                    &["deadline"],
453                    &["v"],
454                    &["r"],
455                    &["s"],
456                ],
457            )
458            && self.is_self_expr(canonical[2])
459            && let Some(cid) = receiver_contract_id(self.gcx, recv)
460            && self.hir.contract(cid).kind == ContractKind::Library
461        {
462            return Some(PermitRecord {
463                token: self.canonical_key(token_key(canonical[0])?),
464                owner: self.canonical(underlying_var(canonical[1])?),
465            });
466        }
467
468        None
469    }
470
471    /// Drops permits referencing `target` by raw id. Permits are stored at the
472    /// canonical chain root, so reassigning an alias var leaves the root's permit
473    /// intact — only reassigning the root itself kills it.
474    fn kill_permits_for(&mut self, target: hir::VariableId) {
475        self.permits.retain(|p| !p.token.touches(target) && p.owner != target);
476    }
477
478    /// Canonicalizes a `TokenKey`: `Var` chases the alias chain; `Field`'s base
479    /// var is canonicalized so `aCfg = cfg; aCfg.token` aliases to `cfg.token`.
480    fn canonical_key(&self, k: TokenKey) -> TokenKey {
481        match k {
482            TokenKey::Var(v) => TokenKey::Var(self.canonical(v)),
483            TokenKey::Field(v, s) => TokenKey::Field(self.canonical(v), s),
484        }
485    }
486
487    /// Drops all facts about `v` (treats it as freshly assigned an unknown value).
488    fn invalidate(&mut self, v: hir::VariableId) {
489        self.safe_vars.remove(&v);
490        self.self_vars.remove(&v);
491        self.aliases.remove(&v);
492        self.sum_of.remove(&v);
493        self.kill_permits_for(v);
494    }
495
496    /// Returns state vars written by `callee` if it resolves to a function in the
497    /// current contract; walks one level of nested internal calls. Resolution is
498    /// conservative: any unresolved or non-local call returns `None`.
499    fn call_state_writes(&self, callee: &hir::Expr<'_>) -> Option<HashSet<hir::VariableId>> {
500        let fid = resolve_internal_fn(callee)?;
501        let f = self.hir.function(fid);
502        let body = f.body?;
503        // Only same-contract calls to non-view/pure user functions can mutate `self`'s state.
504        if matches!(f.state_mutability, ast::StateMutability::Pure | ast::StateMutability::View) {
505            return Some(HashSet::new());
506        }
507        let mut writes = collect_state_writes(self.hir, body.stmts);
508        // One nested level: pull in writes from internal callees within `body`.
509        let mut nested = NestedCallCollector { hir: self.hir, out: HashSet::new() };
510        for s in body.stmts {
511            let _ = nested.visit_stmt(s);
512        }
513        for cid in nested.out {
514            let nf = self.hir.function(cid);
515            if let Some(nb) = nf.body {
516                writes.extend(collect_state_writes(self.hir, nb.stmts));
517            }
518        }
519        Some(writes)
520    }
521
522    fn permit_covers(&self, token: Option<TokenKey>, from: &hir::Expr<'_>) -> bool {
523        let (Some(token), Some(owner)) = (token, underlying_var(from)) else { return false };
524        self.permits.contains(&PermitRecord {
525            token: self.canonical_key(token),
526            owner: self.canonical(owner),
527        })
528    }
529
530    /// `amount_arg` is `amount + fee` syntactically, or a local var bound to that sum.
531    fn amount_matches(
532        &self,
533        amount_arg: &hir::Expr<'_>,
534        amount: hir::VariableId,
535        fee: hir::VariableId,
536    ) -> bool {
537        if is_amount_plus_fee(amount_arg, amount, fee) {
538            return true;
539        }
540        let Some(v) = underlying_var(amount_arg) else { return false };
541        matches!(self.sum_of.get(&v), Some((a, b))
542            if (*a == amount && *b == fee) || (*a == fee && *b == amount))
543    }
544
545    /// Drops pending repayments referencing `target`.
546    fn kill_repayments_for(&mut self, target: hir::VariableId) {
547        self.repayments.retain(|r, _| {
548            r.receiver != target && r.token != target && r.amount != target && r.fee != target
549        });
550    }
551
552    /// Matches `from`/`token` plus a sink call with `to == address(this)` and amount
553    /// `amount + fee` against a pending repayment, consuming one occurrence on hit.
554    fn consume_repayment(
555        &mut self,
556        call_expr: &hir::Expr<'_>,
557        from: &hir::Expr<'_>,
558        token: Option<TokenKey>,
559    ) -> bool {
560        let Some(from_v) = underlying_var(from) else { return false };
561        // Repayments are recorded as `(receiver, token)` raw `VariableId`s; `Field`
562        // sinks cannot match a flash-loan token state var directly.
563        let Some(TokenKey::Var(token_v)) = token else { return false };
564        let ExprKind::Call(_, args, _) = &call_expr.kind else { return false };
565        // Pick `to` and `amount` from whichever sink shape (3-arg member / 4-arg library).
566        let (to_arg, amount_arg) = if let Some(a) =
567            canonical_args(args.kind, &[&["from"], &["to"], &["value", "amount"]])
568        {
569            (a[1], a[2])
570        } else if let Some(a) =
571            canonical_args(args.kind, &[&["token"], &["from"], &["to"], &["value", "amount"]])
572        {
573            (a[2], a[3])
574        } else {
575            return false;
576        };
577        if !self.is_self_expr(to_arg) {
578            return false;
579        }
580        let matched = self.repayments.keys().copied().find(|r| {
581            r.receiver == from_v
582                && r.token == token_v
583                && self.amount_matches(amount_arg, r.amount, r.fee)
584        });
585        if let Some(rep) = matched {
586            match self.repayments.get_mut(&rep) {
587                Some(count) if *count > 1 => *count -= 1,
588                _ => {
589                    self.repayments.remove(&rep);
590                }
591            }
592            true
593        } else {
594            false
595        }
596    }
597
598    /// Records vars proven safe by `pred`. Handles `==`/`!=`, `&&`/`||` and `!` via
599    /// De Morgan; disjunctions keep only facts true on both sides.
600    fn add_facts(&mut self, pred: &hir::Expr<'_>, negate: bool) {
601        match &pred.peel_parens().kind {
602            ExprKind::Binary(lhs, op, rhs) => {
603                let (eq, and, or) = if negate {
604                    (ast::BinOpKind::Ne, ast::BinOpKind::Or, ast::BinOpKind::And)
605                } else {
606                    (ast::BinOpKind::Eq, ast::BinOpKind::And, ast::BinOpKind::Or)
607                };
608                if op.kind == and {
609                    self.add_facts(lhs, negate);
610                    self.add_facts(rhs, negate);
611                } else if op.kind == or {
612                    // Disjunction: keep facts true in both arms.
613                    let baseline = self.snapshot();
614                    self.add_facts(lhs, negate);
615                    let after_lhs = self.snapshot();
616                    self.restore(baseline);
617                    self.add_facts(rhs, negate);
618                    let after_rhs = self.snapshot();
619                    self.restore(FlowState::intersection(&after_lhs, &after_rhs));
620                } else if op.kind == eq {
621                    for (a, b) in [(lhs, rhs), (rhs, lhs)] {
622                        if let Some(v) = underlying_var(b)
623                            && self.is_safe_target(v)
624                        {
625                            if self.is_safe(a) {
626                                self.safe_vars.insert(v);
627                            }
628                            // Equality with `address(this)` also makes `b` a self alias.
629                            if self.is_self_expr(a) {
630                                self.self_vars.insert(v);
631                            }
632                        }
633                    }
634                }
635            }
636            ExprKind::Unary(op, inner) if matches!(op.kind, ast::UnOpKind::Not) => {
637                self.add_facts(inner, !negate);
638            }
639            _ => {}
640        }
641    }
642
643    fn is_safe_target(&self, v: hir::VariableId) -> bool {
644        let var = self.hir.variable(v);
645        !var.kind.is_state() || var.is_immutable() || var.is_constant()
646    }
647
648    /// Drops permits whose token is `Field(base, name)` when the LHS is the
649    /// corresponding `<base>.<name>` (assignment or `delete`).
650    fn kill_field_permits(&mut self, lhs: &hir::Expr<'_>) {
651        let lhs = lhs.peel_parens();
652        if let ExprKind::Member(base, ident) = &lhs.kind
653            && let Some(base_v) = underlying_var(base)
654        {
655            let key = TokenKey::Field(self.canonical(base_v), ident.name);
656            self.permits.retain(|p| p.token != key);
657        }
658    }
659
660    /// Handles single-var and tuple LHS; tuple slots align with a tuple-literal RHS.
661    fn handle_assign(&mut self, lhs: &hir::Expr<'_>, rhs: &hir::Expr<'_>) {
662        let lhs = lhs.peel_parens();
663        if let ExprKind::Tuple(lhs_elems) = &lhs.kind {
664            let rhs_elems = match &rhs.peel_parens().kind {
665                ExprKind::Tuple(r) => Some(*r),
666                _ => None,
667            };
668            // Read all RHS facts before any write — handles `(x, y) = (y, x)` swaps.
669            type EvaluatedSlot<'a> = (Option<&'a hir::Expr<'a>>, AssignRhs);
670            let evaluated: Vec<EvaluatedSlot<'_>> = lhs_elems
671                .iter()
672                .enumerate()
673                .map(|(i, lhs_elem)| {
674                    let lhs_expr = lhs_elem.as_deref();
675                    let rhs_expr = rhs_elems.and_then(|r| r.get(i).copied()).flatten();
676                    (lhs_expr, self.eval_rhs(rhs_expr))
677                })
678                .collect();
679            for (lhs_expr, eval) in evaluated {
680                if let Some(lhs_expr) = lhs_expr {
681                    self.assign_one_flags(lhs_expr, eval);
682                }
683            }
684        } else {
685            self.assign_one(lhs, Some(rhs));
686        }
687    }
688
689    /// `rhs == None` (unknown slot) drops the target's safe-fact.
690    fn assign_one(&mut self, lhs: &hir::Expr<'_>, rhs: Option<&hir::Expr<'_>>) {
691        let eval = self.eval_rhs(rhs);
692        self.assign_one_flags(lhs, eval);
693    }
694
695    /// Pre-evaluates RHS facts; the resulting `AssignRhs` is independent of later
696    /// state changes (needed for tuple-swap assignments).
697    fn eval_rhs(&self, rhs: Option<&hir::Expr<'_>>) -> AssignRhs {
698        let Some(r) = rhs else { return AssignRhs::default() };
699        let alias = underlying_var(r).map(|v| self.canonical(v));
700        let sum = if let ExprKind::Binary(lhs, op, rhs_inner) = &r.peel_parens().kind
701            && matches!(op.kind, ast::BinOpKind::Add)
702        {
703            underlying_var(lhs).zip(underlying_var(rhs_inner))
704        } else {
705            None
706        };
707        AssignRhs { is_safe: self.is_safe(r), is_self: self.is_self_expr(r), alias, sum }
708    }
709
710    /// Applies a pre-evaluated RHS to the LHS variable.
711    fn assign_one_flags(&mut self, lhs: &hir::Expr<'_>, eval: AssignRhs) {
712        let Some(target) = underlying_var(lhs) else { return };
713        self.written.insert(target);
714        self.kill_permits_for(target);
715        self.kill_repayments_for(target);
716        self.safe_vars.remove(&target);
717        self.self_vars.remove(&target);
718        // Drop alias / sum edges that reference the target on either side.
719        self.aliases.remove(&target);
720        self.aliases.retain(|_, dst| *dst != target);
721        self.sum_of.remove(&target);
722        self.sum_of.retain(|_, (a, b)| *a != target && *b != target);
723        // Mutable storage can be rewritten between check and sink; only locals and
724        // immutables/constants are safe to track.
725        let var = self.hir.variable(target);
726        if var.kind.is_state() && !var.is_immutable() && !var.is_constant() {
727            return;
728        }
729        if eval.is_safe {
730            self.safe_vars.insert(target);
731        }
732        if eval.is_self {
733            self.self_vars.insert(target);
734        }
735        if let Some(alias) = eval.alias
736            && alias != target
737        {
738            self.aliases.insert(target, alias);
739        }
740        if let Some(sum) = eval.sum {
741            self.sum_of.insert(target, sum);
742        }
743    }
744
745    /// Visits a body that may execute zero times or out-of-line (loops, try clauses):
746    /// in-body kills survive, in-body additions don't.
747    fn visit_isolated(&mut self, stmts: &'hir [hir::Stmt<'hir>]) {
748        let mut exits = vec![self.snapshot()];
749        if let Some(fallthrough) = self.visit_stmts_until_loop_exit(stmts, &mut exits) {
750            exits.push(fallthrough);
751        }
752        self.restore(FlowState::intersection_all(exits.into_iter()));
753    }
754
755    fn visit_stmts_until_loop_exit(
756        &mut self,
757        stmts: &'hir [hir::Stmt<'hir>],
758        exits: &mut Vec<FlowState>,
759    ) -> Option<FlowState> {
760        for stmt in stmts {
761            self.visit_stmt_until_loop_exit(stmt, exits)?;
762        }
763        Some(self.snapshot())
764    }
765
766    fn visit_stmt_until_loop_exit(
767        &mut self,
768        stmt: &'hir hir::Stmt<'hir>,
769        exits: &mut Vec<FlowState>,
770    ) -> Option<()> {
771        match &stmt.kind {
772            StmtKind::Break | StmtKind::Continue => {
773                exits.push(self.snapshot());
774                None
775            }
776            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
777                let state = self.visit_stmts_until_loop_exit(block.stmts, exits)?;
778                self.restore(state);
779                Some(())
780            }
781            StmtKind::If(cond, then, else_) => {
782                let _ = self.visit_expr(cond);
783                let baseline = self.snapshot();
784
785                self.add_facts(cond, false);
786                let then_fallthrough = self
787                    .visit_stmt_until_loop_exit(then, exits)
788                    .and_then(|_| (!branch_always_exits(then)).then(|| self.snapshot()));
789
790                self.restore(baseline);
791                self.add_facts(cond, true);
792                let else_fallthrough = match else_ {
793                    Some(else_stmt) => self
794                        .visit_stmt_until_loop_exit(else_stmt, exits)
795                        .and_then(|_| (!branch_always_exits(else_stmt)).then(|| self.snapshot())),
796                    None => Some(self.snapshot()),
797                };
798
799                match (then_fallthrough, else_fallthrough) {
800                    (Some(then_state), Some(else_state)) => {
801                        self.restore(FlowState::intersection(&then_state, &else_state));
802                        Some(())
803                    }
804                    (Some(state), None) | (None, Some(state)) => {
805                        self.restore(state);
806                        Some(())
807                    }
808                    (None, None) => None,
809                }
810            }
811            // Nested loops own their own `break` / `continue`; they do not exit this isolated body.
812            StmtKind::Loop(..) => {
813                let _ = self.visit_stmt(stmt);
814                (!branch_always_exits(stmt)).then_some(())
815            }
816            _ => {
817                let _ = self.visit_stmt(stmt);
818                (!branch_always_exits(stmt)).then_some(())
819            }
820        }
821    }
822}
823
824impl<'hir> hir::Visit<'hir> for Analyzer<'hir> {
825    type BreakValue = Never;
826
827    fn hir(&self) -> &'hir hir::Hir<'hir> {
828        self.hir
829    }
830
831    fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
832        match &stmt.kind {
833            // Branch-sensitive join: positive facts flow into `then`; if the then-branch
834            // always exits, negated facts (`!cond`) flow into the fall-through.
835            StmtKind::If(cond, then, else_) => {
836                let _ = self.visit_expr(cond);
837
838                let baseline = self.snapshot();
839                self.add_facts(cond, false);
840                let _ = self.visit_stmt(then);
841                let then_exits = branch_always_exits(then);
842                let after_then = self.snapshot();
843
844                // Both the explicit `else` body and the implicit fall-through inherit `!cond`.
845                self.restore(baseline);
846                self.add_facts(cond, true);
847                let else_exits = match else_ {
848                    Some(e) => {
849                        let _ = self.visit_stmt(e);
850                        branch_always_exits(e)
851                    }
852                    None => false,
853                };
854                let after_else = self.snapshot();
855
856                let joined = match (then_exits, else_exits) {
857                    // Both branches exit: downstream is unreachable, take a conservative
858                    // union to match the previous behaviour.
859                    (true, true) => FlowState {
860                        safe_vars: after_then
861                            .safe_vars
862                            .union(&after_else.safe_vars)
863                            .copied()
864                            .collect(),
865                        self_vars: after_then
866                            .self_vars
867                            .union(&after_else.self_vars)
868                            .copied()
869                            .collect(),
870                        permits: after_then.permits.union(&after_else.permits).copied().collect(),
871                        // Multiset union: max per key.
872                        repayments: {
873                            let mut m = after_then.repayments;
874                            for (k, vb) in &after_else.repayments {
875                                let entry = m.entry(*k).or_insert(0);
876                                if *entry < *vb {
877                                    *entry = *vb;
878                                }
879                            }
880                            m
881                        },
882                        aliases: {
883                            let mut m = after_then.aliases;
884                            for (k, v) in after_else.aliases {
885                                m.entry(k).or_insert(v);
886                            }
887                            m
888                        },
889                        sum_of: {
890                            let mut m = after_then.sum_of;
891                            for (k, v) in after_else.sum_of {
892                                m.entry(k).or_insert(v);
893                            }
894                            m
895                        },
896                    },
897                    (true, false) => after_else,
898                    (false, true) => after_then,
899                    (false, false) => FlowState::intersection(&after_then, &after_else),
900                };
901                self.restore(joined);
902                return ControlFlow::Continue(());
903            }
904
905            StmtKind::Loop(block, source) => {
906                // `do-while` runs the body at least once, so facts flow out — unless a user
907                // `break`/`continue` can skip later assignments.
908                if matches!(source, LoopSource::DoWhile)
909                    && !body_has_break_or_continue(do_while_user_stmts(block.stmts))
910                {
911                    for s in block.stmts {
912                        let _ = self.visit_stmt(s);
913                        if branch_always_exits(s) {
914                            break;
915                        }
916                    }
917                } else if matches!(source, LoopSource::DoWhile) {
918                    // do-while runs at least once: intersect actual break/continue
919                    // exits and the body's fallthrough, without the pre-loop baseline.
920                    let mut exits = vec![];
921                    if let Some(fallthrough) =
922                        self.visit_stmts_until_loop_exit(block.stmts, &mut exits)
923                    {
924                        exits.push(fallthrough);
925                    }
926                    if !exits.is_empty() {
927                        self.restore(FlowState::intersection_all(exits.into_iter()));
928                    }
929                } else {
930                    self.visit_isolated(block.stmts);
931                }
932                return ControlFlow::Continue(());
933            }
934            StmtKind::Try(t) => {
935                // Success clause inherits `t.expr`'s facts; catch clauses don't,
936                // since they only run if the call reverted.
937                let baseline = self.snapshot();
938                let _ = self.visit_expr(&t.expr);
939                let after_call = self.snapshot();
940                let mut post_clauses = Vec::with_capacity(t.clauses.len());
941                for (i, clause) in t.clauses.iter().enumerate() {
942                    self.restore(if i == 0 { after_call.clone() } else { baseline.clone() });
943                    for s in clause.block.stmts {
944                        let _ = self.visit_stmt(s);
945                        if branch_always_exits(s) {
946                            break;
947                        }
948                    }
949                    // Clauses that always exit don't reach the post-try point and
950                    // must not constrain the join.
951                    if !clause.block.stmts.iter().any(branch_always_exits) {
952                        post_clauses.push(self.snapshot());
953                    }
954                }
955                let joined = if post_clauses.is_empty() {
956                    // All clauses exit; downstream is unreachable.
957                    after_call
958                } else {
959                    FlowState::intersection_all(post_clauses.into_iter())
960                };
961                self.restore(joined);
962                return ControlFlow::Continue(());
963            }
964
965            // Stop at the first definite-exit inside a sequential block.
966            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
967                for s in block.stmts {
968                    let _ = self.visit_stmt(s);
969                    if branch_always_exits(s) {
970                        break;
971                    }
972                }
973                return ControlFlow::Continue(());
974            }
975
976            // `assign` only sets facts for vars whose RHS produces them, so it's
977            // safe (and necessary, for `sum_of`) to call on all declarations.
978            StmtKind::DeclSingle(vid) => {
979                if let Some(init) = self.hir.variable(*vid).initializer {
980                    self.assign(*vid, init);
981                }
982            }
983
984            // Position-aligned propagation from a tuple literal RHS.
985            StmtKind::DeclMulti(vars, init) => {
986                if let ExprKind::Tuple(rhs) = &init.peel_parens().kind {
987                    for (lhs, rhs) in vars.iter().zip(rhs.iter()) {
988                        if let (Some(vid), Some(expr)) = (lhs, rhs) {
989                            self.assign(*vid, expr);
990                        }
991                    }
992                }
993            }
994
995            _ => {}
996        }
997        self.walk_stmt(stmt)
998    }
999
1000    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1001        // Short-circuit `&&` / `||`: `rhs` may not execute, so its state mutations must
1002        // not survive the expression. `lhs` facts flow into `rhs` only. `hits` persist —
1003        // a sink in `rhs` is still reported.
1004        if let ExprKind::Binary(lhs, op, rhs) = &expr.kind
1005            && matches!(op.kind, ast::BinOpKind::And | ast::BinOpKind::Or)
1006        {
1007            let _ = self.visit_expr(lhs);
1008            let negate = matches!(op.kind, ast::BinOpKind::Or);
1009            let skipped_rhs = self.snapshot();
1010            self.add_facts(lhs, negate);
1011            let result = self.visit_expr(rhs);
1012            let ran_rhs = self.snapshot();
1013            self.restore(FlowState::intersection(&skipped_rhs, &ran_rhs));
1014            return result;
1015        }
1016
1017        match &expr.kind {
1018            ExprKind::Call(callee, args, _) if is_require_or_assert(callee) => {
1019                // Walk the predicate before recording its facts so sinks inside the
1020                // predicate see the pre-guard state.
1021                let result = self.walk_expr(expr);
1022                if let Some(cond) = args.exprs().next() {
1023                    self.add_facts(cond, false);
1024                }
1025                return result;
1026            }
1027            ExprKind::Call(callee, ..) => {
1028                // EIP-3156: a matching repayment sink consumes the recorded obligation.
1029                if let Some(rep) = match_flash_loan_call(self.gcx, self.hir, expr) {
1030                    *self.repayments.entry(rep).or_insert(0) += 1;
1031                } else if let Some(p) = self.match_permit_call(expr) {
1032                    self.permits.insert(p);
1033                } else if let Some((from, token)) =
1034                    match_sink(self.gcx, self.hir, self.has_solady_lib, expr)
1035                    && !self.is_safe(from)
1036                    && !self.consume_repayment(expr, from, token)
1037                {
1038                    // A matching prior permit doesn't make the sink safe: a non-permit
1039                    // token with a fallback (e.g. WETH) silently accepts the permit.
1040                    let lint = if self.permit_covers(token, from) {
1041                        &ARBITRARY_SEND_ERC20_PERMIT
1042                    } else {
1043                        &ARBITRARY_SEND_ERC20
1044                    };
1045                    self.hits.push((expr.span, lint));
1046                } else if let Some(writes) = self.call_state_writes(callee) {
1047                    // Solidity evaluates args before invoking the callee. Walk first so
1048                    // nested sinks see the still-live facts, then invalidate.
1049                    let result = self.walk_expr(expr);
1050                    for v in writes {
1051                        self.invalidate(v);
1052                    }
1053                    return result;
1054                }
1055            }
1056            ExprKind::Assign(lhs, _, rhs) => {
1057                self.kill_field_permits(lhs);
1058                self.handle_assign(lhs, rhs);
1059            }
1060            // `delete x` resets `x`; treat as unknown reassignment.
1061            ExprKind::Delete(target) => {
1062                self.kill_field_permits(target);
1063                self.assign_one(target.peel_parens(), None);
1064            }
1065            _ => {}
1066        }
1067        self.walk_expr(expr)
1068    }
1069}
1070
1071/// EIP-3156 `onFlashLoan(address,address,uint256,uint256,bytes) returns (bytes32)`. Only
1072/// returns a record when the receiver type declares the exact sig and every tracked arg
1073/// resolves to a `VariableId`; literal args yield `None`.
1074fn match_flash_loan_call<'hir>(
1075    gcx: Gcx<'hir>,
1076    hir: &hir::Hir<'hir>,
1077    expr: &hir::Expr<'hir>,
1078) -> Option<PendingRepayment> {
1079    let ExprKind::Call(callee, args, _) = &expr.kind else { return None };
1080    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
1081    if ident.name.as_str() != "onFlashLoan" {
1082        return None;
1083    }
1084    let args =
1085        canonical_args(args.kind, &[&["initiator"], &["token"], &["amount"], &["fee"], &["data"]])?;
1086    let cid = receiver_contract_id(gcx, recv)?;
1087    if !contract_has_function(
1088        hir,
1089        cid,
1090        "onFlashLoan",
1091        &["address", "address", "uint256", "uint256", "bytes"],
1092        &["bytes32"],
1093    ) {
1094        return None;
1095    }
1096    Some(PendingRepayment {
1097        receiver: underlying_var(recv)?,
1098        token: underlying_var(args[1])?,
1099        amount: underlying_var(args[2])?,
1100        fee: underlying_var(args[3])?,
1101    })
1102}
1103
1104/// True when `expr` is `amount + fee` or `fee + amount`, parens-tolerant.
1105fn is_amount_plus_fee(expr: &hir::Expr<'_>, amount: hir::VariableId, fee: hir::VariableId) -> bool {
1106    let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false };
1107    if !matches!(op.kind, ast::BinOpKind::Add) {
1108        return false;
1109    }
1110    let a = underlying_var(lhs);
1111    let b = underlying_var(rhs);
1112    (a == Some(amount) && b == Some(fee)) || (a == Some(fee) && b == Some(amount))
1113}
1114
1115/// Matches an ERC20-like transfer sink. Returns `(from_arg, token_var)` where `token_var` is
1116/// the receiver's underlying variable id when available (used for permit correlation).
1117///
1118/// Recognised:
1119/// - `recv.transferFrom(from, to, amt)` / `recv.safeTransferFrom(from, to, amt)` where `recv` is
1120///   typed as a contract declaring ERC20's `transferFrom(address,address,uint256)→bool` (ERC721's
1121///   same-named, no-return overload is excluded).
1122/// - `Lib.safeTransferFrom(token, from, to, amt)` library form.
1123fn match_sink<'hir>(
1124    gcx: Gcx<'hir>,
1125    hir: &'hir hir::Hir<'hir>,
1126    has_solady_lib: bool,
1127    expr: &'hir hir::Expr<'hir>,
1128) -> Option<(&'hir hir::Expr<'hir>, Option<TokenKey>)> {
1129    let ExprKind::Call(callee, args, _) = &expr.kind else { return None };
1130    let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None };
1131    let name = ident.name.as_str();
1132
1133    if (name == "transferFrom" || name == "safeTransferFrom")
1134        && let Some(canonical) =
1135            canonical_args(args.kind, &[&["from"], &["to"], &["value", "amount"]])
1136    {
1137        // Contract-typed receiver: must actually declare ERC20's `transferFrom`.
1138        if let Some(cid) = receiver_contract_id(gcx, recv)
1139            && contract_has_function(
1140                hir,
1141                cid,
1142                "transferFrom",
1143                &["address", "address", "uint256"],
1144                &["bool"],
1145            )
1146        {
1147            return Some((canonical[0], token_key(recv)));
1148        }
1149        // `addr.safeTransferFrom(...)` via `using SafeTransferLib for address`. HIR doesn't
1150        // expose `using-for` bindings, so we proxy by requiring `SafeTransferLib` to be
1151        // declared in the compiled sources.
1152        if name == "safeTransferFrom" && has_solady_lib && expr_is_address(gcx, recv) {
1153            return Some((canonical[0], token_key(recv)));
1154        }
1155    }
1156
1157    if name == "safeTransferFrom"
1158        && let Some(canonical) =
1159            canonical_args(args.kind, &[&["token"], &["from"], &["to"], &["value", "amount"]])
1160        && let Some(cid) = receiver_contract_id(gcx, recv)
1161        && hir.contract(cid).kind == ContractKind::Library
1162        && library_has_safe_transfer_from(hir, cid)
1163    {
1164        return Some((canonical[1], token_key(canonical[0])));
1165    }
1166
1167    None
1168}
1169
1170/// Constructs a `TokenKey` for the receiver of a permit/sink call. Supports
1171/// `<var>` (with cast / `payable` peeling via `underlying_var`) and
1172/// `<var>.<field>` (a struct-field path).
1173fn token_key(expr: &hir::Expr<'_>) -> Option<TokenKey> {
1174    if let Some(v) = underlying_var(expr) {
1175        return Some(TokenKey::Var(v));
1176    }
1177    let expr = expr.peel_parens();
1178    if let ExprKind::Member(base, ident) = &expr.kind
1179        && let Some(v) = underlying_var(base)
1180    {
1181        return Some(TokenKey::Field(v, ident.name));
1182    }
1183    None
1184}
1185
1186/// Hoists `require(modParam == msg.sender | address(this))` guards from the modifier
1187/// prefix (statements before `_;`) to the caller's argument. `out_self` receives params
1188/// proven equal to `address(this)`.
1189fn collect_modifier_safety<'hir>(
1190    gcx: Gcx<'hir>,
1191    hir: &'hir hir::Hir<'hir>,
1192    has_solady_lib: bool,
1193    invocation: &hir::Modifier<'hir>,
1194    out_safe: &mut HashSet<hir::VariableId>,
1195    out_self: &mut HashSet<hir::VariableId>,
1196) {
1197    let ItemId::Function(fid) = invocation.id else { return };
1198    let modifier = hir.function(fid);
1199    if !matches!(modifier.kind, hir::FunctionKind::Modifier) {
1200        return;
1201    }
1202    let Some(body) = modifier.body else { return };
1203
1204    // Skip multi-`_` or nested-placeholder modifiers — guard ordering can't be assumed sound.
1205    if count_placeholders(body.stmts) != 1 {
1206        return;
1207    }
1208    let Some(placeholder_idx) =
1209        body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder))
1210    else {
1211        return;
1212    };
1213
1214    // Modifier-param → caller-side var. Supports positional and named call shapes.
1215    let arg_map: Vec<(hir::VariableId, hir::VariableId)> = match invocation.args.kind {
1216        hir::CallArgsKind::Unnamed(exprs) => exprs
1217            .iter()
1218            .enumerate()
1219            .filter_map(|(i, arg)| Some((*modifier.parameters.get(i)?, underlying_var(arg)?)))
1220            .collect(),
1221        hir::CallArgsKind::Named(named) => named
1222            .iter()
1223            .filter_map(|na| {
1224                let mp = modifier.parameters.iter().find(|p| {
1225                    hir.variable(**p).name.is_some_and(|n| n.as_str() == na.name.as_str())
1226                })?;
1227                Some((*mp, underlying_var(&na.value)?))
1228            })
1229            .collect(),
1230    };
1231    if arg_map.is_empty() {
1232        return;
1233    }
1234
1235    // Bail when the prefix contains stmts we can't track writes through (e.g. inline
1236    // assembly, which currently lowers to `StmtKind::Err`).
1237    if contains_unanalysable(&body.stmts[..placeholder_idx]) {
1238        return;
1239    }
1240
1241    let mut a = Analyzer::new(gcx, hir, has_solady_lib);
1242    for stmt in &body.stmts[..placeholder_idx] {
1243        let _ = a.visit_stmt(stmt);
1244    }
1245    // Skip mutable-storage callers (no safe-fact) and params written inside the modifier
1246    // (the local-copy fact doesn't apply to the caller's var).
1247    for (mp, caller) in arg_map {
1248        if !a.is_safe_target(caller) || a.written.contains(&mp) {
1249            continue;
1250        }
1251        if a.safe_vars.contains(&mp) {
1252            out_safe.insert(caller);
1253        }
1254        if a.self_vars.contains(&mp) {
1255            out_self.insert(caller);
1256        }
1257    }
1258}
1259
1260/// Harvests `self_vars` / `safe_vars` facts about immutable / constant state vars
1261/// of `cid`: both declaration initializers and direct constructor assignments.
1262fn seed_immutable_facts<'hir>(
1263    gcx: Gcx<'hir>,
1264    hir: &'hir hir::Hir<'hir>,
1265    has_solady_lib: bool,
1266    cid: hir::ContractId,
1267    out: &mut Analyzer<'hir>,
1268) {
1269    for item in hir.contract_item_ids(cid) {
1270        if let Some(vid) = item.as_variable() {
1271            let v = hir.variable(vid);
1272            if v.kind.is_state()
1273                && (v.is_immutable() || v.is_constant())
1274                && let Some(init) = v.initializer
1275            {
1276                if out.is_safe(init) {
1277                    out.safe_vars.insert(vid);
1278                }
1279                if out.is_self_expr(init) {
1280                    out.self_vars.insert(vid);
1281                }
1282            }
1283        }
1284    }
1285    for item in hir.contract_item_ids(cid) {
1286        let Some(fid) = item.as_function() else { continue };
1287        let f = hir.function(fid);
1288        if !f.is_constructor() {
1289            continue;
1290        }
1291        let Some(body) = f.body else { continue };
1292        let mut ctor = Analyzer::new(gcx, hir, has_solady_lib);
1293        for stmt in body.stmts {
1294            let _ = ctor.visit_stmt(stmt);
1295            if branch_always_exits(stmt) {
1296                break;
1297            }
1298        }
1299        for v in &ctor.safe_vars {
1300            let var = hir.variable(*v);
1301            if var.kind.is_state() && (var.is_immutable() || var.is_constant()) {
1302                out.safe_vars.insert(*v);
1303            }
1304        }
1305        for v in &ctor.self_vars {
1306            let var = hir.variable(*v);
1307            if var.kind.is_state() && (var.is_immutable() || var.is_constant()) {
1308                out.self_vars.insert(*v);
1309            }
1310        }
1311    }
1312}
1313
1314fn seed_internal_callsite_facts<'hir>(
1315    gcx: Gcx<'hir>,
1316    hir: &'hir hir::Hir<'hir>,
1317    has_solady_lib: bool,
1318    func: &'hir hir::Function<'hir>,
1319    out: &mut Analyzer<'hir>,
1320) {
1321    if !is_internal_callsite_seed_candidate(func) {
1322        return;
1323    }
1324
1325    let index = project_index_for(gcx, hir, has_solady_lib);
1326    let ptr = std::ptr::from_ref::<hir::Function<'_>>(func) as usize;
1327    let Some(fid) = index.function_ids_by_ptr.get(&ptr).copied() else { return };
1328    let Some(facts) = index.internal_callsites.get(&fid) else { return };
1329    if facts.unknown {
1330        return;
1331    }
1332
1333    for (i, param) in func.parameters.iter().copied().enumerate() {
1334        if facts.seen.get(i).copied().unwrap_or(false)
1335            && facts.all_safe.get(i).copied().unwrap_or(false)
1336        {
1337            out.safe_vars.insert(param);
1338            if facts.all_self.get(i).copied().unwrap_or(false) {
1339                out.self_vars.insert(param);
1340            }
1341        }
1342    }
1343}
1344
1345const fn is_internal_callsite_seed_candidate(func: &hir::Function<'_>) -> bool {
1346    func.kind.is_function()
1347        && matches!(func.visibility, ast::Visibility::Private | ast::Visibility::Internal)
1348        && !func.parameters.is_empty()
1349}
1350
1351thread_local! {
1352    static PROJECT_INDEX: RefCell<Option<(usize, Rc<ProjectIndex>)>> = const { RefCell::new(None) };
1353}
1354
1355fn project_index_for<'hir>(
1356    gcx: Gcx<'hir>,
1357    hir: &'hir hir::Hir<'hir>,
1358    has_solady_lib: bool,
1359) -> Rc<ProjectIndex> {
1360    let key = std::ptr::from_ref::<hir::Hir<'_>>(hir) as usize;
1361    PROJECT_INDEX.with(|cell| {
1362        let mut slot = cell.borrow_mut();
1363        if let Some((cached_key, cached)) = slot.as_ref()
1364            && *cached_key == key
1365        {
1366            return cached.clone();
1367        }
1368        let fresh = Rc::new(build_project_index(gcx, hir, has_solady_lib));
1369        *slot = Some((key, fresh.clone()));
1370        fresh
1371    })
1372}
1373
1374fn build_project_index<'hir>(
1375    gcx: Gcx<'hir>,
1376    hir: &'hir hir::Hir<'hir>,
1377    has_solady_lib: bool,
1378) -> ProjectIndex {
1379    let mut index = ProjectIndex::default();
1380    for fid in hir.function_ids() {
1381        let func = hir.function(fid);
1382        index
1383            .function_ids_by_ptr
1384            .insert(std::ptr::from_ref::<hir::Function<'_>>(func) as usize, fid);
1385    }
1386
1387    let mut collector =
1388        InternalCallsiteCollector { gcx, hir, has_solady_lib, out: &mut index.internal_callsites };
1389    for fid in hir.function_ids() {
1390        let Some(body) = hir.function(fid).body else { continue };
1391        for stmt in body.stmts {
1392            let _ = collector.visit_stmt(stmt);
1393        }
1394    }
1395    index
1396}
1397
1398struct InternalCallsiteCollector<'a, 'hir> {
1399    gcx: Gcx<'hir>,
1400    hir: &'hir hir::Hir<'hir>,
1401    has_solady_lib: bool,
1402    out: &'a mut HashMap<hir::FunctionId, ParamCallsiteFacts>,
1403}
1404
1405impl<'hir> hir::Visit<'hir> for InternalCallsiteCollector<'_, 'hir> {
1406    type BreakValue = Never;
1407
1408    fn hir(&self) -> &'hir hir::Hir<'hir> {
1409        self.hir
1410    }
1411
1412    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1413        if let ExprKind::Call(callee, args, _) = &expr.kind
1414            && let Some(fid) = resolve_internal_fn(callee)
1415        {
1416            self.record_call(fid, args);
1417        }
1418        self.walk_expr(expr)
1419    }
1420}
1421
1422impl<'hir> InternalCallsiteCollector<'_, 'hir> {
1423    fn record_call(&mut self, fid: hir::FunctionId, args: &'hir hir::CallArgs<'hir>) {
1424        let func = self.hir.function(fid);
1425        if !is_internal_callsite_seed_candidate(func) {
1426            return;
1427        }
1428
1429        let arity = func.parameters.len();
1430        let facts = self.out.entry(fid).or_insert_with(|| ParamCallsiteFacts::new(arity));
1431        if facts.unknown {
1432            return;
1433        }
1434        let Some(call_args) = internal_call_args(self.hir, func, args) else {
1435            facts.unknown = true;
1436            return;
1437        };
1438        if call_args.len() != arity || facts.seen.len() != arity {
1439            facts.unknown = true;
1440            return;
1441        }
1442
1443        let analyzer = Analyzer::new(self.gcx, self.hir, self.has_solady_lib);
1444        for (i, arg) in call_args.into_iter().enumerate() {
1445            facts.seen[i] = true;
1446            facts.all_safe[i] &= analyzer.is_safe(arg);
1447            facts.all_self[i] &= analyzer.is_self_expr(arg);
1448        }
1449    }
1450}
1451
1452fn internal_call_args<'hir>(
1453    hir: &'hir hir::Hir<'hir>,
1454    func: &'hir hir::Function<'hir>,
1455    args: &'hir hir::CallArgs<'hir>,
1456) -> Option<Vec<&'hir hir::Expr<'hir>>> {
1457    match args.kind {
1458        hir::CallArgsKind::Unnamed(exprs) => {
1459            (exprs.len() == func.parameters.len()).then(|| exprs.iter().collect())
1460        }
1461        hir::CallArgsKind::Named(named) => {
1462            if named.len() != func.parameters.len() {
1463                return None;
1464            }
1465            func.parameters
1466                .iter()
1467                .map(|param| {
1468                    let name = hir.variable(*param).name?;
1469                    named
1470                        .iter()
1471                        .find_map(|arg| (arg.name.as_str() == name.as_str()).then_some(&arg.value))
1472                })
1473                .collect()
1474        }
1475    }
1476}
1477
1478/// Collects state-variable assignments / `delete`s reached from the given stmts.
1479/// Single-level: does not follow nested function calls.
1480struct StateWriteCollector<'hir> {
1481    hir: &'hir hir::Hir<'hir>,
1482    out: HashSet<hir::VariableId>,
1483}
1484
1485impl<'hir> hir::Visit<'hir> for StateWriteCollector<'hir> {
1486    type BreakValue = Never;
1487
1488    fn hir(&self) -> &'hir hir::Hir<'hir> {
1489        self.hir
1490    }
1491
1492    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1493        match &expr.kind {
1494            ExprKind::Assign(lhs, _, _) => self.add_target(lhs),
1495            ExprKind::Delete(e) => self.add_target(e),
1496            _ => {}
1497        }
1498        self.walk_expr(expr)
1499    }
1500}
1501
1502impl<'hir> StateWriteCollector<'hir> {
1503    fn add_target(&mut self, lhs: &hir::Expr<'_>) {
1504        let lhs = lhs.peel_parens();
1505        if let ExprKind::Tuple(elems) = &lhs.kind {
1506            for e in elems.iter().flatten() {
1507                self.add_target(e);
1508            }
1509            return;
1510        }
1511        if let Some(vid) = underlying_var(lhs) {
1512            let v = self.hir.variable(vid);
1513            if v.kind.is_state() {
1514                self.out.insert(vid);
1515            }
1516        }
1517    }
1518}
1519
1520fn collect_state_writes<'hir>(
1521    hir: &'hir hir::Hir<'hir>,
1522    stmts: &'hir [hir::Stmt<'hir>],
1523) -> HashSet<hir::VariableId> {
1524    let mut c = StateWriteCollector { hir, out: HashSet::new() };
1525    for s in stmts {
1526        let _ = c.visit_stmt(s);
1527    }
1528    c.out
1529}
1530
1531/// Resolves a call's callee to a `FunctionId` for plain `name()` / `this.name()`
1532/// patterns inside the same contract. Returns `None` for external / library /
1533/// member-of-state-var / unresolved calls.
1534fn resolve_internal_fn(callee: &hir::Expr<'_>) -> Option<hir::FunctionId> {
1535    let callee = callee.peel_parens();
1536    let reses: &[Res] = match &callee.kind {
1537        ExprKind::Ident(reses) => reses,
1538        ExprKind::Member(recv, _) => match &recv.peel_parens().kind {
1539            ExprKind::Ident(reses) => reses,
1540            _ => return None,
1541        },
1542        _ => return None,
1543    };
1544    reses.iter().find_map(|r| match r {
1545        Res::Item(ItemId::Function(fid)) => Some(*fid),
1546        _ => None,
1547    })
1548}
1549
1550/// Walks an expression tree and records every internal callee resolvable to a
1551/// `FunctionId`. Used to widen `collect_state_writes` by one call level.
1552struct NestedCallCollector<'hir> {
1553    hir: &'hir hir::Hir<'hir>,
1554    out: HashSet<hir::FunctionId>,
1555}
1556
1557impl<'hir> hir::Visit<'hir> for NestedCallCollector<'hir> {
1558    type BreakValue = Never;
1559
1560    fn hir(&self) -> &'hir hir::Hir<'hir> {
1561        self.hir
1562    }
1563
1564    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1565        if let ExprKind::Call(callee, ..) = &expr.kind
1566            && let Some(fid) = resolve_internal_fn(callee)
1567        {
1568            self.out.insert(fid);
1569        }
1570        self.walk_expr(expr)
1571    }
1572}
1573
1574/// `true` when the modifier body has a single `_;` and any preceding statement
1575/// definitely exits (making the calling function's body unreachable).
1576fn modifier_prefix_always_exits(hir: &hir::Hir<'_>, invocation: &hir::Modifier<'_>) -> bool {
1577    let ItemId::Function(fid) = invocation.id else { return false };
1578    let modifier = hir.function(fid);
1579    if !matches!(modifier.kind, hir::FunctionKind::Modifier) {
1580        return false;
1581    }
1582    let Some(body) = modifier.body else { return false };
1583    if count_placeholders(body.stmts) != 1 {
1584        return false;
1585    }
1586    let Some(placeholder_idx) =
1587        body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder))
1588    else {
1589        return false;
1590    };
1591    body.stmts[..placeholder_idx].iter().any(branch_always_exits)
1592}
1593
1594/// `true` if any statement is `StmtKind::Err` (currently catches inline assembly,
1595/// which solar doesn't yet lower to HIR).
1596fn contains_unanalysable(stmts: &[hir::Stmt<'_>]) -> bool {
1597    fn in_stmt(s: &hir::Stmt<'_>) -> bool {
1598        match &s.kind {
1599            StmtKind::Err(_) => true,
1600            StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => contains_unanalysable(b.stmts),
1601            StmtKind::If(_, t, e) => in_stmt(t) || e.as_ref().is_some_and(|s| in_stmt(s)),
1602            StmtKind::Loop(b, _) => contains_unanalysable(b.stmts),
1603            StmtKind::Try(t) => t.clauses.iter().any(|c| contains_unanalysable(c.block.stmts)),
1604            _ => false,
1605        }
1606    }
1607    stmts.iter().any(in_stmt)
1608}
1609
1610/// Strips the synthesized trailing `if (!cond) break;` from the HIR `do-while` lowering.
1611fn do_while_user_stmts<'a, 'hir>(stmts: &'a [hir::Stmt<'hir>]) -> &'a [hir::Stmt<'hir>] {
1612    match stmts.split_last() {
1613        Some((last, rest)) if is_loop_termination_if(last) => rest,
1614        _ => stmts,
1615    }
1616}
1617
1618fn is_loop_termination_if(stmt: &hir::Stmt<'_>) -> bool {
1619    let StmtKind::If(_, then_, else_) = &stmt.kind else { return false };
1620    is_break_stmt(then_) || else_.as_ref().is_some_and(|e| is_break_stmt(e))
1621}
1622
1623fn is_break_stmt(stmt: &hir::Stmt<'_>) -> bool {
1624    match &stmt.kind {
1625        StmtKind::Break => true,
1626        StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => {
1627            b.stmts.len() == 1 && is_break_stmt(&b.stmts[0])
1628        }
1629        _ => false,
1630    }
1631}
1632
1633/// `break`/`continue` targeting the current loop (nested loops shadow them).
1634fn body_has_break_or_continue(stmts: &[hir::Stmt<'_>]) -> bool {
1635    fn in_stmt(stmt: &hir::Stmt<'_>) -> bool {
1636        match &stmt.kind {
1637            StmtKind::Break | StmtKind::Continue => true,
1638            StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => body_has_break_or_continue(b.stmts),
1639            StmtKind::If(_, t, e) => in_stmt(t) || e.as_ref().is_some_and(|s| in_stmt(s)),
1640            StmtKind::Try(t) => t.clauses.iter().any(|c| body_has_break_or_continue(c.block.stmts)),
1641            StmtKind::Loop(..) => false,
1642            _ => false,
1643        }
1644    }
1645    stmts.iter().any(in_stmt)
1646}
1647
1648fn count_placeholders(stmts: &[hir::Stmt<'_>]) -> usize {
1649    fn count_in_stmt(stmt: &hir::Stmt<'_>) -> usize {
1650        match &stmt.kind {
1651            StmtKind::Placeholder => 1,
1652            StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => count_placeholders(b.stmts),
1653            StmtKind::If(_, t, e) => count_in_stmt(t) + e.as_ref().map_or(0, |s| count_in_stmt(s)),
1654            StmtKind::Loop(b, _) => count_placeholders(b.stmts),
1655            StmtKind::Try(t) => t.clauses.iter().map(|c| count_placeholders(c.block.stmts)).sum(),
1656            _ => 0,
1657        }
1658    }
1659    stmts.iter().map(count_in_stmt).sum()
1660}
1661
1662/// Resolves a `VariableId` for bare idents and type-cast / `payable(...)` / parens wraps.
1663///
1664/// Strips elementary-type casts (e.g. `address(x)`), contract / interface casts
1665/// (e.g. `IERC20(rawToken)` — encoded as `Call` with the contract ident as callee), and
1666/// `payable(...)` wrappers. Stripping interface casts lets permit and sink correlate when
1667/// both sides wrap the same underlying raw-address variable.
1668fn underlying_var(expr: &hir::Expr<'_>) -> Option<hir::VariableId> {
1669    match &expr.peel_parens().kind {
1670        ExprKind::Ident(reses) => reses.iter().find_map(|r| match r {
1671            Res::Item(ItemId::Variable(vid)) => Some(*vid),
1672            _ => None,
1673        }),
1674        ExprKind::Call(callee, args, _) if is_cast_callee(callee) => {
1675            // Type conversions are unary; bail out on anything else to keep this peel
1676            // strictly a cast-stripping helper.
1677            let mut exprs = args.exprs();
1678            let inner = exprs.next()?;
1679            if exprs.next().is_some() {
1680                return None;
1681            }
1682            underlying_var(inner)
1683        }
1684        ExprKind::Payable(inner) => underlying_var(inner),
1685        _ => None,
1686    }
1687}
1688
1689/// `true` when `callee` is a type-cast head, i.e. `address(...)`, an elementary-type cast,
1690/// or an interface/contract cast like `IERC20(...)`.
1691fn is_cast_callee(callee: &hir::Expr<'_>) -> bool {
1692    match &callee.peel_parens().kind {
1693        ExprKind::Type(_) => true,
1694        ExprKind::Ident(reses) => reses.iter().any(|r| matches!(r, Res::Item(ItemId::Contract(_)))),
1695        _ => false,
1696    }
1697}
1698
1699/// Resolves the static contract type of `recv`: a contract-typed expression, a direct contract
1700/// reference (e.g. a library), or an interface/contract cast.
1701fn receiver_contract_id<'hir>(gcx: Gcx<'hir>, recv: &hir::Expr<'hir>) -> Option<hir::ContractId> {
1702    expr_contract_id(gcx, recv).or_else(|| direct_contract_id(recv))
1703}
1704
1705fn expr_contract_id<'hir>(gcx: Gcx<'hir>, expr: &hir::Expr<'hir>) -> Option<hir::ContractId> {
1706    expr_ty(gcx, expr).and_then(ty_contract_id)
1707}
1708
1709fn ty_contract_id(ty: Ty<'_>) -> Option<hir::ContractId> {
1710    match ty.peel_refs().kind {
1711        TyKind::Contract(id) => Some(id),
1712        TyKind::Type(ty) => ty_contract_id(ty),
1713        _ => None,
1714    }
1715}
1716
1717fn direct_contract_id(expr: &hir::Expr<'_>) -> Option<hir::ContractId> {
1718    match &expr.peel_parens().kind {
1719        ExprKind::Ident(reses) => reses.iter().find_map(|r| match r {
1720            Res::Item(ItemId::Contract(cid)) => Some(*cid),
1721            _ => None,
1722        }),
1723        ExprKind::Call(callee, ..) => direct_contract_id(callee),
1724        _ => None,
1725    }
1726}
1727
1728/// Whether the sources declare a Solady-shaped `SafeTransferLib` library.
1729fn has_solady_safe_transfer_lib(hir: &hir::Hir<'_>) -> bool {
1730    hir.contracts_enumerated().any(|(cid, c)| {
1731        c.kind == ContractKind::Library
1732            && c.name.as_str() == "SafeTransferLib"
1733            && library_has_safe_transfer_from(hir, cid)
1734    })
1735}
1736
1737fn contract_has_function(
1738    hir: &hir::Hir<'_>,
1739    cid: hir::ContractId,
1740    name: &str,
1741    params: &[&str],
1742    returns: &[&str],
1743) -> bool {
1744    hir.contract_item_ids(cid).any(|item| {
1745        let Some(fid) = item.as_function() else { return false };
1746        let f = hir.function(fid);
1747        f.name.is_some_and(|n| n.name.as_str() == name)
1748            && f.parameters.len() == params.len()
1749            && f.returns.len() == returns.len()
1750            && f.parameters.iter().zip(params).all(|(id, abi)| is_elementary(hir, *id, abi))
1751            && f.returns.iter().zip(returns).all(|(id, abi)| is_elementary(hir, *id, abi))
1752    })
1753}
1754
1755/// 4-arg `safeTransferFrom(token, address, address, uint256)`. `token` is either `address`
1756/// (Solady) or a contract declaring ERC20's `transferFrom(...)→bool` (OZ SafeERC20);
1757/// ERC721/1155 helpers are excluded since their `transferFrom` has no return.
1758fn library_has_safe_transfer_from(hir: &hir::Hir<'_>, cid: hir::ContractId) -> bool {
1759    hir.contract_item_ids(cid).any(|item| {
1760        let Some(fid) = item.as_function() else { return false };
1761        let f = hir.function(fid);
1762        if f.parameters.len() != 4 || f.name.is_none_or(|n| n.name.as_str() != "safeTransferFrom") {
1763            return false;
1764        }
1765        let token_ok = match hir.variable(f.parameters[0]).ty.kind {
1766            TypeKind::Elementary(ElementaryType::Address(_)) => true,
1767            TypeKind::Custom(ItemId::Contract(token_cid)) => contract_has_function(
1768                hir,
1769                token_cid,
1770                "transferFrom",
1771                &["address", "address", "uint256"],
1772                &["bool"],
1773            ),
1774            _ => false,
1775        };
1776        token_ok
1777            && is_address(hir, f.parameters[1])
1778            && is_address(hir, f.parameters[2])
1779            && is_elementary(hir, f.parameters[3], "uint256")
1780    })
1781}
1782
1783fn is_address(hir: &hir::Hir<'_>, id: hir::VariableId) -> bool {
1784    matches!(hir.variable(id).ty.kind, TypeKind::Elementary(ElementaryType::Address(_)))
1785}
1786
1787/// True when `expr`'s static type is `address` / `address payable`.
1788fn expr_is_address<'hir>(gcx: Gcx<'hir>, expr: &hir::Expr<'hir>) -> bool {
1789    expr_ty(gcx, expr).is_some_and(ty_is_address)
1790}
1791
1792fn expr_ty<'hir>(gcx: Gcx<'hir>, expr: &hir::Expr<'hir>) -> Option<Ty<'hir>> {
1793    gcx.type_of_expr(expr.peel_parens().id)
1794}
1795
1796fn ty_is_address(ty: Ty<'_>) -> bool {
1797    matches!(ty.peel_refs().kind, TyKind::Elementary(ElementaryType::Address(_)))
1798}
1799
1800fn is_elementary(hir: &hir::Hir<'_>, id: hir::VariableId, abi: &str) -> bool {
1801    matches!(&hir.variable(id).ty.kind, TypeKind::Elementary(ty) if ty.to_abi_str() == abi)
1802}
1803
1804/// Resolves positional or named call args to a fixed positional ordering. `aliases[i]`
1805/// holds the parameter names accepted for slot `i` in the named form. Returns `None` if
1806/// arity differs or any slot is unmatched.
1807fn canonical_args<'hir>(
1808    kind: hir::CallArgsKind<'hir>,
1809    aliases: &[&[&str]],
1810) -> Option<Vec<&'hir hir::Expr<'hir>>> {
1811    match kind {
1812        hir::CallArgsKind::Unnamed(exprs) => {
1813            (exprs.len() == aliases.len()).then(|| exprs.iter().collect())
1814        }
1815        hir::CallArgsKind::Named(named) => {
1816            if named.len() != aliases.len() {
1817                return None;
1818            }
1819            aliases
1820                .iter()
1821                .map(|accepted| {
1822                    named.iter().find_map(|a| {
1823                        accepted.iter().any(|n| a.name.as_str() == *n).then_some(&a.value)
1824                    })
1825                })
1826                .collect()
1827        }
1828    }
1829}
1830
1831fn is_address_cast(callee: &hir::Expr<'_>) -> bool {
1832    matches!(
1833        &callee.peel_parens().kind,
1834        ExprKind::Type(hir::Type { kind: TypeKind::Elementary(ElementaryType::Address(_)), .. })
1835    )
1836}
1837
1838fn is_require_or_assert(callee: &hir::Expr<'_>) -> bool {
1839    let ExprKind::Ident(reses) = &callee.kind else { return false };
1840    reses.iter().any(
1841        |r| matches!(r, Res::Builtin(b) if b.name() == sym::require || b.name() == sym::assert),
1842    )
1843}
1844
1845/// `address(this)` or bare `this`, including through `payable(...)` and parens wraps.
1846fn is_address_self(expr: &hir::Expr<'_>) -> bool {
1847    let expr = expr.peel_parens();
1848    if is_builtin(expr, sym::this) {
1849        return true;
1850    }
1851    if let ExprKind::Payable(inner) = &expr.kind {
1852        return is_address_self(inner);
1853    }
1854    matches!(&expr.kind, ExprKind::Call(callee, args, _) if is_address_cast(callee)
1855        && args.exprs().next().is_some_and(is_address_self))
1856}
1857
1858fn is_builtin(expr: &hir::Expr<'_>, name: solar::interface::Symbol) -> bool {
1859    let ExprKind::Ident(reses) = &expr.peel_parens().kind else { return false };
1860    reses.iter().any(|r| matches!(r, Res::Builtin(b) if b.name() == name))
1861}
1862
1863/// `return`, custom-error `revert`, `revert(...)`, or `assert(false)` / `require(false, ...)`.
1864fn branch_always_exits(stmt: &hir::Stmt<'_>) -> bool {
1865    match &stmt.kind {
1866        StmtKind::Return(_) | StmtKind::Revert(_) => true,
1867        StmtKind::Expr(expr) => is_exit_call(expr),
1868        // Any sequential definite-exit makes the block exit.
1869        StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => b.stmts.iter().any(branch_always_exits),
1870        StmtKind::If(_, t, Some(e)) => branch_always_exits(t) && branch_always_exits(e),
1871        // `do-while` runs the body once: if it can't break/continue out and any stmt
1872        // definitely exits, the loop does too.
1873        StmtKind::Loop(block, LoopSource::DoWhile) => {
1874            let user = do_while_user_stmts(block.stmts);
1875            !body_has_break_or_continue(user) && user.iter().any(branch_always_exits)
1876        }
1877        // `try { ... } catch { ... }` exits the enclosing block only when every clause
1878        // (success and all catches) definitely exits.
1879        StmtKind::Try(t) => t.clauses.iter().all(|c| c.block.stmts.iter().any(branch_always_exits)),
1880        _ => false,
1881    }
1882}
1883
1884fn is_exit_call(expr: &hir::Expr<'_>) -> bool {
1885    let ExprKind::Call(callee, args, _) = &expr.kind else { return false };
1886    if is_builtin(callee, kw::Revert) {
1887        return true;
1888    }
1889    if is_require_or_assert(callee)
1890        && let hir::CallArgsKind::Unnamed(unnamed) = args.kind
1891        && let Some(first) = unnamed.first()
1892        && matches!(
1893            &first.peel_parens().kind,
1894            ExprKind::Lit(lit) if matches!(lit.kind, ast::LitKind::Bool(false))
1895        )
1896    {
1897        return true;
1898    }
1899    false
1900}