Verification Key Rotation
Rotating a key means registering a new verifying key as a new version of a circuit, moving new proofs onto it and retiring the old version. Notes stay spendable throughout. This page is the runbook for that sequence and a record of what the registry holds now.
For why versioning is safe, see On-Chain Verification. For every call and storage item, see the ZK Verifier Reference.
When a key is rotated
| Situation | Outcome |
|---|---|
| The circuit gains or tightens a constraint (for example a canonical-key check, memo binding, or one root per input) | New version of the same circuit id |
| The same circuit gets a new trusted setup (for example the multi-party ceremony) | New version: a different key is a different version |
| The change alters what a note is (its commitment or its nullifier) | Not a rotation. It needs a new circuit id |
| The operation itself goes away | Not a rotation. See Removing a circuit |
The submitter picks the version a spend is verified under. So every version of a circuit must accept the same notes, under rules at least as strict. The full rule is in Version resolution.
The sequence
Every key-management call requires Root. On the testnet, Root is reached through sudo.
1. Publish the artifacts
The circuits package publishes the new version's witness generator, proving key, verifying key
and manifest.json, which records each version's vk_hash.
2. Upgrade the runtime, only if needed
The pallet admits a fixed set of layouts per circuit (see Public input layouts). A key with an arity the runtime does not admit is refused. A rotation that introduces a new layout therefore needs a runtime upgrade first. A new setup or a constraint change that keeps the layout does not.
3. Register and activate
batch_register_verification_keys(entries) takes up to 10 entries and applies them all or not at
all, so a rotation of several circuits cannot land half-registered. Set set_active: true on each
new entry.
Registration refuses:
- a denied key (
DeniedVerificationKey) - a key that does not deserialize as a BN254 Groth16 key, or whose
deltashows phase 2 never ran (InvalidVerificationKey) - a key whose arity gives no admitted layout, including any base-layout (memo-less) spend key
- a version that already exists (
CircuitAlreadyExists) - a removed version with a key other than the one it held (
VersionReused) - a 65th version of one circuit (
TooManyVersions)
The key's prepared form is stored with it, so the first proof under the new version costs the same as any other.
4. Check the hash
curl -s -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"zkVerifier_getAllCircuitVersions","params":[]}' \
https://rpc-1.testnet.orbinum.io
Each vk_hash must equal the manifest's value for that version. A mismatch is cheapest to catch
here. Afterwards, every proof built from the published artifacts would fail on-chain after the
user has spent time proving it.
5. Release the clients
Clients refuse to prove unless the chain supports their version and the chain's vk_hash matches
their artifacts.
| Circuit | Client behaviour | What a rotation needs |
|---|---|---|
| Shield | Proves under the chain's active version | Nothing beyond the published artifacts |
| Transfer, unshield | Prove under the spend version the client is built for | A client release that ships alongside activation |
6. Retire the old version
retire_version(circuit_id, old_version) stops proofs under that version from verifying. Run it as
soon as clients prove under the new version. While the old version is supported, anyone can use it,
including to resubmit a copy of a pending spend or to exploit what the new version fixes.
Prefer retire_version over remove_verification_key. Retiring keeps the key data for auditing,
and unretire_version can reverse it (the key is re-checked against the registration rules).
Removal deletes the key data, and the version can then only take the same key again. Neither call
can touch the active version.
When the old version is unsound, run deny_version(circuit_id, old_version) instead. It retires
the version and records its key's hash in DeniedVkHashes, so no later Root call can unretire it
or register the key under another version. No call undoes it. See
Denied keys.
What happens to existing notes
Nothing. Commitments, leaf positions and the nullifier set are untouched, and every version accepts the same notes. So any unspent note is spent under the new version with no migration step.
The one casualty is a proof built under the retired version but not yet included. It is refused:
a transfer or unshield at pool admission (invalid-transaction code 10), a shield at dispatch with
UnsupportedCircuitVersion. The wallet builds a new proof. See
Spend Validity.
Removing a circuit
purge_circuit(circuit_id) erases every trace of a circuit. That covers every version in
VerificationKeys, PreparedKeys, VkHashes, VerificationStats and RetiredVersions, plus the
active pointer. The CircuitPurged { circuit_id, removed } event counts entries, not versions.
DeniedVkHashes and RemovedVkHashes are kept.
The guard is that the runtime must not know the circuit id. While transfer (1),
unshield (2) and shield (3) are compiled in, purge_circuit refuses them with
CircuitStillInUse, whatever storage holds. It refuses an id with nothing stored with
CircuitHasNoStorage.
So removing a circuit takes two steps:
- A runtime upgrade drops the id from the runtime's known circuits.
purge_circuitclears the storage left behind.
This call is the only way to clear a circuit's last version. retire_version and
remove_verification_key both refuse the active version, so a live circuit always keeps a key to
verify against.
Current on-chain state
Testnet runtime: spec_version 20, transaction_version 7.
| Circuit | Active | Supported | Retired and denied | Layout | vk_hash (active) |
|---|---|---|---|---|---|
| 1 — transfer | 3 | [3] | 1, 2 | Cross-tree, 9 inputs | 0x04e05d74b320601fe1630ffa5b07c617117340c71d9f8d7b639a479f621a769a |
| 2 — unshield | 3 | [3] | 1, 2 | Memo-bound, 8 inputs | 0x24fb6a97b53300b2cf2effa83547ac5a913c0b83f99788dd428ce54498f4e9f8 |
| 3 — shield | 1 | [1] | — | Base, 3 inputs | 0x4835d34f3bb5972c13c2995249bcb0dd8b82b01841d7432b92c96a810f435cb7 |
No other circuit id holds keys. Only the three active versions verify. Transfer and unshield v1 and
v2 let spending-key aliases give one note several nullifiers, so their four keys are in
DeniedVkHashes. These keys are the ones
published in @orbinum/circuits 0.17.1. To read the live registry, run the RPC call in
step 4.
Related
- On-Chain Verification: why rotation never strands a note
- ZK Verifier Reference: extrinsics, errors, layouts and storage
- Trusted Setup: where the keys come from and how to check them
- Spend Validity: admission codes, including an unsupported version