Skip to content

[Bug] [compiler-v2] ==/!= on function values with widened abilities emits bytecode the verifier rejects (EQUALITY_OP_TYPE_MISMATCH_ERROR) #20457

Description

@vineethk

🐛 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:

  1. 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).
  2. 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.
  3. 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.


Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions