Introduction

This document describes how to work with the fungible token standard on Mina. The corresponding code can be found on github, or installed as an npm package.

The fungible token standard uses Mina's native support for custom tokens (see MIP-4). An account on Mina can be created to hold either Mina, or a custom token.

To create a new token, one creates a smart contract, which becomes the owner 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.

The token contract defines the behavior of the token -- how tokens can be minted, burned, transferred, etc. The fungible token standard consists of a smart contract that is suitable for fungible tokens.

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 and FungibleTokenAdmin contracts and deploy them:

const token = new FungibleToken(contract.publicKey)
const adminContract = new FungibleTokenAdmin(admin.publicKey)

const deployTx = await Mina.transaction({
  sender: deployer,
  fee,
}, async () => {
  AccountUpdate.fundNewAccount(deployer, 3)
  await adminContract.deploy({ adminPublicKey: admin.publicKey })
  await token.deploy({
    symbol: "abc",
    src: "https://github.com/MinaFoundation/mina-fungible-token/blob/main/examples/e2e.eg.ts",
  })
  await token.initialize(
    admin.publicKey,
    UInt8.from(9),
    Bool(false),
  )
})
await deployTx.prove()
deployTx.sign([deployer.key, contract.privateKey, admin.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 Owner Account

The token owner 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 owner 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. There are two ways to initiate a transfer: either, the token owner can create the account updates directly (via the transfer method), or the account updates can be created externally, and then approved by the token owner (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 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 Owner account. Use the deriveTokenId() function to get the id of a token.

Approval mechanism

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

[!IMPORTANT] When manually constructing AccountUpdates, make sure to order then appropriately in the call to approveBase(). The contract will not allow flash minting, i.e., tokens cannot be received by an account before they have been sent from an account.

[!NOTE] The number of AccountUpdates that you can pass to approveBase() is limited by the base token contract. The current limit is 9.

API overview

The token standard implementation provides a smart contract FungibleToken that can be deployed as the token owner for a new token. It provides all the user facing functionality that is expected of a fungible token: creating, transferring, and destroying tokens, as well as querying balances and the overall amount of tokens.

Using the standard means using this particular, unmodified, contract. The reason that altering the contract is considered deviating from the standard is the off-chain execution model of MINA: a third party (wallet, exchange, etc.) that wants to integrate a token needs to have access to and execute the code of the token owner contract in order to interact with the token. Agreeing on one particular implementation reduces the burden of integration significantly.

In order to allow for some customization without changing the token owner contract, we delegate some functionality to a secondary admin contract, called FungibleTokenAdmin. This contract controls access to privileged operations such as minting, pausing/resuming transfers, or changing the admin contract itself. This construction allows you to set the rules for monetary expansion, without changing the token owner contract itself. Since the admin contract will only be called from methods of the token contract that are not meant to be called by regular users, the code of the admin contract does not need to be integrated into wallets or other third party applications.

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

The FungibleToken contract

On-chain State and deploy arguments

The on-chain state is defined as follows:

@state(UInt8) decimals = State<UInt8>()
@state(PublicKey) admin = State<PublicKey>()
@state(UInt64) private circulating = State<UInt64>()
@state(Bool) paused = State<Bool>()

The deploy() function takes as arguments

  • A string to use as the token symbol
  • A string pointing to the source code of the contract -- when following the standard, this should point to the source of the standard implementation on github

Immediately after deploying the contract -- ideally, in the same transaction -- the contract needs to be initialized via the initialize() method. Its arguments are

  • The public key of the account that the admin contract has been deployed to
  • A UInt8 for the number of decimals
  • A Bool to determine whether the token contract should start in paused mode. whether token transfers should be enabled immediately. If set to Bool(true), the token contract will be in a paused state initially, and the resume() method will need to be called before tokens can be minted or transferred. This is safer if you have a non-atomic deploy (i.e., if you do not have the admin contract deployed in the same transaction as the token contract is itself is deployed and initialized).

This method initializes the state of the contract. Initially, the circulating supply is set to zero, as no tokens have been created yet.

Methods

The user facing methods of FungibleToken are

@method.returns(AccountUpdate) async burn(from: PublicKey, amount: UInt64): Promise<AccountUpdate>

@method async transfer(from: PublicKey, to: PublicKey, amount: UInt64)
@method async approveBase(updates: AccountUpdateForest): Promise<void>
@method.returns(UInt64) async getBalanceOf(address: PublicKey): Promise<UInt64>
@method.returns(UInt64) async getCirculating(): Promise<UInt64>
@method async updateCirculating()
@method.returns(UInt8) async getDecimals(): Promise<UInt8>

The following methods call the admin account for permission, and are not supposed to be called by regular users

@method async setAdmin(admin: PublicKey)
@method.returns(AccountUpdate) async mint(recipient: PublicKey, amount: UInt64): Promise<AccountUpdate>
@method async pause()
@method async resume()

Minting, burning, and keeping track of the circulating supply

In order to allow multiple minting/burning transactions in a single block, we do not tally the circulating supply as part of the contract state. Instead, we use a special account, the balance of which always corresponds to the total number of tokens in other accounts. The balance of this account is updated in the mint() and burn() methods. Transfers to and from this account are not possible. The getCirculating() method reports the balance of the account.

Note that if you want to require certain limits on the circulation, you should express your constraints using requireBetween() rather than requireEquals(). This is more robust against minting or burning transactions in the same block invalidating your preconditions.

Events

The following events are emitted from FungibleToken when appropriate:

events = {
  SetAdmin: SetAdminEvent,
  Pause: PauseEvent,
  Mint: MintEvent,
  Burn: BurnEvent,
  BalanceChange: BalanceChangeEvent,
}

export class SetAdminEvent extends Struct({
  adminKey: PublicKey,
}) {}

export class PauseEvent extends Struct({
  isPaused: Bool,
}) {}

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

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

export class BalanceChangeEvent extends Struct({
  address: PublicKey,
  amount: Int64,
}) {}

Note that MintEvent, BurnEvent, and BalanceChangeEvent each signal that the balance of an account changes. The difference is that MintEvent and BurnEvent are emitted when tokens are minted/burned, and BalanceChangeEvent is emitted when a transaction takes tokens from some addresses, and sends them to others.

[!NOTE] Note that MintEvent, BurnEvent, and BalanceChangeEvent events can be emitted with amount = 0. If you want to track "true" mints/burns/transfers (for example, to maintain a list of depositors), you will need to filter for non-zero values of amount.

Deploy

Setting up a new fungible token requires three steps: deploying an admin contract, deploying the token contract itself, and initializing the contract

Deploying an admin contract

The first step is deploying the admin contract via its deploy() function.

The admin contract handles permissions for privileged actions, such as minting. It is called by the token contract whenever a user tries to do a privileged action.

The benefit of separating those permissions out into a separate contract is that it allows changing the permission logic without changing the original token contract. That is important because third parties that want to integrate a specific token will need the contract code for that token. If most tokens use the standard token contract, and only modify the admin contract, the integration burden for third parties is reduced significantly.

If you want to change your admin contract, you can write a contract that extends SmartContract and implements FungibleTokenAdminBase.

[!NOTE] Note that if you want to use a custom admin contract, you should write the admin contract from scratch. Inheriting from FungibleTokenAdmin and overwriting specific methods might not work. You can find an example of a custom admin contract in FungibleToken.test.ts.

The initialize() method of FungibleToken takes as one argument the address of the admin contract. If you have written your own admin contract, you will also need to set FungibleToken.AdminContract to that class.

[!NOTE] If you do not use the FungibleToken class as is, third parties that want to integrate your token will need to use your custom contract as well.

[!NOTE] The deploy() function of the admin contract sets permissions such that the admin contract can only be upgraded/replaced in case of a breaking update of the chain, and prevents changing the permissions of the account the contract is deployed to. That way, users can trust that the code of the admin contract will not change arbitrarily. If you write your own admin contract, set permissions accordingly.

Admin Contract and Centralization

The default admin contract uses a single keypair. That is not ideal, as it introduces a single point of failure.

Higher levels of security can be achieved by utilizing a decentralized governance or multi-sig scheme, and it is recommended to do so.

Any user purchasing a token should investigate the key management practices of the token deployer and validate the token contract permissions as one should with any o1js application. In particular, they should check that

  • The verification keys of the admin and token contract are as expected
  • The deployment transaction of the token contract has not been changed to skip the isNew check that has been introduced in Issue 1439. If a malicious deployer were to skip this test, they could mint tokens for themselves before deployment of the token contract.

Initializing and deploying the token contract

Next, the token contract needs to be deployed, via its deploy() function.

After being deployed, the token contract needs to be initialized, by calling the init() function and initialize() method. Those make sure that the contract state is initialized, create an account on the chain that will be used to track the current circulation of the token, set all permissions on the account of the token contract and the account that's tracking the total circulation.

[!NOTE] All three steps above can be carried out in a single transaction, or in separate transactions. It is highly recommended to have a single transaction with all three steps.

[!NOTE] Unless you have a very good reason, please use one transaction that deploys the admin contract, deploys the token contract, and calls initialize() on the token contract.

[!NOTE] Each of the three steps requires funding a new account on the chain via AccountUpdate.fundNewAccount.

[!NOTE] If you use separate transactions for deploying the admin contract and deploying and initializing the token contract, you should start the token contract in paused mode, and only call resume() after you have verified that the admin contract has been successfully deployed.

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

A Note on Upgradeability

Upgradeability of smart contracts is a double edged sword: on one hand, it allows you to fix errors, improve performance, and stay up to date with third party libraries (such as o1js). But on the other hand, the possibility of arbitrary code changes during a redeploy places an enormous amount of trust in the deployer.

In Mina, upgradeability is determined via the permissions of the account that the contract is deployed to. One possibility is to only allow contract upgrades when there has been a breaking change in the protocol itself (see Mina documentation on upgradeability). This was the default behaviour in the original release of the token contract (v1.0.0).

However, this did not allow updating the contract in order to stay up to date with new versions of the o1js library -- which can be desirable, for example to include bug fixes or performance improvements.

In order to allow updates, there is now an option to allow updates of the contract, by setting allowUpdates to true when calling deploy(). This is recommended, in order to allow updating the token contract when there is a new version of o1js. The downside is that this does require token holders to trust the token admin to not make arbitrary changes to the contract. In order to lower the amount of trust needed, we are planning to use a more refined access control (using multi-sig) in an upcoming version of the token standard.

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 mintTx = await Mina.transaction({
  sender: owner,
  fee,
}, async () => {
  // remove this line if a receiver already has token account
  AccountUpdate.fundNewAccount(owner, 1)
  await token.mint(mintTo, new UInt64(2e9))
})
mintTx.sign([owner.privateKey, admin.privateKey])
await mintTx.prove()
await mintTx.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.

Limitations of the Current Design

The design of having one standard implementation of the token contract, and custom admin contracts, allows for some flexibility, but there are some remaining limitations.

  1. Since token transfers should not depend on custom code, the transfer() and approveBase() methods do not call into the admin contract. Consequently, custom transfer logic is not supported.

    Thus, token implementations will struggle to implement the following features:

    1. Fee on transfer. For examples, see here.
    2. Token blacklists or whitelists. For examples, see here.
    3. Circuit-breaking or transfer amount limits. For examples, see here.
  2. Custom burn logic is not supported.

    Many applications may wish to maintain some invariant related to the total supply. For instance, a wMina token contract's admin would have a mechanism to lock or release Mina in return for minting or burning wMina. This would currently be implemented by the wMina admin contract having a method which calls burn on behalf of the user. However, this would only maintain the invariant wMina supply >= locked Mina, rather than strict equality.

    This type of invariant is generally of interest to any token representing a share of some wrapped assets.

  3. Custom balanceOf() logic is not supported:

    1. Rebasable (like stEth) tokens may be difficult to implement. For more examples, see here.

In the future, Mina Foundation and the community might develop more flexible versions of the token implementation that get around some or all of those limitations. That might involve additional hooks, possibly with flags in the main token contract state that determine whether a custom contract should be called or not. But for now, these limitations remain, and token developers should be aware of them.