🐛 Bug
move-compiler-v2 accepts source programs comparing function values whose
ability sets differ (or where a closure's derived abilities exceed the
compared-at type), but the generated bytecode fails the compiler's own
post-generation bytecode verification with
EQUALITY_OP_TYPE_MISMATCH_ERROR, reported to the user as an internal
compiler bug (bug: diagnostic asking to file an issue).
This is a completeness bug, not a soundness hole: valid source is refused,
nothing invalid is accepted. The feature itself is documented as supported:
"Function values support equality and ordering"
(documentation/book/src/functions.md).
Reproduction 1: closure expression vs. narrower parameter
module 0xc0ffee::m {
struct DropStore has drop, store { x: u64 }
public fun with_capture(d: DropStore, y: u64): u64 {
let DropStore { x } = d;
x + y
}
public fun compare(d: DropStore, g: |u64|u64 has drop): bool {
(|y| with_capture(d, y)) == g
}
}
Output (fails at every optimization level):
Error: compilation errors:
bug: bytecode verification failed with unexpected status code `EQUALITY_OP_TYPE_MISMATCH_ERROR`:
Error message: none
┌─ ...
│
│ (|y| with_capture(d, y)) == g
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= please consider reporting this issue (see https://aptos.dev/en/build/smart-contracts/compiler_v2#reporting-an-issue)
The lambda's checked type unifies to |u64|u64 has drop (both == operands
have identical source types), but the emitted pack_closure result carries
the verifier-derived type: with_capture is public (persistent, so
copy + drop + store) intersected with the captured DropStore
(drop + store), giving |u64|u64 has drop + store. Generated bytecode:
move_loc l0
pack_closure with_capture, 1 // stack: |u64|u64 has drop + store (derived)
move_loc l1 // stack: ..., |u64|u64 has drop (declared)
eq // verifier: operand types not equal -> rejected
Reproduction 2: two parameters with different declared abilities
module 0xc0ffee::rigid {
public fun compare(f: |u64|u64 has copy + drop + store, g: |u64|u64 has drop): bool {
g == f
}
}
Fails identically at every optimization level (move_loc l1; move_loc l0; eq
pushes has drop vs has copy + drop + store).
Note the front-end asymmetry: the reversed form f == g is rejected at
source ("type |u64|u64 has drop is missing required abilities
copy + store"), because =='s operands unify left-to-right and the first
operand pins T. So acceptance of == currently depends on operand order,
and the accepted order then fails bytecode verification.
!= fails the same way in both reproductions (same verifier arm).
Workarounds exist but are unstable under optimization
All rows use the same two closure values; only how they reach == differs:
| form |
optimization |
result |
| direct closure expression (repro 1) |
any |
fails |
via let mine: |u64|u64 has drop = ... |
OPTIMIZE off |
passes |
via let binding |
OPTIMIZE on |
fails (binding elided; bytecode identical to direct form) |
via helper fun eq_helper(a: |u64|u64 has drop, b: |u64|u64 has drop) |
default / optimize / no-optimize |
passes |
| via helper |
opt-extra (INLINING_OPTIMIZATION) |
fails (helper inlined, boundary gone) |
The let form works only by accident: the st_loc/move_loc round-trip
through a local declared at the narrower type is the file format's implicit
narrowing point, and optimization removes it.
Root cause
Three layers are individually consistent; the gap is between them:
- Source typing intentionally supports ability widening for function
values (a value with more abilities usable where fewer are required), and
the binary format agrees: SignatureToken::is_assignable_from treats
function abilities as a subset relation
(move-binary-format/src/file_format.rs).
- The file format has no instruction that narrows a function value's
abilities. Narrowing happens only implicitly at the verifier's
assignability sinks (StLoc, call arguments, Ret, WriteRef, Pack,
VecPack). PackClosure's result type is derived by the verifier from
the function handle and captured values (clos_pack,
move-bytecode-verifier/src/type_safety.rs); the compiler cannot declare
it narrower.
Eq/Neq is the one remaining consumer requiring strict type
equality (type_safety.rs, Bytecode::Eq | Bytecode::Neq:
operand1 == operand2). This rule predates function values and was never
revisited for them. (The stack-usage rules force empty stacks at basic
block boundaries, so Eq/Neq is the only point where two independently
produced stack values meet an exact-equality rule.)
The analogous problem for references (&mut usable where & expected, Eq
requires equal types) is solved by the compiler emitting an automatic
FreezeRef (gen_op_call_auto_freeze,
move-compiler-v2/src/bytecode_generator.rs). There is no function-value
counterpart, because no coercion instruction exists to emit.
Runtime semantics are not the obstacle: closure equality compares function
identity, closure mask, and captured values
(move-vm/types/src/values/values_impl.rs); static ability sets play no
role. The helper-call form executes correctly end-to-end (verified with a
transactional //# run task), including under the paranoid runtime type
checker, whose StLoc rule uses assignability.
Test cases
Transactional test, suitable for
third_party/move/move-compiler-v2/transactional-tests/tests/no-v1-comparison/closures/
(needs a SEPARATE_BASELINE entry in tests/tests.rs because opt-extra
output differs):
//# publish
module 0xc0ffee::m {
struct DropStore has drop, store { x: u64 }
public fun with_capture(d: DropStore, y: u64): u64 {
let DropStore { x } = d;
x + y
}
public fun compare(d: DropStore, g: |u64|u64 has drop): bool {
(|y| with_capture(d, y)) == g
}
}
//# publish
module 0xc0ffee::via_call {
struct DropStore has drop, store { x: u64 }
public fun with_capture(d: DropStore, y: u64): u64 {
let DropStore { x } = d;
x + y
}
fun eq_helper(a: |u64|u64 has drop, b: |u64|u64 has drop): bool {
a == b
}
public fun compare(d: DropStore, g: |u64|u64 has drop): bool {
eq_helper(|y| with_capture(d, y), g)
}
fun main() {
let d1 = DropStore { x: 7 };
let d2 = DropStore { x: 7 };
let d3 = DropStore { x: 8 };
let d4 = DropStore { x: 7 };
assert!(compare(d1, |y| with_capture(d2, y)), 0);
assert!(!compare(d3, |y| with_capture(d4, y)), 1);
}
}
//# publish
module 0xc0ffee::rigid {
public fun compare(f: |u64|u64 has copy + drop + store, g: |u64|u64 has drop): bool {
g == f
}
}
//# run 0xc0ffee::via_call::main
Expected results per config:
baseline / optimize / no-optimize: task 0 (m) and task 2 (rigid)
fail publishing with the bug: ... EQUALITY_OP_TYPE_MISMATCH_ERROR
diagnostic; task 1 (via_call) publishes; task 3 runs successfully (both
asserts pass), demonstrating the comparison is semantically well-defined
at runtime.
opt-extra: task 1 also fails (inlining eliminates the eq_helper
boundary), and task 3 consequently fails with LINKER_ERROR.
Run with:
cargo test -p move-compiler-v2-transactional-tests closure_equality_widened
Existing test gap
The repository's only closure-equality tests
(no-v1-comparison/closures/closure_equality.move) compare operands whose
precise types are already identical (|u64|u64 has copy + store + drop on
both sides), so the widening case was never exercised.
Environment
Observed on main (on 2026-08-26), bytecode
version v10, all compiler-v2 optimization configurations.
🐛 Bug
move-compiler-v2accepts source programs comparing function values whoseability sets differ (or where a closure's derived abilities exceed the
compared-at type), but the generated bytecode fails the compiler's own
post-generation bytecode verification with
EQUALITY_OP_TYPE_MISMATCH_ERROR, reported to the user as an internalcompiler bug (
bug:diagnostic asking to file an issue).This is a completeness bug, not a soundness hole: valid source is refused,
nothing invalid is accepted. The feature itself is documented as supported:
"Function values support equality and ordering"
(
documentation/book/src/functions.md).Reproduction 1: closure expression vs. narrower parameter
Output (fails at every optimization level):
The lambda's checked type unifies to
|u64|u64 has drop(both==operandshave identical source types), but the emitted
pack_closureresult carriesthe verifier-derived type:
with_captureis public (persistent, socopy + drop + store) intersected with the capturedDropStore(
drop + store), giving|u64|u64 has drop + store. Generated bytecode:Reproduction 2: two parameters with different declared abilities
Fails identically at every optimization level (
move_loc l1; move_loc l0; eqpushes
has dropvshas copy + drop + store).Note the front-end asymmetry: the reversed form
f == gis rejected atsource ("type
|u64|u64 has dropis missing required abilitiescopy + store"), because=='s operands unify left-to-right and the firstoperand pins
T. So acceptance of==currently depends on operand order,and the accepted order then fails bytecode verification.
!=fails the same way in both reproductions (same verifier arm).Workarounds exist but are unstable under optimization
All rows use the same two closure values; only how they reach
==differs:let mine: |u64|u64 has drop = ...OPTIMIZEoffletbindingOPTIMIZEonfun eq_helper(a: |u64|u64 has drop, b: |u64|u64 has drop)opt-extra(INLINING_OPTIMIZATION)The
letform works only by accident: thest_loc/move_locround-tripthrough a local declared at the narrower type is the file format's implicit
narrowing point, and optimization removes it.
Root cause
Three layers are individually consistent; the gap is between them:
values (a value with more abilities usable where fewer are required), and
the binary format agrees:
SignatureToken::is_assignable_fromtreatsfunction abilities as a subset relation
(
move-binary-format/src/file_format.rs).abilities. Narrowing happens only implicitly at the verifier's
assignability sinks (
StLoc, call arguments,Ret,WriteRef,Pack,VecPack).PackClosure's result type is derived by the verifier fromthe function handle and captured values (
clos_pack,move-bytecode-verifier/src/type_safety.rs); the compiler cannot declareit narrower.
Eq/Neqis the one remaining consumer requiring strict typeequality (
type_safety.rs,Bytecode::Eq | Bytecode::Neq:operand1 == operand2). This rule predates function values and was neverrevisited for them. (The stack-usage rules force empty stacks at basic
block boundaries, so
Eq/Neqis the only point where two independentlyproduced stack values meet an exact-equality rule.)
The analogous problem for references (
&mutusable where&expected,Eqrequires equal types) is solved by the compiler emitting an automatic
FreezeRef(gen_op_call_auto_freeze,move-compiler-v2/src/bytecode_generator.rs). There is no function-valuecounterpart, because no coercion instruction exists to emit.
Runtime semantics are not the obstacle: closure equality compares function
identity, closure mask, and captured values
(
move-vm/types/src/values/values_impl.rs); static ability sets play norole. The helper-call form executes correctly end-to-end (verified with a
transactional
//# runtask), including under the paranoid runtime typechecker, whose
StLocrule uses assignability.Test cases
Transactional test, suitable for
third_party/move/move-compiler-v2/transactional-tests/tests/no-v1-comparison/closures/(needs a
SEPARATE_BASELINEentry intests/tests.rsbecauseopt-extraoutput differs):
Expected results per config:
baseline/optimize/no-optimize: task 0 (m) and task 2 (rigid)fail publishing with the
bug: ... EQUALITY_OP_TYPE_MISMATCH_ERRORdiagnostic; task 1 (
via_call) publishes; task 3 runs successfully (bothasserts pass), demonstrating the comparison is semantically well-defined
at runtime.
opt-extra: task 1 also fails (inlining eliminates theeq_helperboundary), and task 3 consequently fails with
LINKER_ERROR.Run with:
cargo test -p move-compiler-v2-transactional-tests closure_equality_widenedExisting test gap
The repository's only closure-equality tests
(
no-v1-comparison/closures/closure_equality.move) compare operands whoseprecise types are already identical (
|u64|u64 has copy + store + droponboth sides), so the widening case was never exercised.
Environment
Observed on
main(on 2026-08-26), bytecodeversion v10, all compiler-v2 optimization configurations.