Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions frame/zk-verifier/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frame/zk-verifier/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 16 additions & 16 deletions frame/zk-verifier/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Config>(circuit_id: CircuitId, version: u32, key: Vec<u8>) {
Pallet::<T>::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<T: Config>(circuit_id: CircuitId, version: u32) {
VerificationKeys::<T>::insert(
circuit_id,
version,
VerificationKeyInfo {
key_data: sample_verification_key().try_into().unwrap(),
system: ProofSystem::Groth16,
registered_at: frame_system::Pallet::<T>::block_number(),
},
);
store_key::<T>(circuit_id, version, sample_verification_key());
}

/// Benchmark for `verify_proof`, parametrized by the public-input count `n`.
Expand All @@ -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::<T>::block_number(),
};
VerificationKeys::<T>::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::<T>(circuit_id, 1, synthetic_vk(n as usize));
crate::pallet::ActiveCircuitVersion::<T>::insert(circuit_id, 1);

let proof: BoundedVec<u8, T::MaxProofSize> = BENCH_PROOF
Expand Down Expand Up @@ -246,6 +241,11 @@ mod benchmarks {
.is_none()
);
assert!(VkHashes::<T>::iter_key_prefix(circuit_id).next().is_none());
assert!(
PreparedKeys::<T>::iter_key_prefix(circuit_id)
.next()
.is_none()
);
assert!(
VerificationStats::<T>::iter_key_prefix(circuit_id)
.next()
Expand Down
110 changes: 110 additions & 0 deletions frame/zk-verifier/src/keys.rs
Original file line number Diff line number Diff line change
@@ -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<u8>),
/// Only the key itself, prepared per proof: a key with no prepared form,
/// which only storage written outside this module can leave.
Raw(Vec<u8>),
}

impl<T: Config> Pallet<T> {
/// 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::<T>::iter_key_prefix(circuit_id).count() as u32;
ensure!(
count < Self::MAX_VERSIONS_PER_CIRCUIT,
Error::<T>::TooManyVersions
);
let prepared = Self::prepare_vk(&key_data)?;
let hash = sp_io::hashing::blake2_256(key_data.as_slice());
VerificationKeys::<T>::insert(
circuit_id,
version,
VerificationKeyInfo {
key_data,
system: ProofSystem::Groth16,
registered_at: frame_system::Pallet::<T>::block_number(),
},
);
VkHashes::<T>::insert(circuit_id, version, hash);
PreparedKeys::<T>::insert(circuit_id, version, prepared);
Ok(())
}

/// `key_data` validated and prepared, as [`PreparedKeys`] holds it.
pub(crate) fn prepare_vk(key_data: &[u8]) -> Result<PreparedVkBytes, Error<T>> {
orbinum_zk_verifier::VerifyingKey::new(key_data.to_vec())
.prepared_bytes()
.ok()
.and_then(|bytes| bytes.try_into().ok())
.ok_or(Error::<T>::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<StoredKey> {
if let Some(prepared) = PreparedKeys::<T>::get(circuit_id, version) {
return Some(StoredKey::Prepared(prepared.into_inner()));
}
VerificationKeys::<T>::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::<T>::remove(circuit_id, version);
PreparedKeys::<T>::remove(circuit_id, version);
VkHashes::<T>::remove(circuit_id, version);
VerificationStats::<T>::remove(circuit_id, version);
RetiredVersions::<T>::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::<T>::iter_key_prefix(circuit_id).count(),
PreparedKeys::<T>::iter_key_prefix(circuit_id).count(),
VkHashes::<T>::iter_key_prefix(circuit_id).count(),
VerificationStats::<T>::iter_key_prefix(circuit_id).count(),
RetiredVersions::<T>::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::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = PreparedKeys::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = VkHashes::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = VerificationStats::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = RetiredVersions::<T>::clear_prefix(circuit_id, u32::MAX, None);
ActiveCircuitVersion::<T>::remove(circuit_id);
}
}
73 changes: 26 additions & 47 deletions frame/zk-verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ extern crate alloc;
pub use pallet::*;

mod encoding;
mod keys;
pub mod migrations;
mod port;
mod runtime_api;
mod types;
Expand All @@ -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;

Expand All @@ -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)]
Expand Down Expand Up @@ -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<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
CircuitId,
Blake2_128Concat,
u32,
PreparedVkBytes,
OptionQuery,
>;

/// Verification statistics per (circuit_id, version).
#[pallet::storage]
pub type VerificationStats<T: Config> = StorageDoubleMap<
Expand Down Expand Up @@ -320,10 +334,7 @@ pub mod pallet {
.ok_or(Error::<T>::ActiveVersionNotSet)?;
ensure!(active != version, Error::<T>::CannotRemoveActiveVersion);

VerificationKeys::<T>::remove(circuit_id, version);
RetiredVersions::<T>::remove(circuit_id, version);
VkHashes::<T>::remove(circuit_id, version);
VerificationStats::<T>::remove(circuit_id, version);
Self::remove_vk(circuit_id, version);
Self::deposit_event(Event::VerificationKeyRemoved {
circuit_id,
version,
Expand Down Expand Up @@ -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
Expand All @@ -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::<T>::iter_key_prefix(circuit_id).count(),
VkHashes::<T>::iter_key_prefix(circuit_id).count(),
VerificationStats::<T>::iter_key_prefix(circuit_id).count(),
RetiredVersions::<T>::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::<T>::contains_key(circuit_id));
let entries = per_map.iter().sum::<usize>().saturating_add(active_entries);
Expand All @@ -509,19 +515,15 @@ pub mod pallet {
);

// 4. Clear, in one pass: the count proved every prefix fits the cap.
let _ = VerificationKeys::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = VkHashes::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = VerificationStats::<T>::clear_prefix(circuit_id, u32::MAX, None);
let _ = RetiredVersions::<T>::clear_prefix(circuit_id, u32::MAX, None);
ActiveCircuitVersion::<T>::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())
}
}
Expand Down Expand Up @@ -585,28 +587,5 @@ pub mod pallet {
.map(|_| ())
.ok_or(Error::<T>::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::<T>::iter_key_prefix(circuit_id).count() as u32;
ensure!(
count < Self::MAX_VERSIONS_PER_CIRCUIT,
Error::<T>::TooManyVersions
);

let hash = sp_io::hashing::blake2_256(key_data.as_slice());
VerificationKeys::<T>::insert(
circuit_id,
version,
VerificationKeyInfo {
key_data,
system: ProofSystem::Groth16,
registered_at: frame_system::Pallet::<T>::block_number(),
},
);
VkHashes::<T>::insert(circuit_id, version, hash);
Ok(())
}
}
}
Loading
Loading