Skip to main content

forge_lint/sol/med/
unsafe_oz_erc721_mint.rs

1use super::UnsafeOzErc721Mint;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint, analysis::primitives::is_require_or_assert},
5};
6use alloy_primitives::U256;
7use solar::{
8    ast::{ElementaryType, LitKind, StateMutability, Visibility},
9    interface::{kw, source_map::FileName},
10    sema::{
11        Gcx,
12        hir::{self, Expr, ExprKind, FunctionId, Hir, Visit},
13        ty::TyKind,
14    },
15};
16use std::{convert::Infallible, ops::ControlFlow};
17
18declare_forge_lint!(
19    UNSAFE_OZ_ERC721_MINT,
20    Severity::Med,
21    "unsafe-oz-erc721-mint",
22    "`ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`"
23);
24
25impl<'hir> LateLintPass<'hir> for UnsafeOzErc721Mint {
26    fn check_function(
27        &mut self,
28        ctx: &LintContext,
29        gcx: Gcx<'hir>,
30        hir: &'hir Hir<'hir>,
31        func: &'hir hir::Function<'hir>,
32    ) {
33        // Only the canonical OZ `_safeMint` wrapper is exempt: it legitimately calls `_mint`
34        // next to its receiver check. A user-defined `_safeMint` override stays analyzed, since
35        // it can call `_mint` directly without any check.
36        if func.name.is_some_and(|name| name.as_str() == "_safeMint")
37            && func.contract.is_some_and(|id| is_canonical_erc721(hir.contract(id).name.as_str()))
38            && is_openzeppelin_source(hir, func.source)
39        {
40            return;
41        }
42        // A user `_mint` override is part of the mint primitive itself: `super._mint` there is
43        // delegation (the capped/pausable pattern), and `_safeMint` there would re-enter the
44        // override through the virtual dispatch. A delegating override reports at its call
45        // sites instead, where `_safeMint` is the fix.
46        if func.name.is_some_and(|name| name.as_str() == "_mint") && func.override_ {
47            return;
48        }
49        // `ERC721._mint` credits the token without calling `onERC721Received`, so minting to a
50        // contract that cannot handle ERC721 tokens locks the token; `_safeMint` performs the
51        // check. Flag calls that resolve to a `_mint` declared in an ERC721 contract.
52        if let Some(body) = &func.body {
53            let suppress_direct_mint = is_override_delegation_helper(gcx, hir, func);
54            let mut finder = MintCallFinder { gcx, hir, ctx, suppress_direct_mint };
55            for stmt in body.stmts {
56                let _ = finder.visit_stmt(stmt);
57            }
58        }
59    }
60}
61
62struct MintCallFinder<'ctx, 's, 'c, 'hir> {
63    gcx: Gcx<'hir>,
64    hir: &'hir Hir<'hir>,
65    ctx: &'ctx LintContext<'s, 'c>,
66    suppress_direct_mint: bool,
67}
68
69impl<'hir> Visit<'hir> for MintCallFinder<'_, '_, '_, 'hir> {
70    type BreakValue = Infallible;
71
72    fn hir(&self) -> &'hir Hir<'hir> {
73        self.hir
74    }
75
76    fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
77        // `type_of_expr` on the callee is the function the type checker resolved, so overload
78        // selection by argument types (`_mint(to, data)` vs `_mint(to, id)`), override shadowing
79        // (a contract that overrides `_mint` resolves to its own declaration, not the base it
80        // hides) and `super._mint(...)` are all already accounted for.
81        if let Some(function_id) = resolved_callee(self.gcx, expr)
82            && self.is_erc721_mint(function_id)
83            && !self.suppress_direct_mint
84        {
85            self.ctx.emit(&UNSAFE_OZ_ERC721_MINT, expr.span);
86        }
87        self.walk_expr(expr)
88    }
89}
90
91fn is_override_delegation_helper<'hir>(
92    gcx: Gcx<'hir>,
93    hir: &'hir Hir<'hir>,
94    function: &'hir hir::Function<'hir>,
95) -> bool {
96    if !matches!(function.visibility, Visibility::Internal | Visibility::Private)
97        || (function.override_ && function.name.is_some_and(|name| name.as_str() == "_mint"))
98    {
99        return false;
100    }
101    let Some(contract_id) = function.contract else { return false };
102    let contract = hir.contract(contract_id);
103    let Some(function_id) = contract
104        .all_functions()
105        .find(|&function_id| std::ptr::eq(hir.function(function_id), function))
106    else {
107        return false;
108    };
109
110    hir.contract_ids().any(|candidate_contract_id| {
111        let candidate_contract = hir.contract(candidate_contract_id);
112        candidate_contract.linearized_bases.contains(&contract_id)
113            && candidate_contract.all_functions().any(|candidate| {
114                let candidate_function = hir.function(candidate);
115                candidate_function.override_
116                    && candidate_function.name.is_some_and(|name| name.as_str() == "_mint")
117                    && function_reaches(gcx, hir, candidate, function_id, &mut Vec::new())
118            })
119    })
120}
121
122fn function_reaches<'hir>(
123    gcx: Gcx<'hir>,
124    hir: &'hir Hir<'hir>,
125    function_id: FunctionId,
126    target: FunctionId,
127    seen: &mut Vec<FunctionId>,
128) -> bool {
129    if seen.contains(&function_id) {
130        return false;
131    }
132    seen.push(function_id);
133    let Some(body) = hir.function(function_id).body else { return false };
134    let mut scan = CalleeCollector { gcx, hir, calls: Vec::new() };
135    for stmt in body.stmts {
136        let _ = scan.visit_stmt(stmt);
137    }
138    scan.calls.iter().any(|(callee, _)| {
139        *callee == target
140            || (matches!(
141                hir.function(*callee).visibility,
142                Visibility::Internal | Visibility::Private
143            ) && function_reaches(gcx, hir, *callee, target, seen))
144    })
145}
146
147impl MintCallFinder<'_, '_, '_, '_> {
148    /// Whether `function_id` is a `_mint` whose execution skips the receiver check: the
149    /// canonical OZ declaration, or a user override that transitively delegates to one.
150    fn is_erc721_mint(&self, function_id: FunctionId) -> bool {
151        let function = self.hir.function(function_id);
152        let helper = is_override_delegation_helper(self.gcx, self.hir, function);
153        self.unsafe_mint_target(function_id, helper, &mut Vec::new()).is_some()
154    }
155
156    /// The canonical case requires the exact OZ contract name AND an OpenZeppelin source
157    /// path, so a local contract reusing a name like `ERC721Consecutive` stays out. The
158    /// delegation case covers a `_mint` override whose body calls a `_mint` that is itself
159    /// unsafe (the capped/pausable pattern forwarding through `super._mint`): direct calls
160    /// dispatch to the override, but the path still reaches the unchecked base. `seen`
161    /// cuts override cycles.
162    fn unsafe_mint_target(
163        &self,
164        function_id: FunctionId,
165        helper: bool,
166        seen: &mut Vec<FunctionId>,
167    ) -> Option<UnsafeMintTarget> {
168        // A cycle of overrides never reaches the canonical declaration.
169        if seen.contains(&function_id) {
170            return None;
171        }
172        seen.push(function_id);
173        let function = self.hir.function(function_id);
174        let is_mint = function.name.is_some_and(|name| name.as_str() == "_mint");
175        let is_safe_mint = function.name.is_some_and(|name| name.as_str() == "_safeMint");
176        if !is_mint
177            && (!helper
178                || !matches!(function.visibility, Visibility::Internal | Visibility::Private))
179        {
180            return None;
181        }
182        let contract_id = function.contract?;
183        let contract = self.hir.contract(contract_id);
184        if contract.kind.is_library() {
185            return None;
186        }
187        if is_safe_mint
188            && is_canonical_erc721(contract.name.as_str())
189            && is_openzeppelin_source(self.hir, function.source)
190        {
191            return None;
192        }
193        // The canonical unchecked `_mint`: exact OZ name, OZ package provenance. Most
194        // extensions (`ERC721Enumerable`, ...) inherit `_mint` rather than redeclare it, so
195        // resolution still lands here.
196        if is_mint
197            && is_canonical_erc721(contract.name.as_str())
198            && is_openzeppelin_source(self.hir, function.source)
199        {
200            return Some(UnsafeMintTarget {
201                preserves_recipient: true,
202                preserves_token: true,
203                preserves_code_length: true,
204            });
205        }
206        // A delegating override, or an internal/private helper reached from one: any call in
207        // its body dispatching to an unsafe `_mint`. An override whose successful paths prove
208        // the recipient code-less or reject it after the delegation is a safe wrapper like
209        // canonical `_safeMint`, so it is not an unsafe target. See [`walk_guards`].
210        if (function.override_ || helper)
211            && let Some(body) = &function.body
212        {
213            // The minted recipient is the override's first address-typed parameter.
214            let recipient = function.parameters.iter().copied().find(|&vid| {
215                matches!(
216                    self.hir.variable(vid).ty.kind,
217                    hir::TypeKind::Elementary(ElementaryType::Address(_))
218                )
219            });
220            let mut scan = CalleeCollector { gcx: self.gcx, hir: self.hir, calls: Vec::new() };
221            for stmt in body.stmts {
222                let _ = scan.visit_stmt(stmt);
223            }
224            // Each distinct callee is judged once, with the shared `seen` set so override cycles
225            // stop. Judging it per call site would answer `false` for every repeat, `seen` having
226            // recorded the first.
227            let mut unsafe_targets: Vec<FunctionId> = Vec::new();
228            let mut unstable_code_targets: Vec<FunctionId> = Vec::new();
229            let mut judged: Vec<FunctionId> = Vec::new();
230            let mut targets_preserve_recipient = true;
231            let mut targets_preserve_token = true;
232            for (callee, _) in &scan.calls {
233                if judged.contains(callee) {
234                    continue;
235                }
236                judged.push(*callee);
237                // Each callee gets its own `seen`: a cycle is a property of one path, and two
238                // siblings sharing a transitive target would otherwise silence the second.
239                let mut branch = seen.clone();
240                if let Some(target) = self.unsafe_mint_target(*callee, true, &mut branch) {
241                    unsafe_targets.push(*callee);
242                    if !target.preserves_code_length {
243                        unstable_code_targets.push(*callee);
244                    }
245                    targets_preserve_recipient &= target.preserves_recipient;
246                    targets_preserve_token &= target.preserves_token;
247                }
248            }
249            let mut delegates = false;
250            let mut only_to_recipient = true;
251            // The token every delegation credits, when they all name the same variable: the
252            // recipient may accept one token and refuse another, so a guard is only about the
253            // token it was asked about, and two delegations minting different tokens have no
254            // single answer to share.
255            let mut token = None;
256            let mut token_consistent = true;
257            for (callee, args) in &scan.calls {
258                if !unsafe_targets.contains(callee) {
259                    continue;
260                }
261                delegates = true;
262                // A guard covers the recipient it names, and no other. The recipient is what
263                // the delegation binds to the callee's first parameter, the token what it binds
264                // to the second, as the canonical `_mint(address to, uint256 tokenId)` orders
265                // them.
266                let handed_the_recipient = recipient.is_some_and(|recipient| {
267                    argument_bound_to_parameter(self.hir, *callee, args, 0)
268                        .is_some_and(|argument| is_exactly_var(argument, recipient))
269                });
270                if !handed_the_recipient {
271                    only_to_recipient = false;
272                }
273                match argument_bound_to_parameter(self.hir, *callee, args, 1)
274                    .and_then(variable_of)
275                    .filter(|&minted| keeps_its_value(self.hir, minted))
276                {
277                    Some(minted) => {
278                        if token.is_some_and(|token| token != minted) {
279                            token_consistent = false;
280                        }
281                        token = Some(minted);
282                    }
283                    // A token that is not a plain variable, a literal or an expression, cannot
284                    // be matched against what a guard was asked about; neither can a mutable
285                    // state variable that an intervening or recursively delegated call may move
286                    // under the guard's feet, see [`keeps_its_value`].
287                    None => token_consistent = false,
288                }
289            }
290            if !delegates {
291                return None;
292            }
293            if only_to_recipient
294                && targets_preserve_recipient
295                && let Some(recipient) = recipient
296            {
297                // A proof that the recipient has no code is independent of the token being
298                // minted. Reuse the guard walk with the recipient in both identity slots: a
299                // callback guard cannot type-check with an address as its token, so the only
300                // coverage this pass can establish is a code-length proof. This also lets an
301                // address-only helper or modifier establish coverage without inventing a token
302                // parameter, and permits computed or remapped token arguments.
303                let coverage = modifier_coverage_at_body(
304                    self.gcx,
305                    self.hir,
306                    function,
307                    recipient,
308                    recipient,
309                    GuardCoverage::None,
310                );
311                let mut walk = GuardWalk {
312                    coverage: coverage.coverage,
313                    future_coverage: coverage.future_coverage,
314                    ..GuardWalk::default()
315                };
316                walk_guards(
317                    self.gcx,
318                    self.hir,
319                    body.stmts,
320                    recipient,
321                    recipient,
322                    &unsafe_targets,
323                    &unstable_code_targets,
324                    false,
325                    &mut Vec::new(),
326                    &mut walk,
327                );
328                if !walk.failed && !walk.pending {
329                    return None;
330                }
331            }
332            if only_to_recipient
333                && token_consistent
334                && targets_preserve_recipient
335                && targets_preserve_token
336                && let Some(recipient) = recipient
337                && let Some(token) = token
338            {
339                // Modifier expansion may supply a code-less proof before the body or a callback
340                // guard after it. The body is still read in order because reassigning the
341                // recipient or token can make either guard name a different value from the
342                // delegation. See [`walk_guards`].
343                let coverage = modifier_coverage_at_body(
344                    self.gcx,
345                    self.hir,
346                    function,
347                    recipient,
348                    token,
349                    GuardCoverage::None,
350                );
351                let mut walk = GuardWalk {
352                    coverage: coverage.coverage,
353                    future_coverage: coverage.future_coverage,
354                    ..GuardWalk::default()
355                };
356                walk_guards(
357                    self.gcx,
358                    self.hir,
359                    body.stmts,
360                    recipient,
361                    token,
362                    &unsafe_targets,
363                    &unstable_code_targets,
364                    false,
365                    &mut Vec::new(),
366                    &mut walk,
367                );
368                if !walk.failed && !walk.pending {
369                    return None;
370                }
371            }
372            // A guard in an outer override needs every intermediate override to preserve the
373            // identities it relies on: the recipient for a code-less proof, and both recipient
374            // and token for a callback. The current override's own guard above may legitimately
375            // check a remapped local value, so this summary is propagated only to its callers;
376            // it does not gate its own guard correlation.
377            let recipient_parameter = function.parameters.first().copied();
378            let token_parameter = function.parameters.get(1).copied();
379            let recipient_unchanged = recipient_parameter.is_some_and(|recipient| {
380                !body.stmts.iter().any(|stmt| mutates_var(self.hir, stmt, recipient))
381                    && !function.modifiers.iter().any(|modifier| {
382                        modifier.args.exprs().any(|arg| expr_mutates_var(self.hir, arg, recipient))
383                    })
384            });
385            let token_unchanged = token_parameter.is_some_and(|token| {
386                !body.stmts.iter().any(|stmt| mutates_var(self.hir, stmt, token))
387                    && !function.modifiers.iter().any(|modifier| {
388                        modifier.args.exprs().any(|arg| expr_mutates_var(self.hir, arg, token))
389                    })
390            });
391            let forwards_recipient = recipient_parameter.is_some_and(|recipient| {
392                scan.calls.iter().filter(|(callee, _)| unsafe_targets.contains(callee)).all(
393                    |(callee, args)| {
394                        argument_bound_to_parameter(self.hir, *callee, args, 0)
395                            .is_some_and(|argument| is_exactly_var(argument, recipient))
396                    },
397                )
398            });
399            let forwards_token = token_parameter.is_some_and(|token| {
400                scan.calls.iter().filter(|(callee, _)| unsafe_targets.contains(callee)).all(
401                    |(callee, args)| {
402                        argument_bound_to_parameter(self.hir, *callee, args, 1)
403                            .is_some_and(|argument| is_exactly_var(argument, token))
404                    },
405                )
406            });
407            // A caller's code-less proof remains valid through this override only when no path
408            // can change account code before reaching a delegated mint. Seed the same ordered
409            // walk with that entry proof; recursively unstable delegation targets retire it at
410            // their call boundary.
411            let preserves_code_length = recipient.is_some_and(|recipient| {
412                let coverage = modifier_coverage_at_body(
413                    self.gcx,
414                    self.hir,
415                    function,
416                    recipient,
417                    recipient,
418                    GuardCoverage::CodeLess,
419                );
420                let mut code_length_walk = GuardWalk {
421                    coverage: coverage.coverage,
422                    future_coverage: coverage.future_coverage,
423                    ..GuardWalk::default()
424                };
425                walk_guards(
426                    self.gcx,
427                    self.hir,
428                    body.stmts,
429                    recipient,
430                    recipient,
431                    &unsafe_targets,
432                    &unstable_code_targets,
433                    false,
434                    &mut Vec::new(),
435                    &mut code_length_walk,
436                );
437                !code_length_walk.failed && !code_length_walk.pending
438            });
439            return Some(UnsafeMintTarget {
440                preserves_recipient: targets_preserve_recipient
441                    && recipient_unchanged
442                    && forwards_recipient,
443                preserves_token: targets_preserve_token && token_unchanged && forwards_token,
444                preserves_code_length,
445            });
446        }
447        None
448    }
449}
450
451/// An unsafe mint target and whether every recursive hop preserves the recipient and token it
452/// receives. A callback guard needs both guarantees, while a code-less-recipient proof needs
453/// only the first.
454#[derive(Clone, Copy)]
455struct UnsafeMintTarget {
456    preserves_recipient: bool,
457    preserves_token: bool,
458    preserves_code_length: bool,
459}
460
461/// The package-root directory names of the OpenZeppelin distributions: the npm scope and the
462/// git-submodule roots. Provenance is judged against a full path component so a same-name
463/// contract under a merely-substring path such as `src/not-openzeppelin/` is not recognized.
464const OPENZEPPELIN_PACKAGE_ROOTS: [&str; 3] =
465    ["@openzeppelin", "openzeppelin-contracts", "openzeppelin-contracts-upgradeable"];
466
467/// Whether a source file belongs to an OpenZeppelin package, judged by a full path component.
468fn is_openzeppelin_source(hir: &Hir<'_>, source_id: hir::SourceId) -> bool {
469    match &hir.source(source_id).file.name {
470        FileName::Real(path) => path.components().any(|component| {
471            matches!(component, std::path::Component::Normal(name)
472                if OPENZEPPELIN_PACKAGE_ROOTS.iter().any(|root| name.eq_ignore_ascii_case(root)))
473        }),
474        _ => false,
475    }
476}
477
478/// Collects every call in a subtree as its resolved target and its argument list, from which
479/// the caller reads what the delegation binds to the mint's parameters.
480struct CalleeCollector<'hir> {
481    gcx: Gcx<'hir>,
482    hir: &'hir Hir<'hir>,
483    calls: Vec<(FunctionId, &'hir hir::CallArgs<'hir>)>,
484}
485
486impl<'hir> Visit<'hir> for CalleeCollector<'hir> {
487    type BreakValue = Infallible;
488
489    fn hir(&self) -> &'hir Hir<'hir> {
490        self.hir
491    }
492
493    fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
494        if let Some(function_id) = resolved_callee(self.gcx, expr)
495            && let ExprKind::Call(_, args, _) = &expr.kind
496        {
497            self.calls.push((function_id, args));
498        }
499        self.walk_expr(expr)
500    }
501}
502
503/// The argument a call binds to the callee's parameter at `index`. Named arguments come in
504/// source order, which is not the parameter order: `_mint({id: tokenId, to: to})` hands the
505/// token to `to` all the same.
506fn argument_bound_to_parameter<'hir>(
507    hir: &'hir Hir<'hir>,
508    function_id: FunctionId,
509    args: &'hir hir::CallArgs<'hir>,
510    index: usize,
511) -> Option<&'hir Expr<'hir>> {
512    match &args.kind {
513        hir::CallArgsKind::Unnamed(exprs) => exprs.get(index),
514        hir::CallArgsKind::Named(named) => {
515            let parameter = *hir.function(function_id).parameters.get(index)?;
516            let name = hir.variable(parameter).name?;
517            named
518                .iter()
519                .find(|argument| argument.name.as_str() == name.as_str())
520                .map(|argument| &argument.value)
521        }
522    }
523}
524
525/// The function a bare expression names, as the type checker resolved it.
526fn resolved_function(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<FunctionId> {
527    let ty = gcx.type_of_expr(expr.peel_parens().id)?;
528    let TyKind::Fn(function_ty) = ty.kind else { return None };
529    function_ty.function_id
530}
531
532/// The function a call expression dispatches to, as the type checker resolved it.
533fn resolved_callee(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<FunctionId> {
534    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
535    resolved_function(gcx, callee)
536}
537
538/// The declaration a call executes in the current EVM frame. A public function called by name
539/// is internal here, while `this.f()` and other external calls run in another frame whose
540/// assembly `return` cannot bypass the caller's later statements.
541fn resolved_internal_callee(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<FunctionId> {
542    let ExprKind::Call(callee, ..) = &expr.kind else { return None };
543    let ty = gcx.type_of_expr(callee.peel_parens().id)?;
544    let TyKind::Fn(function_ty) = ty.kind else { return None };
545    function_ty.is_internal().then_some(function_ty.function_id).flatten()
546}
547
548/// Whether a call dispatches through an internal function-pointer variable whose target is not
549/// available from the callee type. Such a target may contain assembly that leaves the frame, so
550/// exit analysis must treat the call conservatively.
551fn is_unresolved_internal_pointer_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool {
552    let ExprKind::Call(callee, ..) = &expr.kind else { return false };
553    let Some(ty) = gcx.type_of_expr(callee.peel_parens().id) else { return false };
554    let TyKind::Fn(function_ty) = ty.kind else { return false };
555    function_ty.is_internal()
556        && function_ty.function_id.is_none()
557        && matches!(&callee.peel_parens().kind, ExprKind::Ident(resolutions)
558        if resolutions.iter().any(|resolution| matches!(
559            resolution,
560            hir::Res::Item(hir::ItemId::Variable(_))
561        )))
562}
563
564/// Whether a resolved declaration is the ERC721 receiver hook: the exact name, the exact
565/// `(address, address, uint256, bytes)` shape, and an externally callable declaration of a
566/// non-library contract. The name alone would let a call to a same-name function of an
567/// unrelated interface, which answers on a different selector, pass as the check, and an
568/// attached library or free function runs in the minting contract without any external call,
569/// so resolving to one says nothing about the recipient.
570fn is_receiver_hook(hir: &Hir<'_>, function_id: FunctionId) -> bool {
571    let function = hir.function(function_id);
572    let Some(name) = function.name else { return false };
573    if name.as_str() != "onERC721Received" {
574        return false;
575    }
576    let Some(contract_id) = function.contract else { return false };
577    if hir.contract(contract_id).kind.is_library() {
578        return false;
579    }
580    if !matches!(function.visibility, Visibility::Public | Visibility::External) {
581        return false;
582    }
583    let params = function.parameters;
584    if params.len() != 4 {
585        return false;
586    }
587    let is = |index: usize, expected: fn(&hir::TypeKind<'_>) -> bool| {
588        expected(&hir.variable(params[index]).ty.kind)
589    };
590    is(0, |kind| matches!(kind, hir::TypeKind::Elementary(ElementaryType::Address(_))))
591        && is(1, |kind| matches!(kind, hir::TypeKind::Elementary(ElementaryType::Address(_))))
592        && is(2, |kind| matches!(kind, hir::TypeKind::Elementary(ElementaryType::UInt(_))))
593        && is(3, |kind| matches!(kind, hir::TypeKind::Elementary(ElementaryType::Bytes)))
594}
595
596/// The expression carrying the same value, behind parentheses, `payable(...)` and casts to an
597/// address or contract type, which map every operand to itself. A numeric cast such as
598/// `uint8(...)` truncates: the minted address is then usually not the checked one, so it is
599/// not peeled.
600fn peel_value_preserving<'hir>(expr: &'hir Expr<'hir>) -> &'hir Expr<'hir> {
601    let expr = expr.peel_parens();
602    match &expr.kind {
603        ExprKind::Payable(inner) => peel_value_preserving(inner),
604        // A cast wraps the callee as an identifier resolved to the contract for
605        // `IERC721Receiver(to)`, as a type for `address(to)`.
606        ExprKind::Call(callee, args, _)
607            if matches!(&callee.peel_parens().kind,
608                ExprKind::Type(ty)
609                    if matches!(ty.kind, hir::TypeKind::Elementary(ElementaryType::Address(_))))
610                || matches!(
611                    &callee.peel_parens().kind,
612                    ExprKind::Ident([hir::Res::Item(hir::ItemId::Contract(_)), ..])
613                ) =>
614        {
615            let mut operands = args.exprs();
616            if let Some(inner) = operands.next()
617                && operands.next().is_none()
618            {
619                return peel_value_preserving(inner);
620            }
621            expr
622        }
623        _ => expr,
624    }
625}
626
627/// The variable itself, behind parentheses and value-preserving conversions: `to`, `(to)`,
628/// `IERC721Receiver(to)`, `payable(to)`. Anything else, `guardians[to]` or
629/// `IERC721Receiver(other)`, is a different value, and asking it about the token says nothing
630/// about the recipient.
631fn is_exactly_var<'hir>(expr: &'hir Expr<'hir>, variable: hir::VariableId) -> bool {
632    if let ExprKind::Ident(resolutions) = &peel_value_preserving(expr).kind {
633        return resolutions.iter().any(
634            |res| matches!(res, hir::Res::Item(hir::ItemId::Variable(vid)) if *vid == variable),
635        );
636    }
637    false
638}
639
640/// Whether the variable cannot change between the delegation and the callback guard. An
641/// intervening call, including one inside a recursively delegated override, can reenter and
642/// mutate a state variable after the mint reads it but before the guard does. A local, a
643/// parameter, a `constant` or an `immutable` cannot be moved that way.
644fn keeps_its_value(hir: &Hir<'_>, variable: hir::VariableId) -> bool {
645    let variable = hir.variable(variable);
646    !variable.is_state_variable() || variable.is_constant() || variable.is_immutable()
647}
648
649/// The variable an expression is, behind the same conversions [`is_exactly_var`] sees through,
650/// or nothing when the expression is not a plain variable.
651fn variable_of(expr: &Expr<'_>) -> Option<hir::VariableId> {
652    if let ExprKind::Ident(resolutions) = &peel_value_preserving(expr).kind {
653        return resolutions.iter().find_map(|res| match res {
654            hir::Res::Item(hir::ItemId::Variable(vid)) => Some(*vid),
655            _ => None,
656        });
657    }
658    None
659}
660
661/// `recipient.onERC721Received(..., token, ...)`: the hook, asked of the recipient itself and
662/// about the delegated token itself. A recipient may accept one token and refuse another, so an
663/// answer about a different id, the hook's third parameter, decides nothing for the minted one.
664fn is_hook_call_on<'hir>(
665    gcx: Gcx<'hir>,
666    hir: &'hir Hir<'hir>,
667    expr: &'hir Expr<'hir>,
668    recipient: hir::VariableId,
669    token: hir::VariableId,
670) -> bool {
671    let expr = expr.peel_parens();
672    let ExprKind::Call(callee, args, _) = &expr.kind else { return false };
673    let Some(function_id) = resolved_callee(gcx, expr) else { return false };
674    if !is_receiver_hook(hir, function_id) {
675        return false;
676    }
677    let ExprKind::Member(receiver, _) = &callee.peel_parens().kind else { return false };
678    is_exactly_var(receiver, recipient)
679        && argument_bound_to_parameter(hir, function_id, args, 2)
680            .is_some_and(|asked| is_exactly_var(asked, token))
681}
682
683/// The ERC721 answer meaning the recipient accepts the token, `onERC721Received`'s selector.
684const ERC721_RECEIVED: u64 = 0x150b_7a02;
685
686/// Whether an expression is the accepting answer. Comparing the hook's answer against anything
687/// else mints to a recipient that answered something the ERC721 receiver interface calls a
688/// refusal, so only an operand this reads and finds equal to the selector is credited: the
689/// literal, a conversion of it, a `constant` holding it, or a `selector` member resolving to
690/// the receiver hook itself. The member is resolved rather than matched by name: spelled on a
691/// same-name function of another shape, `.selector` is a different value, which a recipient
692/// answering it never accepted with. A value settled at deployment or at runtime, an
693/// `immutable` or a state variable, is unknown here and does not exempt.
694fn is_received_selector<'hir>(gcx: Gcx<'hir>, hir: &'hir Hir<'hir>, expr: &Expr<'hir>) -> bool {
695    let expr = expr.peel_parens();
696    match &expr.kind {
697        ExprKind::Lit(lit) => {
698            matches!(&lit.kind, LitKind::Number(value) if *value == U256::from(ERC721_RECEIVED))
699        }
700        // `bytes4(0x150b7a02)` and other conversions that preserve the four-byte value at
701        // every hop. Fixed bytes are left-aligned while integers are right-aligned, so merely
702        // peeling casts by width would accept lossy chains through a narrower integer.
703        ExprKind::Call(callee, args, _)
704            if matches!(callee.peel_parens().kind, ExprKind::Type(..)) =>
705        {
706            let mut operands = args.exprs();
707            match (operands.next(), operands.next()) {
708                (Some(inner), None) => {
709                    selector_cast_preserves(gcx, expr, inner)
710                        && is_received_selector(gcx, hir, inner)
711                }
712                _ => false,
713            }
714        }
715        // `IERC721Receiver.onERC721Received.selector`, the answer named rather than spelled.
716        ExprKind::Member(base, member) => {
717            member.as_str() == "selector"
718                && resolved_function(gcx, base)
719                    .is_some_and(|function_id| is_receiver_hook(hir, function_id))
720        }
721        // A constant is worth what it holds.
722        ExprKind::Ident(resolutions) => resolutions.iter().any(|res| match res {
723            hir::Res::Item(hir::ItemId::Variable(vid)) => {
724                let variable = hir.variable(*vid);
725                variable.is_constant()
726                    && variable
727                        .initializer
728                        .is_some_and(|initializer| is_received_selector(gcx, hir, initializer))
729            }
730            _ => false,
731        }),
732        _ => false,
733    }
734}
735
736/// The representation of a selector-sized constant at one conversion step.
737#[derive(Clone, Copy)]
738enum SelectorEncoding {
739    Literal,
740    Integer(u16),
741    FixedBytes(u8),
742}
743
744/// Whether a cast preserves the recognized selector's value and byte alignment. A recognized
745/// integer is exactly the positive selector, so any integer width of at least 32 bits keeps it.
746/// Crossing between right-aligned integers and left-aligned fixed bytes is only trusted at the
747/// four-byte boundary.
748fn selector_cast_preserves(gcx: Gcx<'_>, cast: &Expr<'_>, inner: &Expr<'_>) -> bool {
749    let encoding = |expr: &Expr<'_>| match gcx.type_of_expr(expr.peel_parens().id)?.kind {
750        TyKind::IntLiteral(..) => Some(SelectorEncoding::Literal),
751        TyKind::Elementary(ElementaryType::Int(size) | ElementaryType::UInt(size)) => {
752            Some(SelectorEncoding::Integer(size.bits()))
753        }
754        TyKind::Elementary(ElementaryType::FixedBytes(size)) => {
755            Some(SelectorEncoding::FixedBytes(size.bytes()))
756        }
757        _ => None,
758    };
759    matches!(
760        (encoding(inner), encoding(cast)),
761        (
762            Some(SelectorEncoding::Literal | SelectorEncoding::Integer(_)),
763            Some(SelectorEncoding::Integer(32..) | SelectorEncoding::FixedBytes(4))
764        ) | (Some(SelectorEncoding::FixedBytes(4)), Some(SelectorEncoding::Integer(32)))
765            | (Some(SelectorEncoding::FixedBytes(4..)), Some(SelectorEncoding::FixedBytes(4..)))
766    )
767}
768
769/// `recipient.onERC721Received(...) <op> x`, and nothing else. The comparison must be the whole
770/// expression: in `to == trusted || hook(to) == selector` the hook never runs for `trusted`. The
771/// other operand must be able to hold the accepting answer, and must not be a hook call itself,
772/// which would compare the recipient against itself.
773fn is_hook_comparison<'hir>(
774    gcx: Gcx<'hir>,
775    hir: &'hir Hir<'hir>,
776    expr: &'hir Expr<'hir>,
777    recipient: hir::VariableId,
778    token: hir::VariableId,
779    want: hir::BinOpKind,
780) -> bool {
781    let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false };
782    if op.kind != want {
783        return false;
784    }
785    let compares = |hook: &'hir Expr<'hir>, answer: &'hir Expr<'hir>| {
786        is_hook_call_on(gcx, hir, hook, recipient, token)
787            && !is_hook_call_on(gcx, hir, answer, recipient, token)
788            && is_received_selector(gcx, hir, answer)
789    };
790    compares(lhs, rhs) || compares(rhs, lhs)
791}
792
793/// Whether executing `stmt` always reverts, undoing everything the transaction did. Only a
794/// revert counts, the guards being matched without an order: see [`may_return`] for the escapes
795/// that leave the transaction standing.
796fn branch_always_reverts<'hir>(
797    gcx: Gcx<'hir>,
798    hir: &'hir Hir<'hir>,
799    stmt: &'hir hir::Stmt<'hir>,
800) -> bool {
801    match &stmt.kind {
802        hir::StmtKind::Revert(_) => !may_return(gcx, hir, stmt),
803        hir::StmtKind::Expr(expr) => is_revert_call(expr) && !may_return(gcx, hir, stmt),
804        hir::StmtKind::Block(block) | hir::StmtKind::UncheckedBlock(block) => {
805            // Read in order: a `revert` further down is only reached when nothing before it can
806            // leave the function on its own, and a `return` is exactly such an escape.
807            for stmt in block.stmts {
808                if branch_always_reverts(gcx, hir, stmt) {
809                    return true;
810                }
811                if may_return(gcx, hir, stmt) {
812                    return false;
813                }
814            }
815            false
816        }
817        hir::StmtKind::If(cond, then, Some(else_)) => {
818            !expr_contains_frame_ending_assembly(gcx, hir, cond)
819                && branch_always_reverts(gcx, hir, then)
820                && branch_always_reverts(gcx, hir, else_)
821        }
822        _ => false,
823    }
824}
825
826/// Whether a statement may leave the function while keeping what the transaction already did.
827/// A `return` does, and so does the EVM `return`/`stop` an assembly block can hold. Only the
828/// statements that provably cannot leave answer no, so a guard is never credited on a path this
829/// analysis does not read.
830fn may_return<'hir>(gcx: Gcx<'hir>, hir: &'hir Hir<'hir>, stmt: &'hir hir::Stmt<'hir>) -> bool {
831    if contains_frame_ending_assembly(gcx, hir, std::slice::from_ref(stmt), &mut Vec::new()) {
832        return true;
833    }
834    match &stmt.kind {
835        hir::StmtKind::DeclSingle(_)
836        | hir::StmtKind::DeclMulti(..)
837        | hir::StmtKind::Emit(_)
838        | hir::StmtKind::Revert(_)
839        | hir::StmtKind::Break
840        | hir::StmtKind::Continue
841        | hir::StmtKind::Expr(_)
842        | hir::StmtKind::Placeholder
843        | hir::StmtKind::Err(_) => false,
844        hir::StmtKind::Block(block) | hir::StmtKind::UncheckedBlock(block) => {
845            block.stmts.iter().any(|stmt| may_return(gcx, hir, stmt))
846        }
847        hir::StmtKind::If(_, then, else_) => {
848            may_return(gcx, hir, then) || else_.is_some_and(|else_| may_return(gcx, hir, else_))
849        }
850        hir::StmtKind::Loop(block, _) => block.stmts.iter().any(|stmt| may_return(gcx, hir, stmt)),
851        hir::StmtKind::Return(_) | hir::StmtKind::AssemblyBlock(_) => true,
852        hir::StmtKind::Try(_) | hir::StmtKind::Switch(_) => true,
853    }
854}
855
856/// `revert(...)`, `require(false, ...)` and `assert(false)`.
857fn is_revert_call(expr: &Expr<'_>) -> bool {
858    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return false };
859    if matches!(&callee.peel_parens().kind, ExprKind::Ident(idents)
860        if idents.iter().any(|res| matches!(res, hir::Res::Builtin(builtin) if builtin.name() == kw::Revert)))
861    {
862        return true;
863    }
864    is_require_or_assert(callee)
865        && args.exprs().next().is_some_and(|first| {
866            matches!(&first.peel_parens().kind, ExprKind::Lit(lit)
867                if matches!(lit.kind, LitKind::Bool(false)))
868        })
869}
870
871/// `recipient.code.length`.
872fn is_recipient_code_length<'hir>(expr: &'hir Expr<'hir>, recipient: hir::VariableId) -> bool {
873    let ExprKind::Member(code, length) = &expr.peel_parens().kind else { return false };
874    if length.as_str() != "length" {
875        return false;
876    }
877    let ExprKind::Member(base, member) = &code.peel_parens().kind else { return false };
878    member.as_str() == "code" && is_exactly_var(base, recipient)
879}
880
881/// `recipient.code.length` compared against zero, for one polarity. Nothing else may ride along:
882/// in `to.code.length > 0 && id == 5` the second operand decides whether the branch runs.
883fn is_code_length_test<'hir>(
884    expr: &'hir Expr<'hir>,
885    recipient: hir::VariableId,
886    has_code: bool,
887) -> bool {
888    let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false };
889    let literal = |e: &Expr<'_>| match &e.peel_parens().kind {
890        ExprKind::Lit(lit) => match &lit.kind {
891            LitKind::Number(value) => u8::try_from(*value).ok(),
892            _ => None,
893        },
894        _ => None,
895    };
896    let (bound, flipped) = if is_recipient_code_length(lhs, recipient) {
897        (literal(rhs), false)
898    } else if is_recipient_code_length(rhs, recipient) {
899        (literal(lhs), true)
900    } else {
901        return false;
902    };
903    let Some(bound) = bound else { return false };
904    // `length > 0`, `length != 0` and `length >= 1` all say the recipient carries code; `== 0`,
905    // `< 1` and `<= 0` all say it carries none. Each has a mirror with the operands swapped.
906    if has_code {
907        match (op.kind, flipped) {
908            (hir::BinOpKind::Ne, _) => bound == 0,
909            (hir::BinOpKind::Gt, false) | (hir::BinOpKind::Lt, true) => bound == 0,
910            (hir::BinOpKind::Ge, false) | (hir::BinOpKind::Le, true) => bound == 1,
911            _ => false,
912        }
913    } else {
914        match (op.kind, flipped) {
915            (hir::BinOpKind::Eq, _) => bound == 0,
916            (hir::BinOpKind::Lt, false) | (hir::BinOpKind::Gt, true) => bound == 1,
917            (hir::BinOpKind::Le, false) | (hir::BinOpKind::Ge, true) => bound == 0,
918            _ => false,
919        }
920    }
921}
922
923/// The condition of a `require`/`assert` that passes only if the recipient can receive the token:
924/// the recipient is proven code-less, the hook comparison succeeds, or the account short circuit
925/// `to.code.length == 0 || hook == sel` accepts either case.
926fn acceptance_coverage<'hir>(
927    gcx: Gcx<'hir>,
928    hir: &'hir Hir<'hir>,
929    cond: &'hir Expr<'hir>,
930    recipient: hir::VariableId,
931    token: hir::VariableId,
932) -> GuardCoverage {
933    let cond = cond.peel_parens();
934    if is_code_length_test(cond, recipient, false) {
935        return GuardCoverage::CodeLess;
936    }
937    if is_hook_comparison(gcx, hir, cond, recipient, token, hir::BinOpKind::Eq) {
938        return GuardCoverage::Callback;
939    }
940    let ExprKind::Binary(lhs, op, rhs) = &cond.kind else { return GuardCoverage::None };
941    if op.kind != hir::BinOpKind::Or {
942        return GuardCoverage::None;
943    }
944    let accepts = |skip: &'hir Expr<'hir>, check: &'hir Expr<'hir>| {
945        is_code_length_test(skip, recipient, false)
946            && is_hook_comparison(gcx, hir, check, recipient, token, hir::BinOpKind::Eq)
947    };
948    if accepts(lhs, rhs) || accepts(rhs, lhs) {
949        GuardCoverage::CallbackOrCodeLess
950    } else {
951        GuardCoverage::None
952    }
953}
954
955/// A call handing the checked identities to a same-frame function or modifier whose body proves
956/// acceptance for the parameters they land on. Callback guards receive distinct recipient and
957/// token identities; the recipient-only pass supplies the same identity in both slots, allowing
958/// an address-only helper to prove that it has no code. Arguments are matched by [`is_exactly_var`]
959/// against the callee's parameters and by parameter name for named arguments.
960#[expect(clippy::too_many_arguments)]
961fn callee_guards_recipient<'hir>(
962    gcx: Gcx<'hir>,
963    hir: &'hir Hir<'hir>,
964    function_id: FunctionId,
965    args: &'hir hir::CallArgs<'hir>,
966    recipient: hir::VariableId,
967    token: hir::VariableId,
968    bypass: bool,
969    seen: &mut Vec<FunctionId>,
970) -> GuardCoverage {
971    let Some((recipient, token)) = bound_guard_parameters(hir, function_id, args, recipient, token)
972    else {
973        return GuardCoverage::None;
974    };
975    body_guards(gcx, hir, function_id, recipient, token, bypass, seen)
976}
977
978/// The callee parameters that receive the caller's recipient and token identities.
979fn bound_guard_parameters<'hir>(
980    hir: &'hir Hir<'hir>,
981    function_id: FunctionId,
982    args: &'hir hir::CallArgs<'hir>,
983    recipient: hir::VariableId,
984    token: hir::VariableId,
985) -> Option<(hir::VariableId, hir::VariableId)> {
986    let parameters = hir.function(function_id).parameters;
987    let mut recipient_parameter = None;
988    let mut token_parameter = None;
989    // The parameters the call binds the recipient and the token to, if any: the callee's own
990    // guard is then judged against those.
991    for (index, &parameter) in parameters.iter().enumerate() {
992        let Some(argument) = argument_bound_to_parameter(hir, function_id, args, index) else {
993            continue;
994        };
995        if recipient_parameter.is_none() && is_exactly_var(argument, recipient) {
996            recipient_parameter = Some(parameter);
997        }
998        if token_parameter.is_none() && is_exactly_var(argument, token) {
999            token_parameter = Some(parameter);
1000        }
1001    }
1002    recipient_parameter.zip(token_parameter)
1003}
1004
1005/// How a path established that the recipient can receive the mint. Callback evidence remains
1006/// valid when summarizing a guard helper, while a code-less proof must be retired once a call
1007/// could deploy code at the recipient address. Whether the evidence can cover a future mint is
1008/// tracked separately by [`GuardWalk::future_coverage`].
1009#[derive(Clone, Copy, Default, PartialEq, Eq)]
1010enum GuardCoverage {
1011    #[default]
1012    None,
1013    Callback,
1014    CodeLess,
1015    CallbackOrCodeLess,
1016}
1017
1018impl GuardCoverage {
1019    fn is_covered(self) -> bool {
1020        self != Self::None
1021    }
1022
1023    const fn relies_on_code_length(self) -> bool {
1024        matches!(self, Self::CodeLess | Self::CallbackOrCodeLess)
1025    }
1026
1027    /// The coverage guaranteed after either branch. If one path relies on a code-less proof,
1028    /// the merged proof does too and remains invalidatable by a later call.
1029    const fn merge_paths(self, other: Self) -> Self {
1030        match (self, other) {
1031            (Self::None, _) | (_, Self::None) => Self::None,
1032            (Self::Callback, Self::Callback) => Self::Callback,
1033            (Self::CodeLess, Self::CodeLess) => Self::CodeLess,
1034            _ => Self::CallbackOrCodeLess,
1035        }
1036    }
1037
1038    /// Coverage from guards that all execute. A callback check remains valid when a later call
1039    /// invalidates a separate code-length observation.
1040    const fn combine_guards(self, other: Self) -> Self {
1041        match (self, other) {
1042            (Self::Callback, _) | (_, Self::Callback) => Self::Callback,
1043            (Self::CallbackOrCodeLess, _) | (_, Self::CallbackOrCodeLess) => {
1044                Self::CallbackOrCodeLess
1045            }
1046            (Self::CodeLess, _) | (_, Self::CodeLess) => Self::CodeLess,
1047            _ => Self::None,
1048        }
1049    }
1050}
1051
1052/// The straight-line reading of a body: `coverage` once a guard has run on the path, `pending`
1053/// while a delegated mint has run with no guard before or after it yet, `failed` once a path
1054/// may leave the function successfully with such a mint standing, `escaped` once one may leave
1055/// before any guard ran.
1056#[derive(Clone, Default)]
1057struct GuardWalk {
1058    /// The guards that have run on every path. This is used when summarizing a guard helper.
1059    coverage: GuardCoverage,
1060    /// Coverage that can satisfy a delegation encountered later. A code-less proof can precede
1061    /// the mint; callback coverage can only appear here when a modifier tail guarantees that the
1062    /// callback runs after the body.
1063    future_coverage: GuardCoverage,
1064    pending: bool,
1065    failed: bool,
1066    escaped: bool,
1067}
1068
1069/// Reads a body in statement order and judges the delegated mints against the guards. A code-less
1070/// proof may cover a later delegation, but a callback must run after ownership is established to
1071/// match `_safeMint`: the receiver can inspect `ownerOf`, balances, or reenter during the hook.
1072/// Such a callback covers delegations still pending, the revert undoing them, unless a statement
1073/// in between may leave the function successfully, keeping the unacknowledged token:
1074/// `super._mint(to, id); if (id == 0) return; require(hook...)` walks out with token zero standing.
1075///
1076/// The recognized guard shapes are a closed set, because a hook call that merely appears inside
1077/// a condition proves nothing about whether the revert depends on its answer. They are:
1078/// `require`/`assert` on an acceptance condition, `if (hook != selector) <exits>`,
1079/// `if (hook == selector) {} else <exits>`, and any of those reached through a function or
1080/// modifier. A callback helper receives both identities, the way OpenZeppelin factors
1081/// `_checkOnERC721Received` out of `_safeMint`; a code-less proof needs only the recipient.
1082///
1083/// Branches are read separately and merged: coverage holds only when every path checked, while
1084/// a pending or escaping path taints the whole. The branch a `to.code.length` test dedicates to
1085/// accounts starts covered, an account always accepting the token. A loop body may run zero
1086/// times, so nothing in one is credited, while the delegations and escapes it may hold still
1087/// count.
1088///
1089/// Everything else reports, a `try` whose `catch` may swallow the refusal included, and so are
1090/// an answer stored in a local and a helper returning it as a `bool`. Following the value
1091/// across statements would take a dataflow analysis this detector does not run.
1092#[expect(clippy::too_many_arguments)]
1093fn walk_guards<'hir>(
1094    gcx: Gcx<'hir>,
1095    hir: &'hir Hir<'hir>,
1096    stmts: &'hir [hir::Stmt<'hir>],
1097    recipient: hir::VariableId,
1098    token: hir::VariableId,
1099    delegations: &[FunctionId],
1100    unstable_code_delegations: &[FunctionId],
1101    bypass: bool,
1102    seen: &mut Vec<FunctionId>,
1103    walk: &mut GuardWalk,
1104) {
1105    // Read in order: what a guard covers and what an exit walks out with depend on what already
1106    // ran.
1107    for stmt in stmts {
1108        match &stmt.kind {
1109            hir::StmtKind::Block(block) | hir::StmtKind::UncheckedBlock(block) => {
1110                walk_guards(
1111                    gcx,
1112                    hir,
1113                    block.stmts,
1114                    recipient,
1115                    token,
1116                    delegations,
1117                    unstable_code_delegations,
1118                    bypass,
1119                    seen,
1120                    walk,
1121                );
1122            }
1123            hir::StmtKind::Expr(expr)
1124                if guard_expr_coverage(gcx, hir, expr, recipient, token, seen).is_covered() =>
1125            {
1126                let guard_coverage = guard_expr_coverage(gcx, hir, expr, recipient, token, seen);
1127                // A guard that also reassigns the recipient or the token cannot be trusted:
1128                // evaluation order decides whether the hook read the value the mint credits, so
1129                // an assignment tucked in the guard's other arguments retires coverage instead
1130                // of granting it. Nor can a guard that may leave the frame establish coverage:
1131                // an assembly return in another argument can keep a pending mint before the
1132                // builtin has a chance to revert.
1133                let mutates = mutates_var(hir, stmt, recipient) || mutates_var(hir, stmt, token);
1134                let escapes = may_return(gcx, hir, stmt);
1135                let changes_code = guard_coverage.relies_on_code_length()
1136                    && guard_extra_args_may_change_account_code(
1137                        gcx,
1138                        hir,
1139                        expr,
1140                        delegations,
1141                        unstable_code_delegations,
1142                    );
1143                if mutates {
1144                    if walk.pending {
1145                        walk.failed = true;
1146                    }
1147                    walk.coverage = GuardCoverage::None;
1148                    walk.future_coverage = GuardCoverage::None;
1149                } else if escapes {
1150                    if walk.pending {
1151                        walk.failed = true;
1152                    }
1153                    if !walk.coverage.is_covered() {
1154                        walk.escaped = true;
1155                    }
1156                } else if changes_code {
1157                    if walk.future_coverage.relies_on_code_length() {
1158                        walk.future_coverage = GuardCoverage::None;
1159                    }
1160                } else {
1161                    walk.coverage = walk.coverage.combine_guards(guard_coverage);
1162                    if guard_coverage == GuardCoverage::CodeLess {
1163                        walk.future_coverage = walk.future_coverage.combine_guards(guard_coverage);
1164                    }
1165                    walk.pending = false;
1166                }
1167            }
1168            hir::StmtKind::If(cond, then, else_) => {
1169                // The condition runs before either branch, so an assignment embedded in it,
1170                // `if ((tokenId = tokenId + 1) > 0) {}`, retires coverage exactly as a bare
1171                // assignment statement does. Even a recognized hook comparison may hide one in
1172                // another argument, so mutation also prevents the comparison from covering.
1173                let condition_mutates =
1174                    expr_mutates_var(hir, cond, recipient) || expr_mutates_var(hir, cond, token);
1175                let condition_escapes = expr_contains_frame_ending_assembly(gcx, hir, cond);
1176                if condition_mutates {
1177                    if walk.pending {
1178                        walk.failed = true;
1179                    }
1180                    walk.coverage = GuardCoverage::None;
1181                    walk.future_coverage = GuardCoverage::None;
1182                }
1183                let future_relies_on_code = walk.future_coverage.relies_on_code_length();
1184                if future_relies_on_code
1185                    && expr_may_change_account_code(
1186                        gcx,
1187                        hir,
1188                        cond,
1189                        delegations,
1190                        unstable_code_delegations,
1191                    )
1192                {
1193                    walk.future_coverage = GuardCoverage::None;
1194                }
1195                if condition_escapes {
1196                    if walk.pending {
1197                        walk.failed = true;
1198                    }
1199                    if !walk.coverage.is_covered() {
1200                        walk.escaped = true;
1201                    }
1202                }
1203                // The exiting branch must be the one a refusal takes, not the one an acceptance
1204                // does. Continue reading the accepted branch from the covered state: it may
1205                // still reassign the checked values before delegating. A condition that itself
1206                // reassigns one of them cannot establish coverage for the new value.
1207                let refusal_then = !condition_mutates
1208                    && is_hook_comparison(gcx, hir, cond, recipient, token, hir::BinOpKind::Ne)
1209                    && branch_always_reverts(gcx, hir, then);
1210                let refusal_else = !condition_mutates
1211                    && is_hook_comparison(gcx, hir, cond, recipient, token, hir::BinOpKind::Eq)
1212                    && else_.is_some_and(|else_| branch_always_reverts(gcx, hir, else_));
1213                if refusal_then || refusal_else {
1214                    walk.coverage = walk.coverage.combine_guards(GuardCoverage::Callback);
1215                    walk.pending = false;
1216                    if refusal_then {
1217                        if let Some(accepted) = else_ {
1218                            walk_one(
1219                                gcx,
1220                                hir,
1221                                accepted,
1222                                recipient,
1223                                token,
1224                                delegations,
1225                                unstable_code_delegations,
1226                                bypass,
1227                                seen,
1228                                walk,
1229                            );
1230                        }
1231                    } else {
1232                        walk_one(
1233                            gcx,
1234                            hir,
1235                            then,
1236                            recipient,
1237                            token,
1238                            delegations,
1239                            unstable_code_delegations,
1240                            bypass,
1241                            seen,
1242                            walk,
1243                        );
1244                    }
1245                    continue;
1246                }
1247                // Each branch is read on its own, from what already ran. The branch a
1248                // `to.code.length` test dedicates to accounts starts covered with nothing
1249                // pending: an account always accepts, so the mints already made are as
1250                // satisfied on that path as the ones to come, and its exits break no promise.
1251                let mut then_walk = walk.clone();
1252                let mut else_walk = walk.clone();
1253                if is_code_length_test(cond, recipient, true) {
1254                    else_walk.coverage = else_walk.coverage.combine_guards(GuardCoverage::CodeLess);
1255                    else_walk.future_coverage =
1256                        else_walk.future_coverage.combine_guards(GuardCoverage::CodeLess);
1257                    else_walk.pending = false;
1258                } else if is_code_length_test(cond, recipient, false) {
1259                    then_walk.coverage = then_walk.coverage.combine_guards(GuardCoverage::CodeLess);
1260                    then_walk.future_coverage =
1261                        then_walk.future_coverage.combine_guards(GuardCoverage::CodeLess);
1262                    then_walk.pending = false;
1263                }
1264                walk_one(
1265                    gcx,
1266                    hir,
1267                    then,
1268                    recipient,
1269                    token,
1270                    delegations,
1271                    unstable_code_delegations,
1272                    bypass,
1273                    seen,
1274                    &mut then_walk,
1275                );
1276                if let Some(else_) = else_ {
1277                    walk_one(
1278                        gcx,
1279                        hir,
1280                        else_,
1281                        recipient,
1282                        token,
1283                        delegations,
1284                        unstable_code_delegations,
1285                        bypass,
1286                        seen,
1287                        &mut else_walk,
1288                    );
1289                }
1290                // A guard behind a condition only counts when every path passed one; anything
1291                // pending or escaped on either path stands.
1292                walk.coverage = then_walk.coverage.merge_paths(else_walk.coverage);
1293                walk.future_coverage =
1294                    then_walk.future_coverage.merge_paths(else_walk.future_coverage);
1295                walk.pending = then_walk.pending || else_walk.pending;
1296                walk.failed = then_walk.failed || else_walk.failed;
1297                walk.escaped = then_walk.escaped || else_walk.escaped;
1298            }
1299            _ => {
1300                // Reassigning the recipient or the token retires every guard so far: a guard
1301                // checked their value, and identity here is by variable, so a later delegation
1302                // spelling the same variable now credits a value the guard never saw. A mint
1303                // already pending can no longer be covered by a guard to come either, the guard
1304                // reading the new value. This runs first: an assignment inside a delegation's
1305                // own arguments happens before the call.
1306                if mutates_var(hir, stmt, recipient) || mutates_var(hir, stmt, token) {
1307                    if walk.pending {
1308                        walk.failed = true;
1309                    }
1310                    walk.coverage = GuardCoverage::None;
1311                    walk.future_coverage = GuardCoverage::None;
1312                }
1313                // Unlike a callback acknowledgement, a code-length observation is only a
1314                // snapshot. A later state-changing call can deploy code at that address, so it
1315                // retires coverage for a subsequent delegation. A mint already discharged by
1316                // the observation remains safe.
1317                let future_relies_on_code = walk.future_coverage.relies_on_code_length();
1318                if future_relies_on_code
1319                    && stmt_may_change_account_code(
1320                        gcx,
1321                        hir,
1322                        stmt,
1323                        delegations,
1324                        unstable_code_delegations,
1325                    )
1326                {
1327                    walk.future_coverage = GuardCoverage::None;
1328                }
1329                // An opaque statement: a delegation anywhere inside it mints, unchecked unless
1330                // already covered, and a possible successful exit walks out with whatever is
1331                // pending. The placeholder counts as one when the wrapped body can leave
1332                // through an assembly `return`, which skips everything after `_;`: a guard only
1333                // the modifier's tail holds is then not guaranteed.
1334                if !walk.future_coverage.is_covered()
1335                    && contains_delegation(gcx, hir, stmt, delegations)
1336                {
1337                    walk.pending = true;
1338                }
1339                if may_return(gcx, hir, stmt) || (bypass && contains_placeholder(hir, stmt)) {
1340                    if walk.pending {
1341                        walk.failed = true;
1342                    }
1343                    if !walk.coverage.is_covered() {
1344                        walk.escaped = true;
1345                    }
1346                }
1347            }
1348        }
1349    }
1350}
1351
1352/// [`walk_guards`] on a single statement.
1353#[expect(clippy::too_many_arguments)]
1354fn walk_one<'hir>(
1355    gcx: Gcx<'hir>,
1356    hir: &'hir Hir<'hir>,
1357    stmt: &'hir hir::Stmt<'hir>,
1358    recipient: hir::VariableId,
1359    token: hir::VariableId,
1360    delegations: &[FunctionId],
1361    unstable_code_delegations: &[FunctionId],
1362    bypass: bool,
1363    seen: &mut Vec<FunctionId>,
1364    walk: &mut GuardWalk,
1365) {
1366    walk_guards(
1367        gcx,
1368        hir,
1369        std::slice::from_ref(stmt),
1370        recipient,
1371        token,
1372        delegations,
1373        unstable_code_delegations,
1374        bypass,
1375        seen,
1376        walk,
1377    );
1378}
1379
1380/// Whether a subtree holds the `_;` placeholder of a modifier.
1381fn contains_placeholder<'hir>(hir: &'hir Hir<'hir>, stmt: &'hir hir::Stmt<'hir>) -> bool {
1382    struct PlaceholderFinder<'hir> {
1383        hir: &'hir Hir<'hir>,
1384    }
1385    impl<'hir> Visit<'hir> for PlaceholderFinder<'hir> {
1386        type BreakValue = ();
1387
1388        fn hir(&self) -> &'hir Hir<'hir> {
1389            self.hir
1390        }
1391
1392        fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
1393            if matches!(stmt.kind, hir::StmtKind::Placeholder) {
1394                return ControlFlow::Break(());
1395            }
1396            self.walk_stmt(stmt)
1397        }
1398    }
1399    let mut finder = PlaceholderFinder { hir };
1400    finder.visit_stmt(stmt).is_break()
1401}
1402
1403/// Whether a subtree can reach an assembly block in the same EVM frame, directly or through an
1404/// internal call. An assembly `return` leaves the frame without running a later revert or what
1405/// an outer modifier holds after its placeholder. Every assembly block is treated as capable of
1406/// doing so, conservatively matching [`may_return`].
1407fn contains_frame_ending_assembly<'hir>(
1408    gcx: Gcx<'hir>,
1409    hir: &'hir Hir<'hir>,
1410    stmts: &'hir [hir::Stmt<'hir>],
1411    seen: &mut Vec<FunctionId>,
1412) -> bool {
1413    struct AssemblyFinder<'a, 'hir> {
1414        gcx: Gcx<'hir>,
1415        hir: &'hir Hir<'hir>,
1416        seen: &'a mut Vec<FunctionId>,
1417    }
1418    impl<'hir> Visit<'hir> for AssemblyFinder<'_, 'hir> {
1419        type BreakValue = ();
1420
1421        fn hir(&self) -> &'hir Hir<'hir> {
1422            self.hir
1423        }
1424
1425        fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
1426            if matches!(stmt.kind, hir::StmtKind::AssemblyBlock(_)) {
1427                return ControlFlow::Break(());
1428            }
1429            self.walk_stmt(stmt)
1430        }
1431
1432        fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1433            if is_unresolved_internal_pointer_call(self.gcx, expr)
1434                || resolved_internal_callee(self.gcx, expr).is_some_and(|function_id| {
1435                    callable_contains_frame_ending_assembly(
1436                        self.gcx,
1437                        self.hir,
1438                        function_id,
1439                        self.seen,
1440                    )
1441                })
1442            {
1443                return ControlFlow::Break(());
1444            }
1445            self.walk_expr(expr)
1446        }
1447    }
1448    let mut finder = AssemblyFinder { gcx, hir, seen };
1449    stmts.iter().any(|stmt| finder.visit_stmt(stmt).is_break())
1450}
1451
1452/// [`contains_frame_ending_assembly`] over an expression, used for a refusal condition that may
1453/// itself leave before either branch runs.
1454fn expr_contains_frame_ending_assembly<'hir>(
1455    gcx: Gcx<'hir>,
1456    hir: &'hir Hir<'hir>,
1457    expr: &'hir Expr<'hir>,
1458) -> bool {
1459    struct ExprAssemblyFinder<'a, 'hir> {
1460        gcx: Gcx<'hir>,
1461        hir: &'hir Hir<'hir>,
1462        seen: &'a mut Vec<FunctionId>,
1463    }
1464    impl<'hir> Visit<'hir> for ExprAssemblyFinder<'_, 'hir> {
1465        type BreakValue = ();
1466
1467        fn hir(&self) -> &'hir Hir<'hir> {
1468            self.hir
1469        }
1470
1471        fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1472            if is_unresolved_internal_pointer_call(self.gcx, expr)
1473                || resolved_internal_callee(self.gcx, expr).is_some_and(|function_id| {
1474                    callable_contains_frame_ending_assembly(
1475                        self.gcx,
1476                        self.hir,
1477                        function_id,
1478                        self.seen,
1479                    )
1480                })
1481            {
1482                return ControlFlow::Break(());
1483            }
1484            self.walk_expr(expr)
1485        }
1486    }
1487    let mut seen = Vec::new();
1488    let mut finder = ExprAssemblyFinder { gcx, hir, seen: &mut seen };
1489    finder.visit_expr(expr).is_break()
1490}
1491
1492/// Whether a resolved same-frame callable or one of its applied modifiers can reach assembly.
1493/// The recursion set is a path stack so independent calls are summarized independently.
1494fn callable_contains_frame_ending_assembly<'hir>(
1495    gcx: Gcx<'hir>,
1496    hir: &'hir Hir<'hir>,
1497    function_id: FunctionId,
1498    seen: &mut Vec<FunctionId>,
1499) -> bool {
1500    if seen.contains(&function_id) {
1501        return false;
1502    }
1503    seen.push(function_id);
1504    let function = hir.function(function_id);
1505    let in_modifiers = function.modifiers.iter().any(|modifier| {
1506        matches!(modifier.id, hir::ItemId::Function(id)
1507            if callable_contains_frame_ending_assembly(gcx, hir, id, seen))
1508    });
1509    let in_body = function
1510        .body
1511        .as_ref()
1512        .is_some_and(|body| contains_frame_ending_assembly(gcx, hir, body.stmts, seen));
1513    seen.pop();
1514    in_modifiers || in_body
1515}
1516
1517/// A statement expression that guards the recipient and the token: `require`/`assert` on an
1518/// acceptance condition, or an internal call handing both to a helper that does. Only the
1519/// condition is read: a hook call sitting in the revert message decides nothing, and neither
1520/// does one in any other argument. An external helper would ask from a different contract and
1521/// cannot establish that the recipient accepts the minting contract's callback.
1522fn guard_expr_coverage<'hir>(
1523    gcx: Gcx<'hir>,
1524    hir: &'hir Hir<'hir>,
1525    expr: &'hir Expr<'hir>,
1526    recipient: hir::VariableId,
1527    token: hir::VariableId,
1528    seen: &mut Vec<FunctionId>,
1529) -> GuardCoverage {
1530    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else {
1531        return GuardCoverage::None;
1532    };
1533    if is_require_or_assert(callee) {
1534        return args.exprs().next().map_or(GuardCoverage::None, |cond| {
1535            acceptance_coverage(gcx, hir, cond, recipient, token)
1536        });
1537    }
1538    resolved_internal_callee(gcx, expr.peel_parens()).map_or(GuardCoverage::None, |function_id| {
1539        callee_guards_recipient(gcx, hir, function_id, args, recipient, token, false, seen)
1540    })
1541}
1542
1543/// Whether a recognized `require`/`assert` has another argument that may change account code.
1544/// The first argument is the closed-form acceptance condition itself; its receiver callback is
1545/// part of the proof. Message and other arguments are not, and Solidity does not guarantee they
1546/// run before the condition's code-length snapshot.
1547fn guard_extra_args_may_change_account_code<'hir>(
1548    gcx: Gcx<'hir>,
1549    hir: &'hir Hir<'hir>,
1550    expr: &'hir Expr<'hir>,
1551    delegations: &[FunctionId],
1552    unstable_code_delegations: &[FunctionId],
1553) -> bool {
1554    let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return false };
1555    if !is_require_or_assert(callee) {
1556        return false;
1557    }
1558    args.exprs().skip(1).any(|arg| {
1559        expr_may_change_account_code(gcx, hir, arg, delegations, unstable_code_delegations)
1560    })
1561}
1562
1563/// Whether a statement assigns to `var`: `var = x`, `var += x`, `var++`, `delete var`, or `var`
1564/// as a component of a tuple assignment. An assembly block is treated as an opaque assignment,
1565/// since it can rewrite Solidity locals outside the HIR expression tree. Identity here is by
1566/// variable, not by value, so a guard that checked `var` says nothing once `var` is reassigned:
1567/// the delegation then credits a value or an address the guard never saw, though the two spell
1568/// the same variable.
1569fn mutates_var<'hir>(
1570    hir: &'hir Hir<'hir>,
1571    stmt: &'hir hir::Stmt<'hir>,
1572    var: hir::VariableId,
1573) -> bool {
1574    let mut finder = MutationFinder { hir, var };
1575    finder.visit_stmt(stmt).is_break()
1576}
1577
1578/// [`mutates_var`] over an expression, for the condition of an `if`, which the `If` arm reaches
1579/// before the statement arm ever sees it: `if ((tokenId = x) > 0) {}` reassigns as surely as a
1580/// bare statement, and the condition runs whichever branch is taken.
1581fn expr_mutates_var<'hir>(
1582    hir: &'hir Hir<'hir>,
1583    expr: &'hir Expr<'hir>,
1584    var: hir::VariableId,
1585) -> bool {
1586    let mut finder = MutationFinder { hir, var };
1587    finder.visit_expr(expr).is_break()
1588}
1589
1590/// Finds an assignment to `var` anywhere in a subtree, conservatively including assembly.
1591struct MutationFinder<'hir> {
1592    hir: &'hir Hir<'hir>,
1593    var: hir::VariableId,
1594}
1595
1596impl<'hir> Visit<'hir> for MutationFinder<'hir> {
1597    type BreakValue = ();
1598
1599    fn hir(&self) -> &'hir Hir<'hir> {
1600        self.hir
1601    }
1602
1603    fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
1604        // Yul can assign to Solidity locals, but those assignments are not HIR expressions.
1605        // Treat an assembly block as opaque mutation of every tracked value rather than keep
1606        // coverage across a remap this visitor cannot inspect.
1607        if matches!(stmt.kind, hir::StmtKind::AssemblyBlock(_)) {
1608            return ControlFlow::Break(());
1609        }
1610        self.walk_stmt(stmt)
1611    }
1612
1613    fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1614        let target = match &expr.kind {
1615            ExprKind::Assign(lhs, _, _) => Some(*lhs),
1616            ExprKind::Delete(inner) => Some(*inner),
1617            ExprKind::Unary(op, inner)
1618                if matches!(
1619                    op.kind,
1620                    hir::UnOpKind::PreInc
1621                        | hir::UnOpKind::PreDec
1622                        | hir::UnOpKind::PostInc
1623                        | hir::UnOpKind::PostDec
1624                ) =>
1625            {
1626                Some(*inner)
1627            }
1628            _ => None,
1629        };
1630        if let Some(target) = target
1631            && assigns_to(target, self.var)
1632        {
1633            return ControlFlow::Break(());
1634        }
1635        self.walk_expr(expr)
1636    }
1637}
1638
1639/// Whether an assignment target names `var`, directly or as one component of a tuple.
1640fn assigns_to(target: &Expr<'_>, var: hir::VariableId) -> bool {
1641    match &target.peel_parens().kind {
1642        ExprKind::Ident(resolutions) => resolutions
1643            .iter()
1644            .any(|res| matches!(res, hir::Res::Item(hir::ItemId::Variable(vid)) if *vid == var)),
1645        ExprKind::Tuple(elements) => {
1646            elements.iter().any(|element| element.is_some_and(|inner| assigns_to(inner, var)))
1647        }
1648        _ => false,
1649    }
1650}
1651
1652/// Whether an expression may change the code installed at an account. Pure and view calls are
1653/// stable (external ones execute through `STATICCALL`), while nonpayable/payable calls and
1654/// contract creation can run `CREATE`/`CREATE2`. Calls to the delegated mint itself are excluded:
1655/// the code-length proof is needed precisely until that call begins, though calls in its
1656/// arguments are still inspected.
1657fn expr_may_change_account_code<'hir>(
1658    gcx: Gcx<'hir>,
1659    hir: &'hir Hir<'hir>,
1660    expr: &'hir Expr<'hir>,
1661    delegations: &[FunctionId],
1662    unstable_code_delegations: &[FunctionId],
1663) -> bool {
1664    let mut seen = Vec::new();
1665    let mut finder = AccountCodeChangeFinder {
1666        gcx,
1667        hir,
1668        delegations,
1669        unstable_code_delegations,
1670        seen: &mut seen,
1671    };
1672    finder.visit_expr(expr).is_break()
1673}
1674
1675/// [`expr_may_change_account_code`] over a whole statement. Inline assembly is opaque and may
1676/// deploy code even when it contains no HIR call expression.
1677fn stmt_may_change_account_code<'hir>(
1678    gcx: Gcx<'hir>,
1679    hir: &'hir Hir<'hir>,
1680    stmt: &'hir hir::Stmt<'hir>,
1681    delegations: &[FunctionId],
1682    unstable_code_delegations: &[FunctionId],
1683) -> bool {
1684    let mut seen = Vec::new();
1685    let mut finder = AccountCodeChangeFinder {
1686        gcx,
1687        hir,
1688        delegations,
1689        unstable_code_delegations,
1690        seen: &mut seen,
1691    };
1692    finder.visit_stmt(stmt).is_break()
1693}
1694
1695struct AccountCodeChangeFinder<'a, 'hir> {
1696    gcx: Gcx<'hir>,
1697    hir: &'hir Hir<'hir>,
1698    delegations: &'a [FunctionId],
1699    unstable_code_delegations: &'a [FunctionId],
1700    seen: &'a mut Vec<FunctionId>,
1701}
1702
1703impl<'hir> Visit<'hir> for AccountCodeChangeFinder<'_, 'hir> {
1704    type BreakValue = ();
1705
1706    fn hir(&self) -> &'hir Hir<'hir> {
1707        self.hir
1708    }
1709
1710    fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
1711        if matches!(stmt.kind, hir::StmtKind::AssemblyBlock(_)) {
1712            return ControlFlow::Break(());
1713        }
1714        self.walk_stmt(stmt)
1715    }
1716
1717    fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1718        if let ExprKind::Call(callee, ..) = &expr.kind {
1719            let is_delegation = resolved_callee(self.gcx, expr)
1720                .is_some_and(|function_id| self.delegations.contains(&function_id));
1721            let is_unstable_delegation = resolved_callee(self.gcx, expr)
1722                .is_some_and(|function_id| self.unstable_code_delegations.contains(&function_id));
1723            if !is_delegation || is_unstable_delegation {
1724                if matches!(callee.peel_parens().kind, ExprKind::New(_)) {
1725                    return ControlFlow::Break(());
1726                }
1727                if let Some(ty) = self.gcx.type_of_expr(callee.peel_parens().id)
1728                    && let TyKind::Fn(function_ty) = ty.kind
1729                    && matches!(
1730                        function_ty.state_mutability,
1731                        StateMutability::NonPayable | StateMutability::Payable
1732                    )
1733                {
1734                    if let Some(function_id) = resolved_internal_callee(self.gcx, expr)
1735                        && !self.hir.function(function_id).virtual_
1736                    {
1737                        if callable_may_change_account_code(
1738                            self.gcx,
1739                            self.hir,
1740                            function_id,
1741                            self.seen,
1742                        ) {
1743                            return ControlFlow::Break(());
1744                        }
1745                    } else {
1746                        return ControlFlow::Break(());
1747                    }
1748                }
1749            }
1750        }
1751        self.walk_expr(expr)
1752    }
1753}
1754
1755/// Whether a statically known same-frame callable can create code, directly, through an applied
1756/// modifier, or through another internal call. Recursion cycles alone do not create code; any
1757/// opaque, virtual, or external state-changing call reached by the body remains conservative.
1758fn callable_may_change_account_code<'hir>(
1759    gcx: Gcx<'hir>,
1760    hir: &'hir Hir<'hir>,
1761    function_id: FunctionId,
1762    seen: &mut Vec<FunctionId>,
1763) -> bool {
1764    if seen.contains(&function_id) {
1765        return false;
1766    }
1767    seen.push(function_id);
1768    let function = hir.function(function_id);
1769    let mut finder = AccountCodeChangeFinder {
1770        gcx,
1771        hir,
1772        delegations: &[],
1773        unstable_code_delegations: &[],
1774        seen,
1775    };
1776    let in_modifier_args = function
1777        .modifiers
1778        .iter()
1779        .any(|modifier| modifier.args.exprs().any(|arg| finder.visit_expr(arg).is_break()));
1780    let in_modifiers = !in_modifier_args
1781        && function.modifiers.iter().any(|modifier| {
1782            matches!(modifier.id, hir::ItemId::Function(id)
1783                if callable_may_change_account_code(gcx, hir, id, finder.seen))
1784        });
1785    let in_body = !in_modifier_args
1786        && !in_modifiers
1787        && function
1788            .body
1789            .as_ref()
1790            .is_some_and(|body| body.stmts.iter().any(|stmt| finder.visit_stmt(stmt).is_break()));
1791    finder.seen.pop();
1792    in_modifier_args || in_modifiers || in_body
1793}
1794
1795/// Whether a subtree holds a call dispatching to one of the delegated mints.
1796fn contains_delegation<'hir>(
1797    gcx: Gcx<'hir>,
1798    hir: &'hir Hir<'hir>,
1799    stmt: &'hir hir::Stmt<'hir>,
1800    delegations: &[FunctionId],
1801) -> bool {
1802    struct DelegationFinder<'a, 'hir> {
1803        gcx: Gcx<'hir>,
1804        hir: &'hir Hir<'hir>,
1805        delegations: &'a [FunctionId],
1806    }
1807    impl<'hir> Visit<'hir> for DelegationFinder<'_, 'hir> {
1808        type BreakValue = ();
1809
1810        fn hir(&self) -> &'hir Hir<'hir> {
1811            self.hir
1812        }
1813
1814        fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
1815            if let Some(function_id) = resolved_callee(self.gcx, expr)
1816                && self.delegations.contains(&function_id)
1817            {
1818                return ControlFlow::Break(());
1819            }
1820            self.walk_expr(expr)
1821        }
1822    }
1823    let mut finder = DelegationFinder { gcx, hir, delegations };
1824    finder.visit_stmt(stmt).is_break()
1825}
1826
1827/// [`walk_guards`] on a callee's body, against the parameters the recipient and the token
1828/// landed on: the callee guards when a guard ran before any possible successful exit, which is
1829/// what its caller relies on. `bypass` says the wrapped body can leave through an assembly
1830/// `return`, making an uncovered placeholder such an exit. `seen` cuts recursion cycles.
1831fn body_guards<'hir>(
1832    gcx: Gcx<'hir>,
1833    hir: &'hir Hir<'hir>,
1834    function_id: FunctionId,
1835    recipient: hir::VariableId,
1836    token: hir::VariableId,
1837    bypass: bool,
1838    seen: &mut Vec<FunctionId>,
1839) -> GuardCoverage {
1840    if seen.contains(&function_id) {
1841        return GuardCoverage::None;
1842    }
1843    seen.push(function_id);
1844    let function = hir.function(function_id);
1845    // A `virtual` callee may be replaced by an override that drops the guard, and the body seen
1846    // here is the statically resolved one, not the one dispatched. A helper carrying modifiers
1847    // is likewise not credited until their expansion is modeled: one may skip the placeholder
1848    // and let the helper return without ever running its body.
1849    let guarded = if function.virtual_ || !function.modifiers.is_empty() {
1850        GuardCoverage::None
1851    } else if let Some(body) = &function.body {
1852        // The caller relies on the values it passed in, not on a helper or modifier's local
1853        // parameter after reassignment. Conservatively reject any body that mutates either
1854        // bound parameter; a later guard may then be checking a different value.
1855        let parameters_unchanged = !body
1856            .stmts
1857            .iter()
1858            .any(|stmt| mutates_var(hir, stmt, recipient) || mutates_var(hir, stmt, token));
1859        if parameters_unchanged {
1860            let mut walk = GuardWalk::default();
1861            walk_guards(gcx, hir, body.stmts, recipient, token, &[], &[], bypass, seen, &mut walk);
1862            if walk.escaped {
1863                GuardCoverage::None
1864            } else if walk.future_coverage == GuardCoverage::CodeLess {
1865                GuardCoverage::CodeLess
1866            } else if walk.coverage == GuardCoverage::CodeLess {
1867                // The code-less observation can discharge a mint that preceded the helper, but
1868                // later work invalidated it for a mint after the helper. Use the mixed marker:
1869                // callers treat it as discharge-only, like a callback.
1870                GuardCoverage::CallbackOrCodeLess
1871            } else {
1872                walk.coverage
1873            }
1874        } else {
1875            GuardCoverage::None
1876        }
1877    } else {
1878        GuardCoverage::None
1879    };
1880    seen.pop();
1881    guarded
1882}
1883
1884/// Guard state in effect while a function body runs after expanding its modifier prefixes and
1885/// accounting for guaranteed modifier tails.
1886#[derive(Clone, Copy)]
1887struct ModifierCoverage {
1888    coverage: GuardCoverage,
1889    future_coverage: GuardCoverage,
1890}
1891
1892/// Coverage in effect when a function body starts after expanding its modifiers in declaration
1893/// order. Prefixes are walked in execution order so calls in an inner modifier can retire an
1894/// outer code-length snapshot. A proven tail guard is represented as stable callback coverage
1895/// while walking the body: it runs after the body and can revert every mint the body made, unless
1896/// assembly in the body or an inner modifier can bypass it.
1897fn modifier_coverage_at_body<'hir>(
1898    gcx: Gcx<'hir>,
1899    hir: &'hir Hir<'hir>,
1900    function: &'hir hir::Function<'hir>,
1901    recipient: hir::VariableId,
1902    token: hir::VariableId,
1903    mut coverage: GuardCoverage,
1904) -> ModifierCoverage {
1905    let mut future_coverage = coverage;
1906    let body_bypass = function
1907        .body
1908        .as_ref()
1909        .is_some_and(|body| contains_frame_ending_assembly(gcx, hir, body.stmts, &mut Vec::new()));
1910    let mut has_tail_guard = false;
1911    for (index, modifier) in function.modifiers.iter().enumerate() {
1912        if modifier
1913            .args
1914            .exprs()
1915            .any(|arg| expr_mutates_var(hir, arg, recipient) || expr_mutates_var(hir, arg, token))
1916        {
1917            coverage = GuardCoverage::None;
1918            future_coverage = GuardCoverage::None;
1919            has_tail_guard = false;
1920        }
1921        let argument_may_change_code =
1922            modifier.args.exprs().any(|arg| expr_may_change_account_code(gcx, hir, arg, &[], &[]));
1923        if coverage.relies_on_code_length() && argument_may_change_code {
1924            coverage = GuardCoverage::None;
1925        }
1926        if future_coverage.relies_on_code_length() && argument_may_change_code {
1927            future_coverage = GuardCoverage::None;
1928        }
1929        let hir::ItemId::Function(modifier_id) = modifier.id else { continue };
1930        let modifier_function = hir.function(modifier_id);
1931        let Some(body) = &modifier_function.body else { continue };
1932        let Some((prefix, suffix)) = modifier_body_sides(body.stmts) else {
1933            // Without a single top-level placeholder, the precise prefix is unknown. Still
1934            // retire an inherited snapshot when any path through the modifier may change code;
1935            // keeping it would let a nested/multiple-placeholder deployment reach the body.
1936            let coverage_relies_on_code = coverage.relies_on_code_length();
1937            let future_relies_on_code = future_coverage.relies_on_code_length();
1938            if (coverage_relies_on_code || future_relies_on_code)
1939                && body
1940                    .stmts
1941                    .iter()
1942                    .any(|stmt| stmt_may_change_account_code(gcx, hir, stmt, &[], &[]))
1943            {
1944                if coverage_relies_on_code {
1945                    coverage = GuardCoverage::None;
1946                }
1947                if future_relies_on_code {
1948                    future_coverage = GuardCoverage::None;
1949                }
1950            }
1951            continue;
1952        };
1953        let Some((modifier_recipient, modifier_token)) =
1954            bound_guard_parameters(hir, modifier_id, &modifier.args, recipient, token)
1955        else {
1956            let coverage_relies_on_code = coverage.relies_on_code_length();
1957            let future_relies_on_code = future_coverage.relies_on_code_length();
1958            if (coverage_relies_on_code || future_relies_on_code)
1959                && prefix.iter().any(|stmt| stmt_may_change_account_code(gcx, hir, stmt, &[], &[]))
1960            {
1961                if coverage_relies_on_code {
1962                    coverage = GuardCoverage::None;
1963                }
1964                if future_relies_on_code {
1965                    future_coverage = GuardCoverage::None;
1966                }
1967            }
1968            continue;
1969        };
1970        let parameters_unchanged = !body.stmts.iter().any(|stmt| {
1971            mutates_var(hir, stmt, modifier_recipient) || mutates_var(hir, stmt, modifier_token)
1972        });
1973        if parameters_unchanged {
1974            let mut prefix_walk = GuardWalk { coverage, future_coverage, ..GuardWalk::default() };
1975            walk_guards(
1976                gcx,
1977                hir,
1978                prefix,
1979                modifier_recipient,
1980                modifier_token,
1981                &[],
1982                &[],
1983                false,
1984                &mut Vec::new(),
1985                &mut prefix_walk,
1986            );
1987            coverage = prefix_walk.coverage;
1988            future_coverage = prefix_walk.future_coverage;
1989        } else {
1990            let prefix_may_change_code =
1991                prefix.iter().any(|stmt| stmt_may_change_account_code(gcx, hir, stmt, &[], &[]));
1992            if coverage.relies_on_code_length() && prefix_may_change_code {
1993                coverage = GuardCoverage::None;
1994            }
1995            if future_coverage.relies_on_code_length() && prefix_may_change_code {
1996                future_coverage = GuardCoverage::None;
1997            }
1998        }
1999
2000        let inner_modifier_bypass = function.modifiers[index + 1..].iter().any(|inner| {
2001            matches!(inner.id, hir::ItemId::Function(id)
2002                if callable_contains_frame_ending_assembly(gcx, hir, id, &mut Vec::new()))
2003        });
2004        if parameters_unchanged && !body_bypass && !inner_modifier_bypass {
2005            // The body has already minted when the suffix starts. Seed one pending delegation:
2006            // a guard anywhere before a successful suffix exit clears it, while a call after
2007            // that guard cannot make the earlier mint retroactively unsafe.
2008            let mut suffix_walk = GuardWalk { pending: true, ..GuardWalk::default() };
2009            walk_guards(
2010                gcx,
2011                hir,
2012                suffix,
2013                modifier_recipient,
2014                modifier_token,
2015                &[],
2016                &[],
2017                false,
2018                &mut Vec::new(),
2019                &mut suffix_walk,
2020            );
2021            has_tail_guard |= !suffix_walk.failed && !suffix_walk.pending;
2022        }
2023    }
2024    if has_tail_guard {
2025        coverage = coverage.combine_guards(GuardCoverage::Callback);
2026        future_coverage = future_coverage.combine_guards(GuardCoverage::Callback);
2027    }
2028    ModifierCoverage { coverage, future_coverage }
2029}
2030
2031/// The statements before and after a modifier's single top-level placeholder. More complicated
2032/// expansion shapes are left uncredited rather than guessing which paths execute the body.
2033fn modifier_body_sides<'hir>(
2034    stmts: &'hir [hir::Stmt<'hir>],
2035) -> Option<(&'hir [hir::Stmt<'hir>], &'hir [hir::Stmt<'hir>])> {
2036    let mut placeholders = stmts
2037        .iter()
2038        .enumerate()
2039        .filter(|(_, stmt)| matches!(stmt.kind, hir::StmtKind::Placeholder));
2040    let (index, _) = placeholders.next()?;
2041    if placeholders.next().is_some() {
2042        return None;
2043    }
2044    Some((&stmts[..index], &stmts[index + 1..]))
2045}
2046
2047/// The OpenZeppelin contracts whose `_mint` skips the receiver check. `ERC721` and
2048/// `ERC721Upgradeable` declare the unchecked `_mint`; in the v4 line, `ERC721Consecutive` and
2049/// `ERC721ConsecutiveUpgradeable` override it with a construction guard that forwards to the
2050/// base through `super._mint`, still without a receiver check, so resolving to them is just
2051/// as unsafe. In v5 the Consecutive extension overrides `_update` instead, and the two extra
2052/// names match nothing.
2053fn is_canonical_erc721(name: &str) -> bool {
2054    matches!(
2055        name,
2056        "ERC721" | "ERC721Upgradeable" | "ERC721Consecutive" | "ERC721ConsecutiveUpgradeable"
2057    )
2058}