Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
Custom Token API
This section shows you how to use o1js to perform common token operations, such as minting, burning, and sending tokens.
Minting
Minting generates new tokens whereby the zkApp updates the balance of an account by adding the newly created tokens to it. Minted tokens can be sent to any existing account in the ledger.
To mint new tokens using a zkApp, this example of the this.token
property on the SmartContract
class shows how a zkApp can mint tokens to another account:
class MintExample extends SmartContract {
...
@method mintNewTokens(receiverAddress: PublicKey) {
this.token.mint({
address: receiverAddress,
amount: 100_000,
});
}
}
This example snippet defines a smart contract called MintExample
with a method called mintNewTokens
. Using this.token
, the smart contract specifies the address to mint new tokens for as well as the amount.
Burning
Burning tokens is the opposite of minting. Burning tokens deducts the balance of a certain address by the specified amount. The following examples show how a zkApp can burn tokens of another account:
class BurnExample extends SmartContract {
...
@method burnTokens(addressToDecrease: PublicKey) {
this.token.burn({
address: addressToDecrease,
amount: 100_000,
});
}
}
This example snippet defines a smart contract called BurnExample
with a method called burnTokens
. Similar to minting, the this.token
property calls the burn()
method. This specifies the amount of tokens to burn for the specified address.
A zkApp cannot burn more tokens than the specified account has. If there is such a case, an error is thrown and no such transaction is made.
Sending
To send a custom token, use the send()
method available on this.token
. This example shows how a zkApp can approve sending tokens between two accounts:
class SendExample extends SmartContract {
...
@method sendTokens(
senderAddress: PublicKey,
receiverAddress: PublicKey,
amount: UInt64
) {
this.token.send({
to: receiverAddress,
from: senderAddress,
amount,
});
}
}
This example snippet defines a smart contract called SendExample
with a method called sendTokens()
. Then, in the same fashion, as minting and burning, the this.token
property calls the send()
method.
For a more comprehensive example of how to use custom tokens with a zkApp, see this custom token example provided in token.test.ts.
Proof Authorization
When a zkApp interacts with a custom token that it did not originally create, the calling zkApp must get authorization from the token owner.
A token owner can approve a transaction using a signature or a proof.
Signature Authorization
Signature authorization is the simplest way for a token owner to approve a custom token transfer; however, it is the least flexible. If two separate accounts want to trade a specific custom token, the token owner must sign the transaction before the transaction can be broadcast.
Token zkApps must provide a send()
method. By providing a send()
method, the token zkApp can approve a transaction between two separate accounts and sign it as the token owner. For example:
let tx = await Mina.transaction(feePayerKey, () => {
zkapp.sendTokens(senderAddress, receiverAddress, UInt64.from(1_000)); // Sends 1,000 tokens from `senderAddress` to `receiverAddress`
zkapp.sign(zkappKey); // Signs the transaction with the token owner key
});
await tx.send();
This signature authorization is simple and easy to use, but it is not flexible. If two accounts wanted to trade a custom token, the token owner must sign the transaction before the transaction can be broadcasted. To allow zkApps to get authorization from a token owner without a signature, it makes more sense to let the token owner approve with a proof.
Proof Authorization
Proof authorization is a more flexible way for a token owner to approve a custom token transfer. If two separate accounts want to trade a specific custom token, the token owner can provide a proof that the transaction is valid. This allows the token owner to approve a transaction without signing it.
To allow for proof authorization by the token owner, the child zkApp that is requesting authorization must provide a way for the token owner to inspect the changes it wants to make and verify that they are valid. Token owner contracts have the power to inspect child account updates to enforce custom token rules. For example, a token owner contract could enforce that a child zkApp can send tokens only to a specific address.
Token owner contracts can inspect the updates that a child zkApp wants to make by using a combination of Experimental.Callback
and this.approve
. The first thing that a token contract must do is generate the account updates that a child zkApp wants to make. The child zkApp wraps a function around Experimental.Callback
which contains the changes it wants to make. The token owner can then execute that function with this.approve
and inspect the changes that the child zkApp wants to make.
This example shows how a zkApp can approve a transaction between two accounts by calling a specified SmartContract
method:
/**
* This TokenContract class is used to create a custom token
* and acts as the token owner of the custom token
*/
class TokenContract extends SmartContract {
...
/**
* 'sendTokens()' sends tokens from `senderAddress` to `receiverAddress`.
*
* It does so by deducting the amount of tokens from `senderAddress` by
* authorizing the deduction with a proof. It then creates the receiver
* from `receiverAddress` and sends the amount.
*/
@method sendTokens(
senderAddress: PublicKey,
receiverAddress: PublicKey,
amount: UInt64,
callback: Experimental.Callback<any>
) {
// approves the callback which deductes the amount of tokens from the sender
let senderAccountUpdate = this.approve(callback);
// Create constraints for the sender account update and amount
let negativeAmount = Int64.fromObject(
senderAccountUpdate.body.balanceChange
);
negativeAmount.assertEquals(Int64.from(amount).neg());
let tokenId = this.token.id;
// Create receiver accountUpdate
let receiverAccountUpdate = Experimental.createChildAccountUpdate(
this.self,
receiverAddress,
tokenId
);
receiverAccountUpdate.balance.addInPlace(amount);
}
}
class ZkAppB extends SmartContract {
/*
* This method is used to get authorization from the token owner. Remember,
* the token owner is the one who created the custom token. To debit their
* balance, we must get authorization from the token owner
*/
@method approveSend(amount: UInt64) {
this.balance.subInPlace(amount);
}
}
let tx = await Local.transaction(feePayer, () => {
let amount = UInt64.from(1_000)
// Create a callback inside the transaction that calls the approveSend method.
// This will be executed by the token owner to get authorization.
let approveSendingCallback = Experimental.Callback.create(
zkAppB,
'approveSend',
[amount]
);
// Here, we call the token contract with the callback
tokenZkApp.sendTokens(zkAppBAddress, account1Address, amount, approveSendingCallback);
});
await tx.prove();
tx.sign([zkAppBKey]);
await tx.send();
The result of this example is zkAppB
sending tokens to account1Address
and account1Address
receiving tokens from zkAppB
. The transaction is approved by the token owner without the token owner having to sign the transaction.
For another example of how to approve a transaction with a zkApp, see this authorization example provided.
Understanding Important Terms
If your zkApp interacts with custom tokens, be sure you understand the following essential terms:
Token Id
Token ids are unique identifiers that are used to distinguish between different types of custom tokens. Custom token identifiers are globally unique across the entire network.
Token ids are derived from a zkApp. To check the token id of a zkApp, use the this.token.id
property.
Token Accounts
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 but is specified by a public key and a token id. If an existing account receives a transaction that is specified by a custom token, a token account for that public key and token id is created if it does not exist.
Token accounts are specific for each type of custom token, meaning that a single public key can have many different types of token accounts.
A token account is automatically created for a public key whenever an existing account receives a transaction denoted with a custom token.
Suppose a token account is being created for the first time. In that case, an account creation fee must be paid similarly to creating a new standard account.
Aside from sending custom tokens, custom tokens can be minted and burned by a token owner account. A token owner account is the governing zkApp account for a specific custom token.
Token Owner
A token owner is an account that creates, facilitates, and governs how a custom token is to be used. Concretely, the token owner is the account that created the custom token and is the only account that can mint and burn tokens.
In addition to being the only account that can mint and burn tokens, the token owner is the only account that can approve sending tokens between two accounts. If two accounts want to send tokens to each other, the token owner must approve the transaction. The token owner generates the changes the two accounts want to make and can then make assertions about those changes. The token owner can approve the transaction with a signature or proof.