Skip to content

perf(zk-verifier)!: verify from a key prepared at registration (storage v2) - #162

Merged
nol4lej merged 1 commit into
mainfrom
perf/prepared-verifying-keys
Oct 9, 2026
Merged

nol4lej merged 1 commit into
mainfrom
perf/prepared-verifying-keys

Conversation

@nol4lej

@nol4lej nol4lej commented Oct 9, 2026

Copy link
Copy Markdown
Member

perf(zk-verifier)!: verify from a key prepared at registration (storage v2)

Preparing a Groth16 verifying key takes about half the cost of every proof verification. That covers deserializing it with point validation, computing e(alpha, beta) and preparing -gamma and -delta in G2. Until now it ran on every proof, for a key that never changes.

This PR prepares each key once, when it is registered, stores the result and verifies from it.

Before After
verify_proof (benchmark, same machine) 5.6 ms 2.56 ms
A real private transfer through validate_transaction (live dev node) 8.1–9.0 ms 4.2–4.6 ms
Registering a key (Root, once per key) 1.3 ms 4.3 ms
Storage per key ~0.5 KB +~35 KB prepared form

Proofs arrive through public RPC and the transaction pool, so this halves what an invalid proof costs a node.

Design

orbinum-zk-verifier 3.1.0 (new module prepared.rs, additive):

  • VerifyingKey::prepared_bytes() validates and prepares a key and serializes it uncompressed.
  • prepared_from_stored(bytes) reads that form back without re-validating its points, after a strict layout check:
    • the gamma_abc count is in 1..=MAX_PUBLIC_INPUTS + 1;
    • -gamma and -delta have exactly 87 line coefficients each (fixed by BN254's Miller loop) and a clear infinity flag;
    • the total length is exact, and at most MAX_PREPARED_VK_BYTES.
  • ark-serialize reserves a Vec from its length prefix before reading it. The check runs first, so a forged prefix never reaches the allocator.

pallet-zk-verifier 0.17.0 (breaking: storage v2):

  • New map PreparedKeys: (CircuitId, version) → prepared form.
  • New module keys.rs owns all key storage. A key, its hash and its prepared form are written, removed and purged together there, and nothing else writes PreparedKeys.
    • Skipping point validation on read is sound because of that invariant: every prepared form is the preparation of a key that passed registration's checks.
    • Root could always register any key, so no new authority is introduced.
  • The verifier loads the prepared form. If one is missing, it falls back to preparing the key per proof.
  • The existing rules still apply on the prepared path:
    • a retired version is refused before loading;
    • the arity must give a layout the circuit admits, so a base-layout spend key verifies nothing;
    • a prepared form that does not load fails the proof, is counted, and never panics.

Migration

migrations::v2::MigrateToV2 (VersionedMigration<1, 2, …>) is listed in the runtime's Migrations.

  • It prepares every stored key and overwrites any prepared form already there. A key that does not prepare loses its prepared form and keeps working through the fallback.
  • It is bounded by the per-circuit version cap (64) and by the few circuits Root registered.
  • Under try-runtime, post_upgrade checks that each prepared form equals its key's preparation and that none is orphaned.

Weights

Regenerated here without failures (pallet_zk_verifier and pallet_shielded_pool, --steps 50 --repeat 20, official template), and the output compiles. weights.rs is not replaced: official numbers come from the reference machine.

Call Committed (EPYC) Regenerated (Apple M)
verify_proof 10.37 ms, 3.9 KB proof 2.43 ms, 38 KB proof
register_verification_key 2.36 ms 4.12 ms
batch_register_verification_keys 0.06 + 3.5·n ms 4.10 + 3.5·n ms
set_active_version, unretire_version 0.015 ms 1.23 ms
remove_verification_key, purge_circuit ~unchanged ~unchanged

The last-but-one row predates this PR: since 0.16.0 these two calls validate the key, and the committed weights undercharge them. Both are Root-only.

Upgrade notes

  • Spec version: the migration runs only on a spec_version bump. If this ships with spec 18, nothing else is needed. If spec 18 is deployed first, this needs spec 19.
  • No action by Root: existing keys are prepared during the upgrade.
  • Weights: regenerate both pallets on the reference machine before the release.

Testing

Unit:

Area Coverage
orbinum-zk-verifier Round trip; layout constants match what ark-serialize writes; the bound is exact. Refused: wrong length, absurd prefixes (u64::MAX, 2^40), line counts that add up but differ, set infinity flags
Corruption (real proof) Hundreds of random bit flips in a stored prepared key. No flip makes a proof with a wrong public input verify, and none panics
pallet-zk-verifier Registration, genesis and batch store the prepared form (a batch with one bad key stores nothing). remove and purge leave no orphan in any map. A re-registered version or purged circuit gets the new key's form, never a stale one. The prepared form wins over the raw key. Another circuit's form in the slot fails closed
Migration Prepares every key, overwrites a stale form, drops the form of a key that does not prepare, runs once. post_upgrade accepts the migrated state and refuses a tampered one
Totals 157 tests (165 with runtime-benchmarks,skip-proof-verification), plus the try-runtime test. pallet-shielded-pool, pallet-evm-precompile-shielded-pool and orbinum-runtime pass. clippy -D warnings, fmt, --no-default-features and try-runtime clean

Live dev node, real proofs (ts-tests/e2e-prepared-keys-live.cjs, 22/22, starting from the previous runtime):

# Scenario Result
A Upgrade via setCodeWithoutChecks the migration prepares 3/3 keys; the same transfer validates in 8.1 → 4.6 ms
B Root stores garbage as the prepared key the valid proof fails closed; blocks keep coming
C A prepared key whose prefix claims 2^40 lines refused in ~1 ms, no allocation
D Shield's prepared key in transfer's slot fails closed (arity not admitted)
E Transfer v2's prepared key in v3's slot the v3 proof fails
F The prepared key deleted fallback: the valid proof passes, swapped memos still fail
G A version removed and registered again with another key the new key rules

Regression: e2e-cross-tree-live.cjs 42/42, e2e-spend-hardening-live.cjs 78/78, key-rule checks 12/12.

@nol4lej
nol4lej merged commit 50460cb into main Oct 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant