Introduction

Mina natively supports custom tokens (MIP-4). Each account on Mina can correspond to a custom token.

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 minted, burned and transferred. The contract may also set a token symbol. Uniqueness is not enforced for token names. Instead the public key of the contract is used to derive the token's unique identifier.

SHOW ME THE CODE

The mina-fungible-token repo's e2e example showcases the entire lifecycle of a token.

After running npm i mina-fungible-token, import the FungibleToken contract and deploy it like so.

const token = new FungibleToken(contract.publicKey)

const deployTx = await Mina.transaction({
  sender: deployer.publicKey,
  fee,
}, () => {
  AccountUpdate.fundNewAccount(deployer.publicKey, 1)
  token.deploy({
    owner: owner.publicKey,
    supply: UInt64.from(10_000_000_000_000),
    symbol: "abc",
    src: "https://github.com/MinaFoundation/mina-fungible-token/blob/main/examples/e2e.eg.ts",
  })
})
await deployTx.prove()
deployTx.sign([deployer.privateKey, contract.privateKey])
await deployTx.send()

Note: this example assumes that contract and deployer are valid key pairs in scope.

How?

How is this custom token mechanism implemented in Mina?

Token Manager

The token manager account is a contract with the following capabilities.

  • Set a token symbol (also called token name) for its token. Uniqueness is not enforced for token names because the public key of the manager account is used to derive a unique identifier for each token.
  • Mint new tokens. The zkApp updates an account's balance by adding the newly created tokens to it. You can send minted tokens to any existing account in the network.
  • Burn tokens (the opposite of minting). Burning tokens deducts the balance of a certain address by the specified amount. A zkApp cannot burn more tokens than the specified account has.
  • Send tokens between two accounts. Any account can initiate a transfer, and the transfer must be approved by a Token Manager zkApp (see Approval mechanism).

Token Account

Token accounts are like regular accounts, but they hold a balance of a specific custom token instead of MINA. A token account is created from an existing account and is specified by a public key and a token id.

Token accounts are specific for each type of custom token, so a single public key can have many different token accounts.

A token account is automatically created for a public key whenever an existing account receives a transaction denoted with a custom token.

[!IMPORTANT] When a token account is created for the first time, an account creation fee must be paid the same as creating a new standard account.

Token ID

Token ids are unique identifiers that distinguish between different types of custom tokens. Custom token identifiers are globally unique across the entire network.

Token ids are derived from a Token Manager zkApp. Use deriveTokenId() function to get id of a token.

Approval mechanism

Sending tokens between two accounts must be approved by a Token Manager zkApp. This can be done with approveBase() method of the custom token standard reference implementation.

If you customize the transfer() function or constructing AccountUpdates for sending tokens manually, don't forget to call approveBase().

API overview

The token standard implementation is a Token Manager zkApp that is split in 2 parts: low-level and high-level one.

The low-level implementation is included in o1js library TokenContract abstract class. See the overview in the o1js Custom Tokens tutorial

[!WARNING] Please note that this is a beta release. The implementation will change soon. The API may also change in future.

The high-level part inherits from the TokenContract class and has following user-facing features:

On-chain State, decimals and deploy arguments

The on-chain state is defined as follows:

@state(PublicKey) public owner = State<PublicKey>();
@state(UInt64) public supply = State<UInt64>();
@state(UInt64) public circulating = State<UInt64>();
  • owner is set on deployment, and some of token functionality requires an admin signature.

    If you want to implement admin-only method, just call this.ensureOwnerSignature() helper in the method you want to protect.

  • supply defines a maximum amount of tokens to exist. It is set on deployment and can be modified with setSupply() function (can be called by admin only)

  • circulating tracks the total amount in circulation. When new tokens are minted, the circulating increases by an amount minted.

  • The decimals is a constant, that defines where to place the decimal comma in the token amounts.

  • The deploy() function requires owner and supply to be passed as parameters.

  • Along with state variables initial values, the deploy() function also takes symbol (to set account.tokenSymbol) and src (to set account.zkappUri)

Methods

Methods that can be called only by admin are:

mint(address: PublicKey, amount: UInt64)
setTotalSupply(amount: UInt64)
setOwner(owner: PublicKey)

Transfer and burn functionality is available by following methods:

transfer(from: PublicKey, to: PublicKey, amount: UInt64)
burn(from: PublicKey, amount: UInt64)

Helper methods for reading state variables and account balance

getBalanceOf(address: PublicKey)
getSupply()
getCirculating()
getDecimals()

Events

On each token operation, the event is emitted. The events are declared as follows:

events = {
  SetOwner: PublicKey,
  Mint: MintEvent,
  SetSupply: UInt64,
  Burn: BurnEvent,
  Transfer: TransferEvent,
}

class MintEvent extends Struct({
  recipient: PublicKey,
  amount: UInt64,
}) {}

class BurnEvent extends Struct({
  from: PublicKey,
  amount: UInt64,
}) {}

class TransferEvent extends Struct({
  from: PublicKey,
  to: PublicKey,
  amount: UInt64,
}) {}

That completes a review of a fungible token.

Deploy

To create a token manager smart contract, inherit your smart contract from base custom token implementation, or use the FungibleToken directly

import { FungibleToken } from "mina-fungible-token"

class MyToken extends FungibleToken {}

[!NOTE] If you inherit from FungibleToken to override some functionality, you will need to compile both parent and child contracts to be able to prove code for both of them

To deploy a token manager contract, create and compile the token contract instance, then create, prove and sign the deploy transaction:

await FungibleToken.compile()
await MyToken.compile()

const {
  privateKey: tokenKey,
  publicKey: tokenAddress,
} = PrivateKey.randomKeypair()
const token = new MyToken(tokenAddress)

// paste the private key of the deployer and admin account here
const deployerKey = PrivateKey.fromBase58("...")
const ownerKey = PrivateKey.fromBase58("...")
const owner = PublicKey.fromPrivateKey(ownerKey)
const deployer = PublicKey.fromPrivateKey(deployerKey)

const supply = UInt64.from(21_000_000)
const symbol = "MYTKN"
const src = "https://github.com/MinaFoundation/mina-fungible-token/blob/main/FungibleToken.ts"

const fee = 1e8

const tx = await Mina.transaction({ sender: deployer, fee }, () => {
  AccountUpdate.fundNewAccount(deployer, 1)
  token.deploy(owner, supply, symbol, src)
})

tx.sign([deployerKey, tokenKey])
await tx.prove()
await tx.send()

For this and following samples to work, make sure you have enough funds on deployer and admin accounts.

Refer to examples/e2e.eg.ts to see executable end to end example.

Token Operations

In this section, we will explore the various token operations represented by the standard, which include:

  • Minting
  • Burning
  • Transferring between users

Mint tokens

To mint tokens to some address:

// paste the address where you want to mint tokens to
const mintTo = PublicKey.fromBase58("...")
const mintAmount = UInt64.from(1000)

const tx = await Mina.transaction({ sender: owner, fee }, () => {
  // comment this line if a receiver already has token account
  AccountUpdate.fundNewAccount(owner, 1)
  token.mint(mintTo, mintAmount)
})

tx.sign([tokenAdminKey])
await tx.prove()
await tx.send()

[!IMPORTANT] When a token account is created for the first time, an account creation fee must be paid the same as creating a new standard account.

Burn tokens

To burn tokens owned by some address:

// paste the address where you want to burn tokens from
const burnFrom = PublicKey.fromBase58("...")
const burnAmount = UInt64.from(1000)

const tx = await Mina.transaction({ sender: burnFrom, fee }, () => {
  token.burn(burnFrom, burnAmount)
})

tx.sign([burnFromKey])
await tx.prove()
await tx.send()

Transfer tokens between user accounts

To transfer tokens between two user accounts:

// paste the private key of the sender and the address of the receiver
const sendFrom = PublicKey.fromBase58("...")
const sendFromKey = Private.fromPublicKey(sendFrom)
const sendTo = PublicKey.fromBase58("...")

const sendAmount = UInt64.from(1)

const tx = await Mina.transaction({ sender: sendFrom, fee }, () => {
  token.transfer(sendFrom, sendTo, sendAmount)
})
tx.sign([sendFromKey])
await tx.prove()
await tx.send()

Fetch token balance of the account

To get token balance of some account:

// paste the address of the account you want to read balance of
const anyAccount = PublicKey.fromBase58("...")
const balance = token.getBalanceOf(anyAccount)

Refer to examples/e2e.eg.ts to see executable end to end example.

Use in a ZkApp

With zkApps, you can also build smart contracts that interact with tokens. For example, a simple escrow contract, where tokens can be deposited to and withdrawn from.

Escrow contract code

Interacting with tokens from a zkApp is as simple as writing off-chain code (same code like in previous chapter is executed from within zkApp methods):

export class TokenEscrow extends SmartContract {
  @state(PublicKey)
  tokenAddress = State<PublicKey>()
  @state(UInt64)
  total = State<UInt64>()

  deploy(args: DeployArgs & { tokenAddress: PublicKey }) {
    super.deploy(args)
    this.tokenAddress.set(args.tokenAddress)
    this.total.set(UInt64.zero)
  }

  @method
  deposit(from: PublicKey, amount: UInt64) {
    const token = new FungibleToken(this.tokenAddress.getAndRequireEquals())
    token.transfer(from, this.address, amount)
    const total = this.total.getAndRequireEquals()
    this.total.set(total.add(amount))
  }

  @method
  withdraw(to: PublicKey, amount: UInt64) {
    const token = new FungibleToken(this.tokenAddress.getAndRequireEquals())
    const total = this.total.getAndRequireEquals()
    total.greaterThanOrEqual(amount)
    this.total.set(total.sub(amount))
    token.transfer(this.address, to, amount)
  }
}

Interacting with token escrow

Refer to examples/escrow.eg.ts to see executable TokenEscrow example.