Skip to main content

Assets

1. Definition of an Asset

In the MOI ecosystem, an Asset is a first-class protocol object: a fundamental value type understood and managed natively by the network, similar to how a programming language natively understands types like integers or strings. Unlike legacy blockchains where most tokens are implemented as smart-contract-managed state, an Asset in MOI is a distinct digital object that the protocol recognizes, secures, and indexes.

1.1 Participant-Level Storage: The Asset Tree

In MOI, the key distinction is where ownership state lives and who enforces it. MOI stores ownership directly inside the participant’s account state using a dedicated Sparse Merkle tree.

Specifically, each participant account maintains a Sparse Merkle tree called the Asset Tree, and this tree stores the participant’s Token Objects as leaf nodes. Because ownership is represented in participant-local state, the protocol recognizes and updates it as a first-class operation.

On Ethereum, ETH is protocol-native in the sense that the protocol directly tracks balances and applies transfers as part of the base state transition rules. Most other “assets” on Ethereum (ERC-20s and NFTs) are not native in this sense because the protocol does not understand them as assets; they are represented as smart-contract-managed state.

In practice:

  • An ERC-20 “balance” is a value stored in contract storage (typically balances[address]).
  • An NFT’s ownership is represented through contract mappings (such as ownerOf[tokenId]).

The protocol is therefore not transferring an asset object; it is executing contract code that implements a token interface, while wallets and indexers interpret the resulting contract state as “asset ownership.”

1.2 Asset State Management in Ethereum vs MOI

Because Ethereum token ownership lives in contract state while MOI token ownership lives in participant state, the difference becomes most visible in the transfer write-path.

Ethereum: Contract-Centric Writes (Shared State Hotspot)

Most Ethereum tokens are contract-centric. If Alice owns 1,000 USDC, that ownership record is stored in the USDC contract’s storage, typically as a mapping like:

  • balances[address] -> amount

This mapping is backed by the contract’s storage tree, and a transfer mutates entrees inside that same contract state (e.g., decrement Alice’s entry, increment Bob’s entry), which updates the contract’s storage root.

For heavily used tokens, many independent users are writing into the same contract state domain, increasing contention and contributing to congestion and higher fees when blockspace is scarce.

MOI: Participant-Centric Writes (Independent Roots)

MOI assets are participant-centric. Each user has a Participant Account (their on-chain state container) that includes an Asset Tree (a dedicated Sparse Merkle tree for assets).

A user’s balance is represented as a Token Object stored as a leaf in their Asset Tree.

When Alice transfers an asset to Bob, the protocol:

  1. Uses the global asset rules for validation.
  2. Writes only to participant-local state by updating:
    • Alice’s Token Object (in Alice’s Asset Tree)
    • Bob’s Token Object (in Bob’s Asset Tree)

This updates Alice’s Asset root and Bob’s Asset root only.

Because these roots are independent across participants, transfers that touch disjoint participant accounts can execute concurrently, removing the per-token shared contract-storage hotspot from the write path.

Ethereum (Centralized Balance)MOI (Participant-Centric)
Ethereum Centralized BalanceMOI Participant-Centric

2. The Asset Object: Existence & Definition

MOI decouples the definition of value from the possession of value. In legacy blockchain architectures, an asset and its user balances are tightly coupled inside a single smart contract. MOI separates these into two distinct cryptographic primitives:

  • Asset Object (Class): the global definition (blueprint)
  • Token Object (Instance): the per-user instantiation (owned value)

The Asset Object acts as the asset’s global blueprint, stored in a dedicated Asset Account, defining core parameters like symbol, decimals, max supply, roles, and logic binding. During standard transfers, this global object is typically read-only: the protocol references it for validation but avoids mutating it, so transfers don’t queue on a single shared state. The Asset Account is written during administrative or supply-changing actions such as mint/burn (e.g., circulating supply changes) and manager-authorized metadata updates.

The Token Object represents the actual instantiation of value. These objects exist locally within the participant's own account, inside the Asset Tree. When a transfer occurs, the state change is isolated to the sender and receiver's Asset Trees, leaving the global Asset Object untouched.

Asset = Class. Token = Instance. This separation lets the protocol support high-velocity assets across millions of users without the contention issues common in contract-centric chains. See Participant-Centric Scalability for more on this architecture.

2.1 Anatomy of an Asset Object

The Asset Object serves as the (mostly) immutable blueprint for a digital asset. Encapsulated within the AssetDescriptor structure, it defines the identity, governance, and capabilities of the asset class. This object resides in the global Asset Account.

2.1.1 Core Identity

Every asset is defined by foundational attributes that establish its existence on the network:

  • Asset ID (AssetID): A unique, protocol-generated identifier. It serves as the primary key for all interactions.
  • Symbol (Symbol): The human-readable ticker (e.g., "USDC", "MOI") used for display and identification.
  • Decimals (Decimals): Defines the asset’s divisibility (precision).
    • Standard: 18 decimals (for fungible currency)
    • Atomic: 0 decimals (for indivisible NFTs or discrete items)

2.1.2 Governance Roles

MOI embeds permissioned management directly into the asset structure, separating creation from ongoing administration:

  • Creator (Creator): The address that originally minted the asset. This acts as on-chain provenance or the asset’s “origin story.”
  • Manager (Manager): The active address authorized to perform administrative actions, such as:
    • Updating dynamic metadata
    • Modulating supply (if minting/burning is enabled)
    • Managing logic associations

2.1.3 Supply Dynamics

The protocol enforces supply constraints at the object level to ensure scarcity and auditability:

  • Max Supply (MaxSupply): The immutable hard cap on the total number of tokens that can ever exist.
  • Circulating Supply (CirculatingSupply): A real-time counter of tokens currently active in participant accounts.

2.1.4 The Metadata Layer

To support rich applications without bloating the state, MOI splits metadata into two tiers:

  • Static Metadata: Immutable data defined at genesis. Used for permanent records that must never change (e.g., genesis date, original artwork hash, legal jurisdiction).
  • Dynamic Metadata: Mutable data that the Manager can update. Used for evolving asset states (e.g., token URIs, game levels, project status).

2.1.5 Programmability (Logic Binding)

The Logic ID (LogicID) bridges the static asset definition and dynamic behavior:

  • It links the asset to a specific Logic Object (smart contract). See Asset Logic for details.
  • When a user attempts to move this asset, the protocol loads the referenced Logic ID to validate the transaction against custom rules (e.g., whitelists, tax logic, transfer restrictions).

2.1.6 Configuration

The asset object includes configuration flags to optimize performance and utility:

  • Enable Events (EnableEvents): A boolean toggle controlling whether operations on this asset (transfers, mints) emit event logs. Disabling this for high-frequency assets can reduce storage overhead.

3. Participant-Centric Scalability

The shift from a contract-centric model to a participant-centric model is the primary driver of MOI’s scalability. The key is how state is organized and written.

Contract-Centric Bottlenecks (Ethereum)

In a contract-centric system like Ethereum, a token is a single smart contract that acts as a central ledger. If 10,000 users want to transfer USDC at the same time, they are all trying to write into the same “USDC contract” state (e.g., the balances[address] mapping) and update the same contract storage root.

Conceptually, they’re standing in one line. This creates a shared global bottleneck where a surge in activity for one popular asset can clog blockspace and raise fees for everyone.

Participant-Centric Parallelism (MOI)

MOI removes this shared hotspot by treating each participant as an independent state machine.

Because User A’s assets are stored in User A’s AssetTree, and User B’s assets are stored in User B’s AssetTree, their ownership state is cryptographically independent. A transfer between Alice and Bob updates only Alice’s and Bob’s participant-local state, and does not conflict with a transfer between Charlie and Dave, even if both transfers involve the exact same asset.

Why This Scales

This structure enables parallel execution instead of forced sequential writes into a shared contract. Network throughput can scale with the number of participants (and available execution resources) rather than being constrained by the write-speed of a single central contract state.

For developers building high-volume apps (DEXs, payments, games), this means your app's performance is less likely to be dragged down by congestion created by unrelated activity elsewhere on the network. To build on this architecture, see the MOI Asset Standards or create Custom Assets.

Ethereum vs MOI Comparison

4. The MOI Asset Standards (MAS)

4.1 The Protocol Layer Definition

In legacy blockchain ecosystems (like Ethereum), a “token” is not a native concept. It is a social agreement to follow a specific list of functions in a smart contract (for example, ERC-20). This pushes value transfer implementation onto developers, which increases gas costs and can introduce security issues (for example, re-entrancy patterns and approval pitfalls).

MOI inverts this model by defining Asset Standards natively at the protocol layer. When you create an asset on MOI, you are not writing code to define “transfer”. You are instructing the protocol to instantiate a standard asset object that already has canonical, protocol-managed transfer behavior.

Classification: Recognized vs. Unrecognized

The protocol distinguishes between assets based on whether they adhere to strict native behaviors:

  • Recognized Standards (MAS0, MAS1, MAS2, ...):

    • First-class protocol standards
    • Benefit from native indexing, standardized wallet displays, and optimized execution paths (including parallel-friendly state updates)
  • Unrecognized Standards (MASX):

    • Custom or experimental assets (defined via MAS = math.MaxUint16)
    • Use the Asset Engine's storage model, but define custom logic the protocol does not natively index or optimize in the same way

4.2 MAS0: Fungible Assets

4.2.1 Definition

MAS0 (MOI Asset Standard 0) is MOI’s native standard for fungible assets, where every unit is identical and interchangeable. MAS0 is designed for currency-like assets and high-frequency transfers, with protocol-managed accounting and standardized behavior.

4.2.2 Supply Dynamics

MAS0 supports a single global supply pool managed by the asset Manager:

  • Minting: Increases a specific user’s balance and increments CirculatingSupply
  • Burning: Decreases a user’s balance and decrements CirculatingSupply

4.2.3 The Allowance System (Mandates)

MAS0 includes a native Approve / TransferFrom mechanism, with improvements over legacy patterns:

  • Expiry: Approvals are time-bound (expires_at). Approvals do not remain open indefinitely by default.
  • Granularity: Approvals are stored as Mandates in the user’s Asset Tree, separate from the core balance.
  • Direct vs. Delegated Transfers:
    • Transfer supports direct transfers (caller → beneficiary)
    • TransferFrom supports delegated transfers (benefactor → beneficiary), requiring a valid, unexpired mandate issued via Approve
  • Lockups and Releases:
    • Lockup freezes a specified amount (still owned, but non-transferable until released)
    • Release unlocks previously locked tokens back into spendable balance under the authorization rules

4.2.4 Real-World Use Cases

  • Stablecoins: fiat-pegged currencies (for example, USDC, USDT)
  • Governance Tokens: voting shares (1 token = 1 vote)
  • Loyalty Points: earn and redeem systems (for example, airline miles)

SDK Reference: MAS0AssetLogic

MAS0 Reference Implementation (Coco)
event AssetEvent:
topic operation String
field operator Identifier
field benefactor Identifier
field beneficiary Identifier
field amount U256
field expires_at U64

endpoint Transfer(beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Transfer", operator: Sender, benefactor: Sender,
beneficiary: beneficiary, amount: amount}
asset.Transfer(token_id: 0, beneficiary, amount)

endpoint TransferFrom(benefactor, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "TransferFrom", operator: Sender, benefactor: benefactor,
beneficiary: beneficiary, amount: amount}
asset.TransferFrom(token_id: 0, benefactor, beneficiary, amount)

endpoint dynamic Mint(beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Mint", operator: Sender, beneficiary: beneficiary,
amount: amount}
asset.Mint(token_id: 0, beneficiary, amount)

endpoint dynamic MintWithMetadata(beneficiary Identifier, amount U256, static_metadata Map[String]Bytes):
if asset.EnableEvents():
emit AssetEvent{operation: "Mint", operator: Sender, beneficiary: beneficiary,
amount: amount}
asset.MintWithMetadata(token_id: 0, beneficiary, amount, static_metadata)

endpoint dynamic Burn(amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Burn", operator: Sender, benefactor: Sender,
amount: amount}
asset.Burn(token_id: 0, amount)

endpoint Lockup(beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Lockup", operator: Sender, benefactor: Sender,
beneficiary: beneficiary, amount: amount}
asset.Lockup(token_id: 0, beneficiary, amount)

endpoint Release(benefactor, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Release", operator: Sender, benefactor: benefactor,
beneficiary: beneficiary, amount: amount}
asset.Release(token_id: 0, benefactor, beneficiary, amount)

endpoint Approve(beneficiary Identifier, amount U256, expires_at U64):
if asset.EnableEvents():
emit AssetEvent{operation: "Approve", operator: Sender, benefactor: Sender,
beneficiary: beneficiary, amount: amount, expires_at: expires_at}
asset.Approve(token_id: 0, beneficiary, amount, expires_at)

endpoint Revoke(beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Revoke", operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Revoke(token_id: 0, beneficiary)

// Getters

endpoint Symbol() -> (symbol String):
symbol = asset.Symbol()

endpoint BalanceOf(address Identifier) -> (balance U256):
balance = asset.BalanceOf(token_id: 0, address)

endpoint Creator() -> (creator Identifier):
creator = asset.Creator()

endpoint Manager() -> (manager Identifier):
manager = asset.Manager()

endpoint Decimals() -> (decimals U64):
decimals = asset.Decimals()

endpoint MaxSupply() -> (max_supply U256):
max_supply = asset.MaxSupply()

endpoint CirculatingSupply() -> (circulating_supply U256):
circulating_supply = asset.CirculatingSupply()

endpoint dynamic SetStaticMetadata(key String, value Bytes):
asset.SetStaticMetadata(key, value)

endpoint dynamic SetDynamicMetadata(key String, value Bytes):
asset.SetDynamicMetadata(key, value)

endpoint GetStaticMetadata(key String) -> (value Bytes):
value = asset.GetStaticMetadata(key)

endpoint GetDynamicMetadata(key String) -> (value Bytes):
value = asset.GetDynamicMetadata(key)

4.3 MAS1: Non-Fungible Assets

4.3.1 Definition

MAS1 (MOI Asset Standard 1) is the standard for non-fungible assets (NFTs), where each unit is unique, distinct, and indivisible.

  • Identity: One ID = one item
  • Indivisibility: The protocol enforces amount == 1 (no fractional transfers)

4.3.2 Metadata & Logic

Unlike MAS0 (where metadata is primarily class-level), MAS1 introduces per-token metadata:

  • Token Logic: Maintains a state variable (token_count) to auto-increment and assign unique IDs on mint
  • Granular Data: Each TokenID can have:
    • StaticTokenMetadata: immutable properties (e.g., artwork hash)
    • DynamicTokenMetadata: mutable properties (e.g., game stats)

4.3.3 Real-World Use Cases

  • Digital collectibles (1-of-1 art, PFPs)
  • Identity and credentials (diplomas, licenses, memberships)
  • Real estate and deeds (unique property representation)

SDK Reference: MAS1AssetLogic

MAS1 Reference Implementation (Coco)
const TOKEN_COUNT String = "__token_count__"

event AssetEvent:
topic operation String
field token_id U64
field operator Identifier
field benefactor Identifier
field beneficiary Identifier
field expires_at U64

function get_token_count() -> (token_count U64):
memory value = asset.GetDynamicMetadata(key: TOKEN_COUNT)
token_count = depolorize(U64, value)

function dynamic set_token_count(token_count U64):
asset.SetDynamicMetadata(key: TOKEN_COUNT, polorize(token_count))

endpoint deploy Init():
set_token_count(token_count: 0)

endpoint Transfer(token_id U64, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Transfer", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Transfer(token_id, beneficiary, amount: U256(1))

endpoint TransferFrom(token_id U64, benefactor, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "TransferFrom", token_id: token_id, operator: Sender, benefactor: benefactor,
beneficiary: beneficiary}
asset.TransferFrom(token_id, benefactor, beneficiary, amount: U256(1))

endpoint dynamic Mint(beneficiary Identifier) -> (token_id U64):
memory new_token_id = (token_count) <- get_token_count()
if asset.EnableEvents():
emit AssetEvent{operation: "Mint", token_id: new_token_id, operator: Sender, beneficiary: beneficiary}
asset.Mint(token_id: new_token_id, beneficiary, amount: U256(1))
set_token_count(token_count: new_token_id+1)
token_id = new_token_id

endpoint dynamic MintWithMetadata(beneficiary Identifier, static_metadata Map[String]Bytes) -> (token_id U64):
memory new_token_id = (token_count) <- get_token_count()
if asset.EnableEvents():
emit AssetEvent{operation: "MintWithMetadata", token_id: new_token_id, operator: Sender, beneficiary: beneficiary}
asset.MintWithMetadata(token_id: new_token_id, beneficiary, amount: U256(1), static_metadata)
set_token_count(token_count: new_token_id+1)
token_id = new_token_id

endpoint dynamic Burn(token_id U64):
if asset.EnableEvents():
emit AssetEvent{operation: "Burn", token_id: token_id, operator: Sender, benefactor: Sender}
asset.Burn(token_id, amount: U256(1))

endpoint Lockup(token_id U64, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Lockup", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Lockup(token_id, beneficiary, amount: U256(1))

endpoint Release(token_id U64, benefactor, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Release", token_id: token_id, operator: Sender, benefactor: benefactor,
beneficiary: beneficiary}
asset.Release(token_id, benefactor, beneficiary, amount: U256(1))

endpoint Approve(token_id U64, beneficiary Identifier, expires_at U64):
if asset.EnableEvents():
emit AssetEvent{operation: "Approve", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary, expires_at: expires_at}
asset.Approve(token_id, beneficiary, amount: U256(1), expires_at)

endpoint Revoke(token_id U64, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Revoke", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Revoke(token_id, beneficiary)

endpoint Symbol() -> (symbol String):
symbol = asset.Symbol()

endpoint IsOwner(token_id U64, address Identifier) -> (is_owner Bool):
is_owner = asset.BalanceOf(token_id, address) > 0

endpoint Creator() -> (creator Identifier):
creator = asset.Creator()

endpoint Manager() -> (manager Identifier):
manager = asset.Manager()

endpoint dynamic SetStaticMetadata(key String, value Bytes):
asset.SetStaticMetadata(key, value)

endpoint dynamic SetDynamicMetadata(key String, value Bytes):
if key == TOKEN_COUNT:
throw f"{TOKEN_COUNT} is a reserved key"
asset.SetDynamicMetadata(key, value)

endpoint GetStaticMetadata(key String) -> (value Bytes):
value = asset.GetStaticMetadata(key)

endpoint GetDynamicMetadata(key String) -> (value Bytes):
value = asset.GetDynamicMetadata(key)

endpoint dynamic SetStaticTokenMetadata(token_id U64, key String, value Bytes):
if asset.BalanceOf(token_id, Sender) == 0:
throw "only token owner can set token metadata"
asset.SetStaticTokenMetadata(token_id, key, value)

endpoint dynamic SetDynamicTokenMetadata(token_id U64, key String, value Bytes):
if asset.BalanceOf(token_id, Sender) == 0:
throw "only token owner can set token metadata"
asset.SetDynamicTokenMetadata(token_id, key, value)

endpoint GetStaticTokenMetadata(token_id U64, key String) -> (value Bytes):
value = asset.GetStaticTokenMetadata(token_id, key)

endpoint GetDynamicTokenMetadata(token_id U64, key String) -> (value Bytes):
value = asset.GetDynamicTokenMetadata(token_id, key)

4.4 MAS2: The Multi-Token Standard

4.4.1 Definition

MAS2 (MOI Asset Standard 2) is a hybrid standard that supports multiple token categories within a single asset class, combining MAS0-like quantities with MAS1-like variety.

  • Category Paradigm: Each TokenID represents a type/category (e.g., “Gold Coin”, “Arrow”), not a unique one-of-one item.
  • Quantity Management: Users can hold multiple units of a given TokenID (unlike MAS1).

4.4.2 Supply Dynamics

MAS2 supports dynamic creation of new categories within a single asset:

  • Minting: Mint creates a new category (new TokenID) and issues an initial supply to a beneficiary.
  • Batching: Useful when you need thousands of identical items that are distinct from other batches (e.g., “Batch #101” vs. “Batch #102”).

4.4.3 Real-World Use Cases

  • Gaming economies: Manage Gold (ID 1), Wood (ID 2), and Gems (ID 3) under one asset class.
  • Event ticketing: VIP Tickets (ID 1, Supply 50) and General Admission (ID 2, Supply 1000).
  • Supply chain: Track production batches where items within a batch are identical, but batches are distinct.

SDK Reference: MAS2AssetLogic

MAS2 Reference Implementation (Coco)
const TOKEN_COUNT String = "__token_count__"

event AssetEvent:
topic operation String
field token_id U64
field operator Identifier
field benefactor Identifier
field beneficiary Identifier
field expires_at U64

function get_token_count() -> (token_count U64):
memory value = asset.GetDynamicMetadata(key: TOKEN_COUNT)
token_count = depolorize(U64, value)

function dynamic set_token_count(token_count U64):
asset.SetDynamicMetadata(key: TOKEN_COUNT, polorize(token_count))

endpoint deploy Init():
set_token_count(token_count: 0)

endpoint Transfer(token_id U64, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Transfer", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Transfer(token_id, beneficiary, amount)

endpoint TransferFrom(token_id U64, benefactor, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "TransferFrom", token_id: token_id, operator: Sender, benefactor: benefactor,
beneficiary: beneficiary}
asset.TransferFrom(token_id, benefactor, beneficiary, amount)

endpoint dynamic Mint(beneficiary Identifier, amount U256) -> (token_id U64):
memory new_token_id = (token_count) <- get_token_count()
if asset.EnableEvents():
emit AssetEvent{operation: "Mint", token_id: new_token_id, operator: Sender, beneficiary: beneficiary}
asset.Mint(token_id: new_token_id, beneficiary, amount)
set_token_count(token_count: new_token_id+1)
token_id = new_token_id

endpoint dynamic MintWithMetadata(beneficiary Identifier, amount U256, static_metadata Map[String]Bytes) -> (token_id U64):
memory new_token_id = (token_count) <- get_token_count()
if asset.EnableEvents():
emit AssetEvent{operation: "MintWithMetadata", token_id: new_token_id, operator: Sender, beneficiary: beneficiary}
asset.MintWithMetadata(token_id: new_token_id, beneficiary, amount, static_metadata)
set_token_count(token_count: new_token_id+1)
token_id = new_token_id

endpoint dynamic Burn(token_id U64, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Burn", token_id: token_id, operator: Sender, benefactor: Sender}
asset.Burn(token_id, amount)

endpoint Lockup(token_id U64, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Lockup", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Lockup(token_id, beneficiary, amount)

endpoint Release(token_id U64, benefactor, beneficiary Identifier, amount U256):
if asset.EnableEvents():
emit AssetEvent{operation: "Release", token_id: token_id, operator: Sender, benefactor: benefactor,
beneficiary: beneficiary}
asset.Release(token_id, benefactor, beneficiary, amount)

endpoint Approve(token_id U64, beneficiary Identifier, amount U256, expires_at U64):
if asset.EnableEvents():
emit AssetEvent{operation: "Approve", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary, expires_at: expires_at}
asset.Approve(token_id, beneficiary, amount, expires_at)

endpoint Revoke(token_id U64, beneficiary Identifier):
if asset.EnableEvents():
emit AssetEvent{operation: "Revoke", token_id: token_id, operator: Sender, benefactor: Sender,
beneficiary: beneficiary}
asset.Revoke(token_id, beneficiary)

// Getters

endpoint BalanceOf(token_id U64, address Identifier) -> (balance U256):
balance = asset.BalanceOf(token_id, address)

endpoint Symbol() -> (symbol String):
symbol = asset.Symbol()

endpoint Creator() -> (creator Identifier):
creator = asset.Creator()

endpoint Manager() -> (manager Identifier):
manager = asset.Manager()

endpoint dynamic SetStaticMetadata(key String, value Bytes):
asset.SetStaticMetadata(key, value)

endpoint dynamic SetDynamicMetadata(key String, value Bytes):
if key == TOKEN_COUNT:
throw f"{TOKEN_COUNT} is a reserved key"
asset.SetDynamicMetadata(key, value)

endpoint GetStaticMetadata(key String) -> (value Bytes):
value = asset.GetStaticMetadata(key)

endpoint GetDynamicMetadata(key String) -> (value Bytes):
value = asset.GetDynamicMetadata(key)

endpoint dynamic SetStaticTokenMetadata(token_id U64, key String, value Bytes):
if asset.BalanceOf(token_id, Sender) == 0:
throw "only token owner can set token metadata"
asset.SetStaticTokenMetadata(token_id, key, value)

endpoint dynamic SetDynamicTokenMetadata(token_id U64, key String, value Bytes):
if asset.BalanceOf(token_id, Sender) == 0:
throw "only token owner can set token metadata"
asset.SetDynamicTokenMetadata(token_id, key, value)

endpoint GetStaticTokenMetadata(token_id U64, key String) -> (value Bytes):
value = asset.GetStaticTokenMetadata(token_id, key)

endpoint GetDynamicTokenMetadata(token_id U64, key String) -> (value Bytes):
value = asset.GetDynamicTokenMetadata(token_id, key)

5. Custom Assets and Asset Logic

MOI supports standard asset behaviors (MAS0/MAS1/MAS2) for common use cases. But MOI is also designed so an asset can have custom behavior without re-implementing accounting, approvals, or locking in user code.

MOI achieves this with Asset Logic: an executable program that defines additional rules (policy) for an asset, while the protocol's Asset Engine remains the canonical authority over balances and core invariants.

5.1 What is Asset Logic?

Asset Logic is MOI’s programmable policy layer. It defines when and under what conditions an asset operation is allowed, while the protocol continues to handle all core accounting and invariant enforcement.

  • Logic answers: “Is this allowed?” and “What should happen?”
  • Engine answers: “Apply canonical state transitions safely and atomically.”

Asset Logic is authored in Coco (MOI’s native contract language). A Coco logic script defines callable routines (e.g., Approve, Transfer, Mint) and uses the reserved asset capability to request canonical operations from the Asset Engine.

Every logic invocation ultimately routes through the Asset Engine, which is the only protocol component authorized to mutate asset state. The engine performs:

  • balance updates (transfers)
  • mandate management (approvals / delegated spending)
  • lockups and releases
  • metadata edits (asset-level and token-level)
  • supply adjustments (mint/burn)
  • invariant checks (e.g., no negative balances, mandate expiry validity, supply constraints)

This contrasts with Ethereum's ERC-20 / ERC-721 pattern, where contracts implement their own accounting using internal mappings. In MOI, accounting is a protocol primitive, so Asset Logic developers focus on business rules rather than bookkeeping correctness. For a complete walkthrough, see Custom Asset Lifecycle.

Examples

  • Regulated token: Logic may require only the Manager to issue approvals. It checks caller == manager and then requests the engine to create a time-bounded mandate. Later, the engine validates and consumes the mandate during delegated spending.
  • Community / treasury token: Logic may apply a 5% tax. On transfer, logic computes tax = 5% and net = 95%, then requests two engine transfers (tax → treasury, net → recipient). The engine executes both atomically and enforces balance correctness.

5.2 Asset Engine Interface

The Asset Engine Interface is the protocol’s canonical surface for performing asset operations. Asset Logic can define custom rules and sequencing, but it does not directly mutate balances, mandates, lockups, or supply. Instead, it requests these operations through the engine interface, which applies them according to protocol rules and permission checks.

Functionally, the engine interface covers:

  • balance reads and canonical transfers
  • mint / burn supply accounting
  • mandate lifecycle: create, validate, expire, consume, revoke
  • lockups and releases (spendability constraints)
  • asset and token metadata operations (static/dynamic tiers)
  • atomic application of multi-step state changes

Key security property: invariants are enforced at the moment of state transition:

  • transfers cannot create negative balances
  • delegated transfers require a valid, unexpired mandate
  • lockups reduce spendable balance without deleting ownership
  • supply changes remain consistent with configured constraints
  • multi-step mutations can be applied atomically (no partial effects on failure)

5.2.1 Read Operations (Getters)

These functions are read-only views into protocol-managed asset state.
Use them inside execution to check balances, roles, supply counters, and configuration without mutating state.

// BalanceOf returns the balance held by `address` for the given (assetID, tokenID).
BalanceOf(
address identifiers.Identifier,
assetID identifiers.AssetID,
tokenID common.TokenID,
access map[[32]byte]int,
) (*big.Int, error)

// Symbol returns the human-readable ticker symbol for the asset.
Symbol(assetID identifiers.AssetID, access map[[32]byte]int) (string, error)

// Creator returns the identifier of the asset’s creator/provenance address.
Creator(assetID identifiers.AssetID, access map[[32]byte]int) (identifiers.Identifier, error)

// Manager returns the identifier authorized for administrative operations.
Manager(assetID identifiers.AssetID, access map[[32]byte]int) (identifiers.Identifier, error)

// Decimals returns the divisibility/precision configured for the asset.
Decimals(assetID identifiers.AssetID, access map[[32]byte]int) (uint8, error)

// MaxSupply returns the immutable supply cap configured for the asset.
MaxSupply(assetID identifiers.AssetID, access map[[32]byte]int) (*big.Int, error)

// CirculatingSupply returns the current circulating supply tracked by the protocol for this asset.
CirculatingSupply(assetID identifiers.AssetID, access map[[32]byte]int) (*big.Int, error)

// LogicID returns the LogicID bound to this asset, if any.
LogicID(assetID identifiers.AssetID, access map[[32]byte]int) (identifiers.LogicID, error)

// EnableEvents returns whether this asset emits event logs for ops (transfers/mints/etc.).
EnableEvents(assetID identifiers.AssetID, access map[[32]byte]int) (bool, error)

5.2.2 Asset Metadata Operations

These functions manage asset-level metadata (applies to the whole asset class).
Static metadata is intended to be immutable after creation, while dynamic metadata is manager-updatable for evolving fields.

// SetStaticMetaData sets a static (intended-immutable) metadata key/value at the asset level.
// `participantID` is the actor/authority context used for permission checks.
// `val` is raw bytes to support arbitrary encodings (JSON, CBOR, hashes, etc.).
SetStaticMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
key string, val []byte,
access map[[32]byte]int,
) error

// SetDynamicMetaData sets a dynamic (manager-updatable) metadata key/value at the asset level.
// `participantID` is the actor/authority context used for permission checks.
SetDynamicMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
key string, val []byte,
access map[[32]byte]int,
) error

// GetStaticMetaData returns the static metadata value for `key` at the asset level.
GetStaticMetaData(
assetID identifiers.AssetID,
key string,
access map[[32]byte]int,
) ([]byte, error)

// GetDynamicMetaData returns the dynamic metadata value for `key` at the asset level.
GetDynamicMetaData(
assetID identifiers.AssetID,
key string,
access map[[32]byte]int,
) ([]byte, error)

5.2.3 Token Metadata Operations

These functions manage token-level metadata (applies to a specific tokenID).
Use this for MAS1/MAS2-style per-token/category fields like artwork hashes (static) or game stats (dynamic).

// SetStaticTokenMetaData sets static (intended-immutable) metadata for a specific tokenID under an asset.
// `participantID` is the actor/authority context used for permission checks.
SetStaticTokenMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
tokenID common.TokenID,
key string, val []byte,
access map[[32]byte]int,
) error

// SetDynamicTokenMetaData sets dynamic (manager-updatable) metadata for a specific tokenID under an asset.
// `participantID` is the actor/authority context used for permission checks.
SetDynamicTokenMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
tokenID common.TokenID,
key string, val []byte,
access map[[32]byte]int,
) error

// GetStaticTokenMetaData returns static metadata for a tokenID under an asset.
// `participantID` supplies the actor/authority context where relevant to access rules.
GetStaticTokenMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
tokenID common.TokenID,
key string,
access map[[32]byte]int,
) ([]byte, error)

// GetDynamicTokenMetaData returns dynamic metadata for a tokenID under an asset.
// `participantID` supplies the actor/authority context where relevant to access rules.
GetDynamicTokenMetaData(
assetID identifiers.AssetID,
participantID identifiers.Identifier,
tokenID common.TokenID,
key string,
access map[[32]byte]int,
) ([]byte, error)

5.2.4 Asset Lifecycle Operations

These are the canonical mutation APIs exposed by the Asset Engine.
Asset Logic never edits balances directly. Instead, it requests these ops so the engine can apply updates atomically while enforcing invariants (non-negative balances, mandate validity, supply constraints, etc.).

// CreateAsset instantiates a new asset with its identity, roles, supply cap, metadata, config flags, and logic binding.
CreateAsset(
ixHash common.Hash,
assetID identifiers.AssetID,
symbol string,
decimals uint8,
dimension uint8,
manager identifiers.Identifier,
creator identifiers.Identifier,
maxSupply *big.Int,
staticMetadata map[string][]byte,
dynamicMetadata map[string][]byte,
enableEvents bool,
logicID identifiers.LogicID,
access map[[32]byte]int,
) (uint64, error)

// Transfer moves `amount` of (assetID, tokenID) from benefactor to beneficiary.
Transfer(
assetID identifiers.AssetID,
tokenID common.TokenID,
operatorID identifiers.Identifier,
benefactorID identifiers.Identifier,
beneficiaryID identifiers.Identifier,
amount *big.Int,
access map[[32]byte]int,
) (uint64, error)

// Mint creates `amount` of (assetID, tokenID) into `beneficiaryID`.
Mint(
assetID identifiers.AssetID,
tokenID common.TokenID,
senderID identifiers.Identifier,
beneficiaryID identifiers.Identifier,
amount *big.Int,
staticMetadata map[string][]byte,
access map[[32]byte]int,
) (uint64, error)

// Burn destroys `amount` of (assetID, tokenID) from `benefactorID`.
Burn(
assetID identifiers.AssetID,
tokenID common.TokenID,
benefactorID identifiers.Identifier,
amount *big.Int,
access map[[32]byte]int,
) (uint64, error)

// Approve creates an authorization (mandate/allowance) for `beneficiaryID` to spend up to `amount` from `benefactorID`.
Approve(
assetID identifiers.AssetID,
tokenID common.TokenID,
benefactorID identifiers.Identifier,
beneficiaryID identifiers.Identifier,
amount *big.Int,
expiresAt uint64,
access map[[32]byte]int,
) (uint64, error)

// Revoke removes an existing authorization from `benefactorID` to `beneficiaryID` for (assetID, tokenID).
Revoke(
assetID identifiers.AssetID,
tokenID common.TokenID,
benefactorID identifiers.Identifier,
beneficiaryID identifiers.Identifier,
access map[[32]byte]int,
) (uint64, error)

5.3 Protocol Interaction & Payloads

While the SDK abstracts much of the serialization, developers building custom integrations must understand the raw interaction operations accepted by the protocol. There are two primary interaction types for the asset lifecycle: Creation and Action.

5.3.1 Asset Creation (asset-create)

To deploy a new asset, the network expects an AssetCreatePayload. This defines the asset's immutable properties and, for custom assets (MASX), binds the compiled logic.

FieldTypeDescription
SymbolStringThe ticker symbol (e.g., "MOI", "USDC").
StandardAssetStandardThe standard to adhere to (MAS0, MAS1). If set to MASX, the Logic field is mandatory.
DimensionUint8Defines dimensional properties of the asset.
DecimalsUint8Precision of the asset (e.g., 18 for fungible tokens).
EnableEventsBoolIf true, the asset emits events for state changes (transfers, mints).
ManagerIdentifierThe participant ID authorized to manage supply and dynamic metadata.
LogicLogicPayloadCrucial for Custom Assets: Contains the compiled Manifest and logic bytecode.
MetadataMapInitial StaticMetadata and DynamicMetaData.

Note: The protocol explicitly validates that if Standard == MASX, the Logic payload must be present and the Manifest cannot be empty.

5.3.2 Asset Interaction (asset-interact)

Once an asset exists, all operations (transfers, mints, or custom logic calls) are routed via the AssetActionPayload.

FieldTypeDescription
AssetIDAssetIDThe unique identifier of the target asset.
CallsiteStringThe specific method name to invoke (e.g., Transfer, Mint, or a custom function like IssueBadge).
CalldataBytesThe serialized arguments for the method.
FundsMap(Optional) A map of AssetIDs to amounts, if the interaction requires sending value along with the call.

Routing Logic: The Callsite field determines which function in the Asset Logic (or the Engine default) is executed. For example, sending an action with Callsite: "Transfer" routes the request to the asset's Transfer endpoint defined in its logic or the standard implementation.

5.4 Custom Asset Lifecycle (with examples)

A custom MOI asset begins as Coco policy code that defines behavioral rules such as transfer restrictions, tax logic, mint permissions, or approval constraints, while leaving accounting to the protocol. For standard use cases, see MAS0, MAS1, or MAS2.

1) Write Policy (Coco)

Developer implements custom rules using Asset Logic, calling into the reserved asset capability for canonical ops via the Asset Engine.

Example (Soulbound Badge policy):

coco asset SoulboundBadge

// Soulbound Badge (non-transferable)
// - Only an admin can issue badges
// - Transfers are blocked at the policy layer
// - Accounting + mint state updates are still applied by the Asset Engine

state logic:
admin Identifier

// Deploy: set the deployer as admin
endpoint deploy Init():
mutate Sender -> SoulboundBadge.Logic.admin

// Mint: admin-only issuance
endpoint dynamic IssueBadge(recipient Identifier):
memory admin Identifier
observe admin <- SoulboundBadge.Logic.admin

if Sender != admin:
throw "Only Admin can issue badges!"

// Canonical mint executed by the Asset Engine
asset.Mint(token_id: 0, beneficiary: recipient, amount: U256(1))

// Transfer: disabled (soulbound)
endpoint Transfer(beneficiary Identifier, amount U256):
throw "This asset is Soulbound and cannot be transferred!"

2) Compile + Manifest

Compilation produces an executable artifact plus a manifest (commonly JSON/YAML) describing routines and invocation formats.

Example (terminal):

coco compile 

After compiling, you’ll get a JSON manifest output like this:

{
"syntax": 1,
"engine": {
"kind": "PISA",
"flags": [],
"version": "0.5.0"
},
"kind": "asset",
"elements": [
{
"ptr": 0,
"kind": "literal",
"data": {
"type": "string",
"value": "0x064f6e6c792041646d696e2063616e2069737375652062616467657321"
}
},
{
"ptr": 1,
"kind": "literal",
"data": {
"type": "string",
"value": "0x065468697320617373657420697320536f756c626f756e6420616e642063616e6e6f74206265207472616e7366657272656421"
}
}
]
}

3) Client Encoding (SDK)

Once you’ve compiled your Coco logic into a JSON manifest, the client (via the JS SDK) uses that manifest to: 1) Create the asset and run the deploy routine (Init) to initialize logic state (admin), and
2) Invoke routines like IssueBadge by encoding the call using the manifest.

import { AssetFactory, AssetDriver, LockType, RoutineOption } from "js-moi-sdk";
import manifest from "./logic/soulboundbadge.json" with { type: "json" };

// assume `wallet` is already created + connected
const address = wallet.getIdentifier().toHex();

// ─────────────────────────────
// Create + Init (deploy routine)
// ─────────────────────────────
const ctx = AssetFactory.create(
wallet, // signer
"SBT", // symbol
1000000, // supply
address, // manager
true, // enableEvents
manifest, // compiled logic manifest
"Init", // deploy routine name
[] // Init args
);

const deployResponse = await ctx.send();
const deployReceipt = await deployResponse.wait();

const assetId = deployReceipt.ix_operations?.[0]?.data?.asset_id;
if (!assetId) throw new Error("Failed to get asset ID from deployment");

// ─────────────────────────────
// IssueBadge (invoke routine)
// ─────────────────────────────
const recipient = "0x.....";

const ad = new AssetDriver(assetId, manifest, wallet);

const issueResponse = await ad.routines.IssueBadge(
recipient,
new RoutineOption({
participants: [{ id: recipient, lock_type: LockType.MUTATE_LOCK }],
})
);

4) Asset Creation (on-chain)

At this point, the Asset Engine has instantiated the global Asset Object (symbol, decimals/dimension, manager, metadata, enableEvents) and bound it to your Logic ID. Your Init deploy routine runs once to initialize logic state (admin).

  • Output you keep: assetId
  • Verify in Voyage

5) Ongoing Interactions

  • Issue more badges: call IssueBadge(recipient) again for new recipients
  • Block transfers: your Transfer routine throws, so any attempt fails
  • (Recommended) Add a read routine: expose a getter like HasBadge(address) or BalanceOf(address) wrapper so apps can check ownership cleanly

6) Native Recognition of Custom Assets

The most important property of MOI's custom asset model is this: even when you define entirely custom logic, the protocol still recognizes your asset natively. Your Soulbound Badge, your tax-wrapped token, your game item—they are all first-class protocol objects, not opaque contract state.