Skip to main content

ZK Verifier Reference

Technical reference for the ZK verification stack. For a conceptual overview see On-Chain ZK Verification.

MVP status

Groth16 verification is implemented. PLONK and Halo2 are declared in the type system but not yet wired to a verifier. Governance-grade key rotation is not implemented — key management currently requires Root (sudo).


Component Architecture

ZK verification is split across three crates. Each has a distinct responsibility and can be depended on independently.

CratePathResponsibility
orbinum-zk-coreprimitives/zk-corePoseidon hash and core cryptographic types: Note, Commitment, Nullifier, SpendingKey, Blinding
orbinum-zk-verifierprimitives/zk-verifierGroth16 verification engine: Groth16Verifier, circuit constants, field encoding utilities. No-std, no FRAME.
pallet-zk-verifierframe/zk-verifierFRAME pallet: on-chain VK registry, extrinsics, events, governance. Depends on both primitives.

When a proof is submitted through the shielded pool, pallet-zk-verifier retrieves the active VK from storage and delegates the actual pairing check to orbinum-zk-verifier::Groth16Verifier. Neither primitive touches Substrate storage — they operate on plain byte slices and return a boolean result.


Supported Circuits

IDConstantOperationPublic inputs
1TRANSFERPrivate transfer (2 in → 2 out)7: merkle_root, nullifiers[2], commitments[2], asset_id, fee
2UNSHIELDWithdrawal to public account7: merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment
6VALUE_PROOFNote value binding (fee-claim)4: commitment, value, asset_id, owner_hash

IDs 3, 4, and 5 are unassigned. Querying them returns null.

Shield needs no circuit

Depositing into the pool is public, so shield submits no ZK proof and has no VK registry entry.


Storage Layout

VerificationKeys[circuit_id][version] → VerificationKeyInfo {
key_data: BoundedVec<u8, 8192>, // arkworks compressed VerifyingKey<Bn254>
system: ProofSystem, // Groth16 | Plonk | Halo2
registered_at: BlockNumber,
}

ActiveCircuitVersion[circuit_id] → u32

RetiredVersions[circuit_id][version] → () // presence = retired

VerificationStats[circuit_id][version] → VerificationStatistics {
total_verifications: u64,
successful: u64,
failed: u64,
}

Each (circuit_id, version) pair is stored independently. Registering v2 does not modify v1. The active version pointer is updated separately via set_active_version.


Extrinsics

All key management extrinsics require Root origin.

register_verification_key(circuit_id, version, vk_bytes)

Inserts a VK for the given (circuit_id, version). If the circuit has no active version yet, this version is automatically set as active.

Re-registering an existing (circuit_id, version) overwrites the stored VK.

batch_register_verification_keys(entries)

Registers up to 10 VKs in one transaction, each entry optionally setting itself active. Either every entry lands or none does — useful when bringing up a chain, where a partial registration would leave the pool able to verify some proof types but not others.

set_active_version(circuit_id, version)

Updates the active version pointer for a circuit. The target version must already have a registered VK, otherwise returns VerificationKeyNotFound.

remove_verification_key(circuit_id, version)

Deletes a VK from storage. Returns CannotRemoveActiveVersion if the target version is currently active. To remove the active version, first activate a different one.

retire_version(circuit_id, version)

Rejects future proofs for (circuit_id, version) while preserving its stored data. This is the mechanism for phasing out a superseded or weakened VK.

Returns CannotRetireActiveVersion if the target is the active version — activate a different one first. Returns VersionAlreadyRetired if it is already retired.

Prefer this over remove_verification_key: retiring keeps the key data available for auditing and is reversible.

unretire_version(circuit_id, version)

Reverses retire_version, allowing proofs for that version again. Returns VersionNotRetired if the version was not retired.

purge_circuit(circuit_id)

Erases every trace of a circuit the runtime no longer implements — all versions across VerificationKeys, VkHashes, VerificationStats, RetiredVersions, plus the active pointer.

This covers a gap the calls above cannot: both remove_verification_key and retire_version refuse to touch the active version, which is what stops a live circuit from ending up with no key to verify against. That same guard leaves no way to retire a circuit as a whole, since its last version is by construction the active one.

A circuit is purgeable only when the runtime no longer knows its id. Transfer (1), unshield (2) and value_proof (6) are rejected with CircuitStillInUse for as long as they remain compiled in, whatever storage holds. Returns CircuitHasNoStorage if there is nothing to clear.

The CircuitPurged event reports entries removed across every map, not versions.

verify_proof(circuit_id, proof, public_inputs)

Standalone extrinsic for direct proof verification. Requires a signed origin (any account).

Accepts an implicit Option<u32> version through execute_verify_proof — if None, resolves to the active version.


Version Resolution at Verification Time

Each note carries the circuit version it was created under, and the shielded pool passes that version explicitly. The proof is verified against that version's VK, not merely the currently active one:

version = circuit_version                      // supplied by the caller, per note
vk = VerificationKeys[circuit_id][version]
result = groth16_verify(vk, proof, public_inputs)

This is what makes VK rotation safe. When a new version is activated, notes created under the old one remain spendable with the old proving key — they do not have to be migrated, and no funds are stranded.

A version stops being accepted only when it is explicitly retired via retire_version. Before retiring a version, ensure holders have had time to move notes created under it.

When None is passed — as the standalone verify_proof extrinsic does by default — resolution falls back to ActiveCircuitVersion[circuit_id].

The SDK's CircuitVersionResolver handles this on the client side: it pins the correct prover for a note and fails closed on an unsupported version or VK mismatch.


Events

EventEmitted when
VerificationKeyRegistered { circuit_id, version }VK inserted via register_verification_key
ActiveVersionSet { circuit_id, version }Active version changed, or first VK auto-activated
VerificationKeyRemoved { circuit_id, version }VK deleted via remove_verification_key
VersionRetired { circuit_id, version }Version retired via retire_version
VersionUnretired { circuit_id, version }Version reinstated via unretire_version
ProofVerified { circuit_id, version }Proof accepted, for the version it was verified against
ProofVerificationFailed { circuit_id, version }Proof rejected

Runtime API

Read-only RPC methods exposed by the node. No origin required.

// Returns version info for a single circuit, or null if not registered
zkVerifier_getCircuitVersionInfo(circuit_id: number): CircuitVersionInfo | null

// Returns info for all registered circuits
zkVerifier_getAllCircuitVersions(): CircuitVersionInfo[]

interface CircuitVersionInfo {
circuit_id: number;
active_version: number;
supported_versions: number[];
vk_hashes: { version: number; vk_hash: `0x${string}` }[];
}

vk_hash is a 32-byte Blake2b hash of the stored key_data, useful for verifying that the on-chain VK matches a known artifact.


Genesis Seeding

If zk_verifier.verification_keys is populated in the chain spec, the runtime inserts each entry as version 1 and sets it as active at block 0.

If the array is empty, the registry starts uninitialized. All proof submissions through the shielded pool will fail with CircuitNotFound until keys are registered via sudo.

For nodes launched with an empty genesis, use setup-vk-sync.ts to bootstrap the registry before running tests or accepting transactions.


Proof Encoding

The pallet expects proofs in arkworks compressed format for Groth16 over BN254. The snarkjs JSON proof format is not accepted.

FieldFormat
Proofarkworks compressed Proof<Bn254> — 128 bytes
Public inputsEach element: 32-byte little-endian BN254 field element
VKarkworks compressed VerifyingKey<Bn254> — size scales with the circuit's public input count, bounded at 8192 bytes

The client SDK's compress_snarkjs_proof_wasm() function converts snarkjs output to this format before submission.