ZK Verifier Reference
Reference for the ZK verification stack. For the concepts behind it, see On-Chain Verification. For the governance procedure, see Verification Key Rotation.
Components
| Crate | Path | Responsibility |
|---|---|---|
orbinum-zk-core | primitives/zk-core | Poseidon (PoseidonHasher::hash_2 / hash_4, poseidon_hash_1) and the core note types |
orbinum-zk-verifier | primitives/zk-verifier | Groth16 over BN254: key validation and preparation, verify_prepared, circuit ids and input layouts, field encoding, and the bn254_groth16_verify host function. no_std, no FRAME. |
pallet-zk-verifier | frame/zk-verifier | The on-chain key registry, key rules, statement encoding, extrinsics, events and statistics. Other pallets reach it through ZkVerifierPort. |
The shielded pool never hands the verifier field elements. It passes a statement of domain values (roots, nullifiers, commitments, asset, fee, amount, the recipient's raw bytes, the memo digest) together with a circuit version. The pallet loads that version's prepared key, reads the layout from the key's arity, encodes the statement for that layout and verifies.
Circuits
| ID | Constant | Operation | Admitted key layouts |
|---|---|---|---|
| 1 | TRANSFER | Private transfer, 2 in and 2 out | Memo-bound (8 inputs) or cross-tree (9) |
| 2 | UNSHIELD | Withdrawal to a public account | Memo-bound (8) |
| 3 | SHIELD | Deposit: the commitment opens to the deposited amount | Base (3) |
The runtime knows no other id. Querying an unknown id returns null. A key stored under an unknown
id is only checked to deserialize, and the id's storage can be cleared with purge_circuit.
Storage
VerificationKeys[circuit_id][version] → VerificationKeyInfo {
key_data: BoundedVec<u8, 8192>, // arkworks-compressed VerifyingKey<Bn254>
system: ProofSystem, // always Groth16
registered_at: BlockNumber,
}
PreparedKeys[circuit_id][version] → BoundedVec<u8, MAX_PREPARED_VK_BYTES>
VkHashes[circuit_id][version] → [u8; 32] // blake2_256(key_data)
ActiveCircuitVersion[circuit_id] → u32
RetiredVersions[circuit_id][version] → () // present = retired
VerificationStats[circuit_id][version] → { total_verifications,
successful_verifications,
failed_verifications } // u64 each
DeniedVkHashes[vk_hash] → () // present = denied
RemovedVkHashes[circuit_id][version] → [u8; 32] // hash of the key a removed version held
- Each
(circuit_id, version)is independent, so registering one version modifies no other. - A key, its hash and its prepared form are written and removed together.
- A circuit holds at most 64 versions.
ProofSystemalso declaresPlonkandHalo2, but no verifier exists for them and every stored key is Groth16.VerificationStatscounts each check. A check inside a call that then fails is rolled back with that call.DeniedVkHashesandRemovedVkHashesare append-only. No call removes an entry,remove_verification_keyandpurge_circuitincluded. See Denied keys.
Extrinsics
Every key-management call requires Root. verify_proof takes any signed origin. Every path that
stores or re-enables a key enforces the same rules:
- its hash is not in
DeniedVkHashes(DeniedVerificationKey) - it deserializes as a BN254 Groth16 key, and its
deltais independent: a key whosedeltais±gammaor±the G2 generator, what snarkjs leaves when phase 2 never ran, is refused (InvalidVerificationKey). With such a key, anyone can forge a proof from public key data alone. - its arity gives a layout the circuit admits (see Public input layouts)
| # | Call | Origin | Effect | Refused with |
|---|---|---|---|---|
| 0 | register_verification_key(circuit_id, version, vk) | Root | Stores the key, its hash and its prepared form. Activates it if the circuit has no active version. | EmptyVerificationKey, DeniedVerificationKey, InvalidVerificationKey, CircuitAlreadyExists, VersionReused (a removed version with another key), TooManyVersions |
| 1 | set_active_version(circuit_id, version) | Root | Moves the active pointer | VerificationKeyNotFound, UnsupportedCircuitVersion (retired), DeniedVerificationKey, InvalidVerificationKey |
| 2 | remove_verification_key(circuit_id, version) | Root | Deletes the key, prepared form, hash, statistics and retired flag, and records the key's hash in RemovedVkHashes | VerificationKeyNotFound, ActiveVersionNotSet, CannotRemoveActiveVersion |
| 3 | verify_proof(circuit_id, proof, public_inputs) | Signed | Verifies under the active version, with caller-encoded 32-byte inputs taken as-is | VerificationFailed on an invalid proof |
| 4 | batch_register_verification_keys(entries) | Root | Up to 10 {circuit_id, version, verification_key, set_active} entries. Either all are applied or none. | InvalidBatchSize, or any entry's registration error |
| 5 | retire_version(circuit_id, version) | Root | The version stops verifying, and its data is kept | VerificationKeyNotFound, VersionAlreadyRetired, ActiveVersionNotSet, CannotRetireActiveVersion |
| 6 | unretire_version(circuit_id, version) | Root | The version verifies again, after its key is re-checked against the registration rules | VersionNotRetired, VerificationKeyNotFound, DeniedVerificationKey, InvalidVerificationKey |
| 7 | purge_circuit(circuit_id) | Root | Clears the circuit's keys, prepared forms, hashes, statistics, retired flags and active pointer. DeniedVkHashes and RemovedVkHashes are kept. | CircuitStillInUse (id known to the runtime), CircuitHasNoStorage, TooManyVersions |
| 8 | deny_version(circuit_id, version) | Root | Retires the version and records its key's hash in DeniedVkHashes, for good | VerificationKeyNotFound, CannotRetireActiveVersion, KeyAlreadyDenied |
verify_proof fails before the pairing when the inputs do not match the key's arity. The
extrinsic is limited to 128-byte proofs and 32 public inputs. The removal procedure for
purge_circuit is in Removing a circuit.
A removed version can only take the same key again: RemovedVkHashes pins it, so a client that
cached what a version means is never served another key under it.
Denied keys
DeniedVkHashes is the on-chain record of keys found unsound. deny_version and the chain spec's
denied_vk_hashes fill it, and nothing removes an entry. A denied key:
| Path | Result |
|---|---|
| Register, activate, unretire | Refused with DeniedVerificationKey, under any circuit or version |
| Verification, if the key is stored | Verifies nothing, active or not (UnsupportedCircuitVersion) |
deny_version again | Refused with KeyAlreadyDenied |
deny_version never applies to the active version. Retiring alone is reversible with
unretire_version and leaves the key free to be registered under another version; denying is not.
Version resolution
The shielded pool always names the version. Each extrinsic's circuit_version is passed through,
and the proof is checked against that version's key:
version = circuit_version // from the shield, transfer or unshield call
key = PreparedKeys[circuit_id][version] // falls back to VerificationKeys
valid = verify_prepared(key, proof, encode(statement, layout_of(key)))
- A retired version, a denied key, or a version with no key fails with
UnsupportedCircuitVersion. The denied-key check adds one storage read to every verification. For an unsigned transfer or unshield, the transaction pool checks this first and rejects with invalid-transaction code 10. See Spend Validity. verify_proofpasses no version and resolves to the active one.- The active version is what clients build new shield proofs under. A spend may use any supported version, meaning any registered version that is not retired.
The version is chosen by whoever submits the transaction, and it is not bound into a note's commitment. The registry therefore follows two rules:
- Every version of a circuit accepts the same notes, under rules at least as strict. All versions share one commitment and one nullifier for a given note. A new version may add checks, such as binding the memos, requiring a canonical spending key or taking one root per input. It may never change what a note is. Any such change needs a new circuit id. The pallet checks arity, not semantics, so this rule is enforced by governance.
- A superseded version is retired as soon as its replacement is in use. While it is supported, anyone can submit a proof under it, including a copy of someone else's pending spend or a proof that exploits the weakness the new version fixes.
Public input layouts
The number of public inputs is read from the registered key, never from the caller. A Groth16
key's gamma_abc_g1 has inputs + 1 points, so the key determines its own arity, and that arity
selects the layout:
| Layout | Arity | Admitted for | Inputs |
|---|---|---|---|
| Base | 3 | Shield | The circuit's base inputs |
| Base | 7 | — | Refused for transfer and unshield at registration, activation, unretiring and genesis. A base spend key already in storage verifies nothing. |
| Memo-bound | 8 | Transfer, unshield | Base inputs with memo_hash last |
| Cross-tree | 9 | Transfer | The memo-bound inputs, with merkle_roots[0], merkle_roots[1] in place of the single root |
The active versions take their inputs in this order:
| # | Transfer v3 (cross-tree) | Unshield v3 (memo-bound) | Shield v1 (base) |
|---|---|---|---|
| 0 | merkle_roots[0] | merkle_root | commitment |
| 1 | merkle_roots[1] | nullifier | value |
| 2 | nullifiers[0] | amount | asset_id |
| 3 | nullifiers[1] | recipient | |
| 4 | commitments[0] | asset_id | |
| 5 | commitments[1] | fee | |
| 6 | asset_id | change_commitment | |
| 7 | fee | memo_hash | |
| 8 | memo_hash |
Two inputs are derived by the pallet, not passed through:
| Input | Value |
|---|---|
memo_hash | blake2_256 of the SCALE-encoded memos, reduced mod r. Transfer uses its two output memos in order. Unshield uses [change_memo], which is empty for a total unshield. See Memo binding. |
recipient | blake2_256 of the recipient's 32 account bytes, reduced mod r. An AccountId32 is wider than the field, so without hashing an alias could stand in for the real account. |
A transfer whose two roots differ can only be checked by a cross-tree key. Under a memo-bound key it fails without a pairing. An unshield spends one note, so it has no cross-tree layout.
Every public input is a 32-byte little-endian canonical BN254 element. A u32 fills the first
4 bytes of its slot and a u128 the first 16. A hash is reduced mod the field order.
Prepared Keys
Groth16 verification begins by preparing the key. This step validates its points and precomputes
the pairing inputs that depend only on the key. The work is the same for every proof under a key,
so the pallet does it once, at registration, and stores the result in PreparedKeys.
- A proof is verified from the prepared form, which skips about half the cost of a verification. The prepared form is loaded after a layout check without re-validating its points. This is sound because registration is its only writer, and registration runs after the key has passed validation.
- A key with no prepared form is prepared on the fly. Only storage written outside the pallet can leave a key in that state. If the key fails to prepare, the proof fails.
Native host function
orbinum-zk-verifier defines bn254_groth16_verify, a host function that runs verify_prepared
natively. The runtime imports it as ext_groth_16_host_interface_bn254_groth16_verify_version_1 and
calls it for every proof. A proof takes about 0.6 ms natively against 2.4 ms in Wasm, with the same
result and the same weight.
Every node must run 0.4.0 or later, which registers the function. A node without it cannot instantiate the runtime and does not import blocks. Version 1 of the function is frozen: any observable change becomes a new version.
Events
| Event | Emitted when |
|---|---|
VerificationKeyRegistered { circuit_id, version } | A key is registered, alone or in a batch |
BatchVerificationKeysRegistered { count } | A batch registration succeeds |
ActiveVersionSet { circuit_id, version } | The active version changes, including the automatic activation of a circuit's first key |
VerificationKeyRemoved { circuit_id, version } | remove_verification_key succeeds |
VersionRetired { circuit_id, version } | retire_version succeeds |
VersionUnretired { circuit_id, version } | unretire_version succeeds |
CircuitPurged { circuit_id, removed } | purge_circuit succeeds. removed counts entries across every map |
VerificationKeyDenied { circuit_id, version, vk_hash } | deny_version succeeds |
ProofVerified { circuit_id, version } | The verify_proof extrinsic accepts a proof |
ProofVerificationFailed { circuit_id, version } | Never persisted. verify_proof returns VerificationFailed, which rolls the event back with the call. |
Verifications made for the shielded pool emit no verifier event. The version used is the
circuit_version argument of the pool extrinsic.
Runtime API
Two read-only methods, served over RPC:
// One circuit, or null if it has no supported version or no active version
zkVerifier_getCircuitVersionInfo(circuit_id: number): CircuitVersionInfo | null
// Every circuit for which the call above returns a value
zkVerifier_getAllCircuitVersions(): CircuitVersionInfo[]
interface CircuitVersionInfo {
circuit_id: number;
active_version: number;
supported_versions: number[]; // registered and not retired, ascending
vk_hashes: { version: number; vk_hash: `0x${string}` }[];
}
Retired versions are excluded from supported_versions and vk_hashes. vk_hash is
blake2_256 of the stored key_data. It matches the vk_hash in the @orbinum/circuits
manifest for the same key.
Genesis
| Field | Content |
|---|---|
zk_verifier.verification_keys | A list of (circuit_id, key bytes). Each entry is registered as version 1 and activated at block 0, under the same rules as register_verification_key |
zk_verifier.denied_vk_hashes | A list of 0x… key hashes, written to DeniedVkHashes before any key is registered |
A key that is denied, does not deserialize, or whose layout is not admitted (a base-layout transfer key, for example) stops the chain from building its genesis.
The built-in presets leave the list empty. Until keys are registered by Root, every shield and spend
fails with UnsupportedCircuitVersion. For a development node, scripts/vk/workflows/setup-dev.sh
in the node repository registers the keys.
Proof encoding
| Item | Format |
|---|---|
| Proof | arkworks-compressed Proof<Bn254>, exactly 128 bytes. Trailing bytes are refused, so each proof has a single encoding. |
| Public input | 32-byte little-endian canonical BN254 field element |
| Verifying key | arkworks-compressed VerifyingKey<Bn254>, at most 8192 bytes. Its size grows with the number of public inputs. |
snarkjs JSON proofs are not accepted. compressSnarkjsProofWasm() in @orbinum/proof-generator
converts them to this format.
Related
- On-Chain Verification: why keys live on-chain and why rotation strands no note
- Verification Key Rotation: the Root runbook and the current registry
- ZK Proofs: Groth16, Poseidon and the proof flow
- Spend Validity: the pool's checks around the proof
- Transfer Circuit, Unshield Circuit, Shield Circuit: what each public input means