Skip to main content

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​

CratePathResponsibility
orbinum-zk-coreprimitives/zk-corePoseidon (PoseidonHasher::hash_2 / hash_4, poseidon_hash_1) and the core note types
orbinum-zk-verifierprimitives/zk-verifierGroth16 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-verifierframe/zk-verifierThe 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​

IDConstantOperationAdmitted key layouts
1TRANSFERPrivate transfer, 2 in and 2 outMemo-bound (8 inputs) or cross-tree (9)
2UNSHIELDWithdrawal to a public accountMemo-bound (8)
3SHIELDDeposit: the commitment opens to the deposited amountBase (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.
  • ProofSystem also declares Plonk and Halo2, but no verifier exists for them and every stored key is Groth16.
  • VerificationStats counts each check. A check inside a call that then fails is rolled back with that call.
  • DeniedVkHashes and RemovedVkHashes are append-only. No call removes an entry, remove_verification_key and purge_circuit included. 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 delta is independent: a key whose delta is ±gamma or ± 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)
#CallOriginEffectRefused with
0register_verification_key(circuit_id, version, vk)RootStores 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
1set_active_version(circuit_id, version)RootMoves the active pointerVerificationKeyNotFound, UnsupportedCircuitVersion (retired), DeniedVerificationKey, InvalidVerificationKey
2remove_verification_key(circuit_id, version)RootDeletes the key, prepared form, hash, statistics and retired flag, and records the key's hash in RemovedVkHashesVerificationKeyNotFound, ActiveVersionNotSet, CannotRemoveActiveVersion
3verify_proof(circuit_id, proof, public_inputs)SignedVerifies under the active version, with caller-encoded 32-byte inputs taken as-isVerificationFailed on an invalid proof
4batch_register_verification_keys(entries)RootUp to 10 {circuit_id, version, verification_key, set_active} entries. Either all are applied or none.InvalidBatchSize, or any entry's registration error
5retire_version(circuit_id, version)RootThe version stops verifying, and its data is keptVerificationKeyNotFound, VersionAlreadyRetired, ActiveVersionNotSet, CannotRetireActiveVersion
6unretire_version(circuit_id, version)RootThe version verifies again, after its key is re-checked against the registration rulesVersionNotRetired, VerificationKeyNotFound, DeniedVerificationKey, InvalidVerificationKey
7purge_circuit(circuit_id)RootClears 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
8deny_version(circuit_id, version)RootRetires the version and records its key's hash in DeniedVkHashes, for goodVerificationKeyNotFound, 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:

PathResult
Register, activate, unretireRefused with DeniedVerificationKey, under any circuit or version
Verification, if the key is storedVerifies nothing, active or not (UnsupportedCircuitVersion)
deny_version againRefused 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_proof passes 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:

  1. 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.
  2. 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:

LayoutArityAdmitted forInputs
Base3ShieldThe circuit's base inputs
Base7—Refused for transfer and unshield at registration, activation, unretiring and genesis. A base spend key already in storage verifies nothing.
Memo-bound8Transfer, unshieldBase inputs with memo_hash last
Cross-tree9TransferThe 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)
0merkle_roots[0]merkle_rootcommitment
1merkle_roots[1]nullifiervalue
2nullifiers[0]amountasset_id
3nullifiers[1]recipient
4commitments[0]asset_id
5commitments[1]fee
6asset_idchange_commitment
7feememo_hash
8memo_hash

Two inputs are derived by the pallet, not passed through:

InputValue
memo_hashblake2_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.
recipientblake2_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.

Field encoding

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​

EventEmitted 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​

FieldContent
zk_verifier.verification_keysA 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_hashesA 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​

ItemFormat
Proofarkworks-compressed Proof<Bn254>, exactly 128 bytes. Trailing bytes are refused, so each proof has a single encoding.
Public input32-byte little-endian canonical BN254 field element
Verifying keyarkworks-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.