ZK Verifier Reference
Technical reference for the ZK verification stack. For a conceptual overview see On-Chain ZK Verification.
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.
| Crate | Path | Responsibility |
|---|---|---|
orbinum-zk-core | primitives/zk-core | Poseidon hash and core cryptographic types: Note, Commitment, Nullifier, SpendingKey, Blinding |
orbinum-zk-verifier | primitives/zk-verifier | Groth16 verification engine: Groth16Verifier, circuit constants, field encoding utilities. No-std, no FRAME. |
pallet-zk-verifier | frame/zk-verifier | FRAME 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
| ID | Constant | Operation | Public inputs |
|---|---|---|---|
| 1 | TRANSFER | Private transfer (2 in → 2 out) | 7: merkle_root, nullifiers[2], commitments[2], asset_id, fee |
| 2 | UNSHIELD | Withdrawal to public account | 7: merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment |
| 6 | VALUE_PROOF | Note value binding (fee-claim) | 4: commitment, value, asset_id, owner_hash |
IDs 3, 4, and 5 are unassigned. Querying them returns null.
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
| Event | Emitted 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.
| Field | Format |
|---|---|
| Proof | arkworks compressed Proof<Bn254> — 128 bytes |
| Public inputs | Each element: 32-byte little-endian BN254 field element |
| VK | arkworks 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.