Skip to main content

Token Tutorials

Build token modules and verified cross-canister flows with concrete patterns.

Tutorial: Fungible Token Module

This minimal fungible token module demonstrates:

  • Module hash in identity
  • Minter bound to the canister principal
  • Transfer respects balance
  • toShared burns on return

The core library version is token/Fungible. It additionally separates token-module identity from mint-module authority and exposes checked burn/transfer helpers.

import Runtime "mo:core/Runtime";

module {
public type Token = token { ticker : Text; minter : Principal; var amount : Nat };
public type Id = (Blob, Text, Principal);

public pure func id(symbol : Text, minter : Principal) : Id {
(Runtime.moduleHash(), symbol, minter)
};

public pure func idOf(t : Token) : Id {
(Runtime.moduleHash(), t.ticker, t.minter)
};

public pure func model(t : Token) : (Id, Nat) {
(idOf(t), t.amount)
};

public func mint(symbol : Text, amount : Nat) : Token {
{ ticker = symbol; minter = Runtime.selfId(); var amount = amount }
};

public func emptyLike(t : Token) : Token
ensures idOf(result) == idOf(t);
ensures model(result).1 == 0;
ensures result != t;
{
{ ticker = t.ticker; minter = t.minter; var amount = 0 }
};

public func transfer(from : Token, to : Token, n : Nat) : ()
requires from != to;
requires idOf(from) == idOf(to);
requires model(from).1 >= n;
ensures model(from).1 == old(model(from).1) - n;
ensures model(to).1 == old(model(to).1) + n;
{
from.amount -= n;
to.amount += n;
};

public module TokenShared {
private type Shared = { ticker : Text; minter : Principal; amount : Nat };

private func toShared(t : Token) : Shared {
let amt = t.amount;
t.amount := 0; // burn on return
{ ticker = t.ticker; minter = t.minter; amount = amt }
};

private func fromShared(s : Shared) : Token {
{ ticker = s.ticker; minter = s.minter; var amount = s.amount }
};
};
}

Tutorial: Verified Cross-Canister Transfer

A shared method that accepts or returns token values must use sr9:

import Token "./token";

persistent actor {
public shared sr9 func pay(t : Token.Token) : async Token.Token
entry_requires Token.model(t).1 >= 1;
requires Token.model(t).1 >= 1;
{
let fee = Token.emptyLike(t);
Token.transfer(t, fee, 1);
t
};
}

What happens under the hood:

  • The compiler rewrites the wire signature to use TokenShared.Shared.
  • On entry, it calls TokenShared.fromShared to rehydrate the token.
  • On return, it calls TokenShared.toShared, which burns the returned token inside the canister so it cannot be double spent.
  • In proof-enabled IC/Ref builds, the call is rejected unless the caller presents a valid proof and is a canister principal.
  • The fee receiver is created with emptyLike(t), so Token.transfer can prove both token values have the same identity.

Use entry_requires alongside requires when caller-controlled facts or token preconditions must be enforced at the exported boundary. See entry_requires and shared boundaries.

Tutorial: Nested Token Assets (4 Levels Deep)

Token assets can include other token assets and arrays of them inside the token type itself. This is supported as long as the container types are shareable and toShared consumes returned token values.

Example shapes:

  • Token (fungible core)
  • Relic (contains Token + [Token])
  • Bag (contains Relic + [Relic] + tags)
  • Avatar (contains Bag + [Bag])

Example snippets (simplified):

public type Relic = token { var id : Nat; var core : Token.Token; var stash : [Token.Token] };
public type Bag = token { var name : Text; main : Relic.Relic; var extras : [Relic.Relic]; var tags : [Text] };
public type Avatar = token { var name : Text; main : Bag.Bag; var bags : [Bag.Bag] };

Key rule: nested returns must not duplicate the same live token reference through multiple returned positions. Records, tuples, variants, options, and arrays are all supported by the shared-boundary rewrite, but the returned value still has to satisfy the ownership discipline: one live token value cannot be promised as two independently live returned assets.

Within one verified module graph, non-owner modules can also keep token or opaque handles in ordinary carriers and supported containers, then call the owner module's API with the recovered handle. That is different from exporting the handle over a shared method: shared calls still use the TokenShared wire wrapper and sr9 rules above.

For custody apps, keep live token values in actor state and let the compiler own upgrade persistence. Stable fields should be expressible with token/opaque handle types. Stable token upgrade preserves raw token state only when the defining token module CHASH identity is unchanged; changed token identity is an explicit migration. Local opaque handles follow ordinary Motoko raw stable type compatibility and use explicit owner migrations when the raw payload shape changes. toShared/fromShared remain shared-boundary hooks, not upgrade persistence hooks.

Tutorial: Returning a Token Safely

Consume on return. A safe toShared looks like this:

private func toShared(t : Token) : Shared {
let amt = t.amount;
t.amount := 0; // consume before returning
{ ticker = t.ticker; minter = t.minter; amount = amt }
}

If you skip the burn, you could return a token while keeping a live alias inside the canister unless your contracts and proofs rule that out.

Summary

  • The defining module owns token mutation and can prove safe transfer with contracts.
  • Verified sr9 calls are required for shared token signatures.
  • Non-owner modules may route token/opaque handles internally by identity, but payload mutation still goes through the owner API.
  • Stable upgrade persistence is separate from shared transport: token stability is CHASH-gated by the defining token module, while opaque stability follows Motoko raw stable type compatibility.
  • Always consume token values on return to avoid aliases across canister boundaries.
  • Use module hash identity and shared-boundary rules before exposing token values over canister calls.