Skip to main content

forge_lint/sol/med/
unsafe_oz_erc721_mint.rs

1use super::UnsafeOzErc721Mint;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            OPENZEPPELIN_ROOTS, arg_for_param, for_each_lhs_var, is_address_type, is_builtin,
8            is_literal_false, is_require_or_assert, loop_stmts, source_in_package, underlying_var,
9            unique, write_target,
10        },
11    },
12};
13use alloy_primitives::U256;
14use solar::{
15    ast::{ElementaryType, LitKind, StateMutability, Visibility},
16    interface::{Span, kw},
17    sema::{
18        Gcx,
19        hir::{
20            self, BinOpKind, CallArgs, Expr, ExprKind, FunctionId, Hir, ItemId, Stmt, StmtKind,
21            TypeKind, VariableId, Visit,
22        },
23        ty::{TyFn, TyKind},
24    },
25};
26use std::{ops::ControlFlow, slice};
27
28declare_forge_lint!(
29    UNSAFE_OZ_ERC721_MINT,
30    Severity::Med,
31    "unsafe-oz-erc721-mint",
32    "`ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`"
33);
34
35impl<'gcx> LateLintPass<'gcx> for UnsafeOzErc721Mint {
36    fn check_function(
37        &mut self,
38        ctx: &LintContext,
39        gcx: Gcx<'gcx>,
40        func: &'gcx hir::Function<'gcx>,
41    ) {
42        let cx = Cx { gcx };
43        // Only the canonical OZ `_safeMint` wrapper is exempt: it legitimately calls `_mint`
44        // next to its receiver check. A user-defined `_safeMint` override stays analyzed, since
45        // it can call `_mint` directly without any check.
46        if named(func, "_safeMint")
47            && func
48                .contract
49                .is_some_and(|id| is_canonical_erc721(gcx.hir.contract(id).name.as_str()))
50            && source_in_package(&gcx.hir, func.source, OPENZEPPELIN_ROOTS)
51        {
52            return;
53        }
54        // A user `_mint` override is part of the mint primitive itself: `super._mint` there is
55        // delegation (the capped/pausable pattern), and `_safeMint` there would re-enter the
56        // override through the virtual dispatch. A delegating override reports at its call
57        // sites instead, where `_safeMint` is the fix. The same holds for a helper such an
58        // override delegates through.
59        if (named(func, "_mint") && func.override_) || cx.is_override_delegation_helper(func) {
60            return;
61        }
62        // `ERC721._mint` credits the token without calling `onERC721Received`, so minting to a
63        // contract that cannot handle ERC721 tokens locks the token; `_safeMint` performs the
64        // check. Flag calls that resolve to a `_mint` declared in an ERC721 contract. The type
65        // checker's resolution already accounts for overload selection, override shadowing and
66        // `super._mint(...)`.
67        let Some(body) = &func.body else { return };
68        for (callee, _, span) in cx.calls(body.stmts) {
69            let helper = cx.is_override_delegation_helper(gcx.hir.function(callee));
70            if cx.unsafe_mint_target(callee, helper, &mut Vec::new()).is_some() {
71                ctx.emit(&UNSAFE_OZ_ERC721_MINT, span);
72            }
73        }
74    }
75}
76
77/// An unsafe mint target and whether every recursive hop preserves the recipient and token it
78/// receives. A callback guard needs both guarantees, while a code-less-recipient proof needs
79/// only the first.
80#[derive(Clone, Copy)]
81struct UnsafeMintTarget {
82    preserves_recipient: bool,
83    preserves_token: bool,
84    preserves_code_length: bool,
85}
86
87/// A resolved call: its target, arguments and span.
88type Call<'gcx> = (FunctionId, &'gcx CallArgs<'gcx>, Span);
89
90/// The analysis context.
91#[derive(Clone, Copy)]
92struct Cx<'gcx> {
93    gcx: Gcx<'gcx>,
94}
95
96impl<'gcx> Cx<'gcx> {
97    /// Whether an internal/private function is reached from a user `_mint` override of a
98    /// derived contract, making it part of the mint primitive rather than a call site.
99    fn is_override_delegation_helper(self, function: &'gcx hir::Function<'gcx>) -> bool {
100        if !is_internal(function) || (function.override_ && named(function, "_mint")) {
101            return false;
102        }
103        let Some(contract_id) = function.contract else { return false };
104        let Some(function_id) = self
105            .gcx
106            .hir
107            .contract(contract_id)
108            .all_functions()
109            .find(|&id| std::ptr::eq(self.gcx.hir.function(id), function))
110        else {
111            return false;
112        };
113        self.gcx.hir.contract_ids().any(|candidate| {
114            let candidate = self.gcx.hir.contract(candidate);
115            candidate.linearized_bases.contains(&contract_id)
116                && candidate.all_functions().any(|id| {
117                    let f = self.gcx.hir.function(id);
118                    f.override_
119                        && named(f, "_mint")
120                        && self.function_reaches(id, function_id, &mut Vec::new())
121                })
122        })
123    }
124
125    /// Whether `function_id` calls `target`, directly or through internal functions.
126    fn function_reaches(
127        self,
128        function_id: FunctionId,
129        target: FunctionId,
130        seen: &mut Vec<FunctionId>,
131    ) -> bool {
132        if seen.contains(&function_id) {
133            return false;
134        }
135        seen.push(function_id);
136        let Some(body) = self.gcx.hir.function(function_id).body else { return false };
137        self.calls(body.stmts).iter().any(|&(callee, ..)| {
138            callee == target
139                || (is_internal(self.gcx.hir.function(callee))
140                    && self.function_reaches(callee, target, seen))
141        })
142    }
143
144    /// Whether `function_id` is a `_mint` whose execution skips the receiver check: the
145    /// canonical OZ declaration (exact OZ contract name AND an OpenZeppelin source path, so a
146    /// local contract reusing a name like `ERC721Consecutive` stays out), or a user override
147    /// whose body calls a `_mint` that is itself unsafe (the capped/pausable pattern forwarding
148    /// through `super._mint`). An override whose successful paths prove the recipient code-less
149    /// or reject it after the delegation is a safe wrapper like canonical `_safeMint`. `seen`
150    /// cuts override cycles, which never reach the canonical declaration.
151    fn unsafe_mint_target(
152        self,
153        function_id: FunctionId,
154        helper: bool,
155        seen: &mut Vec<FunctionId>,
156    ) -> Option<UnsafeMintTarget> {
157        if seen.contains(&function_id) {
158            return None;
159        }
160        seen.push(function_id);
161        let function = self.gcx.hir.function(function_id);
162        let is_mint = named(function, "_mint");
163        if !(is_mint || (helper && is_internal(function))) {
164            return None;
165        }
166        let contract = self.gcx.hir.contract(function.contract?);
167        if contract.kind.is_library() {
168            return None;
169        }
170        let canonical = is_canonical_erc721(contract.name.as_str())
171            && source_in_package(&self.gcx.hir, function.source, OPENZEPPELIN_ROOTS);
172        if canonical && named(function, "_safeMint") {
173            return None;
174        }
175        // Most extensions (`ERC721Enumerable`, ...) inherit `_mint` rather than redeclare it, so
176        // resolution still lands here.
177        if canonical && is_mint {
178            return Some(UnsafeMintTarget {
179                preserves_recipient: true,
180                preserves_token: true,
181                preserves_code_length: true,
182            });
183        }
184        if !(function.override_ || helper) {
185            return None;
186        }
187        let body = function.body.as_ref()?;
188        // The minted recipient is the override's first address-typed parameter.
189        let recipient =
190            function.parameters.iter().copied().find(|&vid| is_address_type(&self.gcx.hir, vid));
191        let calls = self.calls(body.stmts);
192        // Each distinct callee is judged once, with its own copy of `seen`: a cycle is a property
193        // of one path, and two siblings sharing a transitive target would otherwise silence the
194        // second.
195        let mut unsafe_targets = Vec::new();
196        let mut unstable_code_targets = Vec::new();
197        let mut judged = Vec::new();
198        let (mut targets_preserve_recipient, mut targets_preserve_token) = (true, true);
199        for &(callee, ..) in &calls {
200            if judged.contains(&callee) {
201                continue;
202            }
203            judged.push(callee);
204            if let Some(target) = self.unsafe_mint_target(callee, true, &mut seen.clone()) {
205                unsafe_targets.push(callee);
206                if !target.preserves_code_length {
207                    unstable_code_targets.push(callee);
208                }
209                targets_preserve_recipient &= target.preserves_recipient;
210                targets_preserve_token &= target.preserves_token;
211            }
212        }
213        let delegations: Vec<_> =
214            calls.iter().filter(|(callee, ..)| unsafe_targets.contains(callee)).collect();
215        if delegations.is_empty() {
216            return None;
217        }
218        // A guard covers the recipient it names, and no other. The recipient is what every
219        // delegation binds to the callee's first parameter, the token what it binds to the
220        // second, as the canonical `_mint(address to, uint256 tokenId)` orders them.
221        let forwards = |index: usize, var: Option<VariableId>| {
222            var.is_some_and(|var| {
223                delegations.iter().all(|&&(callee, args, _)| {
224                    self.arg(callee, args, index).and_then(|expr| underlying_var(self.gcx, expr))
225                        == Some(var)
226                })
227            })
228        };
229        let only_to_recipient = forwards(0, recipient);
230        // The token every delegation credits, when they all name the same variable: the
231        // recipient may accept one token and refuse another, so a guard is only about the token
232        // it was asked about. A token that is not a plain variable, or a mutable state variable
233        // that an intervening call may move under the guard's feet, cannot be matched.
234        let mut token = None;
235        let mut token_consistent = true;
236        for &&(callee, args, _) in &delegations {
237            let minted = self.arg(callee, args, 1).and_then(|expr| underlying_var(self.gcx, expr));
238            match minted.filter(|&minted| keeps_its_value(self.gcx, minted)) {
239                Some(minted) => {
240                    token_consistent &= token.is_none_or(|token| token == minted);
241                    token = Some(minted);
242                }
243                None => token_consistent = false,
244            }
245        }
246        // Modifier expansion may supply a code-less proof before the body or a callback guard
247        // after it. The body is still read in order because reassigning the recipient or token
248        // can make either guard name a different value from the delegation.
249        let guarded = |recipient, token, seed| {
250            let mut walk = self.modifier_coverage_at_body(function, recipient, token, seed);
251            let mut walker = GuardWalker {
252                cx: self,
253                recipient,
254                token,
255                delegations: &unsafe_targets,
256                unstable_code_delegations: &unstable_code_targets,
257                seen: &mut Vec::new(),
258            };
259            walker.walk(body.stmts, &mut walk);
260            !walk.failed && !walk.pending
261        };
262        // A proof that the recipient has no code is independent of the token being minted, so
263        // the recipient stands in both identity slots: a callback guard cannot type-check with
264        // an address as its token. This lets an address-only helper or modifier establish
265        // coverage and permits computed or remapped token arguments.
266        if only_to_recipient
267            && targets_preserve_recipient
268            && let Some(recipient) = recipient
269            && guarded(recipient, recipient, GuardCoverage::None)
270        {
271            return None;
272        }
273        if only_to_recipient
274            && token_consistent
275            && targets_preserve_recipient
276            && targets_preserve_token
277            && let Some(recipient) = recipient
278            && let Some(token) = token
279            && guarded(recipient, token, GuardCoverage::None)
280        {
281            return None;
282        }
283        // A guard in an outer override needs every intermediate override to preserve the
284        // identities it relies on: the recipient for a code-less proof, and both recipient and
285        // token for a callback. This summary is propagated only to callers; the current
286        // override's own guard above may legitimately check a remapped local value.
287        let preserves = |index: usize| {
288            function.parameters.get(index).is_some_and(|&var| {
289                !body.stmts.iter().any(|stmt| self.mutates_var(stmt, var))
290                    && !function.modifiers.iter().any(|modifier| {
291                        modifier.args.exprs().any(|arg| self.expr_mutates_var(arg, var))
292                    })
293                    && forwards(index, Some(var))
294            })
295        };
296        // A caller's code-less proof remains valid through this override only when no path can
297        // change account code before reaching a delegated mint.
298        let preserves_code_length = recipient
299            .is_some_and(|recipient| guarded(recipient, recipient, GuardCoverage::CodeLess));
300        Some(UnsafeMintTarget {
301            preserves_recipient: targets_preserve_recipient && preserves(0),
302            preserves_token: targets_preserve_token && preserves(1),
303            preserves_code_length,
304        })
305    }
306
307    /// Runs `stmt_matches`/`expr_matches` over a subtree and reports whether either held.
308    fn any_in_stmts(
309        self,
310        stmts: &'gcx [Stmt<'gcx>],
311        stmt_matches: impl FnMut(&'gcx Stmt<'gcx>) -> bool,
312        expr_matches: impl FnMut(&'gcx Expr<'gcx>) -> bool,
313    ) -> bool {
314        let mut finder = Finder { gcx: self.gcx, stmt_matches, expr_matches };
315        stmts.iter().any(|stmt| finder.visit_stmt(stmt).is_break())
316    }
317
318    fn any_in_expr(
319        self,
320        expr: &'gcx Expr<'gcx>,
321        expr_matches: impl FnMut(&'gcx Expr<'gcx>) -> bool,
322    ) -> bool {
323        Finder { gcx: self.gcx, stmt_matches: |_| false, expr_matches }.visit_expr(expr).is_break()
324    }
325
326    /// Every resolved call in a subtree, in source order.
327    fn calls(self, stmts: &'gcx [Stmt<'gcx>]) -> Vec<Call<'gcx>> {
328        let mut calls = Vec::new();
329        self.any_in_stmts(
330            stmts,
331            |_| false,
332            |expr| {
333                if let ExprKind::Call(_, args, _) = &expr.kind
334                    && let Some(function_id) = self.resolved_callee(expr)
335                {
336                    calls.push((function_id, args, expr.span));
337                }
338                false
339            },
340        );
341        calls
342    }
343
344    /// The function a call expression dispatches to, as the type checker resolved it.
345    fn resolved_callee(self, expr: &Expr<'_>) -> Option<FunctionId> {
346        let ExprKind::Call(callee, ..) = &expr.kind else { return None };
347        self.gcx.resolved_function(callee)
348    }
349
350    fn callee_fn(self, expr: &Expr<'_>) -> Option<&'gcx TyFn<'gcx>> {
351        let ExprKind::Call(callee, ..) = &expr.kind else { return None };
352        match self.gcx.type_of_expr(callee.peel_parens().id)?.kind {
353            TyKind::Fn(function_ty) => Some(function_ty),
354            _ => None,
355        }
356    }
357
358    /// The declaration a call executes in the current EVM frame. A public function called by
359    /// name is internal here, while `this.f()` and other external calls run in another frame
360    /// whose assembly `return` cannot bypass the caller's later statements.
361    fn resolved_internal_callee(self, expr: &Expr<'_>) -> Option<FunctionId> {
362        let function_ty = self.callee_fn(expr)?;
363        function_ty.is_internal().then_some(function_ty.function_id).flatten()
364    }
365
366    /// Whether a call dispatches through an internal function-pointer variable whose target is
367    /// not available from the callee type. Such a target may contain assembly that leaves the
368    /// frame, so exit analysis must treat the call conservatively.
369    fn is_unresolved_internal_pointer_call(self, expr: &Expr<'_>) -> bool {
370        let ExprKind::Call(callee, ..) = &expr.kind else { return false };
371        self.callee_fn(expr).is_some_and(|f| f.is_internal() && f.function_id.is_none())
372            && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
373            && self.gcx.resolved_variable(callee).is_some()
374    }
375
376    /// The argument a call binds to the callee's parameter at `index`, positional or named.
377    fn arg(
378        self,
379        function_id: FunctionId,
380        args: &'gcx CallArgs<'gcx>,
381        index: usize,
382    ) -> Option<&'gcx Expr<'gcx>> {
383        let function = self.gcx.hir.function(function_id);
384        arg_for_param(self.gcx, function_id, *function.parameters.get(index)?, args)
385    }
386
387    /// Whether a resolved declaration is the ERC721 receiver hook: the exact name, the exact
388    /// `(address, address, uint256, bytes)` shape, and an externally callable declaration of a
389    /// non-library contract. A same-name function of an unrelated interface answers on a
390    /// different selector, and an attached library or free function runs in the minting
391    /// contract without any external call.
392    fn is_receiver_hook(self, function_id: FunctionId) -> bool {
393        let function = self.gcx.hir.function(function_id);
394        let Some(contract) = function.contract else { return false };
395        let &[from, to, id, data] = function.parameters else { return false };
396        let kind = |vid: VariableId| &self.gcx.hir.variable(vid).ty.kind;
397        named(function, "onERC721Received")
398            && !self.gcx.hir.contract(contract).kind.is_library()
399            && matches!(function.visibility, Visibility::Public | Visibility::External)
400            && is_address_type(&self.gcx.hir, from)
401            && is_address_type(&self.gcx.hir, to)
402            && matches!(kind(id), TypeKind::Elementary(ElementaryType::UInt(_)))
403            && matches!(kind(data), TypeKind::Elementary(ElementaryType::Bytes))
404    }
405
406    /// Whether an expression is the accepting answer, `onERC721Received`'s selector: the
407    /// literal, a conversion of it, a `constant` holding it, or a `selector` member resolving to
408    /// the receiver hook itself. The member is resolved rather than matched by name: spelled on
409    /// a same-name function of another shape, `.selector` is a different value. An `immutable`
410    /// or a state variable is unknown here and does not exempt.
411    fn is_received_selector(self, expr: &Expr<'gcx>) -> bool {
412        let expr = expr.peel_parens();
413        match &expr.kind {
414            ExprKind::Lit(lit) => {
415                matches!(&lit.kind, LitKind::Number(value) if *value == U256::from(ERC721_RECEIVED))
416            }
417            ExprKind::Call(callee, args, _)
418                if matches!(callee.peel_parens().kind, ExprKind::Type(..)) =>
419            {
420                args.len() == 1
421                    && args.exprs().next().is_some_and(|inner| {
422                        self.selector_cast_preserves(expr, inner)
423                            && self.is_received_selector(inner)
424                    })
425            }
426            ExprKind::Member(base, member) => {
427                member.as_str() == "selector"
428                    && self.gcx.resolved_function(base).is_some_and(|id| self.is_receiver_hook(id))
429            }
430            // A constant is worth what it holds.
431            ExprKind::Ident(_) => self.gcx.resolved_variable(expr).is_some_and(|vid| {
432                let variable = self.gcx.hir.variable(vid);
433                variable.is_constant()
434                    && variable.initializer.is_some_and(|init| self.is_received_selector(init))
435            }),
436            _ => false,
437        }
438    }
439
440    /// Whether a cast preserves the recognized selector's value and byte alignment. A recognized
441    /// integer is exactly the positive selector, so any integer width of at least 32 bits keeps
442    /// it. Fixed bytes are left-aligned while integers are right-aligned, so crossing between
443    /// them is only trusted at the four-byte boundary.
444    fn selector_cast_preserves(self, cast: &Expr<'_>, inner: &Expr<'_>) -> bool {
445        let encoding = |expr: &Expr<'_>| match self.gcx.type_of_expr(expr.peel_parens().id)?.kind {
446            TyKind::IntLiteral(..) => Some(SelectorEncoding::Literal),
447            TyKind::Elementary(ElementaryType::Int(size) | ElementaryType::UInt(size)) => {
448                Some(SelectorEncoding::Integer(size.bits()))
449            }
450            TyKind::Elementary(ElementaryType::FixedBytes(size)) => {
451                Some(SelectorEncoding::FixedBytes(size.bytes()))
452            }
453            _ => None,
454        };
455        matches!(
456            (encoding(inner), encoding(cast)),
457            (
458                Some(SelectorEncoding::Literal | SelectorEncoding::Integer(_)),
459                Some(SelectorEncoding::Integer(32..) | SelectorEncoding::FixedBytes(4))
460            ) | (Some(SelectorEncoding::FixedBytes(4)), Some(SelectorEncoding::Integer(32)))
461                | (
462                    Some(SelectorEncoding::FixedBytes(4..)),
463                    Some(SelectorEncoding::FixedBytes(4..))
464                )
465        )
466    }
467
468    /// Whether executing `stmt` always reverts, undoing everything the transaction did. Only a
469    /// revert counts, see [`Self::may_return`] for the escapes that leave the transaction
470    /// standing.
471    fn branch_always_reverts(self, stmt: &'gcx Stmt<'gcx>) -> bool {
472        match &stmt.kind {
473            StmtKind::Revert(_) => !self.may_return(stmt),
474            StmtKind::Expr(expr) => is_revert_call(self.gcx, expr) && !self.may_return(stmt),
475            // Read in order: a `revert` further down is only reached when nothing before it can
476            // leave the function on its own.
477            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => block
478                .stmts
479                .iter()
480                .find_map(|stmt| {
481                    self.branch_always_reverts(stmt)
482                        .then_some(true)
483                        .or_else(|| self.may_return(stmt).then_some(false))
484                })
485                .unwrap_or(false),
486            StmtKind::If(cond, then, Some(otherwise)) => {
487                !self.expr_contains_frame_ending_assembly(cond)
488                    && self.branch_always_reverts(then)
489                    && self.branch_always_reverts(otherwise)
490            }
491            _ => false,
492        }
493    }
494
495    /// Whether a statement may leave the function while keeping what the transaction already
496    /// did: a `return`, or the EVM `return`/`stop` an assembly block can hold. Only statements
497    /// that provably cannot leave answer no.
498    fn may_return(self, stmt: &'gcx Stmt<'gcx>) -> bool {
499        self.contains_frame_ending_assembly(slice::from_ref(stmt), &mut Vec::new())
500            || match &stmt.kind {
501                StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
502                    block.stmts.iter().any(|stmt| self.may_return(stmt))
503                }
504                StmtKind::Loop(block, source) => {
505                    loop_stmts(*block, *source).any(|stmt| self.may_return(stmt))
506                }
507                StmtKind::If(_, then, otherwise) => {
508                    self.may_return(then) || otherwise.is_some_and(|stmt| self.may_return(stmt))
509                }
510                StmtKind::Return(_)
511                | StmtKind::AssemblyBlock(_)
512                | StmtKind::Try(_)
513                | StmtKind::Switch(_) => true,
514                _ => false,
515            }
516    }
517
518    /// Whether a subtree can reach an assembly block in the same EVM frame, directly or through
519    /// an internal call. An assembly `return` leaves the frame without running a later revert or
520    /// what an outer modifier holds after its placeholder. Every assembly block is treated as
521    /// capable of doing so.
522    fn contains_frame_ending_assembly(
523        self,
524        stmts: &'gcx [Stmt<'gcx>],
525        seen: &mut Vec<FunctionId>,
526    ) -> bool {
527        self.any_in_stmts(stmts, is_assembly, |expr| self.call_leaves_frame(expr, seen))
528    }
529
530    fn expr_contains_frame_ending_assembly(self, expr: &'gcx Expr<'gcx>) -> bool {
531        let mut seen = Vec::new();
532        self.any_in_expr(expr, |expr| self.call_leaves_frame(expr, &mut seen))
533    }
534
535    fn call_leaves_frame(self, expr: &Expr<'_>, seen: &mut Vec<FunctionId>) -> bool {
536        self.is_unresolved_internal_pointer_call(expr)
537            || self
538                .resolved_internal_callee(expr)
539                .is_some_and(|id| self.callable_contains_frame_ending_assembly(id, seen))
540    }
541
542    /// Whether a same-frame callable or one of its applied modifiers can reach assembly. The
543    /// recursion set is a path stack so independent calls are summarized independently.
544    fn callable_contains_frame_ending_assembly(
545        self,
546        function_id: FunctionId,
547        seen: &mut Vec<FunctionId>,
548    ) -> bool {
549        if seen.contains(&function_id) {
550            return false;
551        }
552        seen.push(function_id);
553        let function = self.gcx.hir.function(function_id);
554        let in_modifiers = function.modifiers.iter().any(|modifier| {
555            matches!(modifier.id, ItemId::Function(id)
556                if self.callable_contains_frame_ending_assembly(id, seen))
557        });
558        let in_body = function
559            .body
560            .as_ref()
561            .is_some_and(|body| self.contains_frame_ending_assembly(body.stmts, seen));
562        seen.pop();
563        in_modifiers || in_body
564    }
565
566    /// Whether a statement assigns to `var`: `var = x`, `var += x`, `var++`, `delete var`, or
567    /// `var` as a component of a tuple assignment. An assembly block is treated as an opaque
568    /// assignment, since it can rewrite Solidity locals outside the HIR expression tree.
569    /// Identity is by variable, not by value, so a guard that checked `var` says nothing once
570    /// `var` is reassigned.
571    fn mutates_var(self, stmt: &'gcx Stmt<'gcx>, var: VariableId) -> bool {
572        self.any_in_stmts(slice::from_ref(stmt), is_assembly, |expr| {
573            assigns_to(self.gcx, expr, var)
574        })
575    }
576
577    fn expr_mutates_var(self, expr: &'gcx Expr<'gcx>, var: VariableId) -> bool {
578        self.any_in_expr(expr, |expr| assigns_to(self.gcx, expr, var))
579    }
580
581    /// Whether a subtree may change the code installed at an account. Inline assembly is opaque
582    /// and may deploy code even when it contains no HIR call expression.
583    fn stmts_may_change_account_code(
584        self,
585        stmts: &'gcx [Stmt<'gcx>],
586        delegations: &[FunctionId],
587        unstable_code_delegations: &[FunctionId],
588        seen: &mut Vec<FunctionId>,
589    ) -> bool {
590        self.any_in_stmts(stmts, is_assembly, |expr| {
591            self.call_may_change_account_code(expr, delegations, unstable_code_delegations, seen)
592        })
593    }
594
595    fn expr_may_change_account_code(
596        self,
597        expr: &'gcx Expr<'gcx>,
598        delegations: &[FunctionId],
599        unstable_code_delegations: &[FunctionId],
600        seen: &mut Vec<FunctionId>,
601    ) -> bool {
602        self.any_in_expr(expr, |expr| {
603            self.call_may_change_account_code(expr, delegations, unstable_code_delegations, seen)
604        })
605    }
606
607    /// Whether a call may change the code installed at an account. Pure and view calls are
608    /// stable (external ones execute through `STATICCALL`), while nonpayable/payable calls and
609    /// contract creation can run `CREATE`/`CREATE2`. Calls to the delegated mint itself are
610    /// excluded unless it is recursively unstable: the code-length proof is needed precisely
611    /// until that call begins.
612    fn call_may_change_account_code(
613        self,
614        expr: &Expr<'_>,
615        delegations: &[FunctionId],
616        unstable_code_delegations: &[FunctionId],
617        seen: &mut Vec<FunctionId>,
618    ) -> bool {
619        let ExprKind::Call(callee, ..) = &expr.kind else { return false };
620        let resolved = self.resolved_callee(expr);
621        if resolved.is_some_and(|id| delegations.contains(&id))
622            && !resolved.is_some_and(|id| unstable_code_delegations.contains(&id))
623        {
624            return false;
625        }
626        if matches!(callee.peel_parens().kind, ExprKind::New(_)) {
627            return true;
628        }
629        if !self.callee_fn(expr).is_some_and(|f| {
630            matches!(f.state_mutability, StateMutability::NonPayable | StateMutability::Payable)
631        }) {
632            return false;
633        }
634        match self.resolved_internal_callee(expr).filter(|&id| !self.gcx.hir.function(id).virtual_)
635        {
636            Some(id) => self.callable_may_change_account_code(id, seen),
637            None => true,
638        }
639    }
640
641    /// Whether a statically known same-frame callable can create code, directly, through an
642    /// applied modifier, or through another internal call. Recursion cycles alone do not create
643    /// code; any opaque, virtual, or external state-changing call reached remains conservative.
644    fn callable_may_change_account_code(
645        self,
646        function_id: FunctionId,
647        seen: &mut Vec<FunctionId>,
648    ) -> bool {
649        if seen.contains(&function_id) {
650            return false;
651        }
652        seen.push(function_id);
653        let function = self.gcx.hir.function(function_id);
654        let may_change = function.modifiers.iter().any(|modifier| {
655            modifier.args.exprs().any(|arg| self.expr_may_change_account_code(arg, &[], &[], seen))
656        }) || function.modifiers.iter().any(|modifier| {
657            matches!(modifier.id, ItemId::Function(id)
658                if self.callable_may_change_account_code(id, seen))
659        }) || function
660            .body
661            .as_ref()
662            .is_some_and(|body| self.stmts_may_change_account_code(body.stmts, &[], &[], seen));
663        seen.pop();
664        may_change
665    }
666
667    /// The callee parameters that receive the caller's recipient and token identities.
668    fn bound_guard_parameters(
669        self,
670        function_id: FunctionId,
671        args: &'gcx CallArgs<'gcx>,
672        recipient: VariableId,
673        token: VariableId,
674    ) -> Option<(VariableId, VariableId)> {
675        let parameters = self.gcx.hir.function(function_id).parameters;
676        let bound_to = |var| {
677            parameters
678                .iter()
679                .enumerate()
680                .find(|&(index, _)| {
681                    self.arg(function_id, args, index)
682                        .and_then(|expr| underlying_var(self.gcx, expr))
683                        == Some(var)
684                })
685                .map(|(_, &parameter)| parameter)
686        };
687        bound_to(recipient).zip(bound_to(token))
688    }
689
690    /// Guard coverage a callee's body establishes for the parameters the recipient and the token
691    /// landed on: the callee guards when a guard ran before any possible successful exit. `seen`
692    /// cuts recursion cycles.
693    fn body_guards(
694        self,
695        function_id: FunctionId,
696        recipient: VariableId,
697        token: VariableId,
698        seen: &mut Vec<FunctionId>,
699    ) -> GuardCoverage {
700        if seen.contains(&function_id) {
701            return GuardCoverage::None;
702        }
703        seen.push(function_id);
704        let function = self.gcx.hir.function(function_id);
705        // A `virtual` callee may be replaced by an override that drops the guard, and a helper
706        // carrying modifiers is not credited until their expansion is modeled: one may skip the
707        // placeholder and let the helper return without ever running its body. The caller
708        // relies on the values it passed in, so a body that mutates either bound parameter is
709        // rejected too.
710        let guarded = match &function.body {
711            Some(body)
712                if !function.virtual_
713                    && function.modifiers.is_empty()
714                    && !body.stmts.iter().any(|stmt| {
715                        self.mutates_var(stmt, recipient) || self.mutates_var(stmt, token)
716                    }) =>
717            {
718                let mut walk = GuardWalk::default();
719                let mut walker = GuardWalker {
720                    cx: self,
721                    recipient,
722                    token,
723                    delegations: &[],
724                    unstable_code_delegations: &[],
725                    seen,
726                };
727                walker.walk(body.stmts, &mut walk);
728                if walk.escaped {
729                    GuardCoverage::None
730                } else if walk.future_coverage == GuardCoverage::CodeLess {
731                    GuardCoverage::CodeLess
732                } else if walk.coverage == GuardCoverage::CodeLess {
733                    // The code-less observation can discharge a mint that preceded the helper,
734                    // but later work invalidated it for a mint after the helper. The mixed
735                    // marker is treated by callers as discharge-only, like a callback.
736                    GuardCoverage::CallbackOrCodeLess
737                } else {
738                    walk.coverage
739                }
740            }
741            _ => GuardCoverage::None,
742        };
743        seen.pop();
744        guarded
745    }
746
747    /// Coverage in effect when a function body starts after expanding its modifiers in
748    /// declaration order. Prefixes are walked in execution order so calls in an inner modifier
749    /// can retire an outer code-length snapshot. A proven tail guard is represented as stable
750    /// callback coverage while walking the body: it runs after the body and can revert every
751    /// mint the body made, unless assembly in the body or an inner modifier can bypass it.
752    fn modifier_coverage_at_body(
753        self,
754        function: &'gcx hir::Function<'gcx>,
755        recipient: VariableId,
756        token: VariableId,
757        seed: GuardCoverage,
758    ) -> GuardWalk {
759        let mut state = GuardWalk { coverage: seed, future_coverage: seed, ..GuardWalk::default() };
760        let body_bypass = function
761            .body
762            .as_ref()
763            .is_some_and(|body| self.contains_frame_ending_assembly(body.stmts, &mut Vec::new()));
764        let mut has_tail_guard = false;
765        for (index, modifier) in function.modifiers.iter().enumerate() {
766            if modifier.args.exprs().any(|arg| {
767                self.expr_mutates_var(arg, recipient) || self.expr_mutates_var(arg, token)
768            }) {
769                state.coverage = GuardCoverage::None;
770                state.future_coverage = GuardCoverage::None;
771                has_tail_guard = false;
772            }
773            state.retire_code_snapshots_if(|| {
774                modifier
775                    .args
776                    .exprs()
777                    .any(|arg| self.expr_may_change_account_code(arg, &[], &[], &mut Vec::new()))
778            });
779            let ItemId::Function(modifier_id) = modifier.id else { continue };
780            let Some(body) = &self.gcx.hir.function(modifier_id).body else { continue };
781            let Some((prefix, suffix)) = modifier_body_sides(body.stmts) else {
782                // Without a single top-level placeholder, the precise prefix is unknown. Still
783                // retire an inherited snapshot when any path through the modifier may change
784                // code.
785                state.retire_code_snapshots_if(|| {
786                    self.stmts_may_change_account_code(body.stmts, &[], &[], &mut Vec::new())
787                });
788                continue;
789            };
790            let prefix_may_change_code =
791                || self.stmts_may_change_account_code(prefix, &[], &[], &mut Vec::new());
792            let Some((modifier_recipient, modifier_token)) =
793                self.bound_guard_parameters(modifier_id, &modifier.args, recipient, token)
794            else {
795                state.retire_code_snapshots_if(prefix_may_change_code);
796                continue;
797            };
798            let parameters_unchanged = !body.stmts.iter().any(|stmt| {
799                self.mutates_var(stmt, modifier_recipient) || self.mutates_var(stmt, modifier_token)
800            });
801            let mut walker = GuardWalker {
802                cx: self,
803                recipient: modifier_recipient,
804                token: modifier_token,
805                delegations: &[],
806                unstable_code_delegations: &[],
807                seen: &mut Vec::new(),
808            };
809            if parameters_unchanged {
810                walker.walk(prefix, &mut state);
811            } else {
812                state.retire_code_snapshots_if(prefix_may_change_code);
813            }
814            let inner_modifier_bypass = function.modifiers[index + 1..].iter().any(|inner| {
815                matches!(inner.id, ItemId::Function(id)
816                    if self.callable_contains_frame_ending_assembly(id, &mut Vec::new()))
817            });
818            if parameters_unchanged && !body_bypass && !inner_modifier_bypass {
819                // The body has already minted when the suffix starts. Seed one pending
820                // delegation: a guard anywhere before a successful suffix exit clears it, while a
821                // call after that guard cannot make the earlier mint retroactively unsafe.
822                let mut suffix_walk = GuardWalk { pending: true, ..GuardWalk::default() };
823                walker.walk(suffix, &mut suffix_walk);
824                has_tail_guard |= !suffix_walk.failed && !suffix_walk.pending;
825            }
826        }
827        if has_tail_guard {
828            state.cover(GuardCoverage::Callback, true);
829        }
830        GuardWalk {
831            coverage: state.coverage,
832            future_coverage: state.future_coverage,
833            ..GuardWalk::default()
834        }
835    }
836}
837
838/// Breaks out of a subtree at the first statement or expression matching a predicate.
839struct Finder<'gcx, S, E> {
840    gcx: Gcx<'gcx>,
841    stmt_matches: S,
842    expr_matches: E,
843}
844
845impl<'gcx, S, E> Visit<'gcx> for Finder<'gcx, S, E>
846where
847    S: FnMut(&'gcx Stmt<'gcx>) -> bool,
848    E: FnMut(&'gcx Expr<'gcx>) -> bool,
849{
850    type BreakValue = ();
851
852    fn hir(&self) -> &'gcx Hir<'gcx> {
853        &self.gcx.hir
854    }
855
856    fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> {
857        if (self.stmt_matches)(stmt) { ControlFlow::Break(()) } else { self.walk_stmt(stmt) }
858    }
859
860    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> {
861        if (self.expr_matches)(expr) { ControlFlow::Break(()) } else { self.walk_expr(expr) }
862    }
863}
864
865/// How a path established that the recipient can receive the mint. Callback evidence remains
866/// valid when summarizing a guard helper, while a code-less proof must be retired once a call
867/// could deploy code at the recipient address. Whether the evidence can cover a future mint is
868/// tracked separately by [`GuardWalk::future_coverage`].
869#[derive(Clone, Copy, Default, PartialEq, Eq)]
870enum GuardCoverage {
871    #[default]
872    None,
873    Callback,
874    CodeLess,
875    CallbackOrCodeLess,
876}
877
878impl GuardCoverage {
879    fn is_covered(self) -> bool {
880        self != Self::None
881    }
882
883    const fn relies_on_code_length(self) -> bool {
884        matches!(self, Self::CodeLess | Self::CallbackOrCodeLess)
885    }
886
887    /// The coverage guaranteed after either branch. If one path relies on a code-less proof,
888    /// the merged proof does too and remains invalidatable by a later call.
889    const fn merge_paths(self, other: Self) -> Self {
890        match (self, other) {
891            (Self::None, _) | (_, Self::None) => Self::None,
892            (Self::Callback, Self::Callback) => Self::Callback,
893            (Self::CodeLess, Self::CodeLess) => Self::CodeLess,
894            _ => Self::CallbackOrCodeLess,
895        }
896    }
897
898    /// Coverage from guards that all execute. A callback check remains valid when a later call
899    /// invalidates a separate code-length observation.
900    const fn combine_guards(self, other: Self) -> Self {
901        match (self, other) {
902            (Self::Callback, _) | (_, Self::Callback) => Self::Callback,
903            (Self::CallbackOrCodeLess, _) | (_, Self::CallbackOrCodeLess) => {
904                Self::CallbackOrCodeLess
905            }
906            (Self::CodeLess, _) | (_, Self::CodeLess) => Self::CodeLess,
907            _ => Self::None,
908        }
909    }
910}
911
912/// The straight-line reading of a body: `coverage` once a guard has run on the path, `pending`
913/// while a delegated mint has run with no guard before or after it yet, `failed` once a path
914/// may leave the function successfully with such a mint standing, `escaped` once one may leave
915/// before any guard ran.
916#[derive(Clone, Default)]
917struct GuardWalk {
918    /// The guards that have run on every path. This is used when summarizing a guard helper.
919    coverage: GuardCoverage,
920    /// Coverage that can satisfy a delegation encountered later. A code-less proof can precede
921    /// the mint; callback coverage can only appear here when a modifier tail guarantees that the
922    /// callback runs after the body.
923    future_coverage: GuardCoverage,
924    pending: bool,
925    failed: bool,
926    escaped: bool,
927}
928
929impl GuardWalk {
930    /// A guard ran: `coverage` is established, and also for later delegations when `future`.
931    const fn cover(&mut self, coverage: GuardCoverage, future: bool) {
932        self.coverage = self.coverage.combine_guards(coverage);
933        if future {
934            self.future_coverage = self.future_coverage.combine_guards(coverage);
935        }
936        self.pending = false;
937    }
938
939    /// The recipient or token was reassigned: every guard so far checked a value a later
940    /// delegation no longer credits, and a mint already pending cannot be covered by a guard to
941    /// come either.
942    const fn retire(&mut self) {
943        self.failed |= self.pending;
944        self.coverage = GuardCoverage::None;
945        self.future_coverage = GuardCoverage::None;
946    }
947
948    /// The path may leave the function successfully here.
949    fn escape(&mut self) {
950        self.failed |= self.pending;
951        self.escaped |= !self.coverage.is_covered();
952    }
953
954    /// Retires code-length snapshots when `may_change_code()` holds; a callback acknowledgement
955    /// is not a snapshot and stays. The check only runs when a snapshot exists.
956    fn retire_code_snapshots_if(&mut self, may_change_code: impl FnOnce() -> bool) {
957        let (coverage, future) =
958            (self.coverage.relies_on_code_length(), self.future_coverage.relies_on_code_length());
959        if (coverage || future) && may_change_code() {
960            if coverage {
961                self.coverage = GuardCoverage::None;
962            }
963            if future {
964                self.future_coverage = GuardCoverage::None;
965            }
966        }
967    }
968
969    /// The state after either of two branches: coverage holds only when every path checked,
970    /// while a pending or escaping path taints the whole.
971    const fn merge(self, other: Self) -> Self {
972        Self {
973            coverage: self.coverage.merge_paths(other.coverage),
974            future_coverage: self.future_coverage.merge_paths(other.future_coverage),
975            pending: self.pending || other.pending,
976            failed: self.failed || other.failed,
977            escaped: self.escaped || other.escaped,
978        }
979    }
980}
981
982/// Reads a body in statement order and judges the delegated mints against the guards for
983/// `recipient` and `token`. A code-less proof may cover a later delegation, but a callback must
984/// run after ownership is established to match `_safeMint`: the receiver can inspect `ownerOf`,
985/// balances, or reenter during the hook. Such a callback covers delegations still pending, the
986/// revert undoing them, unless a statement in between may leave the function successfully,
987/// keeping the unacknowledged token: `super._mint(to, id); if (id == 0) return; require(hook...)`
988/// walks out with token zero standing.
989///
990/// The recognized guard shapes are a closed set, because a hook call that merely appears inside
991/// a condition proves nothing about whether the revert depends on its answer. They are:
992/// `require`/`assert` on an acceptance condition, `if (hook != selector) <exits>`,
993/// `if (hook == selector) {} else <exits>`, and any of those reached through a function or
994/// modifier. A callback helper receives both identities, the way OpenZeppelin factors
995/// `_checkOnERC721Received` out of `_safeMint`; a code-less proof needs only the recipient.
996///
997/// Branches are read separately and merged. The branch a `to.code.length` test dedicates to
998/// accounts starts covered, an account always accepting the token. A loop body may run zero
999/// times, so nothing in one is credited, while the delegations and escapes it may hold still
1000/// count.
1001///
1002/// Everything else reports, a `try` whose `catch` may swallow the refusal included, and so are
1003/// an answer stored in a local and a helper returning it as a `bool`. Following the value
1004/// across statements would take a dataflow analysis this detector does not run.
1005struct GuardWalker<'a, 'gcx> {
1006    cx: Cx<'gcx>,
1007    recipient: VariableId,
1008    token: VariableId,
1009    /// The callees that are unsafe mints, and those among them that may change account code.
1010    delegations: &'a [FunctionId],
1011    unstable_code_delegations: &'a [FunctionId],
1012    seen: &'a mut Vec<FunctionId>,
1013}
1014
1015impl<'gcx> GuardWalker<'_, 'gcx> {
1016    fn walk(&mut self, stmts: &'gcx [Stmt<'gcx>], walk: &mut GuardWalk) {
1017        let cx = self.cx;
1018        // Read in order: what a guard covers and what an exit walks out with depend on what
1019        // already ran.
1020        for stmt in stmts {
1021            let guard = match &stmt.kind {
1022                StmtKind::Expr(expr) => self.guard_expr_coverage(expr),
1023                _ => GuardCoverage::None,
1024            };
1025            match &stmt.kind {
1026                StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
1027                    self.walk(block.stmts, walk);
1028                }
1029                StmtKind::Expr(expr) if guard.is_covered() => {
1030                    // A guard that also reassigns the recipient or the token cannot be trusted:
1031                    // evaluation order decides whether the hook read the value the mint credits.
1032                    // Nor can a guard that may leave the frame establish coverage: an assembly
1033                    // return in another argument can keep a pending mint before the builtin has
1034                    // a chance to revert.
1035                    if self.mutates(stmt) {
1036                        walk.retire();
1037                    } else if cx.may_return(stmt) {
1038                        walk.escape();
1039                    } else if guard.relies_on_code_length()
1040                        && self.guard_extra_args_may_change_account_code(expr)
1041                    {
1042                        if walk.future_coverage.relies_on_code_length() {
1043                            walk.future_coverage = GuardCoverage::None;
1044                        }
1045                    } else {
1046                        walk.cover(guard, guard == GuardCoverage::CodeLess);
1047                    }
1048                }
1049                StmtKind::If(cond, then, otherwise) => {
1050                    // The condition runs before either branch, so an assignment embedded in it,
1051                    // `if ((tokenId = tokenId + 1) > 0) {}`, retires coverage exactly as a bare
1052                    // assignment statement does, and prevents the comparison from covering.
1053                    let condition_mutates = cx.expr_mutates_var(cond, self.recipient)
1054                        || cx.expr_mutates_var(cond, self.token);
1055                    if condition_mutates {
1056                        walk.retire();
1057                    }
1058                    if walk.future_coverage.relies_on_code_length()
1059                        && self.may_change_account_code(slice::from_ref(stmt), Some(cond))
1060                    {
1061                        walk.future_coverage = GuardCoverage::None;
1062                    }
1063                    if cx.expr_contains_frame_ending_assembly(cond) {
1064                        walk.escape();
1065                    }
1066                    // The exiting branch must be the one a refusal takes, not the one an
1067                    // acceptance does. Continue reading the accepted branch from the covered
1068                    // state: it may still reassign the checked values before delegating.
1069                    let refusal_then = !condition_mutates
1070                        && self.is_hook_comparison(cond, BinOpKind::Ne)
1071                        && cx.branch_always_reverts(then);
1072                    let refusal_else = !condition_mutates
1073                        && self.is_hook_comparison(cond, BinOpKind::Eq)
1074                        && otherwise.is_some_and(|otherwise| cx.branch_always_reverts(otherwise));
1075                    if refusal_then || refusal_else {
1076                        walk.cover(GuardCoverage::Callback, false);
1077                        let accepted = if refusal_then { *otherwise } else { Some(*then) };
1078                        if let Some(accepted) = accepted {
1079                            self.walk(slice::from_ref(accepted), walk);
1080                        }
1081                        continue;
1082                    }
1083                    // Each branch is read on its own, from what already ran. The branch a
1084                    // `to.code.length` test dedicates to accounts starts covered with nothing
1085                    // pending: an account always accepts, so the mints already made are as
1086                    // satisfied on that path as the ones to come.
1087                    let mut then_walk = walk.clone();
1088                    let mut else_walk = walk.clone();
1089                    if self.is_code_length_test(cond, true) {
1090                        else_walk.cover(GuardCoverage::CodeLess, true);
1091                    } else if self.is_code_length_test(cond, false) {
1092                        then_walk.cover(GuardCoverage::CodeLess, true);
1093                    }
1094                    self.walk(slice::from_ref(then), &mut then_walk);
1095                    if let Some(otherwise) = otherwise {
1096                        self.walk(slice::from_ref(otherwise), &mut else_walk);
1097                    }
1098                    *walk = then_walk.merge(else_walk);
1099                }
1100                _ => {
1101                    // Reassignment runs first: an assignment inside a delegation's own arguments
1102                    // happens before the call.
1103                    if self.mutates(stmt) {
1104                        walk.retire();
1105                    }
1106                    // Unlike a callback acknowledgement, a code-length observation is only a
1107                    // snapshot. A later state-changing call can deploy code at that address, so
1108                    // it retires coverage for a subsequent delegation. A mint already discharged
1109                    // by the observation remains safe.
1110                    if walk.future_coverage.relies_on_code_length()
1111                        && self.may_change_account_code(slice::from_ref(stmt), None)
1112                    {
1113                        walk.future_coverage = GuardCoverage::None;
1114                    }
1115                    // An opaque statement: a delegation anywhere inside it mints, unchecked
1116                    // unless already covered, and a possible successful exit walks out with
1117                    // whatever is pending.
1118                    let delegations = self.delegations;
1119                    if !walk.future_coverage.is_covered()
1120                        && cx.any_in_stmts(
1121                            slice::from_ref(stmt),
1122                            |_| false,
1123                            |expr| {
1124                                cx.resolved_callee(expr).is_some_and(|id| delegations.contains(&id))
1125                            },
1126                        )
1127                    {
1128                        walk.pending = true;
1129                    }
1130                    if cx.may_return(stmt) {
1131                        walk.escape();
1132                    }
1133                }
1134            }
1135        }
1136    }
1137
1138    fn mutates(&self, stmt: &'gcx Stmt<'gcx>) -> bool {
1139        self.cx.mutates_var(stmt, self.recipient) || self.cx.mutates_var(stmt, self.token)
1140    }
1141
1142    /// [`Cx::stmts_may_change_account_code`] for the walked delegations, over a statement or
1143    /// only the given expression of it.
1144    fn may_change_account_code(
1145        &self,
1146        stmts: &'gcx [Stmt<'gcx>],
1147        expr: Option<&'gcx Expr<'gcx>>,
1148    ) -> bool {
1149        let (delegations, unstable) = (self.delegations, self.unstable_code_delegations);
1150        let mut seen = Vec::new();
1151        match expr {
1152            Some(expr) => {
1153                self.cx.expr_may_change_account_code(expr, delegations, unstable, &mut seen)
1154            }
1155            None => self.cx.stmts_may_change_account_code(stmts, delegations, unstable, &mut seen),
1156        }
1157    }
1158
1159    /// A statement expression that guards the recipient and the token: `require`/`assert` on an
1160    /// acceptance condition, or an internal call handing both to a helper that does. Only the
1161    /// condition is read: a hook call sitting in the revert message decides nothing. An external
1162    /// helper would ask from a different contract and cannot establish that the recipient
1163    /// accepts the minting contract's callback.
1164    fn guard_expr_coverage(&mut self, expr: &'gcx Expr<'gcx>) -> GuardCoverage {
1165        let expr = expr.peel_parens();
1166        let ExprKind::Call(callee, args, _) = &expr.kind else { return GuardCoverage::None };
1167        if is_require_or_assert(self.cx.gcx, callee) {
1168            return args
1169                .exprs()
1170                .next()
1171                .map_or(GuardCoverage::None, |cond| self.acceptance_coverage(cond));
1172        }
1173        let Some(function_id) = self.cx.resolved_internal_callee(expr) else {
1174            return GuardCoverage::None;
1175        };
1176        let Some((recipient, token)) =
1177            self.cx.bound_guard_parameters(function_id, args, self.recipient, self.token)
1178        else {
1179            return GuardCoverage::None;
1180        };
1181        self.cx.body_guards(function_id, recipient, token, self.seen)
1182    }
1183
1184    /// Whether a recognized `require`/`assert` has another argument that may change account
1185    /// code. The first argument is the closed-form acceptance condition itself; its receiver
1186    /// callback is part of the proof. Solidity does not guarantee that the other arguments run
1187    /// before the condition's code-length snapshot.
1188    fn guard_extra_args_may_change_account_code(&self, expr: &'gcx Expr<'gcx>) -> bool {
1189        let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return false };
1190        is_require_or_assert(self.cx.gcx, callee)
1191            && args.exprs().skip(1).any(|arg| self.may_change_account_code(&[], Some(arg)))
1192    }
1193
1194    /// The condition of a `require`/`assert` that passes only if the recipient can receive the
1195    /// token: the recipient is proven code-less, the hook comparison succeeds, or the short
1196    /// circuit `to.code.length == 0 || hook == sel` accepts either case.
1197    fn acceptance_coverage(&self, cond: &'gcx Expr<'gcx>) -> GuardCoverage {
1198        let cond = cond.peel_parens();
1199        if self.is_code_length_test(cond, false) {
1200            return GuardCoverage::CodeLess;
1201        }
1202        if self.is_hook_comparison(cond, BinOpKind::Eq) {
1203            return GuardCoverage::Callback;
1204        }
1205        let ExprKind::Binary(lhs, op, rhs) = &cond.kind else { return GuardCoverage::None };
1206        let accepts = |skip, check| {
1207            self.is_code_length_test(skip, false) && self.is_hook_comparison(check, BinOpKind::Eq)
1208        };
1209        if op.kind == BinOpKind::Or && (accepts(lhs, rhs) || accepts(rhs, lhs)) {
1210            GuardCoverage::CallbackOrCodeLess
1211        } else {
1212            GuardCoverage::None
1213        }
1214    }
1215
1216    /// `recipient.onERC721Received(..., token, ...)`: the hook, asked of the recipient itself and
1217    /// about the delegated token itself. An answer about a different id decides nothing for the
1218    /// minted one.
1219    fn is_hook_call_on(&self, expr: &'gcx Expr<'gcx>) -> bool {
1220        let expr = expr.peel_parens();
1221        let ExprKind::Call(callee, args, _) = &expr.kind else { return false };
1222        let ExprKind::Member(receiver, _) = &callee.peel_parens().kind else { return false };
1223        let Some(function_id) = self.cx.resolved_callee(expr) else { return false };
1224        self.cx.is_receiver_hook(function_id)
1225            && underlying_var(self.cx.gcx, receiver) == Some(self.recipient)
1226            && self.cx.arg(function_id, args, 2).and_then(|expr| underlying_var(self.cx.gcx, expr))
1227                == Some(self.token)
1228    }
1229
1230    /// `recipient.onERC721Received(...) <op> x`, and nothing else. The comparison must be the
1231    /// whole expression: in `to == trusted || hook(to) == selector` the hook never runs for
1232    /// `trusted`. The other operand must be able to hold the accepting answer, and must not be a
1233    /// hook call itself, which would compare the recipient against itself.
1234    fn is_hook_comparison(&self, expr: &'gcx Expr<'gcx>, want: BinOpKind) -> bool {
1235        let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false };
1236        let compares = |hook, answer| {
1237            self.is_hook_call_on(hook)
1238                && !self.is_hook_call_on(answer)
1239                && self.cx.is_received_selector(answer)
1240        };
1241        op.kind == want && (compares(lhs, rhs) || compares(rhs, lhs))
1242    }
1243
1244    /// `recipient.code.length` compared against zero, for one polarity. Nothing else may ride
1245    /// along: in `to.code.length > 0 && id == 5` the second operand decides whether the branch
1246    /// runs.
1247    fn is_code_length_test(&self, expr: &'gcx Expr<'gcx>, has_code: bool) -> bool {
1248        let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false };
1249        let is_code_length = |expr: &Expr<'_>| {
1250            let ExprKind::Member(code, length) = &expr.peel_parens().kind else { return false };
1251            let ExprKind::Member(base, member) = &code.peel_parens().kind else { return false };
1252            length.as_str() == "length"
1253                && member.as_str() == "code"
1254                && underlying_var(self.cx.gcx, base) == Some(self.recipient)
1255        };
1256        let literal = |expr: &Expr<'_>| match &expr.peel_parens().kind {
1257            ExprKind::Lit(lit) => match &lit.kind {
1258                LitKind::Number(value) => u8::try_from(*value).ok(),
1259                _ => None,
1260            },
1261            _ => None,
1262        };
1263        let (bound, flipped) = if is_code_length(lhs) {
1264            (literal(rhs), false)
1265        } else if is_code_length(rhs) {
1266            (literal(lhs), true)
1267        } else {
1268            return false;
1269        };
1270        let Some(bound) = bound else { return false };
1271        // `length > 0`, `length != 0` and `length >= 1` all say the recipient carries code;
1272        // `== 0`, `< 1` and `<= 0` all say it carries none. Each has a mirror with the operands
1273        // swapped.
1274        match (has_code, op.kind, flipped) {
1275            (true, BinOpKind::Ne, _)
1276            | (true, BinOpKind::Gt, false)
1277            | (true, BinOpKind::Lt, true)
1278            | (false, BinOpKind::Eq, _)
1279            | (false, BinOpKind::Le, false)
1280            | (false, BinOpKind::Ge, true) => bound == 0,
1281            (true, BinOpKind::Ge, false)
1282            | (true, BinOpKind::Le, true)
1283            | (false, BinOpKind::Lt, false)
1284            | (false, BinOpKind::Gt, true) => bound == 1,
1285            _ => false,
1286        }
1287    }
1288}
1289
1290/// The representation of a selector-sized constant at one conversion step.
1291#[derive(Clone, Copy)]
1292enum SelectorEncoding {
1293    Literal,
1294    Integer(u16),
1295    FixedBytes(u8),
1296}
1297
1298/// The ERC721 answer meaning the recipient accepts the token, `onERC721Received`'s selector.
1299const ERC721_RECEIVED: u64 = 0x150b_7a02;
1300
1301/// The OpenZeppelin contracts whose `_mint` skips the receiver check. `ERC721` and
1302/// `ERC721Upgradeable` declare the unchecked `_mint`; in the v4 line, `ERC721Consecutive` and
1303/// `ERC721ConsecutiveUpgradeable` override it with a construction guard that forwards to the
1304/// base through `super._mint`, still without a receiver check. In v5 the Consecutive extension
1305/// overrides `_update` instead, and the two extra names match nothing.
1306fn is_canonical_erc721(name: &str) -> bool {
1307    matches!(
1308        name,
1309        "ERC721" | "ERC721Upgradeable" | "ERC721Consecutive" | "ERC721ConsecutiveUpgradeable"
1310    )
1311}
1312
1313fn named(function: &hir::Function<'_>, name: &str) -> bool {
1314    function.name.is_some_and(|n| n.as_str() == name)
1315}
1316
1317const fn is_internal(function: &hir::Function<'_>) -> bool {
1318    matches!(function.visibility, Visibility::Internal | Visibility::Private)
1319}
1320
1321const fn is_assembly(stmt: &Stmt<'_>) -> bool {
1322    matches!(stmt.kind, StmtKind::AssemblyBlock(_))
1323}
1324
1325/// Whether the variable cannot change between the delegation and the callback guard. An
1326/// intervening call can reenter and mutate a state variable after the mint reads it but before
1327/// the guard does. A local, a parameter, a `constant` or an `immutable` cannot be moved that way.
1328fn keeps_its_value(gcx: Gcx<'_>, variable: VariableId) -> bool {
1329    let variable = gcx.hir.variable(variable);
1330    !variable.kind.is_state() || variable.mutability.is_some()
1331}
1332
1333/// Whether an expression writes `var`: `var = x`, `var += x`, `var++` or `delete var`, directly
1334/// or as one component of a tuple.
1335fn assigns_to(gcx: Gcx<'_>, expr: &Expr<'_>, var: VariableId) -> bool {
1336    let Some(target) = write_target(expr) else { return false };
1337    let mut hit = false;
1338    for_each_lhs_var(gcx, target, &mut |vid| hit |= vid == var);
1339    hit
1340}
1341
1342/// `revert(...)`, `require(false, ...)` and `assert(false)`.
1343fn is_revert_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool {
1344    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return false };
1345    is_builtin(gcx, callee, kw::Revert)
1346        || (is_require_or_assert(gcx, callee) && args.exprs().next().is_some_and(is_literal_false))
1347}
1348
1349/// The statements before and after a modifier's single top-level placeholder. More complicated
1350/// expansion shapes are left uncredited rather than guessing which paths execute the body.
1351fn modifier_body_sides<'gcx>(
1352    stmts: &'gcx [Stmt<'gcx>],
1353) -> Option<(&'gcx [Stmt<'gcx>], &'gcx [Stmt<'gcx>])> {
1354    let placeholders =
1355        stmts.iter().enumerate().filter(|(_, stmt)| matches!(stmt.kind, StmtKind::Placeholder));
1356    let index = unique(placeholders.map(|(index, _)| index))?;
1357    Some((&stmts[..index], &stmts[index + 1..]))
1358}