Skip to main content

Tokens and NFTs

Mintlayer supports on-chain fungible tokens and NFTs. The wallet daemon manages the full lifecycle; the crypto module provides low-level encoders for manual flows; the indexer exposes read-only state. All amounts are atom counts in the token's smallest unit.

Fungible tokens

Supply policies

TokenTotalSupplyMeaning
TokenTotalSupply::Fixed(Amount)Capped at the given amount at issuance
TokenTotalSupply::LockableUnlimited minting until LockTokenSupply, then permanently fixed
TokenTotalSupply::UnlimitedMinting always allowed, limited only by the u128 amount type

Issuing via the wallet daemon

use mintlayer_sdk::wallet::{self, IssueTokenParams, TokenMetadata, TokenSupply, TxOptions};

let c = wallet::Client::new("http://127.0.0.1:3034");
let authority = c.new_address(0).await?;

let issue = c.issue_token(IssueTokenParams {
account: 0,
destination_address: authority.clone(), // becomes the token authority
metadata: TokenMetadata {
token_ticker: "MYTOKEN".into(),
number_of_decimals: 2,
metadata_uri: "https://example.com/token".into(),
token_supply: TokenSupply::Lockable,
is_freezable: false,
},
options: TxOptions::default(),
}).await?;
println!("token id: {} (tx {})", issue.token_id, issue.tx_id);

The destination address becomes the authority: the key that controls all later token operations. Keep it secure.

Minting, unminting and supply locking

use mintlayer_sdk::wallet::{Amount, MintParams, TxOptions, UnmintParams};

// Wait for the issuance transaction to confirm before minting.
c.mint_tokens(MintParams {
account: 0,
token_id: issue.token_id.clone(),
address: "mtc1q_recipient...".into(),
amount: Amount::from_atoms(100_000),
options: TxOptions::default(),
}).await?;

// Unminting removes tokens from circulation (the tx burns them).
c.unmint_tokens(UnmintParams {
account: 0,
token_id: issue.token_id.clone(),
amount: Amount::from_atoms(50_000),
options: TxOptions::default(),
}).await?;

// Irreversible: fixes the supply at the current circulating amount.
// Note the field is account_index, not account.
c.lock_token_supply(wallet::LockSupplyParams {
account_index: 0,
token_id: issue.token_id.clone(),
options: TxOptions::default(),
}).await?;

Freezing and authority change

use mintlayer_sdk::wallet::{FreezeParams, TxOptions, UnfreezeParams};

// is_unfreezable: true means the authority can unfreeze later.
c.freeze_token(FreezeParams {
account: 0, token_id: issue.token_id.clone(),
is_unfreezable: true, options: TxOptions::default(),
}).await?;

c.unfreeze_token(UnfreezeParams {
account: 0, token_id: issue.token_id.clone(), options: TxOptions::default(),
}).await?;

c.change_token_authority(wallet::ChangeAuthorityParams {
account: 0,
token_id: issue.token_id.clone(),
address: "mtc1q_new_authority...".into(),
options: TxOptions::default(),
}).await?;

Issuing via the crypto module

For full-custody flows, build the issuance output yourself and pair it with a coin input that covers the issuance fee.

use mintlayer_sdk::crypto::{self, Network, TokenTotalSupply};
use mintlayer_sdk::crypto::types::{TxInput, TxOutput};
use mintlayer_sdk::indexer::Client as IndexerClient;
use mintlayer_sdk::prelude::IsTokenFreezable;

let network = Network::Testnet;
let indexer = IndexerClient::new("http://127.0.0.1:3000");
let inputs: Vec<TxInput> = todo!(); // your UTXO inputs

let height = indexer.tip().await?.block_height + 1;
let fee = crypto::fungible_token_issuance_fee(height, network);
let token_id = crypto::get_token_id(&inputs, height, network)?;

let issue_output = crypto::encode_output_issue_fungible_token(
"mtc1q_authority...", // authority address
"MYTOKEN", // ticker
"https://example.com/meta", // metadata URI
2, // number of decimals
TokenTotalSupply::Lockable, // or Fixed(Amount) / Unlimited
IsTokenFreezable::No,
network,
)?;
let transaction = crypto::encode_transaction(inputs, vec![issue_output], 0)?;
// Sign with encode_witness and submit (see transactions.md).

Issuance parameters are validated against the chain config (crypto::Error::InvalidTokenParameters on failure). Token fees must be covered by the transaction's coin inputs.

Minting manually

Minting is an account command needing the authority's next account nonce from the indexer (token.next_nonce, string-encoded) plus an output delivering the minted tokens:

use mintlayer_sdk::crypto::{self, Amount, Network};
use mintlayer_sdk::crypto::types::TxOutput;

let token_info = indexer.token(&token_id).await?;
let mint_input = crypto::encode_input_for_mint_tokens(
&token_id,
Amount::from_atoms(100_000),
token_info.next_nonce.0,
network,
)?;
let mint_output: TxOutput = crypto::encode_output_token_transfer(
Amount::from_atoms(100_000), "mtc1q_recipient...", &token_id, network)?;

Other account-command inputs follow the same pattern; each consumes one nonce in sequence:

FunctionPurpose
encode_input_for_unmint_tokens(token_id, nonce, network)Pair with encode_output_token_burn
encode_input_for_lock_token_supply(token_id, nonce, network)Irreversible supply fix
encode_input_for_freeze_token(token_id, IsTokenUnfreezable, nonce, network)
encode_input_for_unfreeze_token(token_id, nonce, network)
encode_input_for_change_token_authority(token_id, new_authority, nonce, network)
encode_input_for_change_token_metadata_uri(token_id, new_metadata_uri, nonce, network)

Protocol fees — token_supply_change_fee for mint/unmint, token_freeze_fee for freeze/unfreeze, token_change_authority_fee for authority changes — must be covered by the transaction's coin inputs.

NFTs

Each NFT issuance transaction creates exactly one NFT on an existing token; there is no post-issuance minting.

use mintlayer_sdk::wallet::{IssueNftParams, NftMetadata, TxOptions};

let result = c.issue_nft(IssueNftParams {
account: 0,
destination_address: authority.clone(),
metadata: NftMetadata {
media_hash: "hex-encoded sha256 of the media".into(),
name: "My NFT".into(),
description: "A unique digital collectible".into(),
ticker: "MYNFT".into(),
creator: None, // Option<String>, public key hex
icon_uri: None,
media_uri: Some("https://example.com/media.png".into()),
additional_metadata_uri: None,
},
options: TxOptions::default(),
}).await?;
println!("nft id: {}", result.token_id);
// Manual equivalent (fee: crypto::nft_issuance_fee(height, network)):
let nft_output = crypto::encode_output_issue_nft(
&token_id, // existing collection token id
"mtc1q_authority...", "My NFT", "MYNFT", "A unique digital collectible",
media_hash_bytes, // &[u8]
None, // Option<PublicKey> creator
Some("https://example.com/media.png"),
None, None, // icon_uri, additional_metadata_uri
network,
)?;

The indexer reports NFTs with nft(id) (owner, token id, metadata).

DEX orders

The wallet daemon covers the full order lifecycle (create_order, conclude_order, fill_order, freeze_order, listings). The crypto module exposes the order-creation output for manual flows:

let order_output = crypto::encode_create_order_output(
Amount::from_atoms(1_000_000_000_000), // ask amount
None, // ask currency: None = coin
Amount::from_atoms(500_000), // give amount
Some(&token_id), // give currency: token
"mtc1q_conclude...", // may conclude the order
network,
)?;
// The order id is derived from the inputs:
let order_id = crypto::get_order_id(&inputs, network)?;

Filling an order uses encode_input_for_fill_order, whose inputs must not be signed — use crypto::encode_witness_no_signature() for them. Before the orders V1 fork the nonce and destination parameters are significant; after the fork both are ignored. encode_input_for_freeze_order requires orders V1 (crypto::Error::OrdersV1NotActivated before it).

Reading token state from the indexer

use mintlayer_sdk::indexer::{Client, PageOpts};

let idx = Client::new("http://127.0.0.1:3000");
let token = idx.token(&issue.token_id).await?;
println!("ticker {} supply {} locked {}", token.token_ticker,
token.circulating_supply.decimal, token.is_locked);
println!("frozen {} (unfreezable: {:?})",
token.frozen, token.is_token_unfreezable); // present only when frozen
let txs = idx.token_transactions(&issue.token_id, PageOpts::default()).await?;