From eae025b5b8a55848a740d67e23dc30c9e789ae04 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 9 Oct 2026 12:10:53 -0300 Subject: [PATCH] perf(zk-verifier)!: verify from a key prepared at registration (storage v2) --- Cargo.lock | 4 +- frame/zk-verifier/CHANGELOG.md | 27 ++ frame/zk-verifier/Cargo.toml | 2 +- frame/zk-verifier/src/benchmarking.rs | 32 +-- frame/zk-verifier/src/keys.rs | 110 ++++++++ frame/zk-verifier/src/lib.rs | 73 ++--- frame/zk-verifier/src/migrations.rs | 69 +++++ frame/zk-verifier/src/tests.rs | 313 ++++++++++++++++++++- frame/zk-verifier/src/types.rs | 5 + frame/zk-verifier/src/verifier.rs | 149 +++++++++- primitives/zk-verifier/CHANGELOG.md | 13 + primitives/zk-verifier/Cargo.toml | 2 +- primitives/zk-verifier/src/lib.rs | 2 + primitives/zk-verifier/src/prepared.rs | 229 +++++++++++++++ primitives/zk-verifier/tests/real_proof.rs | 61 +++- template/runtime/RUNTIME_VERSIONS.md | 14 +- template/runtime/src/lib.rs | 2 +- 17 files changed, 1017 insertions(+), 90 deletions(-) create mode 100644 frame/zk-verifier/src/keys.rs create mode 100644 frame/zk-verifier/src/migrations.rs create mode 100644 primitives/zk-verifier/src/prepared.rs diff --git a/Cargo.lock b/Cargo.lock index bcda7283..14164496 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10083,7 +10083,7 @@ dependencies = [ [[package]] name = "orbinum-zk-verifier" -version = "3.0.0" +version = "3.1.0" dependencies = [ "ark-bn254", "ark-ec 0.5.0", @@ -12984,7 +12984,7 @@ dependencies = [ [[package]] name = "pallet-zk-verifier" -version = "0.16.0" +version = "0.17.0" dependencies = [ "ark-bn254", "ark-ec 0.5.0", diff --git a/frame/zk-verifier/CHANGELOG.md b/frame/zk-verifier/CHANGELOG.md index 815523d2..6dc19074 100644 --- a/frame/zk-verifier/CHANGELOG.md +++ b/frame/zk-verifier/CHANGELOG.md @@ -6,6 +6,33 @@ All notable changes to this pallet are documented here. ## [Unreleased] +## [0.17.0] - 2026-10-09 + +### Changed + +- A key is stored prepared as well (`PreparedKeys`, written and removed with it), + and a proof is verified from that form instead of preparing the key each time: + `verify_proof` measured 5.6 → 2.6 ms in Wasm. The prepared form is loaded + without re-validating its points, after a layout check; a missing one falls + back to preparing the key. +- Key storage lives in one module (`keys`): a key, its hash and its prepared + form are written, removed and purged together, and nothing else writes + `PreparedKeys`. +- **Storage v2:** `migrations::v2::MigrateToV2` prepares every key already + stored, overwriting any prepared form there (a key that does not prepare loses + it); the runtime must list it in its migrations. Under `try-runtime`, + `post_upgrade` checks every prepared form is its key's preparation and none is + orphaned. +- Weights need regenerating. Measured here (Apple M, `--steps 50 --repeat 20`): + + | Call | Committed (EPYC) | Regenerated | + |---|---|---| + | `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 (they validate the key since 0.16.0) | + | `remove_verification_key`, `purge_circuit` | ~unchanged | ~unchanged | + ## [0.16.0] - 2026-10-08 ### Security diff --git a/frame/zk-verifier/Cargo.toml b/frame/zk-verifier/Cargo.toml index 118c57dc..7be4f19f 100644 --- a/frame/zk-verifier/Cargo.toml +++ b/frame/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-zk-verifier" -version = "0.16.0" +version = "0.17.0" description = "Zero-Knowledge proof verification pallet for Orbinum" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/zk-verifier/src/benchmarking.rs b/frame/zk-verifier/src/benchmarking.rs index abc22456..b29a79ef 100644 --- a/frame/zk-verifier/src/benchmarking.rs +++ b/frame/zk-verifier/src/benchmarking.rs @@ -65,17 +65,16 @@ mod benchmarks { ) } + /// Store `key` for `(circuit_id, version)` with its hash and prepared form, + /// bypassing registration's checks. + fn store_key(circuit_id: CircuitId, version: u32, key: Vec) { + Pallet::::store_vk(circuit_id, version, key.try_into().unwrap()) + .expect("benchmark key stores"); + } + /// Store the sample key for `(circuit_id, version)`, bypassing registration. fn insert_sample_vk(circuit_id: CircuitId, version: u32) { - VerificationKeys::::insert( - circuit_id, - version, - VerificationKeyInfo { - key_data: sample_verification_key().try_into().unwrap(), - system: ProofSystem::Groth16, - registered_at: frame_system::Pallet::::block_number(), - }, - ); + store_key::(circuit_id, version, sample_verification_key()); } /// Benchmark for `verify_proof`, parametrized by the public-input count `n`. @@ -92,13 +91,9 @@ mod benchmarks { // the pairing and the fit would come out far below the real cost. let circuit_id = CircuitId(200); - // Seed storage with an arity-`n` VK so `do_verify` runs `n` scalar-muls + pairing. - let vk_info = VerificationKeyInfo { - key_data: synthetic_vk(n as usize).try_into().unwrap(), - system: ProofSystem::Groth16, - registered_at: frame_system::Pallet::::block_number(), - }; - VerificationKeys::::insert(circuit_id, 1, vk_info); + // An arity-`n` key, stored prepared as registration leaves it, so + // `do_verify` runs `n` scalar-muls + pairing on the path every proof takes. + store_key::(circuit_id, 1, synthetic_vk(n as usize)); crate::pallet::ActiveCircuitVersion::::insert(circuit_id, 1); let proof: BoundedVec = BENCH_PROOF @@ -246,6 +241,11 @@ mod benchmarks { .is_none() ); assert!(VkHashes::::iter_key_prefix(circuit_id).next().is_none()); + assert!( + PreparedKeys::::iter_key_prefix(circuit_id) + .next() + .is_none() + ); assert!( VerificationStats::::iter_key_prefix(circuit_id) .next() diff --git a/frame/zk-verifier/src/keys.rs b/frame/zk-verifier/src/keys.rs new file mode 100644 index 00000000..a60c5624 --- /dev/null +++ b/frame/zk-verifier/src/keys.rs @@ -0,0 +1,110 @@ +//! Key storage: a key, its hash and its prepared form are written and removed +//! together, and only here. +//! +//! [`PreparedKeys`] is read without re-validating its points, which is sound only +//! while its one writer is [`Pallet::store_vk`] (and the migration, through +//! [`Pallet::prepare_vk`]): every entry is the preparation of a key that passed +//! validation. + +use crate::{ + Pallet, + pallet::{ + ActiveCircuitVersion, Config, Error, PreparedKeys, RetiredVersions, VerificationKeys, + VerificationStats, VkHashes, + }, + types::{CircuitId, PreparedVkBytes, ProofSystem, VerificationKeyInfo, VkBytes}, +}; +use alloc::vec::Vec; +use frame_support::{dispatch::DispatchResult, ensure}; + +/// A key as storage holds it. +pub(crate) enum StoredKey { + /// Prepared at registration ([`PreparedKeys`]): loaded without re-validation. + Prepared(Vec), + /// Only the key itself, prepared per proof: a key with no prepared form, + /// which only storage written outside this module can leave. + Raw(Vec), +} + +impl Pallet { + /// Insert a validated key for `(circuit_id, version)` with its hash and its + /// prepared form, within the per-circuit version cap. + pub(crate) fn store_vk( + circuit_id: CircuitId, + version: u32, + key_data: VkBytes, + ) -> DispatchResult { + let count = VerificationKeys::::iter_key_prefix(circuit_id).count() as u32; + ensure!( + count < Self::MAX_VERSIONS_PER_CIRCUIT, + Error::::TooManyVersions + ); + let prepared = Self::prepare_vk(&key_data)?; + let hash = sp_io::hashing::blake2_256(key_data.as_slice()); + VerificationKeys::::insert( + circuit_id, + version, + VerificationKeyInfo { + key_data, + system: ProofSystem::Groth16, + registered_at: frame_system::Pallet::::block_number(), + }, + ); + VkHashes::::insert(circuit_id, version, hash); + PreparedKeys::::insert(circuit_id, version, prepared); + Ok(()) + } + + /// `key_data` validated and prepared, as [`PreparedKeys`] holds it. + pub(crate) fn prepare_vk(key_data: &[u8]) -> Result> { + orbinum_zk_verifier::VerifyingKey::new(key_data.to_vec()) + .prepared_bytes() + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(Error::::InvalidVerificationKey) + } + + /// The key to verify `(circuit_id, version)` against: its prepared form, else + /// the key itself. + pub(crate) fn load_vk(circuit_id: CircuitId, version: u32) -> Option { + if let Some(prepared) = PreparedKeys::::get(circuit_id, version) { + return Some(StoredKey::Prepared(prepared.into_inner())); + } + VerificationKeys::::get(circuit_id, version) + .map(|info| StoredKey::Raw(info.key_data.into_inner())) + } + + /// Remove everything stored for one version. + pub(crate) fn remove_vk(circuit_id: CircuitId, version: u32) { + VerificationKeys::::remove(circuit_id, version); + PreparedKeys::::remove(circuit_id, version); + VkHashes::::remove(circuit_id, version); + VerificationStats::::remove(circuit_id, version); + RetiredVersions::::remove(circuit_id, version); + } + + /// Entries each per-version map holds for `circuit_id`, counted by iterating: + /// `clear_prefix`'s counters miss this block's overlay writes. + pub(crate) fn circuit_entries(circuit_id: CircuitId) -> [usize; 5] { + [ + VerificationKeys::::iter_key_prefix(circuit_id).count(), + PreparedKeys::::iter_key_prefix(circuit_id).count(), + VkHashes::::iter_key_prefix(circuit_id).count(), + VerificationStats::::iter_key_prefix(circuit_id).count(), + RetiredVersions::::iter_key_prefix(circuit_id).count(), + ] + } + + /// Clear every map of `circuit_id`, its active pointer included. + /// + /// `u32::MAX` as the limit: callers bound the prefixes first + /// ([`Self::circuit_entries`]), so this clears everything in one pass. + pub(crate) fn clear_circuit(circuit_id: CircuitId) { + let _ = VerificationKeys::::clear_prefix(circuit_id, u32::MAX, None); + let _ = PreparedKeys::::clear_prefix(circuit_id, u32::MAX, None); + let _ = VkHashes::::clear_prefix(circuit_id, u32::MAX, None); + let _ = VerificationStats::::clear_prefix(circuit_id, u32::MAX, None); + let _ = RetiredVersions::::clear_prefix(circuit_id, u32::MAX, None); + ActiveCircuitVersion::::remove(circuit_id); + } +} diff --git a/frame/zk-verifier/src/lib.rs b/frame/zk-verifier/src/lib.rs index ddb7ff07..a7f29375 100644 --- a/frame/zk-verifier/src/lib.rs +++ b/frame/zk-verifier/src/lib.rs @@ -29,6 +29,8 @@ extern crate alloc; pub use pallet::*; mod encoding; +mod keys; +pub mod migrations; mod port; mod runtime_api; mod types; @@ -45,8 +47,8 @@ mod benchmarking; pub use port::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort}; pub use types::{ - CircuitId, CircuitVersionInfo, ProofSystem, VerificationKeyInfo, VerificationStatistics, - VkBytes, VkEntry, VkVersionHash, + CircuitId, CircuitVersionInfo, PreparedVkBytes, ProofSystem, VerificationKeyInfo, + VerificationStatistics, VkBytes, VkEntry, VkVersionHash, }; pub use weights::WeightInfo; @@ -61,10 +63,8 @@ pub mod pallet { /// Storage version history: /// - v1: the retired `private_link` circuit (id 5) is dropped. - /// - /// No migration code remains: every live chain is at v1 and a new chain starts - /// there via genesis. - pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + /// - v2: every key also stored prepared ([`PreparedKeys`]); see [`migrations`]. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -120,6 +120,20 @@ pub mod pallet { OptionQuery, >; + /// Each key of `VerificationKeys`, prepared once when it is stored, so a + /// verification skips preparing it: about half the cost of a proof. Written + /// and removed with the key; a missing entry falls back to preparing. + #[pallet::storage] + pub type PreparedKeys = StorageDoubleMap< + _, + Blake2_128Concat, + CircuitId, + Blake2_128Concat, + u32, + PreparedVkBytes, + OptionQuery, + >; + /// Verification statistics per (circuit_id, version). #[pallet::storage] pub type VerificationStats = StorageDoubleMap< @@ -320,10 +334,7 @@ pub mod pallet { .ok_or(Error::::ActiveVersionNotSet)?; ensure!(active != version, Error::::CannotRemoveActiveVersion); - VerificationKeys::::remove(circuit_id, version); - RetiredVersions::::remove(circuit_id, version); - VkHashes::::remove(circuit_id, version); - VerificationStats::::remove(circuit_id, version); + Self::remove_vk(circuit_id, version); Self::deposit_event(Event::VerificationKeyRemoved { circuit_id, version, @@ -466,7 +477,7 @@ pub mod pallet { /// `None`). /// 2. Count every map's entries, plus a stale `ActiveCircuitVersion`. /// 3. Refuse a map wider than `MAX_VERSIONS_PER_CIRCUIT`. - /// 4. Clear all five maps by prefix — so entries without a + /// 4. Clear all six maps by prefix — so entries without a /// `VerificationKeys` row go too — and report the entries cleared. /// /// `ActiveCircuitVersion` is cleared, not required empty: no extrinsic ever @@ -489,12 +500,7 @@ pub mod pallet { // 2. Count, by iterating: `clear_prefix`'s counters miss this block's // overlay writes. Summed per map, as nothing forces the maps to share a // version set. - let per_map = [ - VerificationKeys::::iter_key_prefix(circuit_id).count(), - VkHashes::::iter_key_prefix(circuit_id).count(), - VerificationStats::::iter_key_prefix(circuit_id).count(), - RetiredVersions::::iter_key_prefix(circuit_id).count(), - ]; + let per_map = Self::circuit_entries(circuit_id); // A stale active pointer is an entry to clear too. let active_entries = usize::from(ActiveCircuitVersion::::contains_key(circuit_id)); let entries = per_map.iter().sum::().saturating_add(active_entries); @@ -509,19 +515,15 @@ pub mod pallet { ); // 4. Clear, in one pass: the count proved every prefix fits the cap. - let _ = VerificationKeys::::clear_prefix(circuit_id, u32::MAX, None); - let _ = VkHashes::::clear_prefix(circuit_id, u32::MAX, None); - let _ = VerificationStats::::clear_prefix(circuit_id, u32::MAX, None); - let _ = RetiredVersions::::clear_prefix(circuit_id, u32::MAX, None); - ActiveCircuitVersion::::remove(circuit_id); + Self::clear_circuit(circuit_id); // Entries, not versions: the figure an indexer reconciles against. Self::deposit_event(Event::CircuitPurged { circuit_id, removed: entries as u32, }); - // Weighed by versions: the benchmark charges one read and four writes - // per version, the cost of clearing all four maps. + // Weighed by versions: the benchmark charges one read and a write per + // map for each version. Ok(Some(T::WeightInfo::purge_circuit(widest as u32)).into()) } } @@ -585,28 +587,5 @@ pub mod pallet { .map(|_| ()) .ok_or(Error::::InvalidVerificationKey.into()) } - - /// Insert a validated VK for `(circuit_id, version)` and store its hash, - /// within the per-circuit version cap. - fn store_vk(circuit_id: CircuitId, version: u32, key_data: VkBytes) -> DispatchResult { - let count = VerificationKeys::::iter_key_prefix(circuit_id).count() as u32; - ensure!( - count < Self::MAX_VERSIONS_PER_CIRCUIT, - Error::::TooManyVersions - ); - - let hash = sp_io::hashing::blake2_256(key_data.as_slice()); - VerificationKeys::::insert( - circuit_id, - version, - VerificationKeyInfo { - key_data, - system: ProofSystem::Groth16, - registered_at: frame_system::Pallet::::block_number(), - }, - ); - VkHashes::::insert(circuit_id, version, hash); - Ok(()) - } } } diff --git a/frame/zk-verifier/src/migrations.rs b/frame/zk-verifier/src/migrations.rs new file mode 100644 index 00000000..e3cc5cc8 --- /dev/null +++ b/frame/zk-verifier/src/migrations.rs @@ -0,0 +1,69 @@ +//! Storage migrations. + +/// v1 → v2: store every key prepared ([`crate::pallet::PreparedKeys`]). +pub mod v2 { + use crate::{ + Pallet, WeightInfo, + pallet::{Config, PreparedKeys, VerificationKeys}, + }; + use frame_support::{ + migrations::VersionedMigration, + traits::{Get, UncheckedOnRuntimeUpgrade}, + weights::Weight, + }; + + /// Prepare every stored key, overwriting any prepared form already there, so + /// one written by an older or partial run is replaced too. + /// + /// Bounded: at most `MAX_VERSIONS_PER_CIRCUIT` keys per circuit, and only the + /// few circuits Root registered. A key that fails to prepare is skipped and + /// logged, and any prepared form left for it is dropped: verifying under it + /// falls back to preparing per proof. + pub struct PrepareKeys(core::marker::PhantomData); + + impl UncheckedOnRuntimeUpgrade for PrepareKeys { + fn on_runtime_upgrade() -> Weight { + let mut keys = 0u64; + for (circuit_id, version, info) in VerificationKeys::::iter() { + keys = keys.saturating_add(1); + match Pallet::::prepare_vk(&info.key_data) { + Ok(bytes) => PreparedKeys::::insert(circuit_id, version, bytes), + Err(_) => { + PreparedKeys::::remove(circuit_id, version); + frame_support::__private::log::warn!( + target: "runtime::zk-verifier", + "key {circuit_id:?} v{version} does not prepare; left to the per-proof path", + ); + } + } + } + // Preparing is the cost a registration pays, so charge one per key. + T::DbWeight::get() + .reads_writes(keys, keys) + .saturating_add(T::WeightInfo::register_verification_key().saturating_mul(keys)) + } + + /// Every key has the prepared form `prepare_vk` gives it, and no prepared + /// form is left without its key. + #[cfg(feature = "try-runtime")] + fn post_upgrade(_: alloc::vec::Vec) -> Result<(), sp_runtime::TryRuntimeError> { + for (circuit_id, version, info) in VerificationKeys::::iter() { + let expected = Pallet::::prepare_vk(&info.key_data).ok(); + frame_support::ensure!( + PreparedKeys::::get(circuit_id, version) == expected, + "a key's prepared form is not its preparation" + ); + } + frame_support::ensure!( + PreparedKeys::::iter_keys() + .all(|(c, v)| VerificationKeys::::contains_key(c, v)), + "a prepared form has no key" + ); + Ok(()) + } + } + + /// [`PrepareKeys`], run once when the pallet's storage is at v1. + pub type MigrateToV2 = + VersionedMigration<1, 2, PrepareKeys, Pallet, ::DbWeight>; +} diff --git a/frame/zk-verifier/src/tests.rs b/frame/zk-verifier/src/tests.rs index 30a0ac19..94aea556 100644 --- a/frame/zk-verifier/src/tests.rs +++ b/frame/zk-verifier/src/tests.rs @@ -7,7 +7,8 @@ use crate::{ new_test_ext, real_vk, }, pallet::{ - ActiveCircuitVersion, Event, RetiredVersions, VerificationKeys, VerificationStats, VkHashes, + ActiveCircuitVersion, Event, PreparedKeys, RetiredVersions, VerificationKeys, + VerificationStats, VkHashes, }, types::VkEntry, }; @@ -1500,6 +1501,10 @@ fn genesis_registers_vk_at_version_1_and_activates_it() { CircuitId::TRANSFER, 1u32 )); + assert!(PreparedKeys::::contains_key( + CircuitId::TRANSFER, + 1u32 + )); assert_eq!( ActiveCircuitVersion::::get(CircuitId::TRANSFER), Some(1u32) @@ -1612,3 +1617,309 @@ fn integrity_test_panics_when_verification_disabled() { use frame_support::traits::Hooks; >>::integrity_test(); } + +// ── Prepared keys ───────────────────────────────────────────────────────────── + +fn memo_bound_transfer_key() -> VkBytes { + real_vk(TRANSFER_PUBLIC_INPUTS + MEMO_HASH_INPUTS) +} + +#[test] +fn registration_stores_the_prepared_key() { + new_test_ext().execute_with(|| { + let key = memo_bound_transfer_key(); + assert_ok!(register(CircuitId::TRANSFER, 2, key.clone())); + assert_eq!( + PreparedKeys::::get(CircuitId::TRANSFER, 2), + Some(ZkVerifier::prepare_vk(&key).unwrap()) + ); + }); +} + +#[test] +fn removing_or_purging_a_key_drops_its_prepared_form() { + new_test_ext().execute_with(|| { + assert_ok!(register(CircuitId::TRANSFER, 2, memo_bound_transfer_key())); + assert_ok!(register(CircuitId::TRANSFER, 3, memo_bound_transfer_key())); + activate(CircuitId::TRANSFER, 3); + assert_ok!(ZkVerifier::remove_verification_key( + root().into(), + CircuitId::TRANSFER, + 2 + )); + assert!(!PreparedKeys::::contains_key(CircuitId::TRANSFER, 2)); + + let retired = CircuitId(99); + ZkVerifier::store_vk(retired, 1, real_vk(3)).unwrap(); + activate(retired, 1); + assert_ok!(ZkVerifier::purge_circuit(root().into(), retired)); + assert_eq!(PreparedKeys::::iter_key_prefix(retired).count(), 0); + }); +} + +/// A memo-bound transfer key distinct from [`memo_bound_transfer_key`]. +fn other_transfer_key() -> VkBytes { + use ark_bn254::{Bn254, G1Affine, G2Affine}; + use ark_ec::{AffineRepr, CurveGroup}; + let g = G1Affine::generator(); + let vk = ark_groth16::VerifyingKey:: { + alpha_g1: (g + g).into_affine(), + beta_g2: G2Affine::generator(), + gamma_g2: G2Affine::generator(), + delta_g2: G2Affine::generator(), + gamma_abc_g1: vec![g; TRANSFER_PUBLIC_INPUTS + MEMO_HASH_INPUTS + 1], + }; + orbinum_zk_verifier::VerifyingKey::from_ark_vk(&vk) + .unwrap() + .bytes + .try_into() + .unwrap() +} + +/// Every entry any map holds for `circuit_id`. +fn entries_of(circuit_id: CircuitId) -> usize { + ZkVerifier::circuit_entries(circuit_id) + .iter() + .sum::() + + usize::from(ActiveCircuitVersion::::contains_key(circuit_id)) +} + +/// A version removed and registered again with another key verifies under the +/// new key's prepared form, never a stale one. +#[test] +fn a_reregistered_version_gets_the_new_keys_prepared_form() { + new_test_ext().execute_with(|| { + let (first, second) = (memo_bound_transfer_key(), other_transfer_key()); + assert_ne!(first, second); + assert_ok!(register(CircuitId::TRANSFER, 2, first)); + assert_ok!(register(CircuitId::TRANSFER, 3, memo_bound_transfer_key())); + activate(CircuitId::TRANSFER, 3); + assert_ok!(ZkVerifier::remove_verification_key( + root().into(), + CircuitId::TRANSFER, + 2 + )); + assert_ok!(register(CircuitId::TRANSFER, 2, second.clone())); + assert_eq!( + PreparedKeys::::get(CircuitId::TRANSFER, 2), + Some(ZkVerifier::prepare_vk(&second).unwrap()) + ); + }); +} + +#[test] +fn a_purged_circuit_registered_again_gets_the_new_keys_prepared_form() { + new_test_ext().execute_with(|| { + let retired = CircuitId(99); + ZkVerifier::store_vk(retired, 1, real_vk(3)).unwrap(); + activate(retired, 1); + assert_ok!(ZkVerifier::purge_circuit(root().into(), retired)); + assert_eq!(entries_of(retired), 0); + assert_ok!(register(retired, 1, real_vk(5))); + assert_eq!( + PreparedKeys::::get(retired, 1), + Some(ZkVerifier::prepare_vk(&real_vk(5)).unwrap()) + ); + }); +} + +/// Removing a version leaves nothing of it in any map, and the versions next +/// to it untouched. +#[test] +fn removing_a_version_leaves_no_orphan() { + new_test_ext().execute_with(|| { + assert_ok!(register(CircuitId::TRANSFER, 2, memo_bound_transfer_key())); + assert_ok!(register(CircuitId::TRANSFER, 3, memo_bound_transfer_key())); + activate(CircuitId::TRANSFER, 3); + RetiredVersions::::insert(CircuitId::TRANSFER, 2, ()); + VerificationStats::::insert( + CircuitId::TRANSFER, + 2, + VerificationStatistics::default(), + ); + assert_ok!(ZkVerifier::remove_verification_key( + root().into(), + CircuitId::TRANSFER, + 2 + )); + assert!(!VerificationKeys::::contains_key( + CircuitId::TRANSFER, + 2 + )); + assert!(!PreparedKeys::::contains_key(CircuitId::TRANSFER, 2)); + assert!(!VkHashes::::contains_key(CircuitId::TRANSFER, 2)); + assert!(!RetiredVersions::::contains_key( + CircuitId::TRANSFER, + 2 + )); + assert!(!VerificationStats::::contains_key( + CircuitId::TRANSFER, + 2 + )); + assert!(PreparedKeys::::contains_key(CircuitId::TRANSFER, 3)); + }); +} + +/// A batch is all or nothing: one bad key leaves no key and no prepared form. +#[test] +fn a_batch_with_one_bad_key_stores_nothing() { + new_test_ext().execute_with(|| { + let mut entries: Vec = (1..=9) + .map(|v| make_vk_entry(CircuitId::TRANSFER, v, false)) + .collect(); + entries.push(VkEntry { + circuit_id: CircuitId::TRANSFER, + version: 10, + verification_key: real_vk(TRANSFER_PUBLIC_INPUTS), + set_active: false, + }); + assert_noop!( + ZkVerifier::batch_register_verification_keys(root().into(), batch(entries)), + Error::::InvalidVerificationKey + ); + assert_eq!(PreparedKeys::::iter().count(), 0); + }); +} + +#[test] +fn a_full_batch_stores_every_prepared_form() { + new_test_ext().execute_with(|| { + let entries = (1..=10) + .map(|v| make_vk_entry(CircuitId::TRANSFER, v, false)) + .collect(); + assert_ok!(ZkVerifier::batch_register_verification_keys( + root().into(), + batch(entries) + )); + for v in 1..=10 { + assert!( + PreparedKeys::::contains_key(CircuitId::TRANSFER, v), + "v{v}" + ); + } + }); +} + +mod migration_to_v2 { + use super::*; + use crate::migrations::v2::MigrateToV2; + use frame_support::traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion}; + + /// Keys stored before v2: only the raw key, no prepared form. + fn v1_chain(keys: &[(CircuitId, u32, VkBytes)]) { + StorageVersion::new(1).put::(); + for (circuit_id, version, key) in keys { + crate::mock::insert_key(*circuit_id, *version, key.clone()); + } + } + + #[test] + fn prepares_every_stored_key_and_bumps_the_version() { + new_test_ext().execute_with(|| { + let transfer = memo_bound_transfer_key(); + let shield = real_vk(SHIELD_PUBLIC_INPUTS); + v1_chain(&[ + (CircuitId::TRANSFER, 3, transfer.clone()), + (CircuitId::SHIELD, 1, shield.clone()), + ]); + MigrateToV2::::on_runtime_upgrade(); + assert_eq!( + PreparedKeys::::get(CircuitId::TRANSFER, 3), + Some(ZkVerifier::prepare_vk(&transfer).unwrap()) + ); + assert_eq!( + PreparedKeys::::get(CircuitId::SHIELD, 1), + Some(ZkVerifier::prepare_vk(&shield).unwrap()) + ); + assert_eq!(ZkVerifier::on_chain_storage_version(), 2); + }); + } + + /// Once at v2 it does nothing, whatever storage holds. + #[test] + fn runs_once() { + new_test_ext().execute_with(|| { + v1_chain(&[(CircuitId::TRANSFER, 3, memo_bound_transfer_key())]); + MigrateToV2::::on_runtime_upgrade(); + crate::mock::insert_key(CircuitId::TRANSFER, 4, memo_bound_transfer_key()); + MigrateToV2::::on_runtime_upgrade(); + assert!(!PreparedKeys::::contains_key(CircuitId::TRANSFER, 4)); + }); + } + + /// A prepared form already there is replaced by the key's own preparation: + /// a stale or wrong one does not survive the upgrade. + #[test] + fn overwrites_a_stale_prepared_form() { + new_test_ext().execute_with(|| { + let key = memo_bound_transfer_key(); + v1_chain(&[(CircuitId::TRANSFER, 3, key.clone())]); + let stale = ZkVerifier::prepare_vk(&other_transfer_key()).unwrap(); + PreparedKeys::::insert(CircuitId::TRANSFER, 3, stale); + MigrateToV2::::on_runtime_upgrade(); + assert_eq!( + PreparedKeys::::get(CircuitId::TRANSFER, 3), + Some(ZkVerifier::prepare_vk(&key).unwrap()) + ); + }); + } + + /// A key that does not prepare loses any prepared form it had: verifying + /// under it falls back to the key itself. + #[test] + fn drops_the_prepared_form_of_a_key_that_does_not_prepare() { + new_test_ext().execute_with(|| { + v1_chain(&[(CircuitId::TRANSFER, 3, vec![0x01; 100].try_into().unwrap())]); + let stale = ZkVerifier::prepare_vk(&memo_bound_transfer_key()).unwrap(); + PreparedKeys::::insert(CircuitId::TRANSFER, 3, stale); + MigrateToV2::::on_runtime_upgrade(); + assert!(!PreparedKeys::::contains_key(CircuitId::TRANSFER, 3)); + }); + } + + #[cfg(feature = "try-runtime")] + #[test] + fn post_upgrade_accepts_the_migrated_state_and_refuses_a_tampered_one() { + use crate::migrations::v2::PrepareKeys; + use frame_support::traits::UncheckedOnRuntimeUpgrade; + new_test_ext().execute_with(|| { + let key = memo_bound_transfer_key(); + v1_chain(&[(CircuitId::TRANSFER, 3, key.clone())]); + MigrateToV2::::on_runtime_upgrade(); + assert!(PrepareKeys::::post_upgrade(Vec::new()).is_ok()); + + let wrong = ZkVerifier::prepare_vk(&other_transfer_key()).unwrap(); + PreparedKeys::::insert(CircuitId::TRANSFER, 3, wrong.clone()); + assert!( + PrepareKeys::::post_upgrade(Vec::new()).is_err(), + "a wrong form" + ); + + PreparedKeys::::insert( + CircuitId::TRANSFER, + 3, + ZkVerifier::prepare_vk(&key).unwrap(), + ); + PreparedKeys::::insert(CircuitId::TRANSFER, 4, wrong); + assert!( + PrepareKeys::::post_upgrade(Vec::new()).is_err(), + "an orphan" + ); + }); + } + + /// A key that does not prepare is skipped, not fatal: the others still are. + #[test] + fn skips_a_key_that_does_not_prepare() { + new_test_ext().execute_with(|| { + v1_chain(&[ + (CircuitId::TRANSFER, 3, vec![0x01; 100].try_into().unwrap()), + (CircuitId::SHIELD, 1, real_vk(SHIELD_PUBLIC_INPUTS)), + ]); + MigrateToV2::::on_runtime_upgrade(); + assert!(!PreparedKeys::::contains_key(CircuitId::TRANSFER, 3)); + assert!(PreparedKeys::::contains_key(CircuitId::SHIELD, 1)); + assert_eq!(ZkVerifier::on_chain_storage_version(), 2); + }); + } +} diff --git a/frame/zk-verifier/src/types.rs b/frame/zk-verifier/src/types.rs index 579121d0..39c71214 100644 --- a/frame/zk-verifier/src/types.rs +++ b/frame/zk-verifier/src/types.rs @@ -7,6 +7,11 @@ use serde::{Deserialize, Serialize}; /// A serialized verifying key as stored: at most [`orbinum_zk_verifier::MAX_VK_BYTES`]. pub type VkBytes = BoundedVec>; +/// A verifying key prepared once at registration, uncompressed: at most +/// [`orbinum_zk_verifier::MAX_PREPARED_VK_BYTES`]. +pub type PreparedVkBytes = + BoundedVec>; + /// Circuit identifier type (pallet-specific wrapper) #[derive( Clone, diff --git a/frame/zk-verifier/src/verifier.rs b/frame/zk-verifier/src/verifier.rs index 28e9024a..68102aa5 100644 --- a/frame/zk-verifier/src/verifier.rs +++ b/frame/zk-verifier/src/verifier.rs @@ -1,17 +1,20 @@ //! Core Groth16 proof verification. //! -//! Resolves the circuit version, loads and prepares its key once, picks the -//! [`InputLayout`] the key implies — only an admitted one — and records -//! statistics. Encoding the inputs is the caller's (see [`crate::encoding`]). +//! Resolves the circuit version, loads its key — prepared at registration — +//! picks the [`InputLayout`] the key implies — only an admitted one — and +//! records statistics. Encoding the inputs is the caller's (see +//! [`crate::encoding`]). use crate::{ - Error, - pallet::{ActiveCircuitVersion, Config, RetiredVersions, VerificationKeys, VerificationStats}, + Error, Pallet, + keys::StoredKey, + pallet::{ActiveCircuitVersion, Config, RetiredVersions, VerificationStats}, types::CircuitId, }; use alloc::vec::Vec; use orbinum_zk_verifier::{ Bn254, InputLayout, PreparedVerifyingKey, VerifyingKey, has_memo_layout, input_layout, + prepared_from_stored, }; /// Verify a proof of a circuit statement, encoded for the layout of the key it @@ -63,11 +66,15 @@ fn check( inputs: impl FnOnce(InputLayout, usize) -> Option>, ) -> Result<(bool, u32), sp_runtime::DispatchError> { frame_support::ensure!(!proof.is_empty(), Error::::EmptyProof); - let (key_data, resolved) = resolve_key::(circuit_id, version)?; + let (key, resolved) = resolve_key::(circuit_id, version)?; - // Registration checked the key deserializes; if one ever did not, the proof - // fails rather than verifying under guessed inputs. - let result = match VerifyingKey::new(key_data).prepare() { + // Registration validated the key; if a stored one ever did not load, the + // proof fails rather than verifying under guessed inputs. + let prepared = match key { + StoredKey::Prepared(bytes) => prepared_from_stored(&bytes), + StoredKey::Raw(bytes) => VerifyingKey::new(bytes).prepare(), + }; + let result = match prepared { Ok(pvk) => { let arity = pvk.vk.gamma_abc_g1.len().saturating_sub(1); admitted_layout(circuit_id, arity) @@ -80,11 +87,11 @@ fn check( Ok((result, resolved)) } -/// The key bytes and version to verify against: `version`, or the active one. +/// The key and version to verify against: `version`, or the active one. fn resolve_key( circuit_id: CircuitId, version: Option, -) -> Result<(Vec, u32), Error> { +) -> Result<(StoredKey, u32), Error> { let resolved = version .or_else(|| ActiveCircuitVersion::::get(circuit_id)) .ok_or(Error::::CircuitNotFound)?; @@ -92,12 +99,12 @@ fn resolve_key( !RetiredVersions::::contains_key(circuit_id, resolved), Error::::UnsupportedCircuitVersion ); - let info = VerificationKeys::::get(circuit_id, resolved).ok_or(if version.is_some() { + let key = Pallet::::load_vk(circuit_id, resolved).ok_or(if version.is_some() { Error::::UnsupportedCircuitVersion } else { Error::::VerificationKeyNotFound })?; - Ok((info.key_data.into_inner(), resolved)) + Ok((key, resolved)) } /// Count a verification outcome in `VerificationStats`, failures too. @@ -145,8 +152,9 @@ fn do_verify(pvk: &PreparedVerifyingKey, proof: &[u8], raw_inputs: Vec<[u mod tests { use super::*; use crate::{ - mock::{Test, activate, insert_key, insert_vk, new_test_ext}, - pallet::VerificationStats, + PreparedVkBytes, + mock::{Test, activate, insert_key, insert_vk, new_test_ext, real_vk}, + pallet::{PreparedKeys, VerificationStats}, }; use frame_support::assert_err; use orbinum_zk_verifier::{MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, TRANSFER_PUBLIC_INPUTS}; @@ -386,6 +394,117 @@ mod tests { }); } + // ── Prepared keys ───────────────────────────────────────────────────────── + + fn store(circuit_id: CircuitId, version: u32, arity: usize) { + crate::Pallet::::store_vk(circuit_id, version, real_vk(arity)).unwrap(); + } + + #[test] + fn a_stored_key_verifies_from_its_prepared_form() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 2, KEY); + assert!(PreparedKeys::::contains_key(CircuitId::TRANSFER, 2)); + let mut seen = None; + let res = verify_statement::( + CircuitId::TRANSFER, + Some(2), + &proof(), + encoder(&mut seen, KEY), + ); + assert_eq!(res, Ok((true, 2))); + assert_eq!(seen, Some(InputLayout::MemoBound)); + }); + } + + /// With both forms stored, the prepared one is what verifies: spoiling the + /// raw key changes nothing. + #[test] + fn the_prepared_form_is_read_over_the_raw_key() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 2, KEY); + insert_key(CircuitId::TRANSFER, 2, vec![0x01; 100].try_into().unwrap()); + let res = verify_raw::(CircuitId::TRANSFER, Some(2), &proof(), vec![[0x02; 32]]); + assert_eq!(res, Ok((true, 2))); + }); + } + + /// A prepared form that does not load fails the proof, never panics, and the + /// attempt is counted. + #[test] + fn a_prepared_form_that_does_not_load_fails_the_proof() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 2, KEY); + let good = PreparedKeys::::get(CircuitId::TRANSFER, 2).unwrap(); + let truncated = good[..good.len() - 1].to_vec(); + for (i, bad) in [vec![0xFF; 1000], truncated, vec![]] + .into_iter() + .enumerate() + { + PreparedKeys::::insert( + CircuitId::TRANSFER, + 2, + PreparedVkBytes::truncate_from(bad), + ); + let res = + verify_raw::(CircuitId::TRANSFER, Some(2), &proof(), vec![[0x02; 32]]); + assert_eq!(res, Ok((false, 2))); + assert_eq!( + stats(CircuitId::TRANSFER, 2), + (i as u64 + 1, 0, i as u64 + 1) + ); + } + }); + } + + /// A prepared form of another arity in the slot fails closed: the arity it + /// carries is not one the circuit admits. + #[test] + fn a_prepared_form_of_another_circuit_in_the_slot_fails_closed() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 2, KEY); + let shield = crate::Pallet::::prepare_vk(&real_vk(SHIELD_PUBLIC_INPUTS)).unwrap(); + PreparedKeys::::insert(CircuitId::TRANSFER, 2, shield); + let mut seen = None; + let res = verify_statement::( + CircuitId::TRANSFER, + Some(2), + &proof(), + encoder(&mut seen, KEY), + ); + assert_eq!(res, Ok((false, 2))); + assert_eq!(seen, None); + }); + } + + #[test] + fn a_base_spend_key_verifies_nothing_from_its_prepared_form_either() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 1, BASE); + let mut seen = None; + let res = verify_statement::( + CircuitId::TRANSFER, + Some(1), + &proof(), + encoder(&mut seen, BASE), + ); + assert_eq!(res, Ok((false, 1))); + assert_eq!(seen, None); + }); + } + + #[test] + fn a_retired_version_is_refused_before_its_prepared_form_loads() { + new_test_ext().execute_with(|| { + store(CircuitId::TRANSFER, 2, KEY); + RetiredVersions::::insert(CircuitId::TRANSFER, 2, ()); + assert_err!( + verify_raw::(CircuitId::TRANSFER, Some(2), &proof(), vec![[0x02; 32]]), + Error::::UnsupportedCircuitVersion + ); + }); + } + // ── Statistics ──────────────────────────────────────────────────────────── #[test] diff --git a/primitives/zk-verifier/CHANGELOG.md b/primitives/zk-verifier/CHANGELOG.md index 66da08f2..ac0b3639 100644 --- a/primitives/zk-verifier/CHANGELOG.md +++ b/primitives/zk-verifier/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this crate are documented here. ## [Unreleased] +## [3.1.0] - 2026-10-09 + +### Added + +- `VerifyingKey::prepared_bytes`: the key validated and prepared once, + uncompressed, for storing. `prepared_from_stored` reads it back without + re-validating its points, after checking every length prefix against the + bytes (`ark-serialize` reserves a `Vec` from its prefix before reading it). + `MAX_PREPARED_VK_BYTES` bounds it: the size for `MAX_PUBLIC_INPUTS` inputs. + The layout is fixed but for the input count: 87 line coefficients for each of + `-gamma`, `-delta` and a clear infinity flag, so a prefix that adds up but + differs is refused too. Lives in its own module, `prepared`. + ## [3.0.0] - 2026-10-08 ### Added diff --git a/primitives/zk-verifier/Cargo.toml b/primitives/zk-verifier/Cargo.toml index 416dfacf..6a63c8db 100644 --- a/primitives/zk-verifier/Cargo.toml +++ b/primitives/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-zk-verifier" -version = "3.0.0" +version = "3.1.0" authors = ["Orbinum Network "] edition = "2021" publish = false diff --git a/primitives/zk-verifier/src/lib.rs b/primitives/zk-verifier/src/lib.rs index dcf331f0..a77d70d1 100644 --- a/primitives/zk-verifier/src/lib.rs +++ b/primitives/zk-verifier/src/lib.rs @@ -15,6 +15,7 @@ extern crate alloc; mod circuits; +mod prepared; mod snarkjs; mod types; mod verifier; @@ -33,6 +34,7 @@ pub use circuits::{ CROSS_TREE_INPUTS, MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, }; +pub use prepared::{prepared_from_stored, MAX_PREPARED_VK_BYTES}; pub use snarkjs::SnarkjsProofPoints; #[cfg(feature = "std")] pub use snarkjs::{parse_proof_from_snarkjs, parse_public_inputs_from_snarkjs}; diff --git a/primitives/zk-verifier/src/prepared.rs b/primitives/zk-verifier/src/prepared.rs new file mode 100644 index 00000000..a4f7e671 --- /dev/null +++ b/primitives/zk-verifier/src/prepared.rs @@ -0,0 +1,229 @@ +//! Verifying keys prepared once and stored, instead of prepared per proof. +//! +//! Preparing a key (deserializing it with point validation, computing +//! `e(alpha, beta)`, preparing `-gamma` and `-delta` in G2) is about half the +//! cost of a verification. [`VerifyingKey::prepared_bytes`] does it once, at +//! registration; [`prepared_from_stored`] reads the result back per proof. + +use alloc::vec::Vec; + +use ark_bn254::Bn254; +use ark_groth16::PreparedVerifyingKey; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; + +use crate::{VerifierError, VerifyingKey, MAX_PUBLIC_INPUTS}; + +/// Uncompressed sizes of the parts of a prepared key. +mod layout { + /// `alpha` (G1) then `beta`, `gamma`, `delta` (G2). + pub const KEY_HEADER: usize = 64 + 3 * 128; + /// A `u64` length prefix. + pub const LEN: usize = 8; + pub const G1: usize = 64; + /// `e(alpha, beta)`, an element of GT. + pub const GT: usize = 384; + /// One line coefficient of a G2-prepared point: three Fq2 elements. + pub const LINE: usize = 3 * 64; + /// Line coefficients of any G2-prepared point on BN254: fixed by the curve's + /// Miller loop. + pub const LINES: usize = 87; + /// The `infinity` flag after the lines: a real key's `-gamma`, `-delta` are + /// never the identity (`to_ark_vk` refuses it), so it is always 0. + pub const FLAG: usize = 1; + + /// Size of the prepared key for a key of `points` `gamma_abc` points. + pub const fn size(points: usize) -> usize { + KEY_HEADER + LEN + points * G1 + GT + 2 * (LEN + LINES * LINE + FLAG) + } +} + +/// Largest prepared key [`VerifyingKey::prepared_bytes`] produces: one with +/// [`MAX_PUBLIC_INPUTS`] inputs. [`prepared_from_stored`] refuses anything longer. +pub const MAX_PREPARED_VK_BYTES: usize = layout::size(MAX_PUBLIC_INPUTS + 1); + +impl VerifyingKey { + /// The key validated and prepared once, uncompressed, to be stored and read + /// back with [`prepared_from_stored`] instead of preparing it per proof. + pub fn prepared_bytes(&self) -> Result, VerifierError> { + let mut out = Vec::new(); + self.prepare()? + .serialize_uncompressed(&mut out) + .map_err(|_| VerifierError::SerializationError)?; + Ok(out) + } +} + +/// Read back a key stored by [`VerifyingKey::prepared_bytes`], without +/// re-validating its points: the caller vouches that the bytes came from there. +/// +/// The layout is checked first, as in [`VerifyingKey::to_ark_vk`]: +/// `ark-serialize` reserves every `Vec` from its length prefix before reading +/// it, so a prefix the bytes cannot back would ask for an absurd allocation. +pub fn prepared_from_stored(bytes: &[u8]) -> Result, VerifierError> { + if !layout_fits(bytes) { + return Err(VerifierError::InvalidVerifyingKey); + } + PreparedVerifyingKey::::deserialize_uncompressed_unchecked(bytes) + .map_err(|_| VerifierError::InvalidVerifyingKey) +} + +/// Whether `bytes` has exactly the layout of a prepared key: a `gamma_abc` +/// count in range, then `-gamma` and `-delta` with [`layout::LINES`] lines each +/// and a clear infinity flag, and nothing after. +fn layout_fits(bytes: &[u8]) -> bool { + use layout::*; + let u64_at = |at: usize| { + bytes + .get(at..at + LEN) + .and_then(|b| <[u8; LEN]>::try_from(b).ok()) + .map(u64::from_le_bytes) + }; + // `-gamma` or `-delta` prepared, starting at `at`: the fixed line count and a + // clear flag. + let g2_prepared_fits = |at: usize| { + u64_at(at) == Some(LINES as u64) && bytes.get(at + LEN + LINES * LINE) == Some(&0) + }; + + if bytes.len() > MAX_PREPARED_VK_BYTES { + return false; + } + let Some(points) = u64_at(KEY_HEADER) else { + return false; + }; + if points == 0 || points > (MAX_PUBLIC_INPUTS + 1) as u64 { + return false; + } + let points = points as usize; + let gamma_at = KEY_HEADER + LEN + points * G1 + GT; + let delta_at = gamma_at + LEN + LINES * LINE + FLAG; + bytes.len() == size(points) && g2_prepared_fits(gamma_at) && g2_prepared_fits(delta_at) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::{layout::*, *}; + use alloc::vec; + use ark_bn254::{G1Affine, G2Affine}; + use ark_ec::AffineRepr; + use ark_groth16::VerifyingKey as ArkVK; + + /// A genuine key of `arity` inputs, as registration stores it. + fn key(arity: usize) -> VerifyingKey { + let vk = ArkVK:: { + alpha_g1: G1Affine::generator(), + beta_g2: G2Affine::generator(), + gamma_g2: G2Affine::generator(), + delta_g2: G2Affine::generator(), + gamma_abc_g1: (0..=arity).map(|_| G1Affine::generator()).collect(), + }; + VerifyingKey::from_ark_vk(&vk).expect("serializes") + } + + fn stored(arity: usize) -> Vec { + key(arity).prepared_bytes().expect("prepares") + } + + fn reserialized(pvk: &PreparedVerifyingKey) -> Vec { + let mut out = Vec::new(); + pvk.serialize_uncompressed(&mut out).unwrap(); + out + } + + fn refused(bytes: &[u8]) -> bool { + prepared_from_stored(bytes).err() == Some(VerifierError::InvalidVerifyingKey) + } + + const GAMMA_AT_3: usize = KEY_HEADER + LEN + 4 * G1 + GT; + const DELTA_AT_3: usize = GAMMA_AT_3 + LEN + LINES * LINE + FLAG; + + #[test] + fn a_stored_key_reads_back_as_the_prepared_one() { + for arity in [1, 3, 9] { + let fresh = key(arity).prepare().unwrap(); + let read = prepared_from_stored(&stored(arity)).unwrap(); + assert_eq!(reserialized(&read), reserialized(&fresh), "arity {arity}"); + } + } + + /// The layout constants describe what `ark-serialize` actually writes. + #[test] + fn the_layout_matches_the_serialized_key() { + for arity in [0, 1, 9, MAX_PUBLIC_INPUTS] { + assert_eq!(stored(arity).len(), size(arity + 1), "arity {arity}"); + } + let pvk = key(3).prepare().unwrap(); + assert_eq!(pvk.gamma_g2_neg_pc.ell_coeffs.len(), LINES); + assert_eq!(pvk.delta_g2_neg_pc.ell_coeffs.len(), LINES); + } + + /// The bound is exact: the widest key the crate accepts fills it. + #[test] + fn the_widest_key_fills_the_bound() { + assert_eq!(stored(MAX_PUBLIC_INPUTS).len(), MAX_PREPARED_VK_BYTES); + assert!(prepared_from_stored(&stored(MAX_PUBLIC_INPUTS)).is_ok()); + assert!(refused(&vec![0u8; MAX_PREPARED_VK_BYTES + 1])); + } + + #[test] + fn a_stored_key_of_the_wrong_length_is_refused() { + let good = stored(3); + let mut longer = good.clone(); + longer.push(0); + for bad in [&good[..good.len() - 1], &longer[..], &good[..100], &[][..]] { + assert!(refused(bad), "length {}", bad.len()); + } + } + + /// A length prefix the bytes cannot back is refused before `ark-serialize` + /// reserves memory for it. + #[test] + fn an_absurd_length_prefix_is_refused_before_allocating() { + let good = stored(3); + for (at, value) in [ + (KEY_HEADER, u64::MAX), + (KEY_HEADER, 0), + (KEY_HEADER, MAX_PUBLIC_INPUTS as u64 + 2), + (GAMMA_AT_3, u64::MAX), + (GAMMA_AT_3, 1 << 40), + (DELTA_AT_3, u64::MAX), + ] { + let mut bad = good.clone(); + bad[at..at + LEN].copy_from_slice(&value.to_le_bytes()); + assert!(refused(&bad), "prefix at {at} = {value}"); + } + } + + /// Prefixes that still add up to the right length are refused too: the line + /// counts are fixed by the curve. + #[test] + fn line_counts_that_add_up_but_differ_are_refused() { + let good = stored(3); + for (gamma, delta) in [ + (LINES - 1, LINES + 1), + (LINES + 1, LINES - 1), + (0, 2 * LINES), + ] { + let mut bad = good.clone(); + bad[GAMMA_AT_3..GAMMA_AT_3 + LEN].copy_from_slice(&(gamma as u64).to_le_bytes()); + bad[DELTA_AT_3..DELTA_AT_3 + LEN].copy_from_slice(&(delta as u64).to_le_bytes()); + assert!(refused(&bad), "lines {gamma} / {delta}"); + } + } + + #[test] + fn a_set_infinity_flag_is_refused() { + let good = stored(3); + for flag_at in [ + GAMMA_AT_3 + LEN + LINES * LINE, + DELTA_AT_3 + LEN + LINES * LINE, + ] { + for value in [1u8, 2, 0xFF] { + let mut bad = good.clone(); + bad[flag_at] = value; + assert!(refused(&bad), "flag at {flag_at} = {value}"); + } + } + } +} diff --git a/primitives/zk-verifier/tests/real_proof.rs b/primitives/zk-verifier/tests/real_proof.rs index 88ff7a1e..a3fb5734 100644 --- a/primitives/zk-verifier/tests/real_proof.rs +++ b/primitives/zk-verifier/tests/real_proof.rs @@ -14,7 +14,9 @@ use ark_relations::r1cs::{ConstraintSynthesizer, ConstraintSystemRef, SynthesisE use ark_snark::SNARK; use ark_std::rand::{rngs::StdRng, SeedableRng}; -use orbinum_zk_verifier::{Groth16Verifier, Proof, PublicInputs, VerifierError, VerifyingKey}; +use orbinum_zk_verifier::{ + prepared_from_stored, Groth16Verifier, Proof, PublicInputs, VerifierError, VerifyingKey, +}; /// Circuit proving knowledge of `a`, `b` with `a * b == c`, where `c` is public. #[derive(Clone)] @@ -134,3 +136,60 @@ fn real_proof_rejects_non_canonical_input() { Err(VerifierError::InvalidPublicInput) ); } + +/// A key stored prepared and read back unchecked verifies exactly like one +/// prepared from the VK: the valid statement passes, a wrong one fails. +#[test] +fn a_stored_prepared_key_verifies_like_a_fresh_one() { + let (vk, proof, inputs) = setup_and_prove(); + let pvk = prepared_from_stored(&vk.prepared_bytes().unwrap()).unwrap(); + assert_eq!( + Groth16Verifier::verify_with_prepared_vk(&pvk, &PublicInputs::new(inputs), &proof), + Ok(()) + ); + let mut wrong = [0u8; 32]; + wrong[0] = 34; + assert_eq!( + Groth16Verifier::verify_with_prepared_vk(&pvk, &PublicInputs::new(vec![wrong]), &proof), + Err(VerifierError::VerificationFailed) + ); +} + +/// No corruption of a stored prepared key makes an invalid proof verify, and +/// none panics. Each case flips one random bit; a key that still loads (the +/// flip landed in a coordinate) is driven through the verifier with a wrong +/// public input, which must never pass. +#[test] +fn a_corrupted_stored_key_never_verifies_an_invalid_proof() { + use ark_std::rand::Rng; + let (vk, proof, inputs) = setup_and_prove(); + let good = vk.prepared_bytes().unwrap(); + let mut wrong = [0u8; 32]; + wrong[0] = 34; + let wrong = PublicInputs::new(vec![wrong]); + let right = PublicInputs::new(inputs); + + let mut rng = StdRng::seed_from_u64(7); + let (mut loaded, mut still_valid) = (0, 0); + for _ in 0..400 { + let mut bad = good.clone(); + let at = rng.gen_range(0..bad.len()); + bad[at] ^= 1 << rng.gen_range(0..8); + let Ok(pvk) = prepared_from_stored(&bad) else { + continue; + }; + loaded += 1; + assert!( + Groth16Verifier::verify_with_prepared_vk(&pvk, &wrong, &proof).is_err(), + "a flip at byte {at} let a wrong input verify" + ); + if Groth16Verifier::verify_with_prepared_vk(&pvk, &right, &proof).is_ok() { + still_valid += 1; + } + } + // Most flips land in a coordinate and load; the pairing then rejects them. A + // few land in `beta`/`gamma`/`delta` of the embedded key, which verification + // never reads, and the valid proof still passes. + assert!(loaded > 300, "only {loaded} corrupted keys loaded"); + println!("{loaded} corrupted keys loaded, {still_valid} still verify the valid proof"); +} diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 64eb05f1..817b0128 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -27,8 +27,8 @@ transfer can spend two notes from different trees. `transaction_version` moves: `private_transfer` (call 1) takes `merkle_roots: [Hash; 2]` in place of `merkle_root`. Validator-set only gains calls 6 and 7. Ships with `orbinum-runtime` / `orbinum-node` 0.4.0, `pallet-validator-set` 0.4.0, -`pallet-shielded-pool` 0.23.0, `pallet-zk-verifier` 0.16.0, -`orbinum-zk-verifier` 3.0.0 and `pallet-evm-precompile-shielded-pool` 0.9.0; +`pallet-shielded-pool` 0.23.0, `pallet-zk-verifier` 0.17.0, +`orbinum-zk-verifier` 3.1.0 and `pallet-evm-precompile-shielded-pool` 0.9.0; node 0.4.0 is the first binary that declares its version. #### 1 · Minimum author version @@ -83,11 +83,12 @@ code changes: unshield v3 keeps the 8-input memo-bound layout of v2. The fix is in the keys, so **transfer v2 and unshield v2 must be retired as soon as v3 is active.** -#### 4 · Cheaper sealed-tree paths, safer keys +#### 4 · Cheaper sealed-tree paths and proofs, safer keys - **Prune cut 10 → 6** (`SealedTreePrunedBelowLevel`). A sealed tree's Merkle path - rebuilds its pruned siblings from leaves: 62 leaf reads (~11ms in Wasm) instead - of 1_022 (~180ms), at 32_766 stored nodes per tree instead of 2_046. Paths are + rebuilds its pruned siblings from leaves: 62 leaf reads instead of 1_022 + (measured on a sealed 2^20 tree: ~2.5 ms instead of ~30 ms), at 32_766 stored + nodes per tree instead of 2_046. Paths are public RPC, so that cost was what one request could make a node pay. Lowering the cut is safe whenever it lands: a node pruned under the old cut is rebuilt on demand. @@ -97,6 +98,9 @@ active.** - **zk-verifier:** a spend circuit never takes a base-layout key (no memo binding), at any version: not on registration, activation or unretiring, and a base key already in storage verifies nothing. +- **zk-verifier:** keys are stored prepared (`PreparedKeys`), so a proof no longer + prepares its key: verification ~2.2× cheaper. Storage v2; `MigrateToV2` + prepares the keys already registered, during the upgrade. **After the upgrade, by Root:** register the v3 keys from `@orbinum/circuits` **0.17.1** (release ceremony, beacon = testnet block #1190708), activate both with diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 86cf6041..27d9bb3b 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -119,7 +119,7 @@ pub type SignedPayload = generic::SignedPayload; /// Runs on upgrade, oldest first. Drop an entry once every live chain has passed its /// version: a migration that can no longer run could be re-armed by mistake. -pub type Migrations = (); +pub type Migrations = (pallet_zk_verifier::migrations::v2::MigrateToV2,); pub type Executive = frame_executive::Executive< Runtime,