Skip to main content

Upgrading a zkApp

A zkApp account holds the verification key of its contract. Change the contract and the verification key changes with it, so an upgrade means writing a new key to an account that already holds state.

This example shows the upgrade working, and then shows it going wrong in the way that produces no error at all.

The code is in examples/zkapps/feature-overview/src/upgradability and runs on a local blockchain:

cd examples/zkapps/feature-overview
npm install
npm test

Prerequisites

  • Tutorial 10: Account Updates, because an upgrade is an account update that sets the verification key
  • Node.js 22.19.5 or later, which is what o1js 3 requires

The permission that decides

Whether an upgrade is possible at all is a permission on the account:

this.account.permissions.set({
...Permissions.default(),
setVerificationKey:
Permissions.VerificationKey.signature(),
});
  • signature() — the holder of the private key can replace the contract. The zkApp is upgradable, and its users trust the key holder.
  • impossibleDuringCurrentVersion() — the contract cannot change.
  • proofDuringCurrentVersion() — the key can be replaced only by a transaction that satisfies a method of the contract itself, so the upgrade follows rules the contract states.
  • none() — anyone can replace the contract. Almost never what you want.

The two names that end in DuringCurrentVersion say what they mean: the restriction holds as long as the network accepts the current transaction version. After a hard fork that increments that version, both are treated as signature, so the original account owner can redeploy. An immutable zkApp is immutable until the protocol itself changes underneath it.

See Permissions for the full set, and Secure zkApps for how to choose.

An upgrade that works

AddV1 keeps one state field and adds 2 to it:

export class AddV1 extends SmartContract {
@state(Field) num = State<Field>();

init() {
super.init();
this.num.set(Field(1));
}

@method async update() {
const currentState = this.num.getAndRequireEquals();
this.num.set(currentState.add(2));
}
}

Deploy it and call update() once, and num is 3.

AddV2 is the same contract with one number changed — it adds 4 instead of 2 — so it compiles to a different verification key. Writing that key to the account is an account update like any other:

const tx = await Mina.transaction({ sender, fee }, async () => {
const update = AccountUpdate.createSigned(zkApp.address);
update.account.verificationKey.set(v2Contract.verificationKey);
});
await tx.sign([senderKey, zkAppKey]).prove();
await tx.send().wait();

After that, build transactions with the new class, because the proof has to match the key now on the account:

zkApp = new AddV2(address);

Call update() again and num is 7: 1 at deployment, plus 2 from V1, plus 4 from V2. The state survives the upgrade — only the code that reads and writes it changed.

Adding state in an upgrade

AddV3 adds a second state field, callCount, and three methods that add different amounts. It declares the new field after the existing one:

export class AddV3 extends SmartContract {
@state(Field) num = State<Field>();
@state(Field) callCount = State<Field>();

Upgrade to it and call add10(), and num is 17 and callCount is 1. The existing value is still where the new contract expects it.

The same upgrade, done unsafely

AddV3Unsafe is the same contract with the two fields declared the other way round:

export class AddV3Unsafe extends SmartContract {
@state(Field) callCount = State<Field>();
@state(Field) num = State<Field>();

On-chain state is positional. It is eight field elements in a fixed order, and the names live in your TypeScript, not on the chain. @state assigns slot 0 to the first declaration, slot 1 to the second.

So on an account deployed with V1, where slot 0 holds num = 3:

slotV1 reads it asV3Unsafe reads it as
0num = 3callCount = 3
1unused = 0num = 0

Call add5() on the upgraded account and the result is callCount = 4 and num = 5. The balance of 3 that the account held is now a call counter, and the counter of 0 is now the balance.

caution

Nothing fails. There is no error, no rejected transaction and no warning — the transaction succeeds and the state is silently wrong.

The rule that follows: in an upgrade, add new state fields after the existing ones and never reorder or remove them. The same applies to their types.

Conclusion

You have replaced the verification key of a deployed zkApp, kept its state across the change, and seen the one mistake that produces corrupted state instead of an error.

For the concept behind this, see Upgradability in the o1Labs docs.