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 | VK size |
|---|---|---|---|---|
| 1 | TRANSFER | Private transfer (2 in → 2 out) | 5: merkle_root, nullifier1, nullifier2, commitment1, commitment2 | 424 bytes |
| 2 | UNSHIELD | Withdrawal to public account | 5: merkle_root, nullifier, recipient, amount, asset_id | 424 bytes |
| 3 | SHIELD | Deposit into the shielded pool | — no VK, no proof required | — |
| 4 | DISCLOSURE | Selective disclosure of note contents | 4: commitment, revealed_value, revealed_asset_id, revealed_owner_hash | 392 bytes |
| 5 | PRIVATE_LINK | Account linking with privacy | 2: commitment, call_hash_fe | 328 bytes |
Shield does not require a ZK proof submission from the user. The commitment is computed and inserted on-chain by the runtime, so there is no SHIELD entry in the VK registry.
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
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.
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.
verify_proof(circuit_id, proof, public_inputs)
Standalone extrinsic for direct proof verification against the circuit's active version. 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
The shielded pool calls ZkVerifierPort::verify_transfer_proof(..., version: None). The use case resolves the version as follows:
version = ActiveCircuitVersion[circuit_id] // resolved when None is passed
vk = VerificationKeys[circuit_id][version]
result = groth16_verify(vk, proof, public_inputs)
The standalone verify_proof extrinsic can target any registered version explicitly, but the shielded pool always uses the active version.
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 |
ProofVerified { circuit_id, version } | Proof accepted (version = active at call time) |
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> — 328–424 bytes per circuit |
The client SDK's compress_snarkjs_proof_wasm() function converts snarkjs output to this format before submission.