zkApp programmability is not yet available on the Mina Mainnet, but zkApps can now be deployed on Berkeley Testnet.
Tutorial 10: Account Updates
The fundamental data structure that Mina transactions are built from is called an account update. Account updates are a flexible and powerful data structure that can express all kinds of updates, events, and preconditions you use to develop smart contracts.
Each zkApp transaction constructed by o1js is composed of one or more AccountUpdate classes, which are a set of instructions for the Mina network to perform, such as altering on-chain state, emitting an event, and so on.
Each AccountUpdate
can make assertions about its account, apply updates to its account, and make assertions about its child AccountUpdates
.
Transactions are structured as a list of trees of AccountUpdates
applied with a pre-order traversal.
Permissions, Preconditions, and Composability
Permissions, preconditions, composability, and tokens are the core features of zkApps that are implemented using AccountUpdates
.
To learn more, see these o1js docs:
In this tutorial, you learn the essential account update features.
AccountUpdate contents
The AccountUpdate
class is a set of instructions for the Mina network. It includes preconditions (conditions that must be true for the account update to be applied) and a list of state updates that need to be authorized by a signature or proof.
Each AccountUpdate class has these components:
PublicKey
: The account address for the account updateTokenId
: A unique hash representing the custom token. Defaults to the MINA TokenId (1
).Together,
PublicKey
andTokenId
uniquely identify an account on the Mina network.Preconditions
: Conditions that must be true for the account update to be applied. Corresponds to assertions in an o1js method.Updates
: Things changed by the account update, such as including the zkApp state, permissions, and verification key.BalanceChange
: Any changes to the balanceAuthorization
: How the zkApp is authorized; must be a proof (corresponding to the verification key on the account), a signature, or none. See Authorizations.
Other AccountUpdate
components are available to use, but are not covered in this tutorial:
MayUseToken
: Whether the zkApp has permissions to manipulate its token.Layout
: Allows for assertions about the structure of anAccountUpdate
.
Account updates for a non-upgradable zkApp
If the verification key cannot be changed, the zkApp smart contract is considered non-upgradeable. The setVerificationKey
permission sets the ability to change the verification key of the account.
Now, you can start building an example zkApp to explore permissions, preconditions, and composability with AccountUpdates
.
Visualize transactions
To visualize transactions, use the mina-transaction-visualizer
library. Install this library to use in your own zkApp:
npm install mina-transaction-visualizer --save
Smart Contracts
In this tutorial, you build two smart contracts:
ProofsOnlyZkApp
: Non-upgradeable proof onlySecondaryZkApp
: the other zkApp
The full source code for this tutorial is provided in the examples/zkapps/10-account-updates directory on GitHub.
Non-upgradeable proof only
The full example code is provided in the examples/zkapps/10-account-updates/src/ProofsOnlyZkApp.ts file.
Goal: Configure this zkApp to be modifiable only by using proofs.
For this example, the zkApp is not upgradable after it is deployed. This means that while the zkApp developer owns the private key to initially deploy the zkApp, after its first deployment, the zkApp requires proof authorization and consequently can be updated only transactions that fulfill the zkApp smart contract logic. The private key is no longer useful for anything.
This zkApp has methods that call other methods to let you explore the impacts to a transaction's account updates.
Start by adding the main contents of the zkApp:
export class ProofsOnlyZkApp extends SmartContract {
@state(Field) num = State<Field>();
@state(Field) calls = State<Field>();
deploy(args: DeployArgs) {
super.deploy(args);
this.account.permissions.set({
...Permissions.default(),
setDelegate: Permissions.proof(),
setPermissions: Permissions.proof(),
setVerificationKey: Permissions.proof(),
setZkappUri: Permissions.proof(),
setTokenSymbol: Permissions.proof(),
incrementNonce: Permissions.proof(),
setVotingFor: Permissions.proof(),
setTiming: Permissions.proof(),
});
}
@method async init() {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertFalse();
super.init();
this.num.set(Field(1));
this.calls.set(Field(0));
}
...This code configures the zkApp as described and initializes the zkApp with the values you want.
By asserting that
provedState
isfalse
ininit()
, you ensure thatinit()
cannot be called again after the zkApp is set up during the initial deployment. Without this assertion, your zkApp could be reset by anyone calling theinit()
method on your zkApp.tipThis assertion is a recommended best practice for most zkApps.
Next, add two functions:
...
@method async add(incrementBy: Field) {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const num = this.num.get();
this.num.requireEquals(num);
this.num.set(num.add(incrementBy));
this.incrementCalls();
}
@method async incrementCalls() {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const calls = this.calls.get();
this.calls.requireEquals(calls);
this.calls.set(calls.add(Field(1)));
}
...These methods also assert
provedState
istrue
to ensure the zkApp was initialized as expected becauseprovedState
becomes true afterinit()
is invoked.tipThis assertion is a recommended best practice for most zkApps.
The
add()
method calls theincrementCalls()
method. You can see how this is reflected in theadd()
transaction'sAccountUpdate
structure.Finally, add one more function,
callSecondary()
, that calls a different zkApp:...
@method async callSecondary(secondaryAddr: PublicKey) {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const secondaryContract = new SecondaryZkApp(secondaryAddr);
const num = this.num.get();
this.num.requireEquals(num);
secondaryContract.add(num);
// NOTE this gets the state at the start of the transaction
this.num.set(secondaryContract.num.get());
this.incrementCalls();
}
}The
callSecondary()
method takes the address of the other zkApp,SecondaryZkApp
, and calls a method on it. Note that the impact of calling that method occurs after this set of AccountUpdates—so when you callsecondaryContract.num.get()
, it gets the value before this transaction is applied.
Finally, look briefly at SecondaryZkApp.ts that contains:
export class SecondaryZkApp extends SmartContract {
@state(Field) num = State<Field>();
@method async init() {
super.init();
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertFalse();
this.num.set(Field(12));
}
@method async add(incrementBy: Field) {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const num = this.num.get();
this.num.requireEquals(num);
this.num.set(num.add(incrementBy));
}
}
You declare functions for initializing the account and the add()
method that is called from the earlier ProofOnlyZkApp
.
Running Your Smart Contracts and Visualizing the AccountUpdates
Now it's time to learn about the main.ts file that creates transactions with the earlier smart contracts and the account update visualizations it creates.
Import the transaction visualizer:
...
import { showTxn, saveTxn, printTxn } from 'mina-transaction-visualizer';
...This provides three functions:
// creates a png file of a transaction, and opens it in a local image viewer
async showTxn(txn: Mina.Transaction, name: string, legend: Legend)
// creates a png file of a transaction, and saves it to a path
saveTxn(txn: Mina.Transaction, name: string, legend: Legend, path: string)
// prints a nicely formatted view of a transaction
printTxn(txn: Mina.Transaction, name: string, legend: Legend)
// with legend type, to replace public keys with human readable strings:
type Legend = { [pk: string]: string };Next, define the legend as follows:
const legend = {
[proofsOnlyAddr.toBase58()]: 'proofsOnlyZkApp',
[secondaryAddr.toBase58()]: 'secondaryZkApp',
[deployerPubkey.toBase58()]: 'deployer',
};Create and send a deploy transaction, then visualize it:
const deployTxn = await Mina.transaction(deployerAccount, async () => {
AccountUpdate.fundNewAccount(deployerAccount, 2);
await proofsOnlyInstance.deploy();
await secondaryInstance.deploy();
});
await deploy_txn.prove();
deploy_txn.sign([deployerKey, proofsOnlySk, secondarySk]);
await showTxn(deploy_txn, 'deploy_txn', legend);
await deploy_txn.send();
This yields the following visualization of deploy_txn
.
This visualization is best viewed in a new tab.
The deploy transaction includes 5 accountUpdates represented as ovals. Described from left to right;
- Takes the new account fee from the deployer for deploying the zkApps.
Note the
-2
on thebalanceChange
field. - Deploys the
proofsOnlyZkApp
instance. Note the permissions are all set to the values in the zkApp'sdeploy
field, and thepreconditions
asserting the nonce, so the transaction can't be applied more than once. - Initializes the
proofsOnlyZkApp
. Note the precondition that it can't already be in a proved state. - Deploys an instance of
secondaryZkApp
. Note the permissions here are set to default values, in contrast to the deployment in theproofsOnlyZkApp
example. - Initializes the
secondaryZkApp
instance.
When the transaction is run on chain, these account updates are checked by the Mina network and applied if valid. Each AccountUpdate
class includes either a proof corresponding to the verification key in the zkApp account on-chain or a signature corresponding to the zkApp address. In this case, only proof authorization is allowed.
Call add()
on proofsOnlyZkApp
Call
add()
on your instance ofproofsOnlyZkApp
:const txn1 = await Mina.transaction(deployerAccount, async () => {
await proofsOnlyInstance.add(Field(4));
});
await txn1.prove();
await showTxn(txn1, 'txn1', legend);
await txn1.send();
This returns the following visualization of txn1
:
See download link here.
Two AccountUpdates
Now there are two AccountUpdates:
- The parent corresponds to the
add()
method call - The child corresponds to the
this.incrementCalls()
call that the parent makes.
One update is the child because it was called from the parent, which also implies that the parent has the child included as part of its proof. To learn more about parent/child account updates, see Signing transactions and explicit account updates in the Payment to zkApp example.
As a reminder, this update corresponds to code:
...
@method async add(incrementBy: Field) {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const num = this.num.get();
this.num.requireEquals(num);
this.num.set(num.add(incrementBy));
this.incrementCalls();
}
@method async incrementCalls() {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const calls = this.calls.get();
this.calls.requireEquals(calls);
this.calls.set(calls.add(Field(1)));
}
...
View the account updates visualization again.
- The first AccountUpdate sets the state of
appState[0]
to5
— corresponding to the value passed tothis.num.set()
in theadd()
method. - In the second AccountUpdate,
appState[1]
is set to1
—corresponding to the value passed tothis.calls.set()
in theincrementCalls()
contract.
Finally, call callSecondary
on your instance of proofsOnlyZkApp
:
const txn2 = await Mina.transaction(deployerAccount, async () => {
await proofsOnlyInstance.callSecondary(secondaryAddr);
});
await txn2.prove();
await showTxn(txn2, 'txn2', legend);
await saveTxn(deploy_txn, 'deploy_txn', legend, './txn2.png');
await txn2.send();
This returns the following visualization of txn2
:
This txn2 visualization is best viewed in a new tab.
And a quick reminder of the code for callSecondary()
:
@method async callSecondary(secondaryAddr: PublicKey) {
this.account.provedState.requireEquals(this.account.provedState.get());
this.account.provedState.get().assertTrue();
const secondaryContract = new SecondaryZkApp(secondaryAddr);
const num = this.num.get();
this.num.requireEquals(num);
secondaryContract.add(num);
// NOTE this gets the state at the start of the transaction
this.num.set(secondaryContract.num.get());
this.incrementCalls();
}
This call produces three accountUpdates:
callSecondary()
(the parent)secondaryZkApp.add()
(the left child)incrementCalls()
(the right child)
As described in the code comment, callSecondary
sets this.num
to 12
which is the value of secondaryContract
at the "start" of the transaction.
Conclusion
Congratulations! You have explored the core features of AccountUpdates and learned about visualizing the AccountUpdates for a set of transactions. You can build more complicated transactions that involve multiple zkApps. This tutorial builds a foundational understanding of how o1js and zkApps work to enable permissions, preconditions, and composability.