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
anddeployer
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
AccountUpdate
s, make sure to order then appropriately in the call toapproveBase()
. 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 AccountUpdate
s 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 toBool(true)
, the token contract will be in a paused state initially, and theresume()
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.
-
Since token transfers should not depend on custom code, the
transfer()
andapproveBase()
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:
-
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 releaseMina
in return for minting or burningwMina
. This would currently be implemented by thewMina
admin contract having a method which calls burn on behalf of the user. However, this would only maintain the invariantwMina 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.
-
Custom
balanceOf()
logic is not supported:
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.