Repository navigation
[prover] Opaque callee with an undeclared closure parameter is assumed pure, blocking generic container APIs whose callbacks touch memory #20689
Description
Summary
When a function is pragma opaque and takes a function-typed parameter with no
modifies_of/reads_of declaration, the prover treats that parameter as pure and rejects
any caller whose closure touches global memory. For a generic container operation this default
is unsatisfiable: the container cannot name the resources its callers' callbacks touch.
The rejection happens during bytecode transformation, so it does not fail one function — the
caller's entire module produces zero verification conditions and nothing in it can be verified.
Repro
30 lines, depends only on the framework.
module ex::m {
struct Counter has key { n: u64 }
struct Box has key { v: u64 }
/// A generic container operation that applies a caller-supplied callback
/// to an element. Its body is not verified, so it is opaque.
public fun apply(b: &mut Box, f: |&mut u64|) {
f(&mut b.v)
}
spec apply {
pragma opaque;
pragma verify = false;
// No `modifies_of<f>`. `apply` is generic over its callers, so it
// cannot name the resources any particular callback touches.
}
/// Bookkeeping the callback performs, fully and correctly specified.
fun bump(addr: address) acquires Counter {
Counter[addr].n = Counter[addr].n + 1;
}
spec bump {
pragma opaque;
aborts_if !exists<Counter>(addr);
aborts_if global<Counter>(addr).n + 1 > MAX_U64;
modifies global<Counter>(addr);
ensures global<Counter>(addr).n == old(global<Counter>(addr).n) + 1;
}
/// The caller passes a literal lambda that touches a resource.
public fun caller(b: &mut Box, addr: address) acquires Counter {
apply(b, |v| { *v = *v + 1; bump(addr); })
}
}Result:
error: function argument may modify resource `m::Counter`,
which is not declared in `modifies_of` for `f`
┌─ sources/m.move:31:9
│
31 │ apply(b, |v| { *v = *v + 1; bump(addr); })
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
{ "Error": "Move Prover failed: exiting with bytecode transformation errors" }
Deleting the single line pragma opaque; from spec apply, changing nothing else:
[INFO] 3 verification roots
[INFO] 0.489s ok m::bump [verification]
[INFO] 0.491s ok m::caller [verification]
[INFO] 0.463s ok m::__lambda__1__caller [verification]
{ "Result": "Success" }
bump is fully specified and correct. The callback is doing nothing questionable. It is
rejected solely because apply did not declare that a callback may touch memory.
Where this comes from
third_party/move/move-compiler-v2/src/env_pipeline/spec_rewriter.rs:
// Skip validation only for transparent callees with no declarations.
// For opaque callees, missing declarations means "pure" — validate
// that closures don't access memory.
if access.is_empty() && !callee_env.is_opaque() {
return true;
}Why the existing options do not cover this
Declaring the frame on the callee is the documented pattern, and it works — see
move-prover/tests/sources/functional/closures/lambda_spec_global_memory.move, which is this
exact shape and verifies clean. But it requires naming concrete resources:
modifies_of<f>(v: &mut u64) Counter[@0x42];A generic container cannot do that. It has no knowledge of Counter. Note there are currently
zero uses of modifies_of/reads_of anywhere in aptos-move/framework/ — every use in
the tree is in prover tests, so the feature has not yet been exercised by a generic container
API.
The wildcard modifies_of<f> *; makes the error go away, but it short-circuits the
footprint check entirely (if access.frame_spec.modifies_all { continue; }), so nothing
validates what the callback touches. As the comment in lambda_spec_global_memory.move
explains, the declared frame is also what saves the pre-state the two-state evaluators read and
havocs memory across the opaque call — so a wildcard leaves that machinery without concrete
memory to work from. It does not look safe to recommend as the general answer for container
APIs.
Dropping opaque is not available either when the callee is an intrinsic type's operation:
it exposes an implementation that is deliberately modelled rather than translated.
Suggested direction
For an opaque callee whose function-typed parameter carries no frame declaration, infer the
frame from the actual closure at each call site rather than assuming purity. The checker
already computes that footprint exactly — it computes it in order to produce the rejection.
For a function-typed parameter the effect is genuinely call-site dependent, so a fixed
declaration on a generic container can never be correct for all callers. Inferring it also
yields a tighter frame than any declaration a container author could write.
If changing the default is undesirable, an explicit opt-in that means "this parameter's frame
is whatever the caller passes" would also unblock the case, and would be safer than the
wildcard.
Activity
rahxephon89 commentedon Oct 7, 2026
Some implementation notes from tracing this through, in case they save someone time. Two
things turned out to be non-obvious: which check actually fires, and that the frame has a
soundness role as well as a checking role.
The rejection comes from the bytecode pipeline, not the compiler
There are two independent checks, and they derive the closure's footprint from different
sources.
move-compiler-v2/src/env_pipeline/spec_rewriter.rs,validate_closure_access_of_compliance, computes the footprint viacompute_arg_memory.For a lifted lambda that takes the
Operation::Closurebranch and reads the liftedfunction's spec memory:
A lifted lambda has no spec conditions, so this is empty and the check is inert. I
instrumented it to be sure: on the repro it reports
arg_used=0 arg_old=0and emitsnothing.
The error actually comes from
move-prover/bytecode-pipeline/src/spec_instrumentation.rs,check_fun_arg_frames, which derives the footprint from usage analysis(
closure_footprints) and therefore sees the transitive memory. The relevant default is inDeclaredFrame::of_fun_param:The frame is also load-bearing for soundness
This is the part that makes a naive fix wrong. The declaration feeds two places:
check_fun_arg_frames— the validation above.move-model/bytecode/src/usage_analysis.rs— for a function whose body containsInvoke, the declared frame'sold_memoryis added throughadd_direct_invoke_frame.That is what puts the closure's writes into the callee's modified set, which is what
makes the caller havoc them across the opaque call.
So simply not rejecting when no declaration exists would be unsound: callers would continue
to assume the closure touched nothing. Any fix has to supply (2), not just relax (1). The
same consideration explains why
modifies_of<f> *is not a good general answer for acontainer API — it short-circuits the check without naming memory for the havoc.
Where it gets awkward
The natural inferred frame for an opaque callee's undeclared function-typed parameter is the
union, over all call sites, of the footprints of the closures actually passed. That
information exists —
closure_footprintsalready computes it — but it is derived fromusage analysis, while the place that needs it (
add_direct_invoke_frame) runs insideusage analysis. Feeding it back requires either a global pre-scan collecting, per (callee,
parameter), the closure targets passed at call sites, so their memory is available before
the analysis needs it, or a second pass over the affected callees.
I tried the inference in the compiler-v2 rewriter first, which does not work: at that point
the lifted lambda's spec memory is empty, so there is nothing to infer from. The usable
footprint only exists after lambda lifting and usage analysis.
Minimal repro, unchanged from the issue body
Still reproduces on
main. Adding it asmove-prover/tests/sources/functional/closures/infer_undeclared_frame.movegives a failingtest that should pass once this is addressed.
rahxephon89 commentedon Oct 7, 2026
I tried the obvious fix and it does not work. Writing up what it rules out, since the dead
end is informative and the surviving design is narrower than I expected.
What I tried
Rather than infer the frame, let the callback declare its own and have the call site use
that. This looked attractive because the information is local, author-written, and
checkable, and because a sibling diagnostic already advises "give the lambda an explicit
specification". Lambdas do accept
modifiesin an inline spec block, and that declarationis verified against the body — declaring the wrong resource produces:
So the ingredient exists. The change was small: in
check_fun_arg_frames, when the calleedeclares nothing for a function-typed parameter, accept writes covered by the closure
target's own
modifies.The new repro passes with it, and a negative test confirms the caller still cannot claim
the resource is unchanged across the opaque call.
Why it is wrong anyway
regression/fun_arg_frame_check.movecatches two separate defects.1. The absence of a declaration can be load-bearing.
apply_puredeclares nomodifies_of, but its own postcondition assertsRis unchanged,and it is verified — the proof depends on
fvbeing pure. Letting a callback that declaresmodifies R[a]through makes that postcondition false. So "no declaration" does not mean"the callee is agnostic about this memory"; sometimes it is exactly what makes the callee's
own proof go through.
2. Author-declared and inferred
modifiesare indistinguishable.apply_pure(|x| { R[x].value = R[x].value + 1; }, a)This lambda has no spec block at all, yet
get_modify_targets()on the lifted functionreports
R, inferred from the body. A fallback keyed on the closure's declared modifiestherefore accepts every memory-touching closure, which is the wildcard by another route.
What survives
The callee has to opt in — something like
modifies_of<f> from_closure;, meaning "thisparameter's frame is whatever the caller's callback declares". That:
apply_purerejecting, because it does not opt in, so defect 1 cannot arise;need to distinguish declared from inferred;
It is a small language addition rather than a pure defaulting change, which is more than I
suggested in the earlier comment, but the defaulting change is not available.
One thing I did not fully isolate
The negative test showed the caller cannot claim the resource unchanged across the opaque
call, which suggests no change to
usage_analysisis needed to keep the havoc. But thatcaller also declared
modifieson the resource itself, so the havoc may have come from itsown declaration rather than from the callback's. Worth isolating before relying on it.
Tests
Two files, usable as-is:
functional/closures/infer_undeclared_frame.move— the minimal repro. Currently fails;it is what should go green.
functional/closures/lambda_declared_frame_havoc.move— the soundness test. A callerclaiming the resource is unchanged across the call must still fail. Any fix that makes
the first pass while making this one pass too has silenced the check without supplying
the effect.
[prover] Let a generic opaque callee leave its closure's frame to the…
[prover] Let a generic opaque callee leave its closure's frame to the…
[prover] Let a generic opaque callee leave its closure's frame to the…
[prover] Let a generic opaque callee leave its closure's frame to the…
[prover] Let a generic opaque callee leave its closure's frame to the…