Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
This tutorial was last tested with SnarkyJS 0.9.6.
Tutorial 11: Advanced Account Updates
zkAppManager Accounts and Tokens
Overview
In the last tutorial, you learned how zkApp transactions are composed of account updates and about the structure of account updates.
In this tutorial, you learn about more advanced features of account updates. Namely tokens, zkApp Manager Accounts, and account update assertions.
An earlier tutorial covered how each token is associated with a manager account and how the manager account determines rules for token minting, burning, and transfer.
Internally, what is happening is actually a bit more general than that. The Manager Account for a token actually controls all properties of token accounts.
zkApp Manager Accounts and Tokens
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 criteria of the permissions 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. However, to be allowed to do so, a transaction must get its account update “approved” by the corresponding manager account. The manager account can approve accounts based on itsaccess
permission.
zkApp Token Management
As an example, you can write a token that can approve a zkApp account to send tokens from itself to another zkApp.
To implement this, you would have 3 contracts:
MyToken
: The token contract. On instantiation, serves as the zkAppManagerContract for all instances of the token.TokenUser
: A zkApp contract that manages tokens. This one is instantiated on the Mina (default) tokenId.TokenHolder
: A zkApp contract that stores tokens on behalf of theTokenUser
contract. This one is instantiated on theMyToken
instance TokenId.
The full code is provided in the examples/zkapps/11-advanced-account-updates/ file.
As shown in the example file, a token standard library abstracts many of these details away for standard use. The illustration here helps explain what that library is doing and how to think about zkAppManagerAccounts in detail.
These contracts can be set up as follows:
const myTokenInstance = new MyToken(myTokenAddr);
const tokenUserInstance = new TokenUser(tokenUserAddr);
const tokenHolderInstance = new TokenHolder(
tokenUserAddr,
myTokenInstance.token.id
);
And deployed as follows:
const deploy_txn = await Mina.transaction(deployerAddr, () => {
let feePayerUpdate = AccountUpdate.fundNewAccount(deployerAddr, 4);
feePayerUpdate.send({ to: myTokenAddr, amount: accountFee });
myTokenInstance.deploy();
tokenUserInstance.deploy();
tokenHolderInstance.deploy();
myTokenInstance.approveDeploy(tokenHolderInstance.self);
});
The special thing to note here is 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 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 simply 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 it to be. 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 presented here is just one example of what you could do with it.
A different manager contract could, for example, 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 would contain checks that the on-chain state is only 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, () => {
let feePayerUpdate = AccountUpdate.fundNewAccount(deployerAddr, 1);
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 sendMyTokens(
amount: UInt64,
destination: PublicKey
) {
const tokenHolder = this.tokenHolder;
tokenHolder.transferAway(amount);
this.tokenContract.approveTransfer(tokenHolder.self, destination);
}
Which calls the transferAway
contract on tokenHolder
:
@method 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 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 updates for this transaction looks like:
This shows both the account update for the fee payment - and the main tree that does the token operations. You can see the call to tokenUser
at the top, followed by a call to myToken
that is doing the approving. This transaction approves two account updates:
- One update takes tokens away from the
tokenUser
- The other update 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, and so a valid proof
can be constructed.
As noted earlier, 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. You can now build with Token zkAppManagers.
To see how to apply token managers in a more complex example, see the "wrappedMinaToken" implementation.