Files
lez-programs/docs/token/README.md
T
r4bbit ad020b1c7f docs(token): add token program usage runbook"
A spel-oriented guide for the token program, in the same style as the testnet
runbook but scoped to token operations only: create fungible definitions, hand
out holdings, transfer, mint, burn, rotate/renounce the mint authority, and
print NFTs.

Verified against main (4363f13) on the LEZ v0.2.0 pin: the regenerated IDL
matches artifacts/token-idl.json, all 63 token_program tests pass, and every
quoted abort message exists in programs/token/src.

Documents three constraints that are easy to hit and hard to infer:
- transfer's recipient and mint's holding are not signers, so a fresh account
  cannot be claimed by them — initialize-account first.
- spel has no optional args or optional accounts: Option<account_id> flags are
  always required (pass 'none'), and authority ops are split into self vs
  *-with-authority variants.
- new-definition-with-metadata is unusable from the CLI in spel v0.5.0 and
  v0.6.0 — its serializer has no encoding for IDL 'defined' types.
2026-08-11 12:52:34 +02:00

22 KiB
Raw Blame History

Token program — spel runbook

How to use the token program end to end with spel: create tokens, hand out holdings, transfer, mint, burn, rotate the mint authority, and print NFTs. Each section is self-contained — unlike the AMM runbook there is no fixed order beyond "create a definition before you use it".

Verified against: token program at main (4363f13), building on LEZ v0.2.0 (nssa_core/nssa workspace pin) and spel-framework v0.6.0 (guest pin). CLI behaviour below was exercised with the spel CLI v0.5.0; where v0.6.0 differs it is called out.

Golden rule: every time you recompile the guest, its ProgramId changes, and the new build is a different program. Token accounts record the program_owner of the build that created them, and initialize-account, mint, mint-with-authority, set-authority, and set-authority-with-authority all assert it (Token definition must be owned by token program). Tokens created by an older build are not manageable by a newer one — recreate them. The token program has no PDAs, so there is nothing to re-derive; every account is a plain wallet account.


Contents


0. Prerequisites

  • Docker running (guest builds cross-compile through it) — only if you build/deploy yourself.
  • spel / wallet from the SPEL toolchain, on PATH.
  • Wallet home exported in every shell you use (deploy and spel must point at the same wallet/network):
    export LEE_WALLET_HOME_DIR="$HOME/.lee/wallet"
    
    Older toolchain builds read NSSA_WALLET_HOME_DIR instead — set whichever yours expects, and use the same one everywhere.
  • Wallet pointed at your sequencer:
    wallet config set sequencer_addr https://testnet.lez.logos.co/
    
  • The IDL at artifacts/token-idl.json (regenerate with make idl) and the binary you deployed. Every instruction below is spel --idl <IDL> --program <BIN> -- <instruction> ….

Argument formats (used throughout)

Kind Accepted forms
account id (definitions, holdings, authorities) base58 (e.g. 9qbX…) or 0x-prefixed 32-byte hex. No account_id( … ) wrapper.
amount (u128) plain decimal integer, in base units. The token program stores no decimals field — decimals are a UI convention. 1000000000000000000 = one whole token if your UI assumes 18.
Option<account_id> (--mint-authority, --new-authority) an account id for Some, or the literal none (null also works) for None. The flag itself is always required — spel has no optional args, so omitting it is an error, not None.

1. Build & deploy the token program

Only do this if you want to deploy the program yourself — otherwise use an existing deployment's binary + ProgramId.

make build-programs                       # all guests → target/guest/<program>.bin
wallet deploy-program target/guest/token.bin

Building a single guest directly (debug/iteration) puts the binary at programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin:

cargo risczero build --manifest-path programs/token/methods/guest/Cargo.toml

Record the ProgramId — you need it to configure programs that chain into the token program (the AMM's --token-program-id, the ATA's --token-program-id):

spel -- program-id target/guest/token.bin

It prints the decimal limbs and the 64-char ImageID hex; both forms (plus 0x-hex and base58) are accepted wherever a program id is taken.

The token program itself is not initialized or configured — there is no config account and no global admin. Deploy it and start creating tokens.

2. Wallet CLI basics

Every account you pass below is a wallet account you own. Create one public account per role, with a --label you can recognize later:

wallet account new public --label "Token A Definition"
wallet account new public --label "Token A Holding"
wallet account list        # `ls` is an alias; -l/--long for full details

The listing shows each account's label and base58 id:

/1   Public/4T69U868K6UzX8zbesU5wyr36gxaU7wb91Q45yedP4Rb [Token A Holding]
/0   Public/CER21z16YgmWr3aN8FEHsrmfm2iRfQiwZTac3FQa21US [Token A Definition]

Use those ids as the <DEF>, <HOLDING>, and <AUTHORITY> arguments. spel signs only accounts whose keys your wallet holds — every account marked signer below must be yours.

3. The account model

The token program owns three account types. All are ordinary accounts until an instruction claims them; the type is the shape of the data written into them.

Account Created by Holds
TokenDefinition new-fungible-definition, new-definition-with-metadata name, total/printable supply, optional metadata id, optional mint authority
TokenHolding the same two instructions (first holding), initialize-account, print-nft definition_id + a balance: Fungible { balance }, NftMaster { print_balance }, or NftPrintedCopy { owned }
TokenMetadata new-definition-with-metadata definition_id, standard (Simple/Expanded), uri, creators, primary_sale_date

A holding is bound to one definition. Every operation cross-checks definition_id; mismatches abort (Sender and recipient definition id mismatch, Mismatch Token Definition and Token Holding).

Who signs what

Writing into a fresh account means claiming it, which requires that account's authorization in the same transaction. Instructions that create accounts therefore mark them init, signer — your wallet must hold the key for each:

Creates Account(s) that must sign
new-fungible-definition definition target, holding target
new-definition-with-metadata definition target, holding target, metadata target
initialize-account the new holding
print-nft the master holding and the fresh printed copy

Two instructions are the exception: transfer's recipient and mint's holding are not signers. Their handlers can claim an empty account, but the instruction never collects that account's signature, so over spel the fresh-account path is unreachable — initialize the destination holding first (step 5). This is the single most common source of confusion below.

The IDL also has no notion of an optional account, which is why authority operations come in pairs — mint / mint-with-authority, set-authority / set-authority-with-authority — instead of one instruction with an optional authority account. Pick the variant that matches where the definition's authority currently lives (steps 7 and 8).

4. Create a fungible token

Creates the definition and its first holding, credited with the full total_supply. Both target accounts must be fresh (never written to) and signers.

wallet account new public --label "Token A Definition"
wallet account new public --label "Token A Holding"
spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- new-fungible-definition \
     --name "TOKEN A" \
     --total-supply 1000000000000000000000 \
     --definition-target-account <DEF> \
     --holding-target-account <HOLDING> \
     --mint-authority <AUTHORITY>

--mint-authority decides the supply model. It is a required flag, but all three models are reachable from the CLI:

Value Meaning
<DEF> (the definition's own id) self authority — mint with mint (step 7a), signing as the definition account
some other account id external authority — mint with mint-with-authority (step 7b), signing as that account
none fixed supply — minting is permanently rejected (authority revoked, supply is fixed)

An all-zero authority id is rejected (Mint authority must be a valid non-zero account ID).

Both target accounts are init and signer: your wallet must hold both keys, and re-running with the same accounts fails (Definition target account must have default values).

Verify:

spel --idl artifacts/token-idl.json inspect <DEF>     --type TokenDefinition
spel --idl artifacts/token-idl.json inspect <HOLDING> --type TokenHolding

5. Give someone a holding (initialize-account)

Creates an empty holding for an existing definition. This is the prerequisite for receiving a transfer or a mint. The new holding is a signer — your wallet must hold its key.

wallet account new public --label "Token A Holding (Bob)"
spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- initialize-account \
     --definition-account <DEF> \
     --account-to-initialize <NEW_HOLDING>
  • <DEF> is read-only here and is not signed — anyone can initialize a holding for any definition, as long as they sign the new account.
  • For a fungible definition the result is Fungible { balance: 0 }; for a non-fungible one it is NftPrintedCopy { owned: false } — an empty slot ready to receive a printed copy. There is no way to initialize an NftMaster holding this way.
  • The definition must be owned by this token build, or the call aborts (Token definition must be owned by token program).

6. Transfer

Moves value between two holdings of the same definition. spel signs the sender; the recipient is only written to.

spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- transfer \
     --sender <SENDER_HOLDING> \
     --recipient <RECIPIENT_HOLDING> \
     --amount-to-transfer <AMOUNT>
  • <SENDER_HOLDING> — a holding you own (signer); balance must be ≥ <AMOUNT>, else Insufficient balance.
  • <RECIPIENT_HOLDING> — an already-initialized holding of the same definition (step 5). The recipient is not a signer, so a fresh account cannot be claimed here.
  • Same-definition is enforced: Sender and recipient definition id mismatch.

Per holding type:

Holding type --amount-to-transfer Notes
Fungible any amount ≤ balance ordinary balance move
NftPrintedCopy must be 1 sender must own it, recipient slot must be un-owned
NftMaster must equal the sender's entire print_balance recipient must be an NftMaster holding at 0 — see step 10

Verify:

spel --idl artifacts/token-idl.json inspect <RECIPIENT_HOLDING> --type TokenHolding

7. Mint more supply

Minting adds to both the holding's balance and the definition's total_supply. It is fungible- only (Cannot mint additional supply for Non-Fungible Tokens) and gated on the definition's stored authority. Which of the two instructions you use depends on who that authority is.

The target holding must already exist and belong to <DEF> (step 5) — it is not a signer, so a fresh account cannot be claimed by a mint.

7a. Self / PDA authority (mint)

Use when the stored authority is the definition account itself. The definition signs.

spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- mint \
     --definition-account <DEF> \
     --user-holding-account <HOLDING> \
     --amount-to-mint <AMOUNT>

This is also the path programs use: a PDA-owned definition (e.g. the AMM's LP token) is authorized under its seeds in a chained call instead of by a key.

7b. External authority (mint-with-authority)

Use when the stored authority is a separate account — the normal case when you passed a distinct --mint-authority at creation, or rotated it later. The authority signs; the definition is written but does not sign.

spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- mint-with-authority \
     --definition-account <DEF> \
     --user-holding-account <HOLDING> \
     --authority-account <AUTHORITY> \
     --amount-to-mint <AMOUNT>

Failure modes are shared by both: signer is not the current authority (wrong account, or the wrong variant for where the authority lives), authority revoked, supply is fixed (authority is None), Mismatch Token Definition and Token Holding (holding belongs to another token).

8. Rotate or renounce the mint authority

Same split as minting: set-authority when the definition itself is the current authority, set-authority-with-authority when a separate account is. Fungible-only.

# current authority is the definition account
spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- set-authority \
     --definition-account <DEF> \
     --new-authority <NEW_AUTHORITY>
# current authority is a separate account (it signs)
spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- set-authority-with-authority \
     --definition-account <DEF> \
     --authority-account <CURRENT_AUTHORITY> \
     --new-authority <NEW_AUTHORITY>
  • --new-authority none permanently renounces minting — the supply is fixed and no later rotation is possible (SetAuthority failed: authority already revoked). There is no undo.
  • Rotating hands over control: after --new-authority <BOB>, only Bob can mint, and only via mint-with-authority / set-authority-with-authority.
  • Checks run before any mutation, so a rejected call leaves the previous authority intact.

Confirm the new value in the definition's authority field:

spel --idl artifacts/token-idl.json inspect <DEF> --type TokenDefinition

9. Burn

Destroys supply from a holding you own. The holding signs; the definition is written but not signed, so any holder can burn their own tokens without the issuer's involvement.

spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- burn \
     --definition-account <DEF> \
     --user-holding-account <HOLDING> \
     --amount-to-burn <AMOUNT>
Holding type Effect
Fungible balance -= amount, total_supply -= amount
NftMaster print_balance -= amount, printable_supply -= amount — burns unprinted capacity
NftPrintedCopy amount must be 1; marks the copy un-owned and decrements printable_supply

Burning more than you hold aborts (Insufficient balance to burn). Burning is not gated on the mint authority — a renounced, fixed-supply token can still shrink.

10. NFTs (metadata, master, printed copies)

A non-fungible token is a definition with printable_supply plus a metadata account. Its first holding is an NftMaster, and each print carves an NftPrintedCopy out of it. print_balance reserves one unit for the master itself, so printable_supply: N yields N 1 printable copies.

spel cannot currently create NFT definitions. new-definition-with-metadata takes two structured args (--new-definition, --metadata); spel rejects any value for them with Serialization error: type mismatch: expected Defined { defined: "NewTokenDefinition" }, got Raw(…) — its serializer has no encoding for IDL defined types. This holds for both CLI v0.5.0 (tested) and v0.6.0 (spel-cli/src/parse.rs still wraps the value as Raw, and serialize.rs has no Defined arm). Until that lands, create NFT (and metadata-bearing fungible) definitions programmatically — see token_program::new_definition::new_definition_with_metadata and the integration tests in programs/integration_tests/tests/token.rs. Everything else below works over the CLI against a definition created that way.

Shape of the two args, for when the CLI supports them:

// --new-definition
{ "NonFungible": { "name": "MY NFT", "printable_supply": 10 } }
// or: { "Fungible": { "name": "TOKEN A", "total_supply": 1000, "mint_authority": null } }

// --metadata
{ "standard": "Simple", "uri": "ipfs://…", "creators": "…" }

Printing a copy

Both accounts are signers; the printed target must be fresh (do not pre-initialize it — print-nft claims it itself).

wallet account new public --label "NFT copy #1"
spel --idl artifacts/token-idl.json \
     --program target/guest/token.bin \
     -- print-nft \
     --master-account <NFT_MASTER_HOLDING> \
     --printed-account <NEW_COPY_HOLDING>

Each print decrements the master's print_balance by 1 and writes NftPrintedCopy { owned: true }. Printing requires print_balance > 1, so the last unit can never be printed (Insufficient balance to print another NFT copy).

Moving a printed copy

Initialize a slot for the recipient (step 5 against the NFT definition — it produces an un-owned NftPrintedCopy), then transfer 1:

spel --idl artifacts/token-idl.json --program target/guest/token.bin \
     -- initialize-account --definition-account <NFT_DEF> --account-to-initialize <RECIPIENT_SLOT>

spel --idl artifacts/token-idl.json --program target/guest/token.bin \
     -- transfer --sender <MY_COPY> --recipient <RECIPIENT_SLOT> --amount-to-transfer 1

Transferring a master

Only meaningful into an existing NftMaster holding sitting at print_balance: 0, and the amount must be the sender's whole print_balance. initialize-account cannot produce such a holding (it always makes a printed-copy slot), and transfer cannot claim a fresh account, so in practice the master can only move to an account that previously gave one away. Plan master ownership at creation time.

11. Inspect

Read any token account back — read-only, no signing, no transaction:

spel --idl artifacts/token-idl.json inspect <DEF>      --type TokenDefinition
spel --idl artifacts/token-idl.json inspect <HOLDING>  --type TokenHolding
spel --idl artifacts/token-idl.json inspect <METADATA> --type TokenMetadata

--type is required and must match the account's actual shape — decoding a holding as a definition fails. A holding's definition_id is how you find the token it belongs to.

Instruction reference

S = must sign (your wallet needs the key), init = must be a fresh, never-written account.

Instruction Accounts Args
new-fungible-definition definition-target-account (S, init), holding-target-account (S, init) --name, --total-supply, --mint-authority
new-definition-with-metadata definition-target-account (S, init), holding-target-account (S, init), metadata-target-account (S, init) --new-definition, --metadatasee step 10
initialize-account definition-account, account-to-initialize (S, init)
transfer sender (S), recipient --amount-to-transfer
mint definition-account (S), user-holding-account --amount-to-mint
mint-with-authority definition-account, user-holding-account, authority-account (S) --amount-to-mint
set-authority definition-account (S) --new-authority
set-authority-with-authority definition-account, authority-account (S) --new-authority
burn definition-account, user-holding-account (S) --amount-to-burn
print-nft master-account (S), printed-account (S, init)

Gotchas

  • Recompile ⇒ new ProgramId ⇒ a different program. Tokens created by the old build stay owned by it; initialize-account, mint*, and set-authority* reject them with Token definition must be owned by token program. Recreate your tokens after a rebuild.
  • You cannot credit an account that doesn't exist. Neither transfer nor mint marks its recipient/holder as a signer, so the claim of a fresh account can't be authorized. Run initialize-account first. (print-nft is the exception — its printed target is a signer, so it must be fresh and must not be pre-initialized.)
  • Option args are required flags. spel has no optional args: --mint-authority / --new-authority must always be passed. Pass the literal none for None — that is how you get a fixed-supply token or renounce an authority.
  • No optional accounts either. That is why authority operations are split into mint / mint-with-authority and set-authority / set-authority-with-authority. Using the variant that doesn't match where the authority lives fails with signer is not the current authority, even when you hold the right key.
  • --dry-run only works before the -- separator. It is a global flag: spel --idl … --program … --dry-run -- transfer … resolves and prints the transaction, then exits with Dry run complete — not submitted. Put it after the instruction name instead and it is silently swallowed — the transaction is submitted for real.
  • Account ids must be bare base58/0x-hex — strip the wallet's account_id( … ) display wrapper.
  • Renouncing is permanent. --new-authority none can never be reversed; the supply can then only shrink (via burn).
  • No decimals on-chain. All amounts are raw base units; the definition stores no scale, so every client must agree on one out of band.
  • Same wallet home everywhere. Deploying with one LEE_WALLET_HOME_DIR and running spel with another points them at different networks/keys.