Tutorial 8: Custom Tokens
In this tutorial, you learn to create custom tokens.
Mina comes with native support for custom tokens. Each account on Mina can also have tokens associated with it.
To create a new token, one creates a smart contract, which becomes the manager for the token, and uses that contract to set the rules around how the token can be mint, burned, and sent.
The manager account may also set a token symbol for its token, such as in this example, MYTKN. Uniqueness is not enforced for token names. Instead the public key of the manager account is used to identify tokens.
In this tutorial, you review smart contract code that creates and manages new tokens.
The full example code is provided in the 08-custom-tokens/src/ example files.
For reference, a more extensive example, including all the ways to interact with token smart contracts, is provided in token.test.ts.
Prerequisites
-
Make sure you have the latest version of the zkApp CLI installed:
$ npm install -g zkapp-cli -
Ensure your environment meets the Prerequisites for zkApp Developer Tutorials.
The example project for this tutorial is tested with o1js version 3.0.0 and Node.js 22.
Create the project
- Create or change to a directory where you have write privileges.
- Create a project by using the
zk projectcommand:
$ zk project 08-custom-tokens
The zk project command has the ability to scaffold the UI for your project. For this tutorial, select none:
? Create an accompanying UI project too? …
next
svelte
nuxt
empty
> none
Prepare the project
- Change to the project directory, delete the existing files, and create a new
src/BasicTokenContractsmart contract, and aindex.tsfile:
$ cd 08-custom-tokens
$ rm src/Add.ts
$ rm src/Add.test.ts
$ rm src/interact.ts
$ zk file src/BasicTokenContract
$ touch src/index.ts
- Edit
index.tsto export your new smart contract:
export { BasicTokenContract, tokenSymbol } from './BasicTokenContract.js';
The example project also exports WhitelistedTokenContract from index.ts. See Examples.
Concepts
Before you read the contract, learn the three objects that every custom token on Mina uses: the token manager, the token id, and the token account. Then learn the approval mechanism, which is how the token manager controls every change to a token balance.
The tokenConcepts.test.ts test file in the example project checks each statement in this section on a local blockchain.
Token manager
The token manager is the zkApp account that owns a token. In o1js, you write a token manager as a class that extends TokenContract. The token manager can:
- Set a token symbol for its token, such as
MYTKN. The token symbol is not unique. Two token managers can set the same symbol and still manage two different tokens. - Mint tokens. Minting adds tokens to the balance of an account. In o1js,
this.internal.mint()does this. - Burn tokens. Burning removes tokens from the balance of an account. In o1js,
this.internal.burn()does this, and it requires a signature from the account that loses the tokens. A balance cannot go below zero, so an account cannot burn or send more tokens than it holds. - Approve transfers. Any account that holds the token can start a transfer, but the token manager must approve it. See Approval mechanism.
TokenContract.deploy() sets the access permission of the token manager account to proofOrSignature(), and requires that the account is new. Without this permission, anyone can mint the token without the token manager.
Token id
A token id is a field element that identifies a token. The token id is derived from the public key of the token manager, so each token manager has exactly one token id. That is why the token symbol does not need to be unique.
To get the token id of a token manager, call deriveTokenId() on the contract:
const tokenId = token.deriveTokenId();
This is the same value as TokenId.derive(token.address). The MINA token has the token id TokenId.default, which is 1.
TokenId.derive() also takes the token id of the token manager account as a second argument, which is TokenId.default when you do not supply it. For a token manager that is a normal MINA account, like the one in this tutorial, the two calls give the same value.
Token account
An account on Mina is identified by a public key and a token id. A token account is an account whose token id is the id of a custom token. Its balance is in that token, not in MINA.
So one public key can have many accounts: one MINA account and one token account for each token that it holds. They are separate accounts. When you mint tokens to a public key, its MINA balance does not change.
To read a token balance, give the token id:
Mina.getBalance(address, tokenId); // the token balance
Mina.getBalance(address); // the MINA balance
A token account is created the first time that its public key receives the token. The public key does not need a MINA account. Creating a token account costs the account creation fee, the same as creating a MINA account. Some account in the transaction must pay it, usually with AccountUpdate.fundNewAccount(). If no account pays it, the transaction fails with Invalid fee excess. Later transactions to the same token account do not pay the fee again.
Approval mechanism
Every change to a token account is an account update with that token id. The Mina protocol accepts such an account update only when it is a child of the token manager's account update in the same transaction, or a deeper descendant that inherits the token permission from its parent. A top-level account update that uses a custom token id fails with the error Top-level account update can not use or pass on token permissions. So a token holder cannot move tokens without the token manager, even with a valid signature.
The token manager approves account updates in two ways:
- Its own methods create the account updates, as
this.internal.mint(),this.internal.burn()andthis.internal.send()do. The rules in the method are the rules of the token. - It approves account updates that other code created.
TokenContractleaves theapproveBase()method for you to define. It receives the account updates as anAccountUpdateForestand must fail if it does not approve them. The helperstransfer(),approveAccountUpdate()andapproveAccountUpdates()all put the account updates into a forest and callapproveBase().
checkZeroBalanceChange() is the most common implementation of approveBase(). It proves that the token balance changes of the approved account updates sum to zero, so an approved transaction cannot create tokens. approveBase() can check at most TokenContract.MAX_ACCOUNT_UPDATES account updates, which is 9 by default.
Make approveBase() a @method, so that the token manager can prove its approval. If approveBase() is not a @method, transfer() does not add the token account updates to the transaction, and the transaction moves no tokens. When approveBase() is a @method, any token holder can call transfer(), and signs only for the account that sends the tokens:
const tx = await Mina.transaction(alice, async () => {
AccountUpdate.fundNewAccount(alice); // only if bob has no token account yet
await token.transfer(alice, bob, UInt64.from(400));
});
await tx.prove();
await tx.sign([alice.key]).send();
Basic Token Example
To create a token manager smart contract, create a normal smart contract whose methods call special functions that manipulate tokens.
A full copy of the BasicTokenContract.ts is provided.
Imports and smart contract structure
First, bring in imports and set up the structure for the smart contract.
The single state variable totalAmountInCirculation tracks how many tokens exist.
import {
AccountUpdateForest,
DeployArgs,
method,
Permissions,
PublicKey,
Signature,
State,
state,
TokenContract,
UInt64,
} from 'o1js';
const tokenSymbol = 'MYTKN';
/**
* A token manager: a smart contract whose methods create the account updates
* that mint and move a custom token.
*
* It extends `TokenContract` rather than `SmartContract`, which is what
* supplies `this.internal.mint` / `.send` and the `Approvable` API.
*/
export class BasicTokenContract extends TokenContract {
/** How many tokens exist. */
@state(UInt64) totalAmountInCirculation = State<UInt64>();
async deploy(args?: DeployArgs) {
await super.deploy(args);
const permissionToEdit = Permissions.proof();
this.account.permissions.set({
...Permissions.default(),
editState: permissionToEdit,
setTokenSymbol: permissionToEdit,
send: permissionToEdit,
receive: permissionToEdit,
});
}
The contract extends TokenContract, not SmartContract. TokenContract is the
o1js base class for a token manager: it supplies the this.internal helpers used
below, sets the access permission so that nobody can bypass this contract and
mint the token themselves, and requires one method that says which account
updates the contract approves.
init() and mint() methods
Next, add an init method and a method that mints tokens.
-
Set the token symbol (MYTKN) in the
init()method. -
To start tracking the amount in circulation, set it to zero.
-
Write a function to mint new tokens and send them to a recipient.
This function checks that a signature has been provided by the zkApp account, so that only the zkApp account can call the
mint()method. -
In the
mint()method, track how many tokens are in existence.src/BasicTokenContract.ts@method async init() {
await super.init();
this.account.tokenSymbol.set(tokenSymbol);
this.totalAmountInCirculation.set(UInt64.zero);
}
/**
* Approve the account updates of a transaction that moves this token.
*
* `checkZeroBalanceChange` proves that the balance changes of the approved
* updates sum to zero, so a transaction cannot create tokens out of nothing.
* Minting goes through `mint()` below, which is the only method that raises
* the supply.
*
* It is a `@method`, so the contract can prove its approval. That is what
* lets a token holder call the inherited `transfer()` and
* `approveAccountUpdate()` helpers, which both end in `approveBase()`.
*/
@method async approveBase(forest: AccountUpdateForest) {
this.checkZeroBalanceChange(forest);
}
/**
* Mint `amount` tokens to `receiverAddress`.
*
* The admin signature is over the amount and the receiver, checked against
* this contract's own address, so only the holder of the zkApp key can mint.
*/
@method async mint(
receiverAddress: PublicKey,
amount: UInt64,
adminSignature: Signature
) {
const totalAmountInCirculation = this.totalAmountInCirculation.getAndRequireEquals();
const newTotalAmountInCirculation = totalAmountInCirculation.add(amount);
adminSignature
.verify(
this.address,
amount.toFields().concat(receiverAddress.toFields())
)
.assertTrue();
this.internal.mint({ address: receiverAddress, amount });
this.totalAmountInCirculation.set(newTotalAmountInCirculation);
}
approveBase() is the method TokenContract leaves for you to define: it decides
which account updates this contract approves. checkZeroBalanceChange() proves
that the balance changes of the approved updates sum to zero, so a transaction
cannot create tokens out of nothing. It is a @method, so token holders can call
the inherited transfer() helper (see Approval mechanism). mint() is the only method that raises the
supply, and it is gated by the admin signature.
Send function
Finally, write a send function.
-
Holders of the MYTKN token call the
sendTokens()method to send tokens to other Mina accounts.src/BasicTokenContract.ts/**
* Send `amount` tokens from one account to another.
*
* Holders of MYTKN call this to move the token. The supply does not change,
* so `totalAmountInCirculation` is untouched.
*/
@method async sendTokens(
senderAddress: PublicKey,
receiverAddress: PublicKey,
amount: UInt64
) {
this.internal.send({
from: senderAddress,
to: receiverAddress,
amount,
});
}
}
A transfer moves tokens between accounts, so it does not change
totalAmountInCirculation.
That completes a review of a basic token.
Examples
To see an example of interacting with this contract, see main.ts.
To see an example of putting rules around a token, see WhitelistedTokenContract.ts. Only the public keys on a whitelist can hold or move this token. The contract keeps only the hash of the whitelist in its on-chain state, and each method gets the whitelist from the caller and checks it against that hash.
Rules in approveBase()
Any account can call a @method. So when approveBase() is a @method, it must enforce every rule of the token. If it only calls checkZeroBalanceChange(), a token holder can use transfer() to send tokens to an address that sendTokens() refuses.
WhitelistedTokenContract.approveBase() uses forEachUpdate(), which checkZeroBalanceChange() also uses, to check each account update that uses the token. It also requires that the public key of each of these account updates is on the whitelist:
@method async approveBase(forest: AccountUpdateForest) {
// The whitelist is a private input that approveBase() cannot take as an
// argument. memoizeWitness() keeps the value from the transaction for the
// proof.
const whitelist = new Whitelist(
Experimental.memoizeWitness(Whitelist, () => {
if (this.whitelist === undefined) {
throw Error('set `whitelist` on the contract before you approve updates');
}
return this.whitelist;
})
);
whitelist
.hash()
.assertEquals(
this.whitelistCommitment.getAndRequireEquals(),
'the whitelist given is not the one this contract committed to'
);
let totalBalanceChange = Int64.zero;
this.forEachUpdate(forest, (update, usesToken) => {
usesToken
.implies(whitelist.contains(update.publicKey))
.assertTrue('the address is not on the whitelist');
totalBalanceChange = totalBalanceChange.add(
Provable.if(usesToken, update.balanceChange, Int64.zero)
);
});
totalBalanceChange.assertEquals(0);
}
approveBase() cannot take the whitelist as an argument, because TokenContract sets its arguments. Thus the caller sets the whitelist property of the contract object before it calls transfer(). Experimental.memoizeWitness() keeps the value that the method used when it made the transaction, so that tx.prove() uses the same value:
token.whitelist = Whitelist.from([alice, bob]);
const tx = await Mina.transaction(alice, async () => {
await token.transfer(alice, bob, UInt64.from(100));
});
await tx.prove();
await tx.sign([alice.key]).send();
A transfer to an address that is not on the whitelist fails with the address is not on the whitelist.
Building zkApps that interact with Tokens
With zkApps, you can also build smart contracts that interact with tokens. For example, swapping one token for another or taking deposits of Mina tokens.
For now, see this example of a zkApp implementing an AMM-based DEX.
Conclusion
You have finished reviewing the steps to build a smart contract to manage a token. You learned how to build a smart contract that places custom rules over tokens.
To learn more, see Custom Token API.
Check out Tutorial 9: Recursion to learn how to use recursive ZKPs with o1js, to implement zkRollups, large computations, and more.