Digital Asset System Overview
Module-owned token values and verified calls let you build assets whose shared-boundary behavior is controlled by the defining module.
The point is not only "tokens as data." Sector9 gives asset modules a language
boundary: outside code can hold and transfer a value, but only the owner module
can inspect or mutate its payload. When that value crosses canisters through an
sr9 shared boundary,
proof-enabled builds also carry caller proof material, so verified actors can
compose with other verified actors instead of treating every remote asset
operation as opaque trust.
Concepts
Sector9 combines two ideas:
-
Module-owned token values
- Assets are
tokentypes defined inside a module. - Only the defining module can read or mutate the payload.
- External code can only pass values around or call the module API.
- Each module may define at most one non-polymorphic token type, with a matching
<TokenName>Sharedmodule for shared-boundary conversion.
- Assets are
-
Verified cross-canister calls
- Any shared method that accepts or returns shareable token values must be marked
sr9. - In proof-enabled IC/Ref builds,
sr9methods verify caller proofs and reject non-canister or invalid callers before token conversion. - In non-proof modes, token boundary rewrites still apply, but proof verification is not injected.
- Any shared method that accepts or returns shareable token values must be marked
Together, these rules prevent ordinary counterfeiting and double-spend paths under the verified boundary discipline, while keeping token logic customizable. For local accounting patterns such as mint, burn, and transfer, pair this page with Token Systems.
Token vs Opaque
tokenis for shareable assets that cross canister boundaries through verified calls. The compiler inserts wire wrappers and module-hash tags at shared boundaries.opaqueis verification-only. It enforces module ownership in verification, but it does not introduce wire wrappers and is not allowed in shared signatures.
Inside verified module code, both forms are handled as sealed identities
outside the owner module. Non-owner modules may store, return, and forward
handles, including through records, options, tuples, variants, arrays, BMap,
MBMap, BSeq, and proof collections. They may use a handle only by calling
an owner API whose handle-capability summary is known; they still cannot
inspect, forge, pattern-match, or mutate the owner payload directly.
Actor state may hold live token/opaque handles when those capability rules are
satisfied. Upgrade-persistent state should use the same source-level handle
types, including inside supported containers. Stable upgrade is not
shared-wrapper serialization and must not call toShared/fromShared.
For token, stable compatibility includes the defining token module's CHASH;
same-shaped tokens from a different token module identity are rejected unless
the program performs an explicit migration. For local opaque, stable
compatibility follows the ordinary Motoko raw stable type shape; if the owner
changes the raw payload shape, migrate explicitly through an owner API such as
Mod.upgrade(old).
Opaque payloads may declare anonymous domain invariants inside
opaque { ... }. The verifier folds those facts into the owner module's
carrier predicate: constructors must establish them, mutators must preserve
them, and non-owner modules may rely only on public model/observer facts the
owner exposes. Parent opaque invariants can mention child public models such
as Wallet.model(wallet), not child private fields.
There is no generated public valid(handle) observer. Generic validity is
implicit in possession of the folded handle carrier and is preserved by public
owner APIs through their contracts and lowering. Parent modules should mention
only domain-specific public facts when they need them, such as
Pool.solvent(pool) or Pool.totalLiquidity(pool).
Owner mutators are transaction-shaped. Inside the owner module, a mutator may
temporarily break an opaque invariant while updating multiple payload fields,
but the verifier marks the owner dirty and closes the carrier before return,
await, call boundaries that receive or write the dirty owner, and explicit
owner close points. A failed close means the method crossed a stable boundary
before repairing the invariant; see
test/viper/op4/opaque_owner_two_field_transaction_sat.mo and
test/viper/op4/opaque_owner_dirty_call_boundary_unsat.mo.
This matters for multi-actor protocols. If one actor upgrades to a changed token module while the others still run the old module, its stable tokens must not silently become the new identity. The rest of the actors should reject that new identity until they also upgrade or explicitly opt in.
Collection models over handles expose identity only. A Map<K,H>, Seq<H>,
BMap<K,H>, or BSeq<H> fact can say which handles are present, but it does
not prove that the owner's model(h) stayed unchanged after a possible owner
mutation. Exact BMap.entries / MBMap.entries and BSeq conversion
snapshots are supported enumeration routes, but they preserve handle identity
only. When an invariant needs aggregate payload facts, keep an immutable summary
such as Map<K,Nat> or Map<K,Model> and update it through the owner module's
public API. Do not treat a collection of handles as a collection of payload
snapshots unless a future capability-aware summary API explicitly provides that
authority.
Verified Behavior Matrix
The opaque/token handle rules above are pinned by focused regression fixtures:
| Behavior | Regression fixtures |
|---|---|
Three-plus local modules can pass opaque handles through owner APIs while storing them in records, variants, arrays, BMap, MBMap, and BSeq.XSeq. | test/viper/opaque_apps/dex_sat.mo, test/viper/opaque_apps/fulfillment_sat.mo, test/viper/opaque_apps/marketplace_sat.mo |
Map values may be opaque handles, including through imported/aliased core BMap provenance. | test/viper/mutmap/step11_bmap_foreign_handle_value_sat.mo, test/viper/mutmap/step12_bmap_value_helpers_foreign_handle_sat.mo, test/viper/mutmap/step63_core_bmap_alias_descriptor_sat.mo |
| Exact collection snapshots prove enumeration structure and handle identity, not owner payload stability. | test/viper/op7/bmap_entries_exact_snapshot_sat.mo, test/viper/op7/bmap_entries_handle_identity_not_payload_unsat.mo, test/viper/op7/bseq_toarray_handle_identity_not_payload_unsat.mo, test/viper/op7/mbmap_entries_snapshot_epoch_unsat.mo |
| Records, options, tuples, variants, arrays, and mixed token/opaque structures can carry handles without exposing owner payloads. | test/viper/mutmap/step49_composed_bmap_option_payload_handle_sat.mo, test/viper/mutmap/step60_composed_payload_bmap_value_handle_sat.mo, test/viper/modown/token_opaque_mixed_maps_sat.mo |
BSeq.XSeq<T> is the supported app-facing sequence carrier for handle values; direct iterator-state actor storage remains outside the supported actor-state surface. | test/viper/mutmap/step79_bseq_first_alias_descriptor_sat.mo, test/viper/mutmap/step80_iter_next_value_handle_descriptor_sat.mo, test/viper/mutmap/step82_bseq_iter_next_value_handle_descriptor_sat.mo, test/viper/mutmap/step65_bseq_iterstate_actor_boundary_reject.mo |
Descriptor-backed collection APIs fail closed when a handle-carrying operation is not supported for that container. For example, direct BMap.toArray over handle values remains rejected; use BMap.entries / MBMap.entries or BSeq snapshots when a supported exact identity snapshot is needed. | test/viper/mutmap/step94_bmap_toarray_handle_descriptor_reject.mo |
Actor-state reachable handles recovered from supported containers cannot stay live across await. | test/viper/mutmap/step48_actor_bseq_value_handle_await_reject.mo, test/viper/mutmap/step64_actor_bseq_toarray_handle_await_reject.mo, test/viper/mutmap/step93_bmap_swap_actor_value_await_reject.mo |
| Opaque invariants are folded owner carriers and compose through public model/observer facts, including actor and remote-boundary cases. | test/viper/mutmap/step87_opaque_invariant_fact_sat.mo, test/viper/mutmap/step89_opaque_parent_child_invariant_sat.mo, test/viper/mutmap/step90_imported_opaque_invariant_surface_sat.mo, test/viper/mutmap/step91_actor_opaque_invariant_summary_sat.mo, test/viper/mutmap/step92_remote_opaque_invariant_not_exported_unsat.mo |
| Opaque owner mutators may batch field updates and are rechecked at return, await, and escaping call boundaries. | test/viper/op4/opaque_owner_two_field_transaction_sat.mo, test/viper/op4/opaque_owner_dirty_exit_unsat.mo, test/viper/op4/opaque_owner_dirty_await_boundary_unsat.mo, test/viper/op4/opaque_owner_dirty_call_boundary_unsat.mo |
| Stable token persistence is CHASH-gated by the token owner, while local opaque stable persistence follows raw Motoko stable shape compatibility. | test/viper/modown/token_stable_storage_sat.mo, test/viper/modown/token_stable_container_storage_sat.mo, test/viper/mutmap/step77_opaque_stable_storage_sat.mo, test/viper/mutmap/step78_imported_opaque_stable_storage_sat.mo |
Proof-Enabled Call Flow (High Level)
- The verifier or deployer gate produces proof material for the canister build.
- Proof-enabled IC/Ref builds install proof material as hidden init args or through the generated controller-only setters.
- Verified canisters store the proof and attach it to outbound
sr9calls. sr9methods reject non-canister callers and invalid proofs before decoding token arguments.- Proof payloads bind the caller principal and module hash. Receivers can authenticate the caller principal and read the caller module hash exposed by the runtime hooks. There is not a separate shipped provenance key beyond the current proof and CHASH wiring.
This is the interoperability mechanism that ordinary Motoko does not provide.
A typed actor reference tells you the remote call has the right shape; an
sr9 proof-enabled boundary adds a check that the caller participates in the
Sector9 proof flow. The receiving canister can then connect caller principal,
module identity, and token-wire identity to its own contracts. It still has to
state those contracts explicitly; proof material does not replace protocol
specification.
Module Hash and Token Identity
Each module has a typed-AST hash exposed by Runtime.moduleHash(). It is stable
for the compiled module body and changes when the module's typed definition
changes. A token module typically includes it in asset identity to prevent
cross-module spoofing. The core fungible token library also records the minting
module hash, so mint authority and token-definition identity are separate proof
facts.
Example identity for a fungible asset:
import Runtime "mo:core/Runtime";
public type Id = (Blob, Text, Principal);
public pure func id(symbol : Text, minter : Principal) : Id {
(Runtime.moduleHash(), symbol, minter)
};
For the production-shaped version, see core token/Fungible. It exposes helpers such as idOf, balance, transfer, burnAll, and a verified TokenShared conversion module.
Summary
tokentypes are module-owned values that only the defining module can inspect or mutate.opaquetypes give the same owner-only inspection rule for verification-only handles that do not cross shared boundaries.- Non-owner modules may route handles through capability-summarized module APIs and supported carriers/collections without gaining payload access.
- Opaque domain invariants live in folded owner carriers and compose through public model/observer facts.
sr9shared methods enforce verified cross-canister calls for token-bearing signatures in proof-enabled builds.Runtime.moduleHash()lets you bind asset identity to the module that defines it.- Shared-boundary conversion is defined by the token module's
<TokenName>Sharedmodule, not by callers. - Use shared-boundary rules before exposing token values over canister calls.