Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
Tutorial 11: Advanced Account Updates
In the last tutorial, you learned the structure of account updates and how zkApp transactions are composed of account updates.
In this tutorial, you learn about more advanced features of account updates: tokens, zkApp manager accounts, and account update assertions.
To review the different types of accounts:
- A zkApp account is a smart contract account.
- A zkApp manager account is a specific type of smart contract that manages something; for example, a token.
zkApp Manager Accounts and Tokens
Each token is associated with a zkApp manager account. The manager account determines rules for token minting, burning, and transfer.
Internally, it's more general. The zkApp manager account for a token actually controls all properties of token accounts.
For an account update to be able to modify state (including sending tokens) on a token account, two things must be true:
The account update must meet the permissions criteria on the zkApp account itself.
If the zkApp Account is set up with the
proof
permission, the account update proof must pass the verification key in the account.The account update must set
mayUseToken
to a truthy value (a value that evaluates to true in a Boolean context).However, for this to happen, a transaction must get its account update approved by the corresponding zkApp manager account. The zkApp manager account can approve accounts based on its
access
permission.
zkApp Token Management
For example, you can write a token that can approve a zkApp account to send tokens from itself to another zkApp.
To implement, you need three contracts:
MyToken
: The token contract. On instantiation, it serves as the zkAppManagerContract for all instances of the token.TokenUser
: A zkApp contract that manages tokens. This contract is instantiated on the Mina (default)tokenId
.TokenHolder
: A zkApp contract that stores tokens on behalf of theTokenUser
contract. This contract is instantiated on theMyToken
instance oftokenId
.
The full code is provided in the examples/zkapps/11-advanced-account-updates/ folder.
As shown in the main.ts example file, a token standard library abstracts many of these details away for standard use. This tutorial helps you understand what the library is doing and how to think about a zkApp manager account.
Set up the contracts as follows:
const myTokenInstance = new MyToken(myTokenAddr);
const tokenUserInstance = new TokenUser(tokenUserAddr);
const tokenHolderInstance = new TokenHolder(
tokenUserAddr,
myTokenInstance.token.id
);
And deploy:
const deploy_txn = await Mina.transaction(deployerAddr, async () => {
let feePayerUpdate = AccountUpdate.fundNewAccount(deployerAddr, 4);
await feePayerUpdate.send({ to: myTokenAddr, amount: accountFee });
await myTokenInstance.deploy();
await tokenUserInstance.deploy();
await tokenHolderInstance.deploy();
await myTokenInstance.approveDeploy(tokenHolderInstance.self);
});
Note the myTokenInstance.approveDeploy()
method. Because tokenHolderInstance
is going to exist as a token account, it needs approval to be deployed.
The approveDeploy()
code is as follows:
class MyToken extends SmartContract {
// ...
@method async approveDeploy(deployUpdate: AccountUpdate) {
this.approve(deployUpdate, AccountUpdate.Layout.NoChildren);
// check that balance change is zero
let balanceChange = Int64.fromObject(deployUpdate.body.balanceChange);
balanceChange.assertEquals(Int64.from(0));
}
}
This checks that the deployUpdate
is a single account update, with no children, and that its balance change is zero.
The balance change check is essential: It means the account update isn't creating any additional tokens. Without the check, a user could pass in an account update with a positive balance change, which would mint tokens to its account out of thin air.
Conversely, note that a user can call this method with any account update they like, if it only has a balance change of zero. For example, a user could make their 8-field on-chain state whatever they want. In other words, the balance change is the only property of token accounts that the token manager contract chooses to "manage".
The approval mechanism gives you (the developer) complete freedom in encoding constraints on account updates in your manager contract logic.
The fungible token use case is just one example of possibilities. For example, a different zkApp manager contract could store NFTs (or a commitment to NFTs) in the on-chain state of user accounts. In that case, the on-chain state would be the meaningful part of token accounts. The manager contract wouldn't care about constraining the balance change. Instead, the approval logic contains checks that the on-chain state is updated in a certain way:
this.approve(deployUpdate, AccountUpdate.Layout.NoChildren);
// check that the deploy update doesn't modify on-chain state
deployUpdate.body.update.appState.every((x) => x.isSome.assertFalse());
Next, here is an example of sending tokens from the tokenUser
/tokenHolder
to another account:
const txn2 = await Mina.transaction(deployerAddr, async () => {
let feePayerUpdate = AccountUpdate.fundNewAccount(deployerAddr, 1);
await tokenUserInstance.sendMyTokens(UInt64.from(100), deployerAddr);
});
The fundNewAccount
call is included as the deployer account. In this case, it doesn't have an existing token account.
tokenUserInstance.sendMyTokens(...)
is implemented as follows:
@method async sendMyTokens(
amount: UInt64,
destination: PublicKey
) {
const tokenHolder = this.tokenHolder;
tokenHolder.transferAway(amount);
this.tokenContract.approveTransfer(tokenHolder.self, destination);
}
Which calls the transferAway()
method on tokenHolder
:
@method async transferAway(amount: UInt64) {
// TODO: in a real zkApp, here would be application-specific checks for whether we want to allow sending tokens
this.balance.subInPlace(amount);
this.self.body.mayUseToken = AccountUpdate.MayUseToken.ParentsOwnToken;
}
And is approved by the token contract's approveTransfer
:
@method async approveTransfer(transferUpdate: AccountUpdate, receiver: PublicKey) {
this.approve(transferUpdate, AccountUpdate.Layout.NoChildren);
let balanceChange = Int64.fromObject(transferUpdate.body.balanceChange);
// assert that the balance change is negative
balanceChange.isPositive().not().assertTrue();
// move the same amount to the receiver
this.token.mint({ address: receiver, amount: balanceChange.magnitude });
}
The account update is for the fee payment and the main tree that does the token operations. The call to tokenUser
is followed by a call to myToken
that does the approving.
This transaction approves two account updates:
- Takes tokens away from the
tokenUser
- Sends tokens to the deployer account
Both updates have the mayUseToken
field set to parentsOwnToken
.
Because of the logic in the myToken
approval account, the balances must match between these two account updates. Which they do, so a valid proof
can be constructed.
A token standard library abstracts many of these details away for standard use.
Conclusion
Congratulations! You have explored some of the advanced features of account updates and are equipped to build with token zkApp manager accounts.
To see how to apply token managers in a more complex example, see the wrappedMinaToken implementation.