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
- Which structs are declared
public,package, orfriend, and what does anything downstream assume about how their values were produced? - Does any
entryfunction take a struct or enum parameter? That value was built by the caller. - For each constructor performing a check, is there a consumer treating the resulting value as proof the check ran?
- If a type must be public, does it carry a seal, or are its invariants re-established at the point of use?
- For a public struct, what do the generated field accessors expose?
borrow_mut$grants mutable field access to every module.
Trade-offs
- Pro: a seal costs one zero-sized field and no runtime work, and the guarantee is enforced by the compiler at every call site.
- Con / cost: the sealed type cannot be constructed in another module’s tests or helpers either, so legitimate cross-module construction needs an explicit factory function.
- Trust note: the seal establishes which module built the value, not that the value is correct. A module can still issue an attestation it should not have.
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
- Move on Aptos Book, “Language Versions”, for struct and enum visibility in 2.4
aptos-core,third_party/move/move-bytecode-verifier/src/struct_api_checker.rs, for the generated accessors and the shape the verifier requires of themaptos-core,types/src/on_chain_config/aptos_features.rs, forPUBLIC_STRUCT_ENUM_ARGS