Skip to the content.

Provenance of a Constructed Value

Category: Values & References · Covered by: Yours, since Move 2.4 · Requires: Aptos Move 2.4 for struct visibility; bytecode version 10

What this prevents

A module performs checks in a constructor and then treats the resulting value as evidence that those checks ran. Another module builds the same value directly, skipping the constructor, and the consumer cannot tell the difference.

The checks are still present and still correct. They simply never executed for the value that arrived.

Coverage

Nothing covers this, and what changed is worth being precise about, because the assumption it invalidates was true for most of Move’s life.

Until Move 2.4, only the defining module could construct, destructure, or access the fields of its own types. That rule made “holding a value of this type” a genuine proof of provenance, and a great deal of Move code leans on it, usually without saying so.

Move 2.4 added public, package and friend visibility on structs and enums. The bytecode-level rule did not change: it is still the case that only the defining module packs its own types. What changed is that the defining module can now export that capability, through compiler-generated accessors the verifier pins to exact semantics. Declaring a struct public publishes pack$S, unpack$S and field accessors as part of the module’s public interface.

Separately, the PUBLIC_STRUCT_ENUM_ARGS feature allows public struct and enum types as transaction arguments, so an entry function can receive a struct built by the caller off chain. In that case no part of the value was produced on chain at all.

Both are enabled on Aptos mainnet.

What is left to you

Stop treating a value’s existence as evidence of how it was made.

The question to ask of every type is whether anything downstream depends on the constructor having run. If a value carries an amount that was bounded, an address that was authorised, a level that was capped, or a claim that was verified, then its provenance is load-bearing and needs to be established rather than assumed.

The seal is the cheapest fix. A public struct containing a field of a private type cannot be built from outside, because the outsider cannot build the field. The type stays public, other modules can still read it and hold it, and only the defining module can produce one.

Where a value must be genuinely public and constructible, the alternative is to stop trusting it: re-run the checks at the point of consumption, or record issuance in module state and look it up.

Sample and test

See src/sources/Provenance.move, exercised by src/tests/Provenance_test.move. The sample carries a second module standing in for anything else on chain, which builds an attestation without going through the constructor.

Six cases: the constructor’s checks working, the outsider bypassing them, and the sealed type still issuing normally under the same checks.

One case deliberately passes. An outsider builds an attestation with a level of 99 against a bound of 3, and a consumer reading it sees a well-formed value of the right type. That is the failure shown working.

The defence is a compile-time failure and so cannot be asserted from a test. Adding the equivalent forgery against the sealed type fails to build, with:

error: Invalid operation: pack on `provenance::Seal` can only be done
       within module `0xcafe::provenance`

Key excerpt:

/// Public, so any module may construct one.
public struct Attestation has copy, drop, store { subject: address, level: u8 }

/// Private. Nothing outside this module can build one.
struct Seal has drop, store {}

/// Public, and still unforgeable: constructing one requires a Seal.
public struct SealedAttestation has drop, store {
    subject: address,
    level: u8,
    seal: Seal,
}

Review questions

Trade-offs

Historical note

Struct visibility arrived in Move 2.4, and struct-typed transaction arguments arrived with the PUBLIC_STRUCT_ENUM_ARGS feature. Both are recent, and most Move written before them relies on the older rule without stating it. That makes this a good candidate for review on existing code rather than only on new code: a module that was correct when written can be wrong now if its types were later made public.

References