From aa9d505b92a549328d6301e07e2876b54baa4af7 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 15:20:55 -0400 Subject: [PATCH 1/8] fix(shielded-pool): anchor the historic-root window to blocks, not inserts --- .../evm/precompile/shielded-pool/src/mock.rs | 2 + frame/shielded-pool/src/benchmarking.rs | 6 +- frame/shielded-pool/src/genesis.rs | 24 +- frame/shielded-pool/src/lib.rs | 83 +- frame/shielded-pool/src/merkle.rs | 1366 ----------------- frame/shielded-pool/src/migrations.rs | 577 +++++++ frame/shielded-pool/src/mock.rs | 9 +- frame/shielded-pool/src/storage.rs | 80 +- frame/shielded-pool/src/validate_unsigned.rs | 5 +- template/runtime/src/lib.rs | 17 +- 10 files changed, 760 insertions(+), 1409 deletions(-) delete mode 100644 frame/shielded-pool/src/merkle.rs create mode 100644 frame/shielded-pool/src/migrations.rs diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs index 93aef6c9..0eb444cd 100644 --- a/frame/evm/precompile/shielded-pool/src/mock.rs +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -120,6 +120,7 @@ parameter_types! { pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shldpool"); pub const MaxTreeDepth: u32 = 20; pub const MaxHistoricRoots: u32 = 100; + pub const RootRetentionBlocks: u64 = 128; pub const MaxLeavesPerTree: u32 = 8; pub const MinShieldAmount: u128 = 100; } @@ -245,6 +246,7 @@ impl pallet_shielded_pool::Config for Test { type PalletId = ShieldedPoolPalletId; type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; + type RootRetentionBlocks = RootRetentionBlocks; type MaxLeavesPerTree = MaxLeavesPerTree; type MinShieldAmount = MinShieldAmount; type WeightInfo = (); diff --git a/frame/shielded-pool/src/benchmarking.rs b/frame/shielded-pool/src/benchmarking.rs index 213abf10..acfcfab4 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -23,7 +23,7 @@ use alloc::vec; mod benchmarks { use super::*; use crate::FrameEncryptedMemo; - use crate::pallet::{Assets, HistoricPoseidonRoots, NextAssetId, PoolBalancePerAsset}; + use crate::pallet::{Assets, NextAssetId, PoolBalancePerAsset}; use pallet_relayer::RelayerInterface; use sp_core::H160; use sp_std::vec::Vec; @@ -107,7 +107,7 @@ mod benchmarks { let merkle_root = [1u8; 32]; // Setup valid root in storage - HistoricPoseidonRoots::::insert(merkle_root, true); + crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); let proof: BoundedVec> = vec![0u8; 128].try_into().unwrap(); @@ -154,7 +154,7 @@ mod benchmarks { let amount: BalanceOf = T::MinShieldAmount::get() * 10u32.into(); // Setup valid state: root and pool balance - HistoricPoseidonRoots::::insert(merkle_root, true); + crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); PoolBalancePerAsset::::insert(asset_id, amount * 2u32.into()); // Fund pool account too for actual transfer let _ = >::make_free_balance_be( diff --git a/frame/shielded-pool/src/genesis.rs b/frame/shielded-pool/src/genesis.rs index d3fddb00..5c1e1d49 100644 --- a/frame/shielded-pool/src/genesis.rs +++ b/frame/shielded-pool/src/genesis.rs @@ -2,13 +2,13 @@ use crate::{ pallet::{ - Assets, Config, HistoricPoseidonRoots, HistoricRootsOrder, MerkleTreeFrontier, NextAssetId, - PoseidonRoot, + Assets, Config, HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, + HistoricRootsTail, MerkleTreeFrontier, NextAssetId, PoseidonRoot, }, types::AssetMetadata, types::Hash, }; -use frame_support::{pallet_prelude::*, traits::Get}; +use frame_support::traits::Get; use sp_runtime::traits::AccountIdConversion; /// Helper function to initialize genesis state @@ -22,13 +22,13 @@ pub fn initialize_genesis(initial_root: Hash) { // starts from the correct baseline. MerkleTreeFrontier::::put([[0u8; 32]; 20]); - // Add genesis root to historic roots - HistoricPoseidonRoots::::insert(initial_root, true); - - // Initialize the order list with the genesis root - let mut order = BoundedVec::new(); - let _ = order.try_push(initial_root); - HistoricRootsOrder::::put(order); + // Seed the historic-root window with the genesis root, expiring one full + // retention window from block zero like any other root. + let expires_at = T::RootRetentionBlocks::get(); + HistoricPoseidonRoots::::insert(initial_root, expires_at); + HistoricRootsQueue::::insert(0u64, (initial_root, expires_at)); + HistoricRootsHead::::put(1u64); + HistoricRootsTail::::put(0u64); // Register native asset (asset_id = 0) at genesis let native_asset = AssetMetadata { @@ -83,7 +83,7 @@ mod tests { new_test_ext().execute_with(|| { let root = PoseidonRoot::::get(); assert!( - HistoricPoseidonRoots::::get(root), + HistoricPoseidonRoots::::get(root).is_some(), "Genesis root must be in historic roots" ); }); @@ -104,7 +104,7 @@ mod tests { let custom_root = [0xCDu8; 32]; super::initialize_genesis::(custom_root); assert!( - HistoricPoseidonRoots::::get(custom_root), + HistoricPoseidonRoots::::get(custom_root).is_some(), "Custom root must be in historic roots" ); }); diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 76f6e07e..4c223a1e 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -65,6 +65,7 @@ mod benchmarking; pub mod genesis; pub mod helpers; pub mod merkle; +pub mod migrations; pub mod operations; pub mod storage; pub mod types; @@ -103,11 +104,12 @@ pub mod pallet { /// Storage version history: /// - v1: `MerkleNodes` (internal Merkle tree nodes), backfilled from `MerkleLeaves`. + /// Migration removed once every live chain reached v2 — see git history. /// - v2: multi-tree forest — `SealedTreeRoots` / `SealedRootIndex` (start empty). - /// - /// The v1/v2 migration code was removed once every live chain reached v2; a new - /// chain starts here via genesis. See git history if an old chain ever needs it. - pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + /// Migration removed once every live chain reached v2 — see git history. + /// - v3: historic-root window re-anchored from insert counts to block numbers + /// (`migrations::v3::MigrateToV3`); both historic-root items carry an expiry. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(3); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -143,10 +145,24 @@ pub mod pallet { #[pallet::constant] type MaxLeavesPerTree: Get; - /// Maximum number of historic roots to keep + /// Safety cap on the historic-root queue: the most roots kept at once, + /// and the most a single insert may prune. This bounds worst-case work + /// and storage — the retention *window* is [`Self::RootRetentionBlocks`]. + /// Size it above the roots produced in one window, or it becomes the + /// binding constraint and the window silently shortens. #[pallet::constant] type MaxHistoricRoots: Get; + /// How long a historic root stays spendable, in blocks. + /// + /// Must exceed the mempool longevity of an unsigned transaction + /// (`TX_LONGEVITY`), otherwise a transaction can be admitted against a + /// root that expires before it is included — it would propagate, reach a + /// block, and only then revert with `UnknownMerkleRoot`. Enforced by + /// `integrity_test`. + #[pallet::constant] + type RootRetentionBlocks: Get>; + /// Minimum amount that can be shielded #[pallet::constant] type MinShieldAmount: Get>; @@ -229,15 +245,44 @@ pub mod pallet { #[pallet::storage] pub type SealedRootIndex = StorageMap<_, Blake2_128Concat, Hash, u32, OptionQuery>; - /// Historic Poseidon Merkle roots (for proving against recent states) + /// Historic Poseidon Merkle roots (for proving against recent states), + /// mapped to the block at which each stops being accepted. + /// + /// Expiry is measured in **blocks**, not in insertions, so the window + /// always outlives the mempool longevity a transaction was admitted with. + /// Counting insertions instead made the window rotate faster than + /// transactions expire under load, so honest spends reverted with + /// `UnknownMerkleRoot` after propagating. + #[pallet::storage] + pub type HistoricPoseidonRoots = + StorageMap<_, Blake2_128Concat, Hash, BlockNumberFor, OptionQuery>; + + /// Expiry queue for historic roots: monotonic slot -> `(root, expires_at)`. + /// + /// A map rather than one vector on purpose. The window has to hold a full + /// `RootRetentionBlocks` worth of roots — thousands under load — and a + /// `StorageValue` would be read and rewritten in full on every single leaf + /// insert, turning a hot path into hundreds of KiB of I/O. Keyed by slot, + /// each insert touches exactly one entry plus the few it prunes. + /// + /// Slots are handed out by [`HistoricRootsHead`] and consumed from + /// [`HistoricRootsTail`], so the queue drains in insertion order, which is + /// also expiry order (every insert stores `now + retention` with a + /// non-decreasing `now`). + #[pallet::storage] + pub type HistoricRootsQueue = + StorageMap<_, Twox64Concat, u64, (Hash, BlockNumberFor), OptionQuery>; + + /// Next slot to write in [`HistoricRootsQueue`]. Monotonic; never reset. #[pallet::storage] - pub type HistoricPoseidonRoots = StorageMap<_, Blake2_128Concat, Hash, bool, ValueQuery>; + pub type HistoricRootsHead = StorageValue<_, u64, ValueQuery>; - /// Order of historic roots (FIFO queue for pruning) - /// Stores roots in insertion order, oldest first + /// Oldest slot still queued in [`HistoricRootsQueue`]. Monotonic; never reset. + /// + /// `head - tail` is the number of live entries, bounded in practice by the + /// retention window and hard-capped by `MaxHistoricRoots`. #[pallet::storage] - pub type HistoricRootsOrder = - StorageValue<_, BoundedVec, ValueQuery>; + pub type HistoricRootsTail = StorageValue<_, u64, ValueQuery>; /// Encrypted memos for commitments /// @@ -340,8 +385,20 @@ pub mod pallet { assert!( T::MaxHistoricRoots::get() > 0, - "MaxHistoricRoots must be non-zero, otherwise the historic-root map \ - grows unbounded and the root window never evicts" + "MaxHistoricRoots must be non-zero, otherwise no root can ever be stored" + ); + + // The retention window must outlive the mempool longevity an unsigned + // transaction is admitted with, or a spend can pass validation, get + // gossiped, and only revert once included — the failure SP-20 fixed. + let retention: u64 = + sp_runtime::traits::UniqueSaturatedInto::::unique_saturated_into( + T::RootRetentionBlocks::get(), + ); + assert!( + retention > crate::validate_unsigned::TX_LONGEVITY, + "RootRetentionBlocks must exceed TX_LONGEVITY, otherwise a root can \ + expire while a transaction admitted against it is still valid in the pool" ); let cap = T::MaxLeavesPerTree::get(); diff --git a/frame/shielded-pool/src/merkle.rs b/frame/shielded-pool/src/merkle.rs deleted file mode 100644 index cf6544e3..00000000 --- a/frame/shielded-pool/src/merkle.rs +++ /dev/null @@ -1,1366 +0,0 @@ -//! Merkle tree — data structures, Poseidon hashing, and tree service. -//! -//! Merges the former `infrastructure/merkle_tree.rs` (IncrementalMerkleTree, -//! hash functions) with `infrastructure/services/merkle_tree_service.rs` -//! (on-chain tree management using repositories). - -use crate::{ - pallet::{CommitmentMemos, Config, Error, Event, Pallet}, - storage::{MerkleRepository, PoolStatsRepository}, - types::{Commitment, DefaultMerklePath, Hash, MerklePath}, -}; -use alloc::boxed::Box; -use ark_ff::BigInteger; -use frame_support::{ensure, pallet_prelude::*, traits::Get}; -use sp_std::vec::Vec; - -// ════════════════════════════════════════════════════════════════════════════ -// Hash helpers -// ════════════════════════════════════════════════════════════════════════════ - -/// Default hash for empty nodes at each level. -pub fn zero_hash_at_level(level: usize) -> [u8; 32] { - if level == 0 { - return [0u8; 32]; - } - let prev = zero_hash_at_level(level - 1); - hash_pair(&prev, &prev) -} - -/// Cached zero hashes for Poseidon (lazy-initialized, thread-safe). -static ZERO_HASHES_POSEIDON: once_cell::race::OnceBox<[[u8; 32]; 21]> = - once_cell::race::OnceBox::new(); - -/// Get precomputed zero hash at level (optimized with cache). -#[inline] -pub fn get_zero_hash_cached(level: usize) -> [u8; 32] { - if level < 21 { - let cache = ZERO_HASHES_POSEIDON.get_or_init(|| { - let mut hashes = [[0u8; 32]; 21]; - hashes[0] = [0u8; 32]; - for i in 1..21 { - hashes[i] = hash_pair_poseidon(&hashes[i - 1], &hashes[i - 1]); - } - Box::new(hashes) - }); - return cache[level]; - } - zero_hash_at_level(level) -} - -/// Hash two nodes together using Poseidon (ZK-friendly, ~300 constraints). -#[inline] -pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - use ark_bn254::Fr as Bn254Fr; - use ark_ff::PrimeField; - use orbinum_zk_core::{FieldElement, PoseidonHasher}; - - let left_fr = Bn254Fr::from_le_bytes_mod_order(left); - let right_fr = Bn254Fr::from_le_bytes_mod_order(right); - - #[cfg(feature = "poseidon-native")] - let hasher = orbinum_zk_core::NativePoseidonHasher; - #[cfg(not(feature = "poseidon-native"))] - let hasher = orbinum_zk_core::LightPoseidonHasher; - - let hash_fr = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]); - - let mut hash_bytes = [0u8; 32]; - let bigint = hash_fr.inner().into_bigint(); - let bytes = bigint.to_bytes_le(); - hash_bytes.copy_from_slice(&bytes[..32]); - hash_bytes -} - -/// Hash pair — always uses Poseidon. -pub fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - hash_pair_poseidon(left, right) -} - -// ════════════════════════════════════════════════════════════════════════════ -// IncrementalMerkleTree (data structure, no storage access) -// ════════════════════════════════════════════════════════════════════════════ - -#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug)] -pub struct IncrementalMerkleTree { - pub frontier: [[u8; 32]; DEPTH], - pub next_index: u32, - pub root: [u8; 32], -} - -impl Default for IncrementalMerkleTree { - fn default() -> Self { - Self::new() - } -} - -impl IncrementalMerkleTree { - pub fn new() -> Self { - let root = Self::compute_empty_root(); - Self { - frontier: [[0u8; 32]; DEPTH], - next_index: 0, - root, - } - } - - fn compute_empty_root() -> [u8; 32] { - let mut current = [0u8; 32]; - for _ in 0..DEPTH { - current = hash_pair(¤t, ¤t); - } - current - } - - fn zero_hash(level: usize) -> [u8; 32] { - get_zero_hash_cached(level) - } - - pub fn capacity(&self) -> u32 { - 1u32 << DEPTH - } - pub fn is_full(&self) -> bool { - self.next_index >= self.capacity() - } - - pub fn insert(&mut self, leaf: [u8; 32]) -> Result { - if self.is_full() { - return Err("Merkle tree is full"); - } - let index = self.next_index; - let mut current_hash = leaf; - let mut current_index = index; - - for level in 0..DEPTH { - if current_index % 2 == 0 { - self.frontier[level] = current_hash; - let zero = Self::zero_hash(level); - current_hash = hash_pair(¤t_hash, &zero); - } else { - current_hash = hash_pair(&self.frontier[level], ¤t_hash); - } - current_index /= 2; - } - - self.root = current_hash; - self.next_index += 1; - Ok(index) - } - - pub fn root(&self) -> [u8; 32] { - self.root - } - pub fn size(&self) -> u32 { - self.next_index - } - - pub fn generate_proof( - &self, - leaf_index: u32, - leaves: &[[u8; 32]], - ) -> Result, &'static str> { - if leaf_index >= self.next_index { - return Err("Leaf index out of bounds"); - } - if leaves.len() != self.next_index as usize { - return Err("Leaves count mismatch"); - } - - let mut siblings = [[0u8; 32]; DEPTH]; - let mut indices = [0u8; DEPTH]; - let mut current_level = leaves.to_vec(); - let mut target_index = leaf_index as usize; - - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - current_level.push(Self::zero_hash(level)); - } - let sibling_index = if target_index % 2 == 0 { - indices[level] = 0; - target_index + 1 - } else { - indices[level] = 1; - target_index - 1 - }; - siblings[level] = if sibling_index < current_level.len() { - current_level[sibling_index] - } else { - Self::zero_hash(level) - }; - let mut next_level = Vec::new(); - for chunk in current_level.chunks(2) { - let left = chunk[0]; - let right = if chunk.len() > 1 { - chunk[1] - } else { - Self::zero_hash(level) - }; - next_level.push(hash_pair(&left, &right)); - } - current_level = next_level; - target_index /= 2; - } - Ok(MerklePath { siblings, indices }) - } - - pub fn verify_proof(root: &[u8; 32], leaf: &[u8; 32], path: &MerklePath) -> bool { - let mut current = *leaf; - for level in 0..DEPTH { - let sibling = &path.siblings[level]; - current = if path.indices[level] == 0 { - hash_pair(¤t, sibling) - } else { - hash_pair(sibling, ¤t) - }; - } - ¤t == root - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Root computation helpers -// ════════════════════════════════════════════════════════════════════════════ - -pub fn compute_root_from_leaves_poseidon(leaves: &[Hash]) -> Hash { - if leaves.is_empty() { - return [0u8; 32]; - } - - let mut zero_hashes = [[0u8; 32]; 21]; - zero_hashes[0] = [0u8; 32]; - for i in 1..=20 { - zero_hashes[i] = hash_pair_poseidon(&zero_hashes[i - 1], &zero_hashes[i - 1]); - } - - let mut current_level: Vec = leaves.to_vec(); - - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - current_level.push(zero_hashes[level]); - } - let mut next_level = Vec::new(); - for i in (0..current_level.len()).step_by(2) { - next_level.push(hash_pair_poseidon(¤t_level[i], ¤t_level[i + 1])); - } - current_level = next_level; - if current_level.len() == 1 && level + 1 < DEPTH { - let mut root = current_level[0]; - for zero_hash in zero_hashes.iter().skip(level + 1).take(DEPTH - level - 1) { - root = hash_pair_poseidon(&root, zero_hash); - } - return root; - } - } - current_level.first().copied().unwrap_or([0u8; 32]) -} - -pub fn compute_root_from_leaves(leaves: &[Hash]) -> Hash { - if leaves.is_empty() { - let mut current = [0u8; 32]; - for _ in 0..DEPTH { - current = hash_pair(¤t, ¤t); - } - return current; - } - let mut current_level: Vec = leaves.to_vec(); - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - let mut zero = [0u8; 32]; - for _ in 0..level { - zero = hash_pair(&zero, &zero); - } - current_level.push(zero); - } - let mut next_level = Vec::new(); - for chunk in current_level.chunks(2) { - let left = chunk[0]; - let right = if chunk.len() > 1 { - chunk[1] - } else { - let mut zero = [0u8; 32]; - for _ in 0..level { - zero = hash_pair(&zero, &zero); - } - zero - }; - next_level.push(hash_pair(&left, &right)); - } - current_level = next_level; - if current_level.len() == 1 && level + 1 < DEPTH { - let mut zero = [0u8; 32]; - for _ in 0..=level { - zero = hash_pair(&zero, &zero); - } - for _ in (level + 1)..DEPTH { - current_level[0] = hash_pair(¤t_level[0], &zero); - zero = hash_pair(&zero, &zero); - } - break; - } - } - current_level.first().copied().unwrap_or([0u8; 32]) -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerkleTreeService (on-chain tree management, uses repositories) -// ════════════════════════════════════════════════════════════════════════════ - -pub struct MerkleTreeService; - -impl MerkleTreeService { - /// Insert a new leaf into the Merkle tree. - /// - /// Uses an incremental frontier algorithm: O(depth) hashes per insert, - /// replacing the former O(n) full recomputation from all leaves. - pub fn insert_leaf(commitment: Commitment) -> Result { - let index = MerkleRepository::get_tree_size::(); - // Absolute forest ceiling: the global u32 leaf index must stay - // representable (4096 trees at depth 20). Per-tree fullness rolls - // over to a fresh tree below instead of erroring. - ensure!(index < u32::MAX, Error::::MerkleTreeFull); - ensure!( - !CommitmentMemos::::contains_key(commitment), - Error::::CommitmentAlreadyExists - ); - - let cap = T::MaxLeavesPerTree::get(); - let tree_id = index / cap; - let local = index % cap; - - // Load frontier from storage and run one incremental update. - // Depth is always DEFAULT_TREE_DEPTH (20) — matches the fixed-size frontier array. - let mut frontier = MerkleRepository::get_frontier::(); - let mut current_hash = commitment.0; - let mut current_index = local; - - for (level, frontier_slot) in frontier.iter_mut().enumerate() { - if current_index % 2 == 0 { - // Left node: save in frontier, pair with zero-sibling - *frontier_slot = current_hash; - let zero = get_zero_hash_cached(level); - current_hash = hash_pair(¤t_hash, &zero); - } else { - // Right node: combine with stored left sibling - current_hash = hash_pair(frontier_slot, ¤t_hash); - } - current_index /= 2; - // current_hash is now the node at (level + 1, current_index). Persist - // levels 1..=19 so proof reads are O(depth); level 20 is PoseidonRoot. - if level + 1 < crate::types::DEFAULT_TREE_DEPTH { - MerkleRepository::set_node::( - tree_id, - (level + 1) as u8, - current_index, - current_hash, - ); - } - } - - let new_poseidon_root = current_hash; - let old_poseidon_root = MerkleRepository::get_poseidon_root::(); - - MerkleRepository::insert_leaf::(index, commitment); - MerkleRepository::set_commitment_leaf_index::(commitment, index); - MerkleRepository::set_tree_size::(index.saturating_add(1)); - PoolStatsRepository::increment_commitments_inserted::(); - MerkleRepository::set_frontier::(frontier); - MerkleRepository::set_poseidon_root::(new_poseidon_root); - Self::add_poseidon_historic_root::(new_poseidon_root); - - // The freshly inserted leaf belongs to `new_poseidon_root`, so this - // event fires before any seal resets the active root. - Pallet::::deposit_event(Event::MerkleRootUpdated { - old_root: old_poseidon_root, - new_root: new_poseidon_root, - tree_size: index.saturating_add(1), - }); - - if local + 1 == cap { - Self::seal_tree::(tree_id, new_poseidon_root, cap); - } - Ok(index) - } - - /// Seal a full tree and open a fresh one, eagerly in the same insert. - /// - /// The final root becomes a permanent anchor (`SealedTreeRoots` / - /// `SealedRootIndex`) — unlike the historic ring it never expires, so - /// notes in sealed trees stay spendable forever. The active tree resets - /// to the empty state; the empty root joins the historic ring to keep - /// the `PoseidonRoot ∈ known roots` invariant. - fn seal_tree(tree_id: u32, final_root: Hash, cap: u32) { - MerkleRepository::insert_sealed_root::(tree_id, final_root); - MerkleRepository::set_frontier::([[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]); - let empty_root = get_zero_hash_cached(crate::types::DEFAULT_TREE_DEPTH); - MerkleRepository::set_poseidon_root::(empty_root); - Self::add_poseidon_historic_root::(empty_root); - - Pallet::::deposit_event(Event::TreeSealed { - tree_id, - final_root, - first_leaf_index: tree_id.saturating_mul(cap), - leaf_count: cap, - }); - } - - pub(crate) fn add_poseidon_historic_root(poseidon_root: Hash) { - let mut order = MerkleRepository::get_historic_roots_order::(); - if order.len() >= T::MaxHistoricRoots::get() as usize { - if let Some(oldest_root) = order.first().copied() { - order.remove(0); - if !order.contains(&oldest_root) { - MerkleRepository::remove_poseidon_historic_root::(&oldest_root); - } - } - } - if order.try_push(poseidon_root).is_ok() { - MerkleRepository::add_historic_poseidon_root::(poseidon_root); - MerkleRepository::set_historic_roots_order::(order); - } - } - - pub fn is_known_root(root: &Hash) -> bool { - MerkleRepository::is_known_root::(root) - } - - /// Build the sibling path for `leaf_index` from stored nodes. - /// - /// O(depth) point reads: level-0 siblings come from `MerkleLeaves`, upper - /// siblings from `MerkleNodes`. A missing entry means an empty subtree, so - /// the canonical zero hash for that level is used. - pub fn get_merkle_path(leaf_index: u32) -> Option { - let size = MerkleRepository::get_tree_size::(); - if leaf_index >= size { - return None; - } - - let depth = crate::types::DEFAULT_TREE_DEPTH; - let cap = T::MaxLeavesPerTree::get(); - let tree_id = leaf_index / cap; - let local = leaf_index % cap; - let mut siblings = [[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]; - let mut indices = [0u8; crate::types::DEFAULT_TREE_DEPTH]; - - for level in 0..depth { - let node_index = local >> level; - indices[level] = (node_index & 1) as u8; - let sibling_index = node_index ^ 1; - let sibling = if level == 0 { - // Level-0 nodes are the leaves; map the tree-local sibling - // back to its global MerkleLeaves index. - MerkleRepository::get_leaf::(tree_id * cap + sibling_index).map(|c| c.0) - } else { - MerkleRepository::get_node::(tree_id, level as u8, sibling_index) - }; - siblings[level] = sibling.unwrap_or_else(|| get_zero_hash_cached(level)); - } - Some(DefaultMerklePath { siblings, indices }) - } - - pub fn verify_merkle_proof(root: &Hash, leaf: &Hash, path: &DefaultMerklePath) -> bool { - IncrementalMerkleTree::<20>::verify_proof(root, leaf, path) - } - - pub fn find_leaf_index(commitment: &Commitment) -> Option { - MerkleRepository::find_leaf_index::(commitment) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - mock::{Test, new_test_ext}, - types::Commitment, - }; - - // ── hash functions ────────────────────────────────────────────────────── - - #[test] - fn hash_pair_poseidon_deterministic() { - let left = [0x01u8; 32]; - let right = [0x02u8; 32]; - let h1 = hash_pair_poseidon(&left, &right); - let h2 = hash_pair_poseidon(&left, &right); - assert_eq!(h1, h2); - assert_ne!(h1, [0u8; 32]); - } - - #[test] - fn hash_pair_poseidon_not_commutative() { - let left = [0x01u8; 32]; - let right = [0x02u8; 32]; - let h1 = hash_pair_poseidon(&left, &right); - let h2 = hash_pair_poseidon(&right, &left); - assert_ne!(h1, h2); - } - - #[test] - fn hash_pair_delegates_to_poseidon() { - let left = [0x03u8; 32]; - let right = [0x04u8; 32]; - assert_eq!(hash_pair(&left, &right), hash_pair_poseidon(&left, &right)); - } - - #[test] - fn zero_hash_level_0_is_all_zeros() { - assert_eq!(zero_hash_at_level(0), [0u8; 32]); - } - - #[test] - fn zero_hash_level_1_is_hash_of_zeros() { - let expected = hash_pair(&[0u8; 32], &[0u8; 32]); - assert_eq!(zero_hash_at_level(1), expected); - } - - #[test] - fn zero_hash_levels_are_distinct() { - let h0 = zero_hash_at_level(0); - let h1 = zero_hash_at_level(1); - let h2 = zero_hash_at_level(2); - assert_ne!(h0, h1); - assert_ne!(h1, h2); - } - - #[test] - fn get_zero_hash_cached_matches_uncached() { - for level in 0..5usize { - assert_eq!(get_zero_hash_cached(level), zero_hash_at_level(level)); - } - } - - // ── IncrementalMerkleTree ──────────────────────────────────────────────── - - #[test] - fn tree_new_has_zero_size() { - let tree = IncrementalMerkleTree::<4>::new(); - assert_eq!(tree.size(), 0); - } - - #[test] - fn tree_new_root_is_non_zero() { - let tree = IncrementalMerkleTree::<4>::new(); - assert_ne!(tree.root(), [0u8; 32]); - } - - #[test] - fn tree_capacity_is_power_of_two() { - assert_eq!(IncrementalMerkleTree::<4>::new().capacity(), 16); - assert_eq!(IncrementalMerkleTree::<2>::new().capacity(), 4); - } - - #[test] - fn tree_insert_returns_sequential_indices() { - let mut tree = IncrementalMerkleTree::<4>::new(); - assert_eq!(tree.insert([0x01u8; 32]).unwrap(), 0); - assert_eq!(tree.insert([0x02u8; 32]).unwrap(), 1); - assert_eq!(tree.insert([0x03u8; 32]).unwrap(), 2); - assert_eq!(tree.size(), 3); - } - - #[test] - fn tree_root_changes_after_insert() { - let mut tree = IncrementalMerkleTree::<4>::new(); - let root_before = tree.root(); - tree.insert([0xAAu8; 32]).unwrap(); - assert_ne!(tree.root(), root_before); - } - - #[test] - fn tree_full_rejects_further_inserts() { - let mut tree = IncrementalMerkleTree::<2>::new(); - for i in 0u8..4 { - tree.insert([i; 32]).unwrap(); - } - assert!(tree.is_full()); - assert!(tree.insert([0xFFu8; 32]).is_err()); - } - - #[test] - fn tree_default_equals_new() { - let t1 = IncrementalMerkleTree::<4>::new(); - let t2 = IncrementalMerkleTree::<4>::default(); - assert_eq!(t1.root(), t2.root()); - assert_eq!(t1.size(), t2.size()); - } - - #[test] - fn tree_generate_and_verify_proof_passes() { - let mut tree = IncrementalMerkleTree::<4>::new(); - let leaves = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]]; - for &leaf in &leaves { - tree.insert(leaf).unwrap(); - } - let proof = tree.generate_proof(0, &leaves).unwrap(); - assert!(IncrementalMerkleTree::<4>::verify_proof( - &tree.root(), - &leaves[0], - &proof - )); - } - - #[test] - fn tree_proof_fails_for_wrong_leaf() { - let mut tree = IncrementalMerkleTree::<4>::new(); - let leaves = [[0x01u8; 32], [0x02u8; 32]]; - for &l in &leaves { - tree.insert(l).unwrap(); - } - let proof = tree.generate_proof(0, &leaves).unwrap(); - assert!(!IncrementalMerkleTree::<4>::verify_proof( - &tree.root(), - &[0xFFu8; 32], - &proof - )); - } - - #[test] - fn tree_generate_proof_out_of_bounds_fails() { - let mut tree = IncrementalMerkleTree::<4>::new(); - tree.insert([0x01u8; 32]).unwrap(); - let leaves = [[0x01u8; 32]]; - assert!(tree.generate_proof(5, &leaves).is_err()); - } - - // ── compute_root_from_leaves_poseidon ──────────────────────────────────── - - #[test] - fn compute_root_poseidon_empty_is_zero() { - assert_eq!(compute_root_from_leaves_poseidon::<4>(&[]), [0u8; 32]); - } - - #[test] - fn compute_root_poseidon_single_leaf_nonzero() { - let root = compute_root_from_leaves_poseidon::<4>(&[[0x01u8; 32]]); - assert_ne!(root, [0u8; 32]); - assert_ne!(root, [0x01u8; 32]); - } - - #[test] - fn compute_root_poseidon_same_leaves_same_root() { - let leaves = [[0x01u8; 32], [0x02u8; 32]]; - let r1 = compute_root_from_leaves_poseidon::<4>(&leaves); - let r2 = compute_root_from_leaves_poseidon::<4>(&leaves); - assert_eq!(r1, r2); - } - - #[test] - fn compute_root_poseidon_different_leaves_different_roots() { - let r1 = compute_root_from_leaves_poseidon::<4>(&[[0x01u8; 32]]); - let r2 = compute_root_from_leaves_poseidon::<4>(&[[0x02u8; 32]]); - assert_ne!(r1, r2); - } - - // ── MerkleTreeService (FRAME-backed) ───────────────────────────────────── - - #[test] - fn service_insert_leaf_returns_sequential_indices() { - new_test_ext().execute_with(|| { - let c0 = Commitment::new([0x01u8; 32]); - let c1 = Commitment::new([0x02u8; 32]); - assert_eq!(MerkleTreeService::insert_leaf::(c0).unwrap(), 0); - assert_eq!(MerkleTreeService::insert_leaf::(c1).unwrap(), 1); - }); - } - - #[test] - fn service_insert_duplicate_fails() { - new_test_ext().execute_with(|| { - let c = Commitment::new([0x01u8; 32]); - MerkleTreeService::insert_leaf::(c).unwrap(); - // Duplicate detection is based on CommitmentMemos; simulate a prior memo insert - // (operations layer stores the memo when shielding/transferring) - use crate::storage::CommitmentRepository; - use crate::types::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; - CommitmentRepository::store_memo::( - c, - EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(), - ); - assert!(MerkleTreeService::insert_leaf::(c).is_err()); - }); - } - - #[test] - fn service_insert_updates_poseidon_root() { - new_test_ext().execute_with(|| { - use crate::storage::MerkleRepository; - let root_before = MerkleRepository::get_poseidon_root::(); - MerkleTreeService::insert_leaf::(Commitment::new([0xAAu8; 32])).unwrap(); - let root_after = MerkleRepository::get_poseidon_root::(); - assert_ne!(root_before, root_after); - }); - } - - #[test] - fn service_insert_adds_root_to_historic() { - new_test_ext().execute_with(|| { - MerkleTreeService::insert_leaf::(Commitment::new([0xBBu8; 32])).unwrap(); - use crate::storage::MerkleRepository; - let root = MerkleRepository::get_poseidon_root::(); - assert!(MerkleTreeService::is_known_root::(&root)); - }); - } - - #[test] - fn service_get_merkle_path_none_for_empty_tree() { - new_test_ext().execute_with(|| { - assert!(MerkleTreeService::get_merkle_path::(0).is_none()); - }); - } - - #[test] - fn service_get_merkle_path_some_after_insert() { - new_test_ext().execute_with(|| { - MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); - assert!(MerkleTreeService::get_merkle_path::(0).is_some()); - }); - } - - #[test] - fn service_get_merkle_path_none_out_of_bounds() { - new_test_ext().execute_with(|| { - MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); - assert!(MerkleTreeService::get_merkle_path::(99).is_none()); - }); - } - - #[test] - fn service_verify_merkle_proof_valid_round_trip() { - new_test_ext().execute_with(|| { - let leaf = [0x11u8; 32]; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - use crate::storage::MerkleRepository; - let root = MerkleRepository::get_poseidon_root::(); - let path = MerkleTreeService::get_merkle_path::(0).unwrap(); - assert!(MerkleTreeService::verify_merkle_proof(&root, &leaf, &path)); - }); - } - - #[test] - fn service_verify_merkle_proof_fails_for_wrong_root() { - new_test_ext().execute_with(|| { - let leaf = [0x12u8; 32]; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - let path = MerkleTreeService::get_merkle_path::(0).unwrap(); - assert!(!MerkleTreeService::verify_merkle_proof( - &[0xFFu8; 32], - &leaf, - &path - )); - }); - } - - #[test] - fn service_find_leaf_index_none_for_unknown() { - new_test_ext().execute_with(|| { - let c = Commitment::new([0xCCu8; 32]); - assert!(MerkleTreeService::find_leaf_index::(&c).is_none()); - }); - } - - #[test] - fn service_find_leaf_index_correct_after_multiple_inserts() { - new_test_ext().execute_with(|| { - let c0 = Commitment::new([0x01u8; 32]); - let c1 = Commitment::new([0x02u8; 32]); - MerkleTreeService::insert_leaf::(c0).unwrap(); - MerkleTreeService::insert_leaf::(c1).unwrap(); - assert_eq!(MerkleTreeService::find_leaf_index::(&c0), Some(0)); - assert_eq!(MerkleTreeService::find_leaf_index::(&c1), Some(1)); - }); - } - - #[test] - fn insert_leaf_populates_commitment_to_leaf_index() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - let c0 = Commitment::new([0xD0u8; 32]); - let c1 = Commitment::new([0xD1u8; 32]); - let c2 = Commitment::new([0xD2u8; 32]); - MerkleTreeService::insert_leaf::(c0).unwrap(); - MerkleTreeService::insert_leaf::(c1).unwrap(); - MerkleTreeService::insert_leaf::(c2).unwrap(); - // Reverse index must be populated for every inserted commitment - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&c0), - Some(0) - ); - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&c1), - Some(1) - ); - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&c2), - Some(2) - ); - // Unknown commitment returns None - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&Commitment::new([0xFFu8; 32])), - None - ); - }); - } - - #[test] - fn insert_leaf_increments_total_commitments_counter() { - use crate::storage::PoolStatsRepository; - new_test_ext().execute_with(|| { - assert_eq!( - PoolStatsRepository::get_total_commitments_inserted::(), - 0 - ); - MerkleTreeService::insert_leaf::(Commitment::new([0xF0u8; 32])).unwrap(); - assert_eq!( - PoolStatsRepository::get_total_commitments_inserted::(), - 1 - ); - MerkleTreeService::insert_leaf::(Commitment::new([0xF1u8; 32])).unwrap(); - MerkleTreeService::insert_leaf::(Commitment::new([0xF2u8; 32])).unwrap(); - assert_eq!( - PoolStatsRepository::get_total_commitments_inserted::(), - 3 - ); - }); - } - - // ── Stored-node path reads vs recomputed reference ─────────────────────── - - /// Reference sibling-path builder: recomputes every level from the full - /// leaf set. Oracle for the O(depth) stored-node read path. - fn reference_path(leaves: &[[u8; 32]], leaf_index: usize) -> Vec<[u8; 32]> { - let mut current_level = leaves.to_vec(); - let mut path = Vec::with_capacity(20); - let mut target = leaf_index; - for level in 0..20 { - if current_level.len() % 2 != 0 { - current_level.push(get_zero_hash_cached(level)); - } - let sibling_idx = target ^ 1; - path.push(if sibling_idx < current_level.len() { - current_level[sibling_idx] - } else { - get_zero_hash_cached(level) - }); - let mut next = Vec::with_capacity(current_level.len().div_ceil(2)); - for chunk in current_level.chunks(2) { - let right = chunk - .get(1) - .copied() - .unwrap_or_else(|| get_zero_hash_cached(level)); - next.push(hash_pair_poseidon(&chunk[0], &right)); - } - current_level = next; - target /= 2; - } - path - } - - #[test] - fn stored_node_paths_match_recomputed_reference_for_every_leaf() { - new_test_ext().execute_with(|| { - // 7 leaves (< MaxLeavesPerTree): odd count exercises zero-hash - // padding without sealing the tree. - let leaves: Vec<[u8; 32]> = (0..7u8).map(|i| [i + 1; 32]).collect(); - for leaf in &leaves { - MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); - } - let root = crate::storage::MerkleRepository::get_poseidon_root::(); - for (i, leaf) in leaves.iter().enumerate() { - let path = MerkleTreeService::get_merkle_path::(i as u32).unwrap(); - let expected = reference_path(&leaves, i); - assert_eq!( - path.siblings.to_vec(), - expected, - "stored-node path for leaf {i} must equal recomputed path" - ); - assert!( - MerkleTreeService::verify_merkle_proof(&root, leaf, &path), - "leaf {i} proof must verify against the current root" - ); - } - }); - } - - #[test] - fn first_and_last_leaf_paths_verify() { - new_test_ext().execute_with(|| { - let leaves: Vec<[u8; 32]> = (0..6u8).map(|i| [0xA0 + i; 32]).collect(); - for leaf in &leaves { - MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); - } - let root = crate::storage::MerkleRepository::get_poseidon_root::(); - for i in [0u32, 5] { - let path = MerkleTreeService::get_merkle_path::(i).unwrap(); - assert!(MerkleTreeService::verify_merkle_proof( - &root, - &leaves[i as usize], - &path - )); - } - }); - } - - #[test] - fn single_leaf_tree_path_is_all_zero_hashes() { - new_test_ext().execute_with(|| { - let leaf = [0x77u8; 32]; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - let path = MerkleTreeService::get_merkle_path::(0).unwrap(); - for (level, sibling) in path.siblings.iter().enumerate() { - assert_eq!(*sibling, get_zero_hash_cached(level)); - } - let root = crate::storage::MerkleRepository::get_poseidon_root::(); - assert!(MerkleTreeService::verify_merkle_proof(&root, &leaf, &path)); - }); - } - - #[test] - fn stored_top_nodes_derive_poseidon_root() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - for i in 0..5u8 { - MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); - } - let left = MerkleRepository::get_node::(0, 19, 0).expect("top-left node stored"); - let right = MerkleRepository::get_node::(0, 19, 1) - .unwrap_or_else(|| get_zero_hash_cached(19)); - assert_eq!( - hash_pair_poseidon(&left, &right), - MerkleRepository::get_poseidon_root::(), - "level-19 nodes must hash to the stored root" - ); - }); - } - - // ── Incremental frontier vs batch consistency ──────────────────────────── - - #[test] - fn incremental_root_matches_batch_root_after_single_insert() { - new_test_ext().execute_with(|| { - let leaf = [0x11u8; 32]; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - - let incremental_root = crate::storage::MerkleRepository::get_poseidon_root::(); - let batch_root = compute_root_from_leaves_poseidon::<20>(&[leaf]); - assert_eq!( - incremental_root, batch_root, - "incremental and batch roots must agree after 1 insert" - ); - }); - } - - #[test] - fn incremental_root_matches_batch_root_after_multiple_inserts() { - new_test_ext().execute_with(|| { - let leaves = [ - [0x01u8; 32], - [0x02u8; 32], - [0x03u8; 32], - [0x04u8; 32], - [0x05u8; 32], - ]; - for &leaf in &leaves { - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - } - - let incremental_root = crate::storage::MerkleRepository::get_poseidon_root::(); - let batch_root = compute_root_from_leaves_poseidon::<20>(&leaves); - assert_eq!( - incremental_root, batch_root, - "incremental and batch roots must agree after multiple inserts" - ); - }); - } - - #[test] - fn incremental_proof_verifies_against_incremental_root() { - new_test_ext().execute_with(|| { - let leaves = [[0x0Au8; 32], [0x0Bu8; 32], [0x0Cu8; 32]]; - for &leaf in &leaves { - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - } - - // Proof computed from all leaves (batch), root stored incrementally. - // Both must be consistent. - let root = crate::storage::MerkleRepository::get_poseidon_root::(); - for (i, &leaf) in leaves.iter().enumerate() { - let path = MerkleTreeService::get_merkle_path::(i as u32).unwrap(); - assert!( - MerkleTreeService::verify_merkle_proof(&root, &leaf, &path), - "proof for leaf {i} must verify against incremental root" - ); - } - }); - } - - #[test] - fn merkle_root_updated_event_carries_correct_old_root() { - use crate::mock::RuntimeEvent; - new_test_ext().execute_with(|| { - let c0 = Commitment::new([0xA0u8; 32]); - let c1 = Commitment::new([0xA1u8; 32]); - MerkleTreeService::insert_leaf::(c0).unwrap(); - let root_after_first = crate::storage::MerkleRepository::get_poseidon_root::(); - MerkleTreeService::insert_leaf::(c1).unwrap(); - - // The second MerkleRootUpdated event must carry the root stored after the first insert. - let found = frame_system::Pallet::::events().into_iter().any(|r| { - matches!( - &r.event, - RuntimeEvent::ShieldedPool(crate::Event::MerkleRootUpdated { - old_root, .. - }) if *old_root == root_after_first - ) - }); - assert!( - found, - "MerkleRootUpdated event must carry the previous root as old_root" - ); - }); - } - - // Simulates storage round-trip across multiple separate execute_with calls, - // mimicking the frontier being persisted between blocks. - // Verifies SCALE serialization of [[u8; 32]; 20] survives storage read/write cycles. - #[test] - fn frontier_survives_storage_round_trip_across_separate_calls() { - use crate::pallet::MerkleTreeFrontier; - - let mut ext = new_test_ext(); - - // Block 1: insert first leaf - let root_b1 = ext.execute_with(|| { - MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); - MerkleRepository::get_poseidon_root::() - }); - - // Block 2: insert second leaf — frontier must be correctly recovered from storage - let root_b2 = ext.execute_with(|| { - // Frontier was written in block 1; verify it is non-zero (was persisted) - let frontier = MerkleTreeFrontier::::get(); - assert_ne!( - frontier[0], [0u8; 32], - "frontier slot 0 must be set after first insert" - ); - - MerkleTreeService::insert_leaf::(Commitment::new([0x02u8; 32])).unwrap(); - MerkleRepository::get_poseidon_root::() - }); - - assert_ne!(root_b1, root_b2, "root must change with each insert"); - - // Block 3: the root after 2 incremental inserts must equal the batch root for same leaves - let expected = ext.execute_with(|| { - compute_root_from_leaves_poseidon::<20>(&[[0x01u8; 32], [0x02u8; 32]]) - }); - - assert_eq!( - root_b2, expected, - "frontier root after 2 round-trips must match batch root" - ); - } - - // ── tree-depth consistency ──────────────────────────────────────────────── - - /// integrity_test passes when MaxTreeDepth equals the fixed tree depth. The - /// mock is aligned to MAX_TREE_DEPTH, so construction must not panic; a - /// divergent config would abort at runtime construction. - #[test] - fn integrity_test_accepts_aligned_tree_depth() { - use frame_support::traits::Hooks; - new_test_ext().execute_with(|| { - as Hooks>>::integrity_test(); - }); - } - - /// The per-tree capacity must divide the fixed depth-20 leaf space so the - /// forest's global u32 index spans whole trees. - #[test] - fn per_tree_capacity_fits_fixed_depth() { - use crate::types::MAX_TREE_DEPTH; - assert_eq!(MAX_TREE_DEPTH, 20); - let cap = ::MaxLeavesPerTree::get(); - assert!(cap.is_power_of_two() && cap <= 1 << MAX_TREE_DEPTH); - } - - // ── Multi-tree forest: sealing and rollover ────────────────────────────── - - fn fill_leaves(from: u8, count: u8) { - for i in 0..count { - MerkleTreeService::insert_leaf::(Commitment::new([from + i; 32])).unwrap(); - } - } - - #[test] - fn filling_insert_seals_tree_and_resets_active_state() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - frame_system::Pallet::::set_block_number(1); - fill_leaves(1, 7); - let last = MerkleTreeService::insert_leaf::(Commitment::new([8u8; 32])).unwrap(); - assert_eq!(last, 7, "filling insert still returns its global index"); - - let sealed = MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"); - assert!(MerkleRepository::is_known_root::(&sealed)); - // Active tree reset: empty frontier, empty root, empty root known. - assert_eq!(MerkleRepository::get_frontier::(), [[0u8; 32]; 20]); - let empty_root = get_zero_hash_cached(20); - assert_eq!(MerkleRepository::get_poseidon_root::(), empty_root); - assert!(MerkleRepository::is_known_root::(&empty_root)); - - // Event order: MerkleRootUpdated carries the FINAL root (the new - // leaf belongs to it), then TreeSealed. - let events: sp_std::vec::Vec<_> = frame_system::Pallet::::events() - .into_iter() - .map(|r| r.event) - .collect(); - let root_pos = events - .iter() - .position(|e| { - matches!(e, crate::mock::RuntimeEvent::ShieldedPool( - Event::MerkleRootUpdated { new_root, tree_size: 8, .. } - ) if *new_root == sealed) - }) - .expect("MerkleRootUpdated with final root"); - let seal_pos = events - .iter() - .position(|e| { - matches!(e, crate::mock::RuntimeEvent::ShieldedPool( - Event::TreeSealed { tree_id: 0, final_root, first_leaf_index: 0, leaf_count: 8 } - ) if *final_root == sealed) - }) - .expect("TreeSealed event"); - assert!(root_pos < seal_pos); - }); - } - - /// The single most important forest test: a sealed tree's final root must - /// survive unbounded activity in later trees — eviction would freeze the - /// funds of every unspent note in the sealed tree. - #[test] - fn sealed_root_survives_historic_ring_eviction() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - fill_leaves(1, 8); // seal tree 0 - let sealed = MerkleRepository::get_sealed_root::(0).unwrap(); - let leaf0 = MerkleRepository::get_leaf::(0).unwrap().0; - let path0 = MerkleTreeService::get_merkle_path::(0).unwrap(); - - // MaxHistoricRoots = 100: push far past the window (also sealing - // more trees along the way). - for i in 0..120u32 { - let mut leaf = [0u8; 32]; - leaf[..4].copy_from_slice(&i.to_le_bytes()); - leaf[31] = 0xAA; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - } - - assert!( - MerkleTreeService::is_known_root::(&sealed), - "sealed root must never expire" - ); - assert!( - MerkleTreeService::verify_merkle_proof(&sealed, &leaf0, &path0), - "tree-0 note must still prove against its sealed root" - ); - }); - } - - #[test] - fn straddling_inserts_land_in_consecutive_trees() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - fill_leaves(1, 7); - let a = MerkleTreeService::insert_leaf::(Commitment::new([0xE1; 32])).unwrap(); - let b = MerkleTreeService::insert_leaf::(Commitment::new([0xE2; 32])).unwrap(); - assert_eq!( - (a, b), - (7, 8), - "global index keeps counting across the seal" - ); - - // b is local leaf 0 of tree 1: its root evolved from the empty tree. - let root = MerkleRepository::get_poseidon_root::(); - let path_b = MerkleTreeService::get_merkle_path::(8).unwrap(); - assert!(MerkleTreeService::verify_merkle_proof( - &root, - &[0xE2; 32], - &path_b - )); - assert_eq!(path_b.indices, [0u8; 20], "local index 0 is all left turns"); - - // a still proves against tree 0's sealed root. - let sealed = MerkleRepository::get_sealed_root::(0).unwrap(); - let path_a = MerkleTreeService::get_merkle_path::(7).unwrap(); - assert!(MerkleTreeService::verify_merkle_proof( - &sealed, - &[0xE1; 32], - &path_a - )); - }); - } - - #[test] - fn duplicate_commitment_rejected_across_trees() { - new_test_ext().execute_with(|| { - let dup = Commitment::new([0xD7; 32]); - MerkleTreeService::insert_leaf::(dup).unwrap(); - use crate::storage::CommitmentRepository; - use crate::types::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; - CommitmentRepository::store_memo::( - dup, - EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(), - ); - fill_leaves(1, 7); // seals tree 0; now in tree 1 - assert!( - MerkleTreeService::insert_leaf::(dup).is_err(), - "same commitment in a later tree would alias the nullifier" - ); - }); - } - - /// try_state invariants must hold before, across, and after a seal. - /// Runs only with `--features try-runtime` (the hook is feature-gated). - #[cfg(feature = "try-runtime")] - #[test] - fn try_state_holds_across_seal() { - use frame_support::traits::Hooks; - new_test_ext().execute_with(|| { - let try_state = || { - as Hooks< - frame_system::pallet_prelude::BlockNumberFor, - >>::try_state(0) - }; - assert!(try_state().is_ok(), "empty forest"); - fill_leaves(1, 7); - assert!(try_state().is_ok(), "partially filled tree 0"); - fill_leaves(8, 2); // seals tree 0, opens tree 1 - assert!(try_state().is_ok(), "across the seal"); - }); - } - - /// Mirrors the sealed-tree spend E2E: a note in tree 0 must still verify - /// against the sealed root after enough later inserts to rotate the whole - /// historic ring. - #[test] - fn sealed_tree_leaf_verifies_after_ring_rotation() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - let target = Commitment::new([0x9Au8; 32]); - MerkleTreeService::insert_leaf::(target).unwrap(); - for i in 0..120u32 { - let mut leaf = [0u8; 32]; - leaf[..4].copy_from_slice(&i.to_le_bytes()); - leaf[31] = 0x5A; - MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); - } - let sealed = MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"); - let path = MerkleTreeService::get_merkle_path::(0).expect("path for leaf 0"); - assert!( - MerkleTreeService::verify_merkle_proof(&sealed, &target.0, &path), - "tree-0 leaf must verify against the sealed root after 120 later inserts" - ); - }); - } - - #[test] - fn multiple_rollovers_keep_every_tree_provable() { - use crate::storage::MerkleRepository; - new_test_ext().execute_with(|| { - // Fill trees 0 and 1, half-fill tree 2 (cap = 8). - for i in 0..20u8 { - MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); - } - let roots = [ - MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"), - MerkleRepository::get_sealed_root::(1).expect("tree 1 sealed"), - MerkleRepository::get_poseidon_root::(), - ]; - assert!(MerkleRepository::get_sealed_root::(2).is_none()); - - for i in 0..20u32 { - let leaf = [(i + 1) as u8; 32]; - let path = MerkleTreeService::get_merkle_path::(i).unwrap(); - let root = roots[(i / 8) as usize]; - assert!( - MerkleTreeService::verify_merkle_proof(&root, &leaf, &path), - "leaf {i} must prove against its tree's root" - ); - } - }); - } - - // ── historic-root window ────────────────────────────────────────────────── - - /// integrity_test rejects a zero root window (checked via the mock's non-zero - /// MaxHistoricRoots passing construction). - #[test] - fn integrity_test_accepts_nonzero_root_window() { - use frame_support::traits::Hooks; - new_test_ext().execute_with(|| { - assert!(::MaxHistoricRoots::get() > 0); - as Hooks>>::integrity_test(); - }); - } - - /// Once the window is full, the oldest root is evicted from both the order - /// vector and the known-root map, and the map/order stay in sync (SP-15). - #[test] - fn historic_root_window_evicts_oldest() { - new_test_ext().execute_with(|| { - let cap = ::MaxHistoricRoots::get(); - // Fill the window with `cap` distinct roots. - for i in 0..cap { - let mut root = [0u8; 32]; - root[..4].copy_from_slice(&i.to_le_bytes()); - MerkleTreeService::add_poseidon_historic_root::(root); - } - let mut oldest = [0u8; 32]; - oldest[..4].copy_from_slice(&0u32.to_le_bytes()); - assert!(MerkleTreeService::is_known_root::(&oldest)); - - // One more root evicts the oldest. - let mut newest = [0u8; 32]; - newest[..4].copy_from_slice(&cap.to_le_bytes()); - MerkleTreeService::add_poseidon_historic_root::(newest); - - assert!( - !MerkleTreeService::is_known_root::(&oldest), - "oldest must be evicted" - ); - assert!( - MerkleTreeService::is_known_root::(&newest), - "newest must be known" - ); - // Order vector holds exactly `cap` entries — no unbounded growth. - assert_eq!( - MerkleRepository::get_historic_roots_order::().len(), - cap as usize - ); - }); - } - - /// A duplicate root value is not removed from the map while another copy of it - /// still sits in the window (guards the map/order multiplicity mismatch). - #[test] - fn historic_root_duplicate_survives_partial_eviction() { - new_test_ext().execute_with(|| { - let dup = [0x77u8; 32]; - // Two copies of `dup` plus fillers spanning the window. - MerkleTreeService::add_poseidon_historic_root::(dup); - MerkleTreeService::add_poseidon_historic_root::(dup); - let cap = ::MaxHistoricRoots::get(); - for i in 0..(cap - 1) { - let mut root = [0u8; 32]; - root[..4].copy_from_slice(&i.to_le_bytes()); - root[31] = 1; // distinct namespace from `dup` - MerkleTreeService::add_poseidon_historic_root::(root); - } - // The first `dup` was evicted, but the second copy keeps it known. - assert!( - MerkleTreeService::is_known_root::(&dup), - "duplicate root must stay known while a copy remains in the window" - ); - }); - } -} diff --git a/frame/shielded-pool/src/migrations.rs b/frame/shielded-pool/src/migrations.rs new file mode 100644 index 00000000..d530d159 --- /dev/null +++ b/frame/shielded-pool/src/migrations.rs @@ -0,0 +1,577 @@ +//! Storage migrations for `pallet-shielded-pool`. +//! +//! Only migrations that still have a chain to run on live here. Once every +//! deployment has passed a version, its code is removed — see the git history +//! for the v1 and v2 migrations. + +use crate::{ + pallet::{ + Config, HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, + Pallet, + }, + types::Hash, +}; +use frame_support::{ + pallet_prelude::*, + traits::{GetStorageVersion, OnRuntimeUpgrade}, + weights::Weight, +}; +use sp_runtime::traits::Saturating; +use sp_std::vec::Vec; + +pub mod v3 { + use super::*; + + /// Storage key of the v2 `HistoricRootsOrder` value, which no longer exists + /// as a type. Read through the raw key so the old `Vec` can be decoded + /// and cleared without keeping a dead definition around. + fn old_order_key() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricRootsOrder").as_slice(), + ] + .concat() + } + + /// Storage prefix of `HistoricPoseidonRoots`. Enumerated by raw key so the + /// old 1-byte `bool` values can be rewritten without `translate`, which + /// leaves undecodable entries in place rather than clearing them. + /// + /// The pallet name must match the `construct_runtime!` identifier + /// (`ShieldedPool`); a mismatch would make every read here return nothing. + fn historic_roots_prefix() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat() + } + + /// Count raw keys under a prefix. Typed `iter()` silently skips entries whose + /// value fails to decode, which is exactly the failure this migration must be + /// able to observe, so the try-runtime checks count keys instead. + #[cfg(feature = "try-runtime")] + fn count_raw_keys(prefix: &[u8]) -> u32 { + let mut count: u32 = 0; + let mut key = prefix.to_vec(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(prefix) { + break; + } + count = count.saturating_add(1); + key = next; + } + count + } + + /// Re-anchor the historic-root window from insert counts to block numbers + /// (storage v2 -> v3). + /// + /// Before: `HistoricPoseidonRoots: Hash -> bool` plus a single + /// `HistoricRootsOrder: BoundedVec`, evicted purely by how many roots + /// had been inserted. After: the map carries the block at which each root + /// stops being accepted, and the queue is a slot-indexed map so a leaf + /// insert touches a couple of entries instead of rewriting the whole vector. + /// + /// Both value types changed, so old entries cannot be decoded by the new + /// definitions and must be rewritten here. Every existing root is kept and + /// given a full window from the upgrade block rather than dropped: a root on + /// chain backs proofs wallets may be about to submit, and voiding them would + /// be a worse failure than the one this fixes. The extra window costs one + /// retention period once and never rejects a spend that used to be valid. + pub struct MigrateToV3(core::marker::PhantomData); + + impl OnRuntimeUpgrade for MigrateToV3 { + fn on_runtime_upgrade() -> Weight { + if Pallet::::on_chain_storage_version() >= 3 { + return T::DbWeight::get().reads(1); + } + + let now = frame_system::Pallet::::block_number(); + let expires_at = now.saturating_add(T::RootRetentionBlocks::get()); + let cap = T::MaxHistoricRoots::get() as u64; + + let mut reads: u64 = 2; // storage version + old order vector + let mut writes: u64 = 4; // version + head + tail + old order kill + + // Collect the surviving roots in their original insertion order. + // + // The old order vector is the source of truth: it preserves the order + // the drain loop relies on. It was a `BoundedVec`, + // which encodes exactly like `Vec`, and is read through its raw key + // because the type no longer exists. Reading it raw also sidesteps the + // bound check a typed read would apply with the *new* constant. + let old_key = old_order_key(); + let ordered: Option> = + sp_io::storage::get(&old_key).and_then(|raw| Decode::decode(&mut &raw[..]).ok()); + + // Enumerate the old map by raw key rather than `translate`. + // + // `translate` does NOT delete an entry whose old value fails to decode: + // it logs, skips, and leaves the bytes in place (see + // `frame_support::storage::generator::map::translate_next`). Here the old + // value was a 1-byte `bool` and the new one is a 4-byte block number, so + // a skipped entry would stay as an undecodable blob under a live key — + // unreadable by `is_known_poseidon_root` and invisible to `iter()`, hence + // impossible to ever prune. Rewriting every key unconditionally leaves no + // such residue. + let map_prefix = historic_roots_prefix(); + let mut raw_roots: Vec = Vec::new(); + let mut key = map_prefix.clone(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(&map_prefix) { + break; + } + reads = reads.saturating_add(1); + // Key layout: prefix ++ Blake2_128Concat(root) = prefix ++ 16-byte + // hash ++ the 32-byte root itself. Recover the root from the concat + // tail; anything of an unexpected shape is not a root we can honour, + // so drop the key rather than leave it dangling. + match next.len().checked_sub(32) { + Some(start) if start >= map_prefix.len().saturating_add(16) => { + let mut root = [0u8; 32]; + root.copy_from_slice(&next[start..]); + raw_roots.push(root); + } + _ => { + sp_io::storage::clear(&next); + writes = writes.saturating_add(1); + } + } + key = next; + } + + // Prefer the recorded order; fall back to key order when the vector is + // unreadable. Both are safe for the drain loop because every entry gets + // the same expiry here — if per-root expiries are ever introduced, this + // fallback must sort by expiry or the loop's early `break` would strand + // live roots behind an expired one. + let roots = match ordered { + Some(mut v) => { + // Anything present in the map but missing from the vector would + // otherwise be written to the map and never queued, so it could + // never be pruned. Append the strays. + for root in &raw_roots { + if !v.contains(root) { + v.push(*root); + } + } + v + } + None => { + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "MigrateToV3: HistoricRootsOrder undecodable; rebuilding the queue from map key order", + ); + raw_roots.clone() + } + }; + + // Clear the whole old map before rewriting: every key is re-created + // below, so nothing survives that the queue does not also track. This is + // what makes map membership and queue membership impossible to diverge. + for root in &raw_roots { + HistoricPoseidonRoots::::remove(root); + writes = writes.saturating_add(1); + } + sp_io::storage::clear(&old_key); + + // Write map and queue together, one slot per root, so the two can never + // disagree. Past the cap a root is dropped from *both* — never left in + // the map alone, which would leak an unprunable spendable root. + let mut slot: u64 = 0; + let mut seen: Vec = Vec::new(); + for root in roots { + if slot >= cap { + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "MigrateToV3: historic-root cap reached; dropping the remaining roots", + ); + break; + } + if seen.contains(&root) { + continue; // one slot per distinct root; expiries are identical + } + seen.push(root); + HistoricPoseidonRoots::::insert(root, expires_at); + HistoricRootsQueue::::insert(slot, (root, expires_at)); + slot = slot.saturating_add(1); + writes = writes.saturating_add(2); + } + + // The active root must always be provable against, even if the old state + // was empty or unreadable. Evict the oldest slot if the cap leaves no + // room — an unprovable active root would halt every spend. + let active = crate::storage::MerkleRepository::get_poseidon_root::(); + let mut tail: u64 = 0; + if !seen.contains(&active) { + if slot >= cap { + // Evict the oldest slot to make room. Shift the tail rather than + // rewriting every slot: the drain loop reads from tail forward, + // so a moved tail is all it needs. + if let Some((evicted, _)) = HistoricRootsQueue::::get(tail) { + HistoricPoseidonRoots::::remove(evicted); + } + HistoricRootsQueue::::remove(tail); + tail = tail.saturating_add(1); + writes = writes.saturating_add(2); + } + HistoricPoseidonRoots::::insert(active, expires_at); + HistoricRootsQueue::::insert(slot, (active, expires_at)); + slot = slot.saturating_add(1); + writes = writes.saturating_add(2); + } + + HistoricRootsTail::::put(tail); + HistoricRootsHead::::put(slot); + StorageVersion::new(3).put::>(); + + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + // Count raw keys, not typed entries: the new definition cannot decode + // the old 1-byte `bool` values, so `iter()` would skip every one. + Ok(count_raw_keys(&historic_roots_prefix()).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + // Propagate a decode failure instead of defaulting to zero, which would + // make the count check below trivially pass. + let before = u32::decode(&mut state.as_slice()).map_err(|_| { + sp_runtime::TryRuntimeError::Other("MigrateToV3: pre_upgrade state must decode") + })?; + + // Compare raw key counts on both sides. A typed count here would hide + // exactly the failure this migration exists to avoid: an entry left with + // an undecodable value is invisible to `iter()` but still occupies a key. + let raw_after = count_raw_keys(&historic_roots_prefix()); + let typed_after = HistoricPoseidonRoots::::iter().count() as u32; + ensure!( + raw_after == typed_after, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left an undecodable entry in HistoricPoseidonRoots" + ) + ); + + // The migration may add the active root, and drops duplicates and + // anything past the cap — so the count can move either way by a bounded + // amount, but must never collapse. + ensure!( + raw_after <= before.saturating_add(1), + sp_runtime::TryRuntimeError::Other("MigrateToV3 created unexpected roots") + ); + ensure!( + before == 0 || raw_after > 0, + sp_runtime::TryRuntimeError::Other("MigrateToV3 lost every historic root") + ); + ensure!( + Pallet::::on_chain_storage_version() == 3, + sp_runtime::TryRuntimeError::Other("MigrateToV3 did not set storage version 3") + ); + + // Every migrated root must still be spendable. + let now = frame_system::Pallet::::block_number(); + for (_root, expiry) in HistoricPoseidonRoots::::iter() { + ensure!( + expiry > now, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 produced an already-expired root" + ) + ); + } + + // Queue and map must agree in BOTH directions. A root in the map but not + // in the queue can never be pruned — a permanent leak of a spendable + // root — and a queued root missing from the map would be drained as if + // it had expired. + let head = HistoricRootsHead::::get(); + let tail = HistoricRootsTail::::get(); + ensure!( + head >= tail, + sp_runtime::TryRuntimeError::Other("MigrateToV3 left head behind tail") + ); + ensure!( + head.saturating_sub(tail) <= T::MaxHistoricRoots::get() as u64, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 overfilled the historic-root queue" + ) + ); + ensure!( + head.saturating_sub(tail) == typed_after as u64, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left the map and queue with different lengths" + ) + ); + for s in tail..head { + let Some((root, _)) = HistoricRootsQueue::::get(s) else { + return Err(sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left a hole in the queue", + )); + }; + ensure!( + HistoricPoseidonRoots::::contains_key(root), + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 queued a root that is not in the map" + ) + ); + } + + // The old value must be gone, and the active root provable against. + ensure!( + sp_io::storage::get(&old_order_key()).is_none(), + sp_runtime::TryRuntimeError::Other("MigrateToV3 left HistoricRootsOrder behind") + ); + let active = crate::storage::MerkleRepository::get_poseidon_root::(); + ensure!( + crate::storage::MerkleRepository::is_known_root::(&active), + sp_runtime::TryRuntimeError::Other("MigrateToV3 left the active root unprovable") + ); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::v3::MigrateToV3; + use crate::{ + Config, + mock::{Test, new_test_ext}, + pallet::{ + HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, + PoseidonRoot, + }, + storage::MerkleRepository, + types::Hash, + }; + use frame_support::traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion}; + use parity_scale_codec::Encode; + + fn old_order_key() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricRootsOrder").as_slice(), + ] + .concat() + } + + /// Write a `HistoricPoseidonRoots` entry the way storage v2 did: a raw + /// 1-byte `bool` under a `Blake2_128Concat` key. + fn write_v2_root(root: Hash, value: bool) { + let prefix = [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat(); + let mut key = prefix; + key.extend_from_slice(&sp_io::hashing::blake2_128(&root.encode())); + key.extend_from_slice(&root.encode()); + sp_io::storage::set(&key, &value.encode()); + } + + fn seed_v2_state(roots: &[Hash]) { + StorageVersion::new(2).put::>(); + // Genesis already seeded v3-shaped state; clear it so the fixture is + // purely v2. + let _ = HistoricRootsQueue::::clear(u32::MAX, None); + HistoricRootsHead::::kill(); + HistoricRootsTail::::kill(); + let _ = HistoricPoseidonRoots::::clear(u32::MAX, None); + + for root in roots { + write_v2_root(*root, true); + } + sp_io::storage::set(&old_order_key(), &roots.to_vec().encode()); + } + + fn root_of(n: u8) -> Hash { + [n; 32] + } + + #[test] + fn migrates_roots_and_preserves_order() { + new_test_ext().execute_with(|| { + let roots = [root_of(1), root_of(2), root_of(3)]; + PoseidonRoot::::put(root_of(3)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(crate::Pallet::::on_chain_storage_version(), 3); + assert_eq!(HistoricRootsTail::::get(), 0); + assert_eq!(HistoricRootsHead::::get(), 3); + + // Insertion order preserved, map and queue agree. + for (slot, root) in roots.iter().enumerate() { + let (queued, _) = + HistoricRootsQueue::::get(slot as u64).expect("slot filled"); + assert_eq!(queued, *root); + assert!(MerkleRepository::is_known_root::(root)); + } + // The old value is gone. + assert!(sp_io::storage::get(&old_order_key()).is_none()); + }); + } + + /// The value type changed from 1-byte `bool` to a 4-byte block number. + /// `translate` would leave an undecodable entry in place; the raw rewrite + /// must not. + #[test] + fn rewrites_every_entry_leaving_no_undecodable_residue() { + new_test_ext().execute_with(|| { + let roots = [root_of(7), root_of(8)]; + PoseidonRoot::::put(root_of(7)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + + // Every raw key must now decode under the new type: raw count and + // typed count agree. + let prefix = [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat(); + let mut raw = 0u32; + let mut key = prefix.clone(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(&prefix) { + break; + } + raw += 1; + key = next; + } + let typed = HistoricPoseidonRoots::::iter().count() as u32; + assert_eq!(raw, typed, "no key may be left with an undecodable value"); + assert_eq!(typed, 2); + }); + } + + /// A root in the map but missing from the order vector must still be queued, + /// or it could never be pruned — a permanent leak of a spendable root. + #[test] + fn strays_missing_from_the_order_vector_are_queued() { + new_test_ext().execute_with(|| { + let ordered = [root_of(1)]; + PoseidonRoot::::put(root_of(1)); + seed_v2_state(&ordered); + // A root the order vector never listed. + write_v2_root(root_of(9), true); + + MigrateToV3::::on_runtime_upgrade(); + + let head = HistoricRootsHead::::get(); + assert_eq!(head, 2, "the stray must occupy a slot too"); + assert_eq!( + HistoricPoseidonRoots::::iter().count() as u64, + head, + "map and queue must have equal length" + ); + assert!(MerkleRepository::is_known_root::(&root_of(9))); + }); + } + + /// An unreadable order vector must still produce a usable window rather than + /// leaving the chain with no provable root. + #[test] + fn falls_back_to_key_order_when_the_vector_is_undecodable() { + new_test_ext().execute_with(|| { + let roots = [root_of(4), root_of(5)]; + PoseidonRoot::::put(root_of(4)); + seed_v2_state(&roots); + sp_io::storage::set(&old_order_key(), &[0xFFu8; 3]); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(HistoricRootsHead::::get(), 2); + for root in &roots { + assert!(MerkleRepository::is_known_root::(root)); + } + }); + } + + /// The active root must always be provable, even from empty v2 state. + #[test] + fn seeds_the_active_root_from_empty_state() { + new_test_ext().execute_with(|| { + PoseidonRoot::::put(root_of(42)); + seed_v2_state(&[]); + + MigrateToV3::::on_runtime_upgrade(); + + assert!(MerkleRepository::is_known_root::(&root_of(42))); + assert_eq!(HistoricRootsHead::::get(), 1); + assert_eq!(HistoricRootsTail::::get(), 0); + }); + } + + /// Migrated roots get a full retention window, so nothing arrives expired. + /// + /// Uses a non-active root: the active one is accepted unconditionally by + /// `is_known_root`, which would mask the expiry this test checks. + #[test] + fn migrated_roots_are_spendable_for_a_full_window() { + new_test_ext().execute_with(|| { + let migrated = root_of(1); + let active = root_of(2); + PoseidonRoot::::put(active); + seed_v2_state(&[migrated, active]); + let start = frame_system::Pallet::::block_number(); + + MigrateToV3::::on_runtime_upgrade(); + + let retention: u64 = ::RootRetentionBlocks::get(); + frame_system::Pallet::::set_block_number(start + retention); + assert!( + MerkleRepository::is_known_root::(&migrated), + "must stay spendable through the final block of the window" + ); + frame_system::Pallet::::set_block_number(start + retention + 1); + assert!( + !MerkleRepository::is_known_root::(&migrated), + "must expire once the window elapses" + ); + assert!( + MerkleRepository::is_known_root::(&active), + "the active root stays provable regardless of its window" + ); + }); + } + + #[test] + fn is_idempotent_and_skips_a_v3_chain() { + new_test_ext().execute_with(|| { + let roots = [root_of(1), root_of(2)]; + PoseidonRoot::::put(root_of(2)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + let head = HistoricRootsHead::::get(); + let tail = HistoricRootsTail::::get(); + + // A second run must change nothing. + MigrateToV3::::on_runtime_upgrade(); + assert_eq!(HistoricRootsHead::::get(), head); + assert_eq!(HistoricRootsTail::::get(), tail); + assert_eq!(crate::Pallet::::on_chain_storage_version(), 3); + }); + } + + /// A chain built from genesis is already at v3; the migration must leave its + /// seeded queue untouched. + #[test] + fn leaves_a_genesis_chain_untouched() { + new_test_ext().execute_with(|| { + let head_before = HistoricRootsHead::::get(); + let tail_before = HistoricRootsTail::::get(); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(HistoricRootsHead::::get(), head_before); + assert_eq!(HistoricRootsTail::::get(), tail_before); + }); + } +} diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index 3085f8ba..a6850d1c 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -46,7 +46,13 @@ impl pallet_balances::Config for Test { parameter_types! { pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shldpool"); pub const MaxTreeDepth: u32 = 20; - pub const MaxHistoricRoots: u32 = 100; + /// Safety cap on queue length, not the retention window. Sized well above + /// what a test inserts within one window so the cap branch stays the + /// exceptional path here, as it is in production. + pub const MaxHistoricRoots: u32 = 4096; + /// Above `TX_LONGEVITY` (64), as `integrity_test` requires. Small enough that + /// a test can advance past it to exercise expiry. + pub const RootRetentionBlocks: u64 = 128; pub const MaxLeavesPerTree: u32 = 8; pub const MinShieldAmount: u128 = 100; pub const MaxProofSize: u32 = 256; @@ -152,6 +158,7 @@ impl pallet_shielded_pool::Config for Test { type PalletId = ShieldedPoolPalletId; type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; + type RootRetentionBlocks = RootRetentionBlocks; type MaxLeavesPerTree = MaxLeavesPerTree; type MinShieldAmount = MinShieldAmount; type WeightInfo = (); diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage.rs index b7251fb9..461dd786 100644 --- a/frame/shielded-pool/src/storage.rs +++ b/frame/shielded-pool/src/storage.rs @@ -6,9 +6,10 @@ use crate::{ pallet::{ Assets, BalanceOf, CommitmentMemos, CommitmentToLeafIndex, Config, HistoricPoseidonRoots, - HistoricRootsOrder, MerkleLeaves, MerkleNodes, MerkleTreeFrontier, MerkleTreeSize, - NextAssetId, NullifierSet, PoolBalancePerAsset, PoseidonRoot, SealedRootIndex, - SealedTreeRoots, TotalCommitmentsInserted, TotalNullifiersSpent, + HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, MerkleLeaves, MerkleNodes, + MerkleTreeFrontier, MerkleTreeSize, NextAssetId, NullifierSet, PoolBalancePerAsset, + PoseidonRoot, SealedRootIndex, SealedTreeRoots, TotalCommitmentsInserted, + TotalNullifiersSpent, }, types::{AssetMetadata, Commitment, EncryptedMemo, Hash}, }; @@ -100,11 +101,31 @@ impl MerkleRepository { pub fn insert_leaf(index: u32, commitment: Commitment) { MerkleLeaves::::insert(index, commitment); } + /// A historic root is spendable until its expiry block, inclusive. + /// + /// Checked against the expiry stored at insert time rather than pruned + /// eagerly: pruning is lazy (see `MerkleTreeService::add_poseidon_historic_root`), + /// so an expired entry can outlive its window in storage. Reading the + /// expiry here makes that harmless. pub fn is_known_poseidon_root(root: &Hash) -> bool { - HistoricPoseidonRoots::::get(root) - } + match HistoricPoseidonRoots::::get(root) { + Some(expires_at) => frame_system::Pallet::::block_number() <= expires_at, + None => false, + } + } + /// A root is spendable if it is the active root, is still inside its + /// retention window, or anchors a sealed tree. + /// + /// The active root is accepted unconditionally. Expiries are only refreshed + /// by a leaf insert, so on a chain that goes quiet for a full retention + /// window the current root would otherwise expire while still being the one + /// every wallet proves against — wedging the pool, since `private_transfer` + /// and `unshield` both need a known root and only a funded `shield` could + /// mint a new one. pub fn is_known_root(root: &Hash) -> bool { - Self::is_known_poseidon_root::(root) || SealedRootIndex::::contains_key(root) + *root == PoseidonRoot::::get() + || Self::is_known_poseidon_root::(root) + || SealedRootIndex::::contains_key(root) } pub fn insert_sealed_root(tree_id: u32, root: Hash) { SealedTreeRoots::::insert(tree_id, root); @@ -113,17 +134,54 @@ impl MerkleRepository { pub fn get_sealed_root(tree_id: u32) -> Option { SealedTreeRoots::::get(tree_id) } + /// Record `root` as spendable for one full retention window from now. pub fn add_historic_poseidon_root(root: Hash) { - HistoricPoseidonRoots::::insert(root, true); + let expires_at = + frame_system::Pallet::::block_number().saturating_add(T::RootRetentionBlocks::get()); + Self::add_historic_poseidon_root_until::(root, expires_at); + } + + /// Record `root` as spendable until `expires_at` (inclusive). + /// + /// A root re-inserted at a later block extends its expiry; it never shortens + /// it, so a duplicate root cannot cut short the window of the earlier entry. + pub fn add_historic_poseidon_root_until(root: Hash, expires_at: BlockNumberFor) { + HistoricPoseidonRoots::::mutate(root, |slot| match slot { + Some(current) if *current >= expires_at => {} + _ => *slot = Some(expires_at), + }); } pub fn remove_poseidon_historic_root(root: &Hash) { HistoricPoseidonRoots::::remove(root); } - pub fn get_historic_roots_order() -> BoundedVec { - HistoricRootsOrder::::get() + pub fn get_historic_root_expiry(root: &Hash) -> Option> { + HistoricPoseidonRoots::::get(root) + } + pub fn get_historic_root_slot(slot: u64) -> Option<(Hash, BlockNumberFor)> { + HistoricRootsQueue::::get(slot) + } + pub fn set_historic_root_slot(slot: u64, root: Hash, expires_at: BlockNumberFor) { + HistoricRootsQueue::::insert(slot, (root, expires_at)); + } + pub fn remove_historic_root_slot(slot: u64) { + HistoricRootsQueue::::remove(slot); + } + pub fn get_historic_roots_head() -> u64 { + HistoricRootsHead::::get() + } + pub fn set_historic_roots_head(head: u64) { + HistoricRootsHead::::put(head); + } + pub fn get_historic_roots_tail() -> u64 { + HistoricRootsTail::::get() + } + pub fn set_historic_roots_tail(tail: u64) { + HistoricRootsTail::::put(tail); } - pub fn set_historic_roots_order(order: BoundedVec) { - HistoricRootsOrder::::put(order); + /// Number of slots still queued. Bounded by the retention window in practice + /// and hard-capped by `MaxHistoricRoots`. + pub fn historic_roots_queued() -> u64 { + HistoricRootsHead::::get().saturating_sub(HistoricRootsTail::::get()) } pub fn get_frontier() -> [[u8; 32]; 20] { MerkleTreeFrontier::::get() diff --git a/frame/shielded-pool/src/validate_unsigned.rs b/frame/shielded-pool/src/validate_unsigned.rs index 06dc5653..59a6524d 100644 --- a/frame/shielded-pool/src/validate_unsigned.rs +++ b/frame/shielded-pool/src/validate_unsigned.rs @@ -27,7 +27,10 @@ const CIRCUIT_UNSHIELD: u32 = 2; /// How long an unsigned transaction stays valid in the pool, in blocks. Bounded /// so a transaction that never gets included does not linger indefinitely. -const TX_LONGEVITY: u64 = 64; +/// +/// `Config::RootRetentionBlocks` must exceed this (checked in `integrity_test`): +/// a root has to outlive every transaction admitted against it. +pub(crate) const TX_LONGEVITY: u64 = 64; /// Validate an incoming `private_transfer` unsigned transaction. pub fn validate_private_transfer( diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index b59dfc57..98d7a7e9 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -150,7 +150,10 @@ pub type CheckedExtrinsic = pub type SignedPayload = generic::SignedPayload; /// Storage migrations run on runtime upgrade, oldest first. -pub type Migrations = (); +/// +/// Drop an entry once every live chain has passed its version — a migration +/// that can no longer run is dead weight that could be re-armed by mistake. +pub type Migrations = (pallet_shielded_pool::migrations::v3::MigrateToV3,); /// Executive: handles dispatch to the various modules. pub type Executive = frame_executive::Executive< @@ -629,7 +632,17 @@ impl pallet_shielded_pool::Config for Runtime { /// Merkle tree depth: 2^20 = 1M notes max (see MERKLE_TREE_SCALABILITY.md) type MaxTreeDepth = ConstU32<20>; /// Historic roots: allows proofs against past states (30s window) - type MaxHistoricRoots = ConstU32<100>; + /// Safety cap on the historic-root queue, not the retention window. A root + /// expires by elapsed blocks; this only bounds worst-case storage. + /// + /// Steady state is `RootRetentionBlocks × commitments-per-block`: 1200 at a + /// sustained 2 transfers/block, 6000 at 10. Sized for ~27 transfers/block + /// sustained across a full window, well past the ~127 proof verifications a + /// block can fit, so the window — never this bound — is what expires a root. + type MaxHistoricRoots = ConstU32<16384>; + /// Roots stay spendable for 300 blocks (~30 min at 6s), comfortably above + /// the 64-block mempool longevity of an unsigned transaction. + type RootRetentionBlocks = ConstU32<300>; // Pinned to 2^20: clients derive tree_id = leaf_index >> 20 from this. type MaxLeavesPerTree = ConstU32<1_048_576>; /// Minimum shield amount: prevents spam, 1 ORB = 1e18 wei From e41810af2b50a8d99f8b666c7a83e46a190a1c02 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 15:21:49 -0400 Subject: [PATCH 2/8] refactor: separate responsibilities --- frame/shielded-pool/src/merkle/batch.rs | 89 ++ frame/shielded-pool/src/merkle/hashing.rs | 67 ++ frame/shielded-pool/src/merkle/mod.rs | 1128 +++++++++++++++++++++ frame/shielded-pool/src/merkle/service.rs | 254 +++++ frame/shielded-pool/src/merkle/tree.rs | 146 +++ 5 files changed, 1684 insertions(+) create mode 100644 frame/shielded-pool/src/merkle/batch.rs create mode 100644 frame/shielded-pool/src/merkle/hashing.rs create mode 100644 frame/shielded-pool/src/merkle/mod.rs create mode 100644 frame/shielded-pool/src/merkle/service.rs create mode 100644 frame/shielded-pool/src/merkle/tree.rs diff --git a/frame/shielded-pool/src/merkle/batch.rs b/frame/shielded-pool/src/merkle/batch.rs new file mode 100644 index 00000000..1edd4b92 --- /dev/null +++ b/frame/shielded-pool/src/merkle/batch.rs @@ -0,0 +1,89 @@ +//! Whole-tree root computation from a leaf set. +//! +//! O(n) in the number of leaves, so it is **not** used on any dispatchable path +//! — the on-chain insert walks the frontier in O(depth) instead. Kept for tests +//! and off-chain tooling that need to rebuild a root from scratch. + +use super::hashing::{hash_pair, hash_pair_poseidon}; +use crate::types::Hash; +use sp_std::vec::Vec; + +pub fn compute_root_from_leaves_poseidon(leaves: &[Hash]) -> Hash { + if leaves.is_empty() { + return [0u8; 32]; + } + + let mut zero_hashes = [[0u8; 32]; 21]; + zero_hashes[0] = [0u8; 32]; + for i in 1..=20 { + zero_hashes[i] = hash_pair_poseidon(&zero_hashes[i - 1], &zero_hashes[i - 1]); + } + + let mut current_level: Vec = leaves.to_vec(); + + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + current_level.push(zero_hashes[level]); + } + let mut next_level = Vec::new(); + for i in (0..current_level.len()).step_by(2) { + next_level.push(hash_pair_poseidon(¤t_level[i], ¤t_level[i + 1])); + } + current_level = next_level; + if current_level.len() == 1 && level + 1 < DEPTH { + let mut root = current_level[0]; + for zero_hash in zero_hashes.iter().skip(level + 1).take(DEPTH - level - 1) { + root = hash_pair_poseidon(&root, zero_hash); + } + return root; + } + } + current_level.first().copied().unwrap_or([0u8; 32]) +} + +pub fn compute_root_from_leaves(leaves: &[Hash]) -> Hash { + if leaves.is_empty() { + let mut current = [0u8; 32]; + for _ in 0..DEPTH { + current = hash_pair(¤t, ¤t); + } + return current; + } + let mut current_level: Vec = leaves.to_vec(); + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + let mut zero = [0u8; 32]; + for _ in 0..level { + zero = hash_pair(&zero, &zero); + } + current_level.push(zero); + } + let mut next_level = Vec::new(); + for chunk in current_level.chunks(2) { + let left = chunk[0]; + let right = if chunk.len() > 1 { + chunk[1] + } else { + let mut zero = [0u8; 32]; + for _ in 0..level { + zero = hash_pair(&zero, &zero); + } + zero + }; + next_level.push(hash_pair(&left, &right)); + } + current_level = next_level; + if current_level.len() == 1 && level + 1 < DEPTH { + let mut zero = [0u8; 32]; + for _ in 0..=level { + zero = hash_pair(&zero, &zero); + } + for _ in (level + 1)..DEPTH { + current_level[0] = hash_pair(¤t_level[0], &zero); + zero = hash_pair(&zero, &zero); + } + break; + } + } + current_level.first().copied().unwrap_or([0u8; 32]) +} diff --git a/frame/shielded-pool/src/merkle/hashing.rs b/frame/shielded-pool/src/merkle/hashing.rs new file mode 100644 index 00000000..9568a5c8 --- /dev/null +++ b/frame/shielded-pool/src/merkle/hashing.rs @@ -0,0 +1,67 @@ +//! Poseidon hashing for Merkle nodes. +//! +//! Pure functions over 32-byte field elements — no storage, no `Config`. The +//! zero-hash ladder is cached because every path read and every insert needs the +//! empty-subtree digest for each level. + +use alloc::boxed::Box; +use ark_ff::BigInteger; + +/// Default hash for empty nodes at each level. +pub fn zero_hash_at_level(level: usize) -> [u8; 32] { + if level == 0 { + return [0u8; 32]; + } + let prev = zero_hash_at_level(level - 1); + hash_pair(&prev, &prev) +} + +/// Cached zero hashes for Poseidon (lazy-initialized, thread-safe). +static ZERO_HASHES_POSEIDON: once_cell::race::OnceBox<[[u8; 32]; 21]> = + once_cell::race::OnceBox::new(); + +/// Get precomputed zero hash at level (optimized with cache). +#[inline] +pub fn get_zero_hash_cached(level: usize) -> [u8; 32] { + if level < 21 { + let cache = ZERO_HASHES_POSEIDON.get_or_init(|| { + let mut hashes = [[0u8; 32]; 21]; + hashes[0] = [0u8; 32]; + for i in 1..21 { + hashes[i] = hash_pair_poseidon(&hashes[i - 1], &hashes[i - 1]); + } + Box::new(hashes) + }); + return cache[level]; + } + zero_hash_at_level(level) +} + +/// Hash two nodes together using Poseidon (ZK-friendly, ~300 constraints). +#[inline] +pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + use ark_bn254::Fr as Bn254Fr; + use ark_ff::PrimeField; + use orbinum_zk_core::{FieldElement, PoseidonHasher}; + + let left_fr = Bn254Fr::from_le_bytes_mod_order(left); + let right_fr = Bn254Fr::from_le_bytes_mod_order(right); + + #[cfg(feature = "poseidon-native")] + let hasher = orbinum_zk_core::NativePoseidonHasher; + #[cfg(not(feature = "poseidon-native"))] + let hasher = orbinum_zk_core::LightPoseidonHasher; + + let hash_fr = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]); + + let mut hash_bytes = [0u8; 32]; + let bigint = hash_fr.inner().into_bigint(); + let bytes = bigint.to_bytes_le(); + hash_bytes.copy_from_slice(&bytes[..32]); + hash_bytes +} + +/// Hash pair — always uses Poseidon. +pub fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + hash_pair_poseidon(left, right) +} diff --git a/frame/shielded-pool/src/merkle/mod.rs b/frame/shielded-pool/src/merkle/mod.rs new file mode 100644 index 00000000..e5ac1892 --- /dev/null +++ b/frame/shielded-pool/src/merkle/mod.rs @@ -0,0 +1,1128 @@ +//! Merkle tree — hashing, the incremental structure, and the on-chain service. +//! +//! Split by responsibility so the storage-touching code is separable from the +//! pure maths: +//! +//! - [`hashing`] — Poseidon over field elements, plus the zero-hash ladder. +//! - [`tree`] — `IncrementalMerkleTree`, the in-memory frontier structure. +//! - [`batch`] — whole-tree root computation, off-chain and test use only. +//! - [`service`] — `MerkleTreeService`: leaf insertion, sealing, and the +//! historic-root window. The only module here that reads or writes storage. + +pub mod batch; +pub mod hashing; +pub mod service; +pub mod tree; + +pub use batch::{compute_root_from_leaves, compute_root_from_leaves_poseidon}; +pub use hashing::{get_zero_hash_cached, hash_pair, hash_pair_poseidon, zero_hash_at_level}; +pub use service::MerkleTreeService; +pub use tree::IncrementalMerkleTree; + +/// Most expired historic roots a single leaf insert may prune. +/// +/// Pruning is amortised across inserts so one extrinsic never pays for a backlog +/// it did not create: after a long idle stretch the whole queue can be expired +/// at once, and clearing it in one call would be storage work outside the +/// benchmarked weight. Leftovers are cleaned by the following inserts, and an +/// expired entry lingering in the queue is harmless — spendability is decided by +/// the expiry stored per root, never by queue membership. +/// +/// Each pruned slot costs at most 2 reads and 2 writes (the queue slot and the +/// map entry), so this bounds the extra work per insert at 8 reads and 8 writes +/// on top of the benchmarked cost. +pub(crate) const MAX_ROOTS_PRUNED_PER_INSERT: usize = 4; + +#[cfg(test)] +mod tests { + use super::{ + MAX_ROOTS_PRUNED_PER_INSERT, + batch::{compute_root_from_leaves, compute_root_from_leaves_poseidon}, + hashing::{get_zero_hash_cached, hash_pair, hash_pair_poseidon, zero_hash_at_level}, + service::MerkleTreeService, + tree::IncrementalMerkleTree, + }; + use crate::{ + mock::{System, Test, new_test_ext}, + pallet::Event, + storage::MerkleRepository, + types::{Commitment, Hash}, + }; + use frame_support::traits::Hooks; + + // ── hash functions ────────────────────────────────────────────────────── + + #[test] + fn hash_pair_poseidon_deterministic() { + let left = [0x01u8; 32]; + let right = [0x02u8; 32]; + let h1 = hash_pair_poseidon(&left, &right); + let h2 = hash_pair_poseidon(&left, &right); + assert_eq!(h1, h2); + assert_ne!(h1, [0u8; 32]); + } + + #[test] + fn hash_pair_poseidon_not_commutative() { + let left = [0x01u8; 32]; + let right = [0x02u8; 32]; + let h1 = hash_pair_poseidon(&left, &right); + let h2 = hash_pair_poseidon(&right, &left); + assert_ne!(h1, h2); + } + + #[test] + fn hash_pair_delegates_to_poseidon() { + let left = [0x03u8; 32]; + let right = [0x04u8; 32]; + assert_eq!(hash_pair(&left, &right), hash_pair_poseidon(&left, &right)); + } + + #[test] + fn zero_hash_level_0_is_all_zeros() { + assert_eq!(zero_hash_at_level(0), [0u8; 32]); + } + + #[test] + fn zero_hash_level_1_is_hash_of_zeros() { + let expected = hash_pair(&[0u8; 32], &[0u8; 32]); + assert_eq!(zero_hash_at_level(1), expected); + } + + #[test] + fn zero_hash_levels_are_distinct() { + let h0 = zero_hash_at_level(0); + let h1 = zero_hash_at_level(1); + let h2 = zero_hash_at_level(2); + assert_ne!(h0, h1); + assert_ne!(h1, h2); + } + + #[test] + fn get_zero_hash_cached_matches_uncached() { + for level in 0..5usize { + assert_eq!(get_zero_hash_cached(level), zero_hash_at_level(level)); + } + } + + // ── IncrementalMerkleTree ──────────────────────────────────────────────── + + #[test] + fn tree_new_has_zero_size() { + let tree = IncrementalMerkleTree::<4>::new(); + assert_eq!(tree.size(), 0); + } + + #[test] + fn tree_new_root_is_non_zero() { + let tree = IncrementalMerkleTree::<4>::new(); + assert_ne!(tree.root(), [0u8; 32]); + } + + #[test] + fn tree_capacity_is_power_of_two() { + assert_eq!(IncrementalMerkleTree::<4>::new().capacity(), 16); + assert_eq!(IncrementalMerkleTree::<2>::new().capacity(), 4); + } + + #[test] + fn tree_insert_returns_sequential_indices() { + let mut tree = IncrementalMerkleTree::<4>::new(); + assert_eq!(tree.insert([0x01u8; 32]).unwrap(), 0); + assert_eq!(tree.insert([0x02u8; 32]).unwrap(), 1); + assert_eq!(tree.insert([0x03u8; 32]).unwrap(), 2); + assert_eq!(tree.size(), 3); + } + + #[test] + fn tree_root_changes_after_insert() { + let mut tree = IncrementalMerkleTree::<4>::new(); + let root_before = tree.root(); + tree.insert([0xAAu8; 32]).unwrap(); + assert_ne!(tree.root(), root_before); + } + + #[test] + fn tree_full_rejects_further_inserts() { + let mut tree = IncrementalMerkleTree::<2>::new(); + for i in 0u8..4 { + tree.insert([i; 32]).unwrap(); + } + assert!(tree.is_full()); + assert!(tree.insert([0xFFu8; 32]).is_err()); + } + + #[test] + fn tree_default_equals_new() { + let t1 = IncrementalMerkleTree::<4>::new(); + let t2 = IncrementalMerkleTree::<4>::default(); + assert_eq!(t1.root(), t2.root()); + assert_eq!(t1.size(), t2.size()); + } + + #[test] + fn tree_generate_and_verify_proof_passes() { + let mut tree = IncrementalMerkleTree::<4>::new(); + let leaves = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]]; + for &leaf in &leaves { + tree.insert(leaf).unwrap(); + } + let proof = tree.generate_proof(0, &leaves).unwrap(); + assert!(IncrementalMerkleTree::<4>::verify_proof( + &tree.root(), + &leaves[0], + &proof + )); + } + + #[test] + fn tree_proof_fails_for_wrong_leaf() { + let mut tree = IncrementalMerkleTree::<4>::new(); + let leaves = [[0x01u8; 32], [0x02u8; 32]]; + for &l in &leaves { + tree.insert(l).unwrap(); + } + let proof = tree.generate_proof(0, &leaves).unwrap(); + assert!(!IncrementalMerkleTree::<4>::verify_proof( + &tree.root(), + &[0xFFu8; 32], + &proof + )); + } + + #[test] + fn tree_generate_proof_out_of_bounds_fails() { + let mut tree = IncrementalMerkleTree::<4>::new(); + tree.insert([0x01u8; 32]).unwrap(); + let leaves = [[0x01u8; 32]]; + assert!(tree.generate_proof(5, &leaves).is_err()); + } + + // ── compute_root_from_leaves_poseidon ──────────────────────────────────── + + #[test] + fn compute_root_poseidon_empty_is_zero() { + assert_eq!(compute_root_from_leaves_poseidon::<4>(&[]), [0u8; 32]); + } + + #[test] + fn compute_root_poseidon_single_leaf_nonzero() { + let root = compute_root_from_leaves_poseidon::<4>(&[[0x01u8; 32]]); + assert_ne!(root, [0u8; 32]); + assert_ne!(root, [0x01u8; 32]); + } + + #[test] + fn compute_root_poseidon_same_leaves_same_root() { + let leaves = [[0x01u8; 32], [0x02u8; 32]]; + let r1 = compute_root_from_leaves_poseidon::<4>(&leaves); + let r2 = compute_root_from_leaves_poseidon::<4>(&leaves); + assert_eq!(r1, r2); + } + + #[test] + fn compute_root_poseidon_different_leaves_different_roots() { + let r1 = compute_root_from_leaves_poseidon::<4>(&[[0x01u8; 32]]); + let r2 = compute_root_from_leaves_poseidon::<4>(&[[0x02u8; 32]]); + assert_ne!(r1, r2); + } + + // ── MerkleTreeService (FRAME-backed) ───────────────────────────────────── + + #[test] + fn service_insert_leaf_returns_sequential_indices() { + new_test_ext().execute_with(|| { + let c0 = Commitment::new([0x01u8; 32]); + let c1 = Commitment::new([0x02u8; 32]); + assert_eq!(MerkleTreeService::insert_leaf::(c0).unwrap(), 0); + assert_eq!(MerkleTreeService::insert_leaf::(c1).unwrap(), 1); + }); + } + + #[test] + fn service_insert_duplicate_fails() { + new_test_ext().execute_with(|| { + let c = Commitment::new([0x01u8; 32]); + MerkleTreeService::insert_leaf::(c).unwrap(); + // Duplicate detection is based on CommitmentMemos; simulate a prior memo insert + // (operations layer stores the memo when shielding/transferring) + use crate::storage::CommitmentRepository; + use crate::types::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; + CommitmentRepository::store_memo::( + c, + EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(), + ); + assert!(MerkleTreeService::insert_leaf::(c).is_err()); + }); + } + + #[test] + fn service_insert_updates_poseidon_root() { + new_test_ext().execute_with(|| { + use crate::storage::MerkleRepository; + let root_before = MerkleRepository::get_poseidon_root::(); + MerkleTreeService::insert_leaf::(Commitment::new([0xAAu8; 32])).unwrap(); + let root_after = MerkleRepository::get_poseidon_root::(); + assert_ne!(root_before, root_after); + }); + } + + #[test] + fn service_insert_adds_root_to_historic() { + new_test_ext().execute_with(|| { + MerkleTreeService::insert_leaf::(Commitment::new([0xBBu8; 32])).unwrap(); + use crate::storage::MerkleRepository; + let root = MerkleRepository::get_poseidon_root::(); + assert!(MerkleTreeService::is_known_root::(&root)); + }); + } + + #[test] + fn service_get_merkle_path_none_for_empty_tree() { + new_test_ext().execute_with(|| { + assert!(MerkleTreeService::get_merkle_path::(0).is_none()); + }); + } + + #[test] + fn service_get_merkle_path_some_after_insert() { + new_test_ext().execute_with(|| { + MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); + assert!(MerkleTreeService::get_merkle_path::(0).is_some()); + }); + } + + #[test] + fn service_get_merkle_path_none_out_of_bounds() { + new_test_ext().execute_with(|| { + MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); + assert!(MerkleTreeService::get_merkle_path::(99).is_none()); + }); + } + + #[test] + fn service_verify_merkle_proof_valid_round_trip() { + new_test_ext().execute_with(|| { + let leaf = [0x11u8; 32]; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + use crate::storage::MerkleRepository; + let root = MerkleRepository::get_poseidon_root::(); + let path = MerkleTreeService::get_merkle_path::(0).unwrap(); + assert!(MerkleTreeService::verify_merkle_proof(&root, &leaf, &path)); + }); + } + + #[test] + fn service_verify_merkle_proof_fails_for_wrong_root() { + new_test_ext().execute_with(|| { + let leaf = [0x12u8; 32]; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + let path = MerkleTreeService::get_merkle_path::(0).unwrap(); + assert!(!MerkleTreeService::verify_merkle_proof( + &[0xFFu8; 32], + &leaf, + &path + )); + }); + } + + #[test] + fn service_find_leaf_index_none_for_unknown() { + new_test_ext().execute_with(|| { + let c = Commitment::new([0xCCu8; 32]); + assert!(MerkleTreeService::find_leaf_index::(&c).is_none()); + }); + } + + #[test] + fn service_find_leaf_index_correct_after_multiple_inserts() { + new_test_ext().execute_with(|| { + let c0 = Commitment::new([0x01u8; 32]); + let c1 = Commitment::new([0x02u8; 32]); + MerkleTreeService::insert_leaf::(c0).unwrap(); + MerkleTreeService::insert_leaf::(c1).unwrap(); + assert_eq!(MerkleTreeService::find_leaf_index::(&c0), Some(0)); + assert_eq!(MerkleTreeService::find_leaf_index::(&c1), Some(1)); + }); + } + + #[test] + fn insert_leaf_populates_commitment_to_leaf_index() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + let c0 = Commitment::new([0xD0u8; 32]); + let c1 = Commitment::new([0xD1u8; 32]); + let c2 = Commitment::new([0xD2u8; 32]); + MerkleTreeService::insert_leaf::(c0).unwrap(); + MerkleTreeService::insert_leaf::(c1).unwrap(); + MerkleTreeService::insert_leaf::(c2).unwrap(); + // Reverse index must be populated for every inserted commitment + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&c0), + Some(0) + ); + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&c1), + Some(1) + ); + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&c2), + Some(2) + ); + // Unknown commitment returns None + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&Commitment::new([0xFFu8; 32])), + None + ); + }); + } + + #[test] + fn insert_leaf_increments_total_commitments_counter() { + use crate::storage::PoolStatsRepository; + new_test_ext().execute_with(|| { + assert_eq!( + PoolStatsRepository::get_total_commitments_inserted::(), + 0 + ); + MerkleTreeService::insert_leaf::(Commitment::new([0xF0u8; 32])).unwrap(); + assert_eq!( + PoolStatsRepository::get_total_commitments_inserted::(), + 1 + ); + MerkleTreeService::insert_leaf::(Commitment::new([0xF1u8; 32])).unwrap(); + MerkleTreeService::insert_leaf::(Commitment::new([0xF2u8; 32])).unwrap(); + assert_eq!( + PoolStatsRepository::get_total_commitments_inserted::(), + 3 + ); + }); + } + + // ── Stored-node path reads vs recomputed reference ─────────────────────── + + /// Reference sibling-path builder: recomputes every level from the full + /// leaf set. Oracle for the O(depth) stored-node read path. + fn reference_path(leaves: &[[u8; 32]], leaf_index: usize) -> Vec<[u8; 32]> { + let mut current_level = leaves.to_vec(); + let mut path = Vec::with_capacity(20); + let mut target = leaf_index; + for level in 0..20 { + if current_level.len() % 2 != 0 { + current_level.push(get_zero_hash_cached(level)); + } + let sibling_idx = target ^ 1; + path.push(if sibling_idx < current_level.len() { + current_level[sibling_idx] + } else { + get_zero_hash_cached(level) + }); + let mut next = Vec::with_capacity(current_level.len().div_ceil(2)); + for chunk in current_level.chunks(2) { + let right = chunk + .get(1) + .copied() + .unwrap_or_else(|| get_zero_hash_cached(level)); + next.push(hash_pair_poseidon(&chunk[0], &right)); + } + current_level = next; + target /= 2; + } + path + } + + #[test] + fn stored_node_paths_match_recomputed_reference_for_every_leaf() { + new_test_ext().execute_with(|| { + // 7 leaves (< MaxLeavesPerTree): odd count exercises zero-hash + // padding without sealing the tree. + let leaves: Vec<[u8; 32]> = (0..7u8).map(|i| [i + 1; 32]).collect(); + for leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + for (i, leaf) in leaves.iter().enumerate() { + let path = MerkleTreeService::get_merkle_path::(i as u32).unwrap(); + let expected = reference_path(&leaves, i); + assert_eq!( + path.siblings.to_vec(), + expected, + "stored-node path for leaf {i} must equal recomputed path" + ); + assert!( + MerkleTreeService::verify_merkle_proof(&root, leaf, &path), + "leaf {i} proof must verify against the current root" + ); + } + }); + } + + #[test] + fn first_and_last_leaf_paths_verify() { + new_test_ext().execute_with(|| { + let leaves: Vec<[u8; 32]> = (0..6u8).map(|i| [0xA0 + i; 32]).collect(); + for leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + for i in [0u32, 5] { + let path = MerkleTreeService::get_merkle_path::(i).unwrap(); + assert!(MerkleTreeService::verify_merkle_proof( + &root, + &leaves[i as usize], + &path + )); + } + }); + } + + #[test] + fn single_leaf_tree_path_is_all_zero_hashes() { + new_test_ext().execute_with(|| { + let leaf = [0x77u8; 32]; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + let path = MerkleTreeService::get_merkle_path::(0).unwrap(); + for (level, sibling) in path.siblings.iter().enumerate() { + assert_eq!(*sibling, get_zero_hash_cached(level)); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + assert!(MerkleTreeService::verify_merkle_proof(&root, &leaf, &path)); + }); + } + + #[test] + fn stored_top_nodes_derive_poseidon_root() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + for i in 0..5u8 { + MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); + } + let left = MerkleRepository::get_node::(0, 19, 0).expect("top-left node stored"); + let right = MerkleRepository::get_node::(0, 19, 1) + .unwrap_or_else(|| get_zero_hash_cached(19)); + assert_eq!( + hash_pair_poseidon(&left, &right), + MerkleRepository::get_poseidon_root::(), + "level-19 nodes must hash to the stored root" + ); + }); + } + + // ── Incremental frontier vs batch consistency ──────────────────────────── + + #[test] + fn incremental_root_matches_batch_root_after_single_insert() { + new_test_ext().execute_with(|| { + let leaf = [0x11u8; 32]; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + + let incremental_root = crate::storage::MerkleRepository::get_poseidon_root::(); + let batch_root = compute_root_from_leaves_poseidon::<20>(&[leaf]); + assert_eq!( + incremental_root, batch_root, + "incremental and batch roots must agree after 1 insert" + ); + }); + } + + #[test] + fn incremental_root_matches_batch_root_after_multiple_inserts() { + new_test_ext().execute_with(|| { + let leaves = [ + [0x01u8; 32], + [0x02u8; 32], + [0x03u8; 32], + [0x04u8; 32], + [0x05u8; 32], + ]; + for &leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + } + + let incremental_root = crate::storage::MerkleRepository::get_poseidon_root::(); + let batch_root = compute_root_from_leaves_poseidon::<20>(&leaves); + assert_eq!( + incremental_root, batch_root, + "incremental and batch roots must agree after multiple inserts" + ); + }); + } + + #[test] + fn incremental_proof_verifies_against_incremental_root() { + new_test_ext().execute_with(|| { + let leaves = [[0x0Au8; 32], [0x0Bu8; 32], [0x0Cu8; 32]]; + for &leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + } + + // Proof computed from all leaves (batch), root stored incrementally. + // Both must be consistent. + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + for (i, &leaf) in leaves.iter().enumerate() { + let path = MerkleTreeService::get_merkle_path::(i as u32).unwrap(); + assert!( + MerkleTreeService::verify_merkle_proof(&root, &leaf, &path), + "proof for leaf {i} must verify against incremental root" + ); + } + }); + } + + #[test] + fn merkle_root_updated_event_carries_correct_old_root() { + use crate::mock::RuntimeEvent; + new_test_ext().execute_with(|| { + let c0 = Commitment::new([0xA0u8; 32]); + let c1 = Commitment::new([0xA1u8; 32]); + MerkleTreeService::insert_leaf::(c0).unwrap(); + let root_after_first = crate::storage::MerkleRepository::get_poseidon_root::(); + MerkleTreeService::insert_leaf::(c1).unwrap(); + + // The second MerkleRootUpdated event must carry the root stored after the first insert. + let found = frame_system::Pallet::::events().into_iter().any(|r| { + matches!( + &r.event, + RuntimeEvent::ShieldedPool(crate::Event::MerkleRootUpdated { + old_root, .. + }) if *old_root == root_after_first + ) + }); + assert!( + found, + "MerkleRootUpdated event must carry the previous root as old_root" + ); + }); + } + + // Simulates storage round-trip across multiple separate execute_with calls, + // mimicking the frontier being persisted between blocks. + // Verifies SCALE serialization of [[u8; 32]; 20] survives storage read/write cycles. + #[test] + fn frontier_survives_storage_round_trip_across_separate_calls() { + use crate::pallet::MerkleTreeFrontier; + + let mut ext = new_test_ext(); + + // Block 1: insert first leaf + let root_b1 = ext.execute_with(|| { + MerkleTreeService::insert_leaf::(Commitment::new([0x01u8; 32])).unwrap(); + MerkleRepository::get_poseidon_root::() + }); + + // Block 2: insert second leaf — frontier must be correctly recovered from storage + let root_b2 = ext.execute_with(|| { + // Frontier was written in block 1; verify it is non-zero (was persisted) + let frontier = MerkleTreeFrontier::::get(); + assert_ne!( + frontier[0], [0u8; 32], + "frontier slot 0 must be set after first insert" + ); + + MerkleTreeService::insert_leaf::(Commitment::new([0x02u8; 32])).unwrap(); + MerkleRepository::get_poseidon_root::() + }); + + assert_ne!(root_b1, root_b2, "root must change with each insert"); + + // Block 3: the root after 2 incremental inserts must equal the batch root for same leaves + let expected = ext.execute_with(|| { + compute_root_from_leaves_poseidon::<20>(&[[0x01u8; 32], [0x02u8; 32]]) + }); + + assert_eq!( + root_b2, expected, + "frontier root after 2 round-trips must match batch root" + ); + } + + // ── tree-depth consistency ──────────────────────────────────────────────── + + /// integrity_test passes when MaxTreeDepth equals the fixed tree depth. The + /// mock is aligned to MAX_TREE_DEPTH, so construction must not panic; a + /// divergent config would abort at runtime construction. + #[test] + fn integrity_test_accepts_aligned_tree_depth() { + use frame_support::traits::Hooks; + new_test_ext().execute_with(|| { + as Hooks>>::integrity_test(); + }); + } + + /// The per-tree capacity must divide the fixed depth-20 leaf space so the + /// forest's global u32 index spans whole trees. + #[test] + fn per_tree_capacity_fits_fixed_depth() { + use crate::types::MAX_TREE_DEPTH; + assert_eq!(MAX_TREE_DEPTH, 20); + let cap = ::MaxLeavesPerTree::get(); + assert!(cap.is_power_of_two() && cap <= 1 << MAX_TREE_DEPTH); + } + + // ── Multi-tree forest: sealing and rollover ────────────────────────────── + + fn fill_leaves(from: u8, count: u8) { + for i in 0..count { + MerkleTreeService::insert_leaf::(Commitment::new([from + i; 32])).unwrap(); + } + } + + #[test] + fn filling_insert_seals_tree_and_resets_active_state() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + fill_leaves(1, 7); + let last = MerkleTreeService::insert_leaf::(Commitment::new([8u8; 32])).unwrap(); + assert_eq!(last, 7, "filling insert still returns its global index"); + + let sealed = MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"); + assert!(MerkleRepository::is_known_root::(&sealed)); + // Active tree reset: empty frontier, empty root, empty root known. + assert_eq!(MerkleRepository::get_frontier::(), [[0u8; 32]; 20]); + let empty_root = get_zero_hash_cached(20); + assert_eq!(MerkleRepository::get_poseidon_root::(), empty_root); + assert!(MerkleRepository::is_known_root::(&empty_root)); + + // Event order: MerkleRootUpdated carries the FINAL root (the new + // leaf belongs to it), then TreeSealed. + let events: sp_std::vec::Vec<_> = frame_system::Pallet::::events() + .into_iter() + .map(|r| r.event) + .collect(); + let root_pos = events + .iter() + .position(|e| { + matches!(e, crate::mock::RuntimeEvent::ShieldedPool( + Event::MerkleRootUpdated { new_root, tree_size: 8, .. } + ) if *new_root == sealed) + }) + .expect("MerkleRootUpdated with final root"); + let seal_pos = events + .iter() + .position(|e| { + matches!(e, crate::mock::RuntimeEvent::ShieldedPool( + Event::TreeSealed { tree_id: 0, final_root, first_leaf_index: 0, leaf_count: 8 } + ) if *final_root == sealed) + }) + .expect("TreeSealed event"); + assert!(root_pos < seal_pos); + }); + } + + /// The single most important forest test: a sealed tree's final root must + /// survive unbounded activity in later trees — eviction would freeze the + /// funds of every unspent note in the sealed tree. + #[test] + fn sealed_root_survives_historic_ring_eviction() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + fill_leaves(1, 8); // seal tree 0 + let sealed = MerkleRepository::get_sealed_root::(0).unwrap(); + let leaf0 = MerkleRepository::get_leaf::(0).unwrap().0; + let path0 = MerkleTreeService::get_merkle_path::(0).unwrap(); + + // MaxHistoricRoots = 100: push far past the window (also sealing + // more trees along the way). + for i in 0..120u32 { + let mut leaf = [0u8; 32]; + leaf[..4].copy_from_slice(&i.to_le_bytes()); + leaf[31] = 0xAA; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + } + + assert!( + MerkleTreeService::is_known_root::(&sealed), + "sealed root must never expire" + ); + assert!( + MerkleTreeService::verify_merkle_proof(&sealed, &leaf0, &path0), + "tree-0 note must still prove against its sealed root" + ); + }); + } + + #[test] + fn straddling_inserts_land_in_consecutive_trees() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + fill_leaves(1, 7); + let a = MerkleTreeService::insert_leaf::(Commitment::new([0xE1; 32])).unwrap(); + let b = MerkleTreeService::insert_leaf::(Commitment::new([0xE2; 32])).unwrap(); + assert_eq!( + (a, b), + (7, 8), + "global index keeps counting across the seal" + ); + + // b is local leaf 0 of tree 1: its root evolved from the empty tree. + let root = MerkleRepository::get_poseidon_root::(); + let path_b = MerkleTreeService::get_merkle_path::(8).unwrap(); + assert!(MerkleTreeService::verify_merkle_proof( + &root, + &[0xE2; 32], + &path_b + )); + assert_eq!(path_b.indices, [0u8; 20], "local index 0 is all left turns"); + + // a still proves against tree 0's sealed root. + let sealed = MerkleRepository::get_sealed_root::(0).unwrap(); + let path_a = MerkleTreeService::get_merkle_path::(7).unwrap(); + assert!(MerkleTreeService::verify_merkle_proof( + &sealed, + &[0xE1; 32], + &path_a + )); + }); + } + + #[test] + fn duplicate_commitment_rejected_across_trees() { + new_test_ext().execute_with(|| { + let dup = Commitment::new([0xD7; 32]); + MerkleTreeService::insert_leaf::(dup).unwrap(); + use crate::storage::CommitmentRepository; + use crate::types::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; + CommitmentRepository::store_memo::( + dup, + EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(), + ); + fill_leaves(1, 7); // seals tree 0; now in tree 1 + assert!( + MerkleTreeService::insert_leaf::(dup).is_err(), + "same commitment in a later tree would alias the nullifier" + ); + }); + } + + /// try_state invariants must hold before, across, and after a seal. + /// Runs only with `--features try-runtime` (the hook is feature-gated). + #[cfg(feature = "try-runtime")] + #[test] + fn try_state_holds_across_seal() { + use frame_support::traits::Hooks; + new_test_ext().execute_with(|| { + let try_state = || { + as Hooks< + frame_system::pallet_prelude::BlockNumberFor, + >>::try_state(0) + }; + assert!(try_state().is_ok(), "empty forest"); + fill_leaves(1, 7); + assert!(try_state().is_ok(), "partially filled tree 0"); + fill_leaves(8, 2); // seals tree 0, opens tree 1 + assert!(try_state().is_ok(), "across the seal"); + }); + } + + /// Mirrors the sealed-tree spend E2E: a note in tree 0 must still verify + /// against the sealed root after enough later inserts to rotate the whole + /// historic ring. + #[test] + fn sealed_tree_leaf_verifies_after_ring_rotation() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + let target = Commitment::new([0x9Au8; 32]); + MerkleTreeService::insert_leaf::(target).unwrap(); + for i in 0..120u32 { + let mut leaf = [0u8; 32]; + leaf[..4].copy_from_slice(&i.to_le_bytes()); + leaf[31] = 0x5A; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + } + let sealed = MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"); + let path = MerkleTreeService::get_merkle_path::(0).expect("path for leaf 0"); + assert!( + MerkleTreeService::verify_merkle_proof(&sealed, &target.0, &path), + "tree-0 leaf must verify against the sealed root after 120 later inserts" + ); + }); + } + + #[test] + fn multiple_rollovers_keep_every_tree_provable() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + // Fill trees 0 and 1, half-fill tree 2 (cap = 8). + for i in 0..20u8 { + MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); + } + let roots = [ + MerkleRepository::get_sealed_root::(0).expect("tree 0 sealed"), + MerkleRepository::get_sealed_root::(1).expect("tree 1 sealed"), + MerkleRepository::get_poseidon_root::(), + ]; + assert!(MerkleRepository::get_sealed_root::(2).is_none()); + + for i in 0..20u32 { + let leaf = [(i + 1) as u8; 32]; + let path = MerkleTreeService::get_merkle_path::(i).unwrap(); + let root = roots[(i / 8) as usize]; + assert!( + MerkleTreeService::verify_merkle_proof(&root, &leaf, &path), + "leaf {i} must prove against its tree's root" + ); + } + }); + } + + // ── historic-root window ────────────────────────────────────────────────── + + /// integrity_test rejects a zero root window (checked via the mock's non-zero + /// MaxHistoricRoots passing construction). + #[test] + fn integrity_test_accepts_nonzero_root_window() { + use frame_support::traits::Hooks; + new_test_ext().execute_with(|| { + assert!(::MaxHistoricRoots::get() > 0); + as Hooks>>::integrity_test(); + }); + } + + fn retention() -> u64 { + ::RootRetentionBlocks::get() + } + + /// Distinct test root. The last byte is set so `root_numbered(0)` can never + /// collide with the all-zero genesis root, which `is_known_root` accepts + /// unconditionally as the active root and would mask a real expiry. + fn root_numbered(i: u32) -> Hash { + let mut root = [0u8; 32]; + root[..4].copy_from_slice(&i.to_le_bytes()); + root[31] = 0xAA; + root + } + + /// SP-20: activity alone must never expire a root. Inserting far more roots + /// than `MaxHistoricRoots` within one retention window leaves the oldest + /// spendable — under the old insert-counted window it was evicted after + /// `MaxHistoricRoots` inserts regardless of how little time had passed. + #[test] + fn historic_root_survives_heavy_activity_within_window() { + new_test_ext().execute_with(|| { + let oldest = root_numbered(0); + MerkleTreeService::add_poseidon_historic_root::(oldest); + + // Far more inserts than the cap, all in the same block. + let cap = ::MaxHistoricRoots::get(); + for i in 1..(cap * 2) { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + } + + assert!( + MerkleTreeService::is_known_root::(&oldest), + "a root must not expire from activity alone, only from elapsed blocks" + ); + }); + } + + /// The active root must stay spendable no matter how long the chain idles. + /// + /// Expiries are only refreshed by a leaf insert, so on a quiet chain the + /// current root's window elapses while it is still the root every wallet + /// proves against. Without the active-root exemption the pool wedges: every + /// spend reverts with `UnknownMerkleRoot`, and only a funded `shield` could + /// mint a new root to escape. + #[test] + fn active_root_never_expires_on_an_idle_chain() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let active = MerkleRepository::get_poseidon_root::(); + + // Idle well past a full retention window — no inserts at all. + System::set_block_number(start + retention() * 4); + + assert!( + MerkleTreeService::is_known_root::(&active), + "the active root must stay provable on an idle chain" + ); + }); + } + + /// A root stays spendable for the whole retention window and stops being + /// accepted once it has elapsed. + #[test] + fn historic_root_expires_only_after_retention_blocks() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let root = root_numbered(0xAA); + MerkleTreeService::add_poseidon_historic_root::(root); + + // Last block of the window: still spendable. + System::set_block_number(start + retention()); + assert!( + MerkleTreeService::is_known_root::(&root), + "root must stay spendable through the final block of its window" + ); + + // One block past the window: gone. + System::set_block_number(start + retention() + 1); + assert!( + !MerkleTreeService::is_known_root::(&root), + "root must stop being accepted once its window has elapsed" + ); + }); + } + + /// SP-20 end to end: a root must outlive the mempool longevity a transaction + /// was admitted with, even while the chain keeps inserting commitments. + #[test] + fn root_outlives_tx_longevity_under_load() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let anchor = root_numbered(0xBB); + MerkleTreeService::add_poseidon_historic_root::(anchor); + + // Simulate the chain filling blocks for the whole longevity window, + // two commitments per block, as `private_transfer` does. + for block in 1..=crate::validate_unsigned::TX_LONGEVITY { + System::set_block_number(start + block); + MerkleTreeService::add_poseidon_historic_root::(root_numbered( + block as u32 * 2, + )); + MerkleTreeService::add_poseidon_historic_root::(root_numbered( + block as u32 * 2 + 1, + )); + } + + assert!( + MerkleTreeService::is_known_root::(&anchor), + "a transaction still valid in the pool must find its root on chain" + ); + }); + } + + /// Expired entries drain from the queue as inserts happen, so it does not + /// grow without bound. + #[test] + fn expired_roots_are_pruned_from_the_queue() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + // Genesis seeds one entry; add four more. + let queued_before = MerkleRepository::historic_roots_queued::(); + for i in 0..4u32 { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + } + assert_eq!( + MerkleRepository::historic_roots_queued::(), + queued_before + 4 + ); + + // Past the window every prior entry is expired; one insert drains up + // to MAX_ROOTS_PRUNED_PER_INSERT of them and appends itself. + System::set_block_number(start + retention() + 1); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(999)); + + // Genesis seeds one entry, so the backlog is 5 against a cap of 4: + // one insert drains the cap and appends itself. + assert_eq!( + MerkleRepository::historic_roots_queued::(), + queued_before + 4 - MAX_ROOTS_PRUNED_PER_INSERT as u64 + 1, + "one insert drains exactly the cap and appends itself" + ); + for i in 0..4u32 { + assert!( + !MerkleTreeService::is_known_root::(&root_numbered(i)), + "expired roots must no longer be spendable" + ); + } + assert!(MerkleTreeService::is_known_root::(&root_numbered( + 999 + ))); + }); + } + + /// Draining is capped per insert so one extrinsic never pays for a whole + /// backlog; the leftovers clear on subsequent inserts. + #[test] + fn queue_drain_is_capped_per_insert() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let backlog = (MAX_ROOTS_PRUNED_PER_INSERT * 3) as u32; + for i in 0..backlog { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + } + + System::set_block_number(start + retention() + 1); + let before = MerkleRepository::historic_roots_queued::(); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(9001)); + + // Drained at most the cap, then appended one — so the queue shrank by + // strictly less than the whole backlog. + let after = MerkleRepository::historic_roots_queued::(); + assert_eq!( + after, + before - MAX_ROOTS_PRUNED_PER_INSERT as u64 + 1, + "one insert drains exactly the cap and appends itself" + ); + + // Enough further inserts clear the rest. + for i in 0..backlog { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(20_000 + i)); + } + assert!( + MerkleRepository::historic_roots_queued::() <= backlog as u64 + 2, + "the backlog must not accumulate once inserts keep coming" + ); + }); + } + + /// Head and tail only ever move forward, and the queue never reports a + /// negative or oversized length. + #[test] + fn queue_head_and_tail_stay_monotonic() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let mut last_head = MerkleRepository::get_historic_roots_head::(); + let mut last_tail = MerkleRepository::get_historic_roots_tail::(); + + for i in 0..40u32 { + // Advance past the window every so often so draining kicks in. + if i % 10 == 0 { + System::set_block_number(start + retention() * (i as u64 / 10 + 1)); + } + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + + let head = MerkleRepository::get_historic_roots_head::(); + let tail = MerkleRepository::get_historic_roots_tail::(); + assert!(head >= last_head, "head must never move backwards"); + assert!(tail >= last_tail, "tail must never move backwards"); + assert!(head >= tail, "tail must never overtake head"); + last_head = head; + last_tail = tail; + } + }); + } + + /// A duplicate root value stays known while any live copy remains, and its + /// expiry is extended rather than shortened by the later insert. + #[test] + fn historic_root_duplicate_survives_partial_eviction() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let dup = [0x77u8; 32]; + + // First copy at `start`, second one block later — the later insert + // extends the expiry. + MerkleTreeService::add_poseidon_historic_root::(dup); + System::set_block_number(start + 1); + MerkleTreeService::add_poseidon_historic_root::(dup); + + // Past the first copy's expiry but inside the second's. + System::set_block_number(start + retention() + 1); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(1)); + assert!( + MerkleTreeService::is_known_root::(&dup), + "duplicate root must stay known while a live copy remains" + ); + + // Past the second copy's expiry too. + System::set_block_number(start + retention() + 2); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(2)); + assert!( + !MerkleTreeService::is_known_root::(&dup), + "duplicate root must expire once every copy has elapsed" + ); + }); + } +} diff --git a/frame/shielded-pool/src/merkle/service.rs b/frame/shielded-pool/src/merkle/service.rs new file mode 100644 index 00000000..681ea8bb --- /dev/null +++ b/frame/shielded-pool/src/merkle/service.rs @@ -0,0 +1,254 @@ +//! On-chain Merkle tree: leaf insertion, tree sealing, and the historic-root +//! window. +//! +//! This is the only part of the Merkle code that touches storage. Everything it +//! needs from the pure layers comes through [`super::hashing`]. + +use super::{ + MAX_ROOTS_PRUNED_PER_INSERT, + hashing::{get_zero_hash_cached, hash_pair}, + tree::IncrementalMerkleTree, +}; +use crate::{ + pallet::{CommitmentMemos, Config, Error, Event, Pallet}, + storage::{MerkleRepository, PoolStatsRepository}, + types::{Commitment, DefaultMerklePath, Hash}, +}; +use frame_support::{ensure, pallet_prelude::*, traits::Get}; +use sp_runtime::traits::Saturating; + +pub struct MerkleTreeService; + +impl MerkleTreeService { + /// Insert a new leaf into the Merkle tree. + /// + /// Uses an incremental frontier algorithm: O(depth) hashes per insert, + /// replacing the former O(n) full recomputation from all leaves. + pub fn insert_leaf(commitment: Commitment) -> Result { + let index = MerkleRepository::get_tree_size::(); + // Absolute forest ceiling: the global u32 leaf index must stay + // representable (4096 trees at depth 20). Per-tree fullness rolls + // over to a fresh tree below instead of erroring. + ensure!(index < u32::MAX, Error::::MerkleTreeFull); + ensure!( + !CommitmentMemos::::contains_key(commitment), + Error::::CommitmentAlreadyExists + ); + + let cap = T::MaxLeavesPerTree::get(); + let tree_id = index / cap; + let local = index % cap; + + // Load frontier from storage and run one incremental update. + // Depth is always DEFAULT_TREE_DEPTH (20) — matches the fixed-size frontier array. + let mut frontier = MerkleRepository::get_frontier::(); + let mut current_hash = commitment.0; + let mut current_index = local; + + for (level, frontier_slot) in frontier.iter_mut().enumerate() { + if current_index % 2 == 0 { + // Left node: save in frontier, pair with zero-sibling + *frontier_slot = current_hash; + let zero = get_zero_hash_cached(level); + current_hash = hash_pair(¤t_hash, &zero); + } else { + // Right node: combine with stored left sibling + current_hash = hash_pair(frontier_slot, ¤t_hash); + } + current_index /= 2; + // current_hash is now the node at (level + 1, current_index). Persist + // levels 1..=19 so proof reads are O(depth); level 20 is PoseidonRoot. + if level + 1 < crate::types::DEFAULT_TREE_DEPTH { + MerkleRepository::set_node::( + tree_id, + (level + 1) as u8, + current_index, + current_hash, + ); + } + } + + let new_poseidon_root = current_hash; + let old_poseidon_root = MerkleRepository::get_poseidon_root::(); + + MerkleRepository::insert_leaf::(index, commitment); + MerkleRepository::set_commitment_leaf_index::(commitment, index); + MerkleRepository::set_tree_size::(index.saturating_add(1)); + PoolStatsRepository::increment_commitments_inserted::(); + MerkleRepository::set_frontier::(frontier); + MerkleRepository::set_poseidon_root::(new_poseidon_root); + Self::add_poseidon_historic_root::(new_poseidon_root); + + // The freshly inserted leaf belongs to `new_poseidon_root`, so this + // event fires before any seal resets the active root. + Pallet::::deposit_event(Event::MerkleRootUpdated { + old_root: old_poseidon_root, + new_root: new_poseidon_root, + tree_size: index.saturating_add(1), + }); + + if local + 1 == cap { + Self::seal_tree::(tree_id, new_poseidon_root, cap); + } + Ok(index) + } + + /// Seal a full tree and open a fresh one, eagerly in the same insert. + /// + /// The final root becomes a permanent anchor (`SealedTreeRoots` / + /// `SealedRootIndex`) — unlike the historic ring it never expires, so + /// notes in sealed trees stay spendable forever. The active tree resets + /// to the empty state; the empty root joins the historic ring to keep + /// the `PoseidonRoot ∈ known roots` invariant. + fn seal_tree(tree_id: u32, final_root: Hash, cap: u32) { + MerkleRepository::insert_sealed_root::(tree_id, final_root); + MerkleRepository::set_frontier::([[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]); + let empty_root = get_zero_hash_cached(crate::types::DEFAULT_TREE_DEPTH); + MerkleRepository::set_poseidon_root::(empty_root); + Self::add_poseidon_historic_root::(empty_root); + + Pallet::::deposit_event(Event::TreeSealed { + tree_id, + final_root, + first_leaf_index: tree_id.saturating_mul(cap), + leaf_count: cap, + }); + } + + /// Record the new root and drop the ones whose retention window has passed. + /// + /// Retention is measured in blocks (`RootRetentionBlocks`), so the window + /// always outlives the mempool longevity a transaction was admitted with. + /// `MaxHistoricRoots` is only a safety cap on queue length. + /// + /// TODO(weights): `weights.rs` still records the pre-v3 window — it declares + /// `HistoricRootsOrder`, which no longer exists, and omits + /// `HistoricRootsQueue` / `HistoricRootsHead` / `HistoricRootsTail`. The + /// per-insert pruning below is therefore uncharged. Re-benchmark + /// `shield` / `shield_batch` / `private_transfer` / `unshield` before deploying. + pub(crate) fn add_poseidon_historic_root(poseidon_root: Hash) { + let now = frame_system::Pallet::::block_number(); + let expires_at = now.saturating_add(T::RootRetentionBlocks::get()); + + let mut head = MerkleRepository::get_historic_roots_head::(); + let mut tail = MerkleRepository::get_historic_roots_tail::(); + + // Drain expired slots from the tail. Slots sit in expiry order because + // every insert stores `now + retention` with a non-decreasing `now`, so + // the first live slot ends the scan. + let mut pruned = 0usize; + while tail < head && pruned < MAX_ROOTS_PRUNED_PER_INSERT { + let Some((root, slot_expiry)) = MerkleRepository::get_historic_root_slot::(tail) + else { + // Defensive: this path never leaves holes, but skipping keeps the + // queue draining instead of wedging on one forever. Counted against + // the cap so a long run of holes cannot turn into an unbounded read + // loop inside a dispatchable. + tail = tail.saturating_add(1); + pruned = pruned.saturating_add(1); + continue; + }; + if slot_expiry >= now { + break; // still live — and so is every slot behind it + } + MerkleRepository::remove_historic_root_slot::(tail); + tail = tail.saturating_add(1); + pruned = pruned.saturating_add(1); + + // A root can occupy several slots (re-inserted, or an insert that left + // the root unchanged). The map holds one expiry per root — the latest, + // since `add_historic_poseidon_root_until` only extends it — so it is + // the authority on whether the root is still spendable. O(1) per slot, + // which keeps this loop linear. + match MerkleRepository::get_historic_root_expiry::(&root) { + Some(expiry) if expiry >= now => {} + _ => MerkleRepository::remove_poseidon_historic_root::(&root), + } + } + + // The cap is a backstop, not the window: reaching it means the window + // holds more roots than `MaxHistoricRoots` allows, which would silently + // shorten it. Evict the oldest so inserts keep working, and log the + // misconfiguration rather than failing quietly. + if head.saturating_sub(tail) >= T::MaxHistoricRoots::get() as u64 { + // Not `defensive!`: that expands to `debug_assert!(false)` and panics in + // any debug-assertions build. This branch is a reachable operational + // state, so a validator on a debug build must not halt where a release + // build keeps producing. + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "historic-root cap reached before retention elapsed; \ + MaxHistoricRoots is too small for RootRetentionBlocks", + ); + if let Some((root, slot_expiry)) = MerkleRepository::get_historic_root_slot::(tail) { + MerkleRepository::remove_historic_root_slot::(tail); + match MerkleRepository::get_historic_root_expiry::(&root) { + Some(expiry) if expiry >= now => { + // Still live: re-queue at the head rather than drop it. + // Deleting the slot while keeping the map entry would strand + // that entry outside `[tail, head)` — unreachable by the drain + // loop, so never prunable and permanently spendable. Forgetting + // it instead would reject spends that are still valid. + MerkleRepository::set_historic_root_slot::(head, root, slot_expiry); + head = head.saturating_add(1); + } + _ => MerkleRepository::remove_poseidon_historic_root::(&root), + } + } + tail = tail.saturating_add(1); + } + + MerkleRepository::set_historic_root_slot::(head, poseidon_root, expires_at); + head = head.saturating_add(1); + + MerkleRepository::add_historic_poseidon_root_until::(poseidon_root, expires_at); + MerkleRepository::set_historic_roots_head::(head); + MerkleRepository::set_historic_roots_tail::(tail); + } + + pub fn is_known_root(root: &Hash) -> bool { + MerkleRepository::is_known_root::(root) + } + + /// Build the sibling path for `leaf_index` from stored nodes. + /// + /// O(depth) point reads: level-0 siblings come from `MerkleLeaves`, upper + /// siblings from `MerkleNodes`. A missing entry means an empty subtree, so + /// the canonical zero hash for that level is used. + pub fn get_merkle_path(leaf_index: u32) -> Option { + let size = MerkleRepository::get_tree_size::(); + if leaf_index >= size { + return None; + } + + let depth = crate::types::DEFAULT_TREE_DEPTH; + let cap = T::MaxLeavesPerTree::get(); + let tree_id = leaf_index / cap; + let local = leaf_index % cap; + let mut siblings = [[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]; + let mut indices = [0u8; crate::types::DEFAULT_TREE_DEPTH]; + + for level in 0..depth { + let node_index = local >> level; + indices[level] = (node_index & 1) as u8; + let sibling_index = node_index ^ 1; + let sibling = if level == 0 { + // Level-0 nodes are the leaves; map the tree-local sibling + // back to its global MerkleLeaves index. + MerkleRepository::get_leaf::(tree_id * cap + sibling_index).map(|c| c.0) + } else { + MerkleRepository::get_node::(tree_id, level as u8, sibling_index) + }; + siblings[level] = sibling.unwrap_or_else(|| get_zero_hash_cached(level)); + } + Some(DefaultMerklePath { siblings, indices }) + } + + pub fn verify_merkle_proof(root: &Hash, leaf: &Hash, path: &DefaultMerklePath) -> bool { + IncrementalMerkleTree::<20>::verify_proof(root, leaf, path) + } + + pub fn find_leaf_index(commitment: &Commitment) -> Option { + MerkleRepository::find_leaf_index::(commitment) + } +} diff --git a/frame/shielded-pool/src/merkle/tree.rs b/frame/shielded-pool/src/merkle/tree.rs new file mode 100644 index 00000000..769198ae --- /dev/null +++ b/frame/shielded-pool/src/merkle/tree.rs @@ -0,0 +1,146 @@ +//! `IncrementalMerkleTree` — the in-memory frontier structure. +//! +//! Holds no storage handles and knows nothing about `Config`: it is the pure +//! data structure, used by tests and by off-chain callers. The on-chain tree +//! lives in [`super::service`], which keeps the same frontier in storage. + +use super::hashing::{get_zero_hash_cached, hash_pair}; +use crate::types::MerklePath; +use frame_support::pallet_prelude::*; +use sp_std::vec::Vec; + +#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug)] +pub struct IncrementalMerkleTree { + pub frontier: [[u8; 32]; DEPTH], + pub next_index: u32, + pub root: [u8; 32], +} + +impl Default for IncrementalMerkleTree { + fn default() -> Self { + Self::new() + } +} + +impl IncrementalMerkleTree { + pub fn new() -> Self { + let root = Self::compute_empty_root(); + Self { + frontier: [[0u8; 32]; DEPTH], + next_index: 0, + root, + } + } + + fn compute_empty_root() -> [u8; 32] { + let mut current = [0u8; 32]; + for _ in 0..DEPTH { + current = hash_pair(¤t, ¤t); + } + current + } + + fn zero_hash(level: usize) -> [u8; 32] { + get_zero_hash_cached(level) + } + + pub fn capacity(&self) -> u32 { + 1u32 << DEPTH + } + pub fn is_full(&self) -> bool { + self.next_index >= self.capacity() + } + + pub fn insert(&mut self, leaf: [u8; 32]) -> Result { + if self.is_full() { + return Err("Merkle tree is full"); + } + let index = self.next_index; + let mut current_hash = leaf; + let mut current_index = index; + + for level in 0..DEPTH { + if current_index % 2 == 0 { + self.frontier[level] = current_hash; + let zero = Self::zero_hash(level); + current_hash = hash_pair(¤t_hash, &zero); + } else { + current_hash = hash_pair(&self.frontier[level], ¤t_hash); + } + current_index /= 2; + } + + self.root = current_hash; + self.next_index += 1; + Ok(index) + } + + pub fn root(&self) -> [u8; 32] { + self.root + } + pub fn size(&self) -> u32 { + self.next_index + } + + pub fn generate_proof( + &self, + leaf_index: u32, + leaves: &[[u8; 32]], + ) -> Result, &'static str> { + if leaf_index >= self.next_index { + return Err("Leaf index out of bounds"); + } + if leaves.len() != self.next_index as usize { + return Err("Leaves count mismatch"); + } + + let mut siblings = [[0u8; 32]; DEPTH]; + let mut indices = [0u8; DEPTH]; + let mut current_level = leaves.to_vec(); + let mut target_index = leaf_index as usize; + + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + current_level.push(Self::zero_hash(level)); + } + let sibling_index = if target_index % 2 == 0 { + indices[level] = 0; + target_index + 1 + } else { + indices[level] = 1; + target_index - 1 + }; + siblings[level] = if sibling_index < current_level.len() { + current_level[sibling_index] + } else { + Self::zero_hash(level) + }; + let mut next_level = Vec::new(); + for chunk in current_level.chunks(2) { + let left = chunk[0]; + let right = if chunk.len() > 1 { + chunk[1] + } else { + Self::zero_hash(level) + }; + next_level.push(hash_pair(&left, &right)); + } + current_level = next_level; + target_index /= 2; + } + Ok(MerklePath { siblings, indices }) + } + + pub fn verify_proof(root: &[u8; 32], leaf: &[u8; 32], path: &MerklePath) -> bool { + let mut current = *leaf; + for level in 0..DEPTH { + let sibling = &path.siblings[level]; + current = if path.indices[level] == 0 { + hash_pair(¤t, sibling) + } else { + hash_pair(sibling, ¤t) + }; + } + ¤t == root + } +} From bcb56870f6362fe78f551a0e43532ef5ec97cd4f Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 15:25:29 -0400 Subject: [PATCH 3/8] refactor: separate responsibilities --- frame/shielded-pool/src/storage.rs | 658 ------------------ frame/shielded-pool/src/storage/asset.rs | 50 ++ frame/shielded-pool/src/storage/balance.rs | 35 + frame/shielded-pool/src/storage/commitment.rs | 26 + frame/shielded-pool/src/storage/merkle.rs | 149 ++++ frame/shielded-pool/src/storage/mod.rs | 398 +++++++++++ frame/shielded-pool/src/storage/nullifier.rs | 28 + frame/shielded-pool/src/storage/stats.rs | 26 + 8 files changed, 712 insertions(+), 658 deletions(-) delete mode 100644 frame/shielded-pool/src/storage.rs create mode 100644 frame/shielded-pool/src/storage/asset.rs create mode 100644 frame/shielded-pool/src/storage/balance.rs create mode 100644 frame/shielded-pool/src/storage/commitment.rs create mode 100644 frame/shielded-pool/src/storage/merkle.rs create mode 100644 frame/shielded-pool/src/storage/mod.rs create mode 100644 frame/shielded-pool/src/storage/nullifier.rs create mode 100644 frame/shielded-pool/src/storage/stats.rs diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage.rs deleted file mode 100644 index 461dd786..00000000 --- a/frame/shielded-pool/src/storage.rs +++ /dev/null @@ -1,658 +0,0 @@ -//! Storage access — typed wrappers over every `StorageValue` / `StorageMap`. -//! -//! One struct per logical domain (Assets, Commitments, Merkle, Nullifiers, -//! PoolBalance, Audit). All functions are static; no instance state. - -use crate::{ - pallet::{ - Assets, BalanceOf, CommitmentMemos, CommitmentToLeafIndex, Config, HistoricPoseidonRoots, - HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, MerkleLeaves, MerkleNodes, - MerkleTreeFrontier, MerkleTreeSize, NextAssetId, NullifierSet, PoolBalancePerAsset, - PoseidonRoot, SealedRootIndex, SealedTreeRoots, TotalCommitmentsInserted, - TotalNullifiersSpent, - }, - types::{AssetMetadata, Commitment, EncryptedMemo, Hash}, -}; -use frame_support::pallet_prelude::*; -use frame_system::pallet_prelude::BlockNumberFor; -use sp_runtime::traits::Saturating; - -// ════════════════════════════════════════════════════════════════════════════ -// AssetRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct AssetRepository; - -impl AssetRepository { - pub fn get_asset( - asset_id: u32, - ) -> Option>> { - Assets::::get(asset_id) - } - pub fn store_asset( - asset_id: u32, - metadata: AssetMetadata>, - ) { - Assets::::insert(asset_id, metadata); - } - pub fn exists(asset_id: u32) -> bool { - Assets::::contains_key(asset_id) - } - pub fn get_next_asset_id() -> u32 { - NextAssetId::::get() - } - pub fn increment_asset_id() -> u32 { - let current = Self::get_next_asset_id::(); - NextAssetId::::put(current.saturating_add(1)); - current - } - pub fn set_verified(asset_id: u32, is_verified: bool) -> bool { - Assets::::mutate(asset_id, |maybe_asset| { - if let Some(asset) = maybe_asset { - asset.is_verified = is_verified; - true - } else { - false - } - }) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// CommitmentRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct CommitmentRepository; - -impl CommitmentRepository { - pub fn get_memo(commitment: &Commitment) -> Option { - CommitmentMemos::::get(commitment) - } - pub fn store_memo(commitment: Commitment, memo: EncryptedMemo) { - CommitmentMemos::::insert(commitment, memo); - } - pub fn exists(commitment: &Commitment) -> bool { - CommitmentMemos::::contains_key(commitment) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerkleRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct MerkleRepository; - -impl MerkleRepository { - pub fn get_poseidon_root() -> Hash { - PoseidonRoot::::get() - } - pub fn set_poseidon_root(root: Hash) { - PoseidonRoot::::put(root); - } - pub fn get_tree_size() -> u32 { - MerkleTreeSize::::get() - } - pub fn set_tree_size(size: u32) { - MerkleTreeSize::::put(size); - } - pub fn get_leaf(index: u32) -> Option { - MerkleLeaves::::get(index) - } - pub fn insert_leaf(index: u32, commitment: Commitment) { - MerkleLeaves::::insert(index, commitment); - } - /// A historic root is spendable until its expiry block, inclusive. - /// - /// Checked against the expiry stored at insert time rather than pruned - /// eagerly: pruning is lazy (see `MerkleTreeService::add_poseidon_historic_root`), - /// so an expired entry can outlive its window in storage. Reading the - /// expiry here makes that harmless. - pub fn is_known_poseidon_root(root: &Hash) -> bool { - match HistoricPoseidonRoots::::get(root) { - Some(expires_at) => frame_system::Pallet::::block_number() <= expires_at, - None => false, - } - } - /// A root is spendable if it is the active root, is still inside its - /// retention window, or anchors a sealed tree. - /// - /// The active root is accepted unconditionally. Expiries are only refreshed - /// by a leaf insert, so on a chain that goes quiet for a full retention - /// window the current root would otherwise expire while still being the one - /// every wallet proves against — wedging the pool, since `private_transfer` - /// and `unshield` both need a known root and only a funded `shield` could - /// mint a new one. - pub fn is_known_root(root: &Hash) -> bool { - *root == PoseidonRoot::::get() - || Self::is_known_poseidon_root::(root) - || SealedRootIndex::::contains_key(root) - } - pub fn insert_sealed_root(tree_id: u32, root: Hash) { - SealedTreeRoots::::insert(tree_id, root); - SealedRootIndex::::insert(root, tree_id); - } - pub fn get_sealed_root(tree_id: u32) -> Option { - SealedTreeRoots::::get(tree_id) - } - /// Record `root` as spendable for one full retention window from now. - pub fn add_historic_poseidon_root(root: Hash) { - let expires_at = - frame_system::Pallet::::block_number().saturating_add(T::RootRetentionBlocks::get()); - Self::add_historic_poseidon_root_until::(root, expires_at); - } - - /// Record `root` as spendable until `expires_at` (inclusive). - /// - /// A root re-inserted at a later block extends its expiry; it never shortens - /// it, so a duplicate root cannot cut short the window of the earlier entry. - pub fn add_historic_poseidon_root_until(root: Hash, expires_at: BlockNumberFor) { - HistoricPoseidonRoots::::mutate(root, |slot| match slot { - Some(current) if *current >= expires_at => {} - _ => *slot = Some(expires_at), - }); - } - pub fn remove_poseidon_historic_root(root: &Hash) { - HistoricPoseidonRoots::::remove(root); - } - pub fn get_historic_root_expiry(root: &Hash) -> Option> { - HistoricPoseidonRoots::::get(root) - } - pub fn get_historic_root_slot(slot: u64) -> Option<(Hash, BlockNumberFor)> { - HistoricRootsQueue::::get(slot) - } - pub fn set_historic_root_slot(slot: u64, root: Hash, expires_at: BlockNumberFor) { - HistoricRootsQueue::::insert(slot, (root, expires_at)); - } - pub fn remove_historic_root_slot(slot: u64) { - HistoricRootsQueue::::remove(slot); - } - pub fn get_historic_roots_head() -> u64 { - HistoricRootsHead::::get() - } - pub fn set_historic_roots_head(head: u64) { - HistoricRootsHead::::put(head); - } - pub fn get_historic_roots_tail() -> u64 { - HistoricRootsTail::::get() - } - pub fn set_historic_roots_tail(tail: u64) { - HistoricRootsTail::::put(tail); - } - /// Number of slots still queued. Bounded by the retention window in practice - /// and hard-capped by `MaxHistoricRoots`. - pub fn historic_roots_queued() -> u64 { - HistoricRootsHead::::get().saturating_sub(HistoricRootsTail::::get()) - } - pub fn get_frontier() -> [[u8; 32]; 20] { - MerkleTreeFrontier::::get() - } - pub fn set_frontier(frontier: [[u8; 32]; 20]) { - MerkleTreeFrontier::::put(frontier); - } - pub fn get_commitment_leaf_index(commitment: &Commitment) -> Option { - CommitmentToLeafIndex::::get(commitment) - } - pub fn set_commitment_leaf_index(commitment: Commitment, index: u32) { - CommitmentToLeafIndex::::insert(commitment, index); - } - pub fn find_leaf_index(commitment: &Commitment) -> Option { - Self::get_commitment_leaf_index::(commitment) - } - pub fn get_node(tree_id: u32, level: u8, index: u32) -> Option { - MerkleNodes::::get((tree_id, level, index)) - } - pub fn set_node(tree_id: u32, level: u8, index: u32, node: Hash) { - MerkleNodes::::insert((tree_id, level, index), node); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// NullifierRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct NullifierRepository; - -impl NullifierRepository { - pub fn is_used(nullifier: &crate::types::Nullifier) -> bool { - NullifierSet::::contains_key(nullifier) - } - pub fn mark_as_used(nullifier: crate::types::Nullifier, block: BlockNumberFor) { - NullifierSet::::insert(nullifier, block); - PoolStatsRepository::increment_nullifiers_spent::(); - } - pub fn get_usage_block( - nullifier: &crate::types::Nullifier, - ) -> Option> { - NullifierSet::::get(nullifier) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// PoolStatsRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct PoolStatsRepository; - -impl PoolStatsRepository { - pub fn increment_commitments_inserted() { - TotalCommitmentsInserted::::mutate(|n| *n = n.saturating_add(1)); - } - pub fn get_total_commitments_inserted() -> u64 { - TotalCommitmentsInserted::::get() - } - pub fn increment_nullifiers_spent() { - TotalNullifiersSpent::::mutate(|n| *n = n.saturating_add(1)); - } - pub fn get_total_nullifiers_spent() -> u64 { - TotalNullifiersSpent::::get() - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// PoolBalanceRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct PoolBalanceRepository; - -impl PoolBalanceRepository { - pub fn get_asset_balance(asset_id: u32) -> BalanceOf { - PoolBalancePerAsset::::get(asset_id) - } - pub fn set_asset_balance(asset_id: u32, balance: BalanceOf) { - PoolBalancePerAsset::::insert(asset_id, balance); - } - pub fn increase_balance(asset_id: u32, amount: BalanceOf) { - PoolBalancePerAsset::::mutate(asset_id, |balance| { - *balance = balance.saturating_add(amount); - }); - } - pub fn decrease_balance(asset_id: u32, amount: BalanceOf) { - PoolBalancePerAsset::::mutate(asset_id, |balance| { - // The unshield guard (`>= amount + fee`) makes `balance >= amount` always - // hold here; `defensive!` trips in tests/try-runtime if that invariant is - // ever broken, while `saturating_sub` keeps production safe. - frame_support::defensive_assert!(*balance >= amount); - *balance = balance.saturating_sub(amount); - }); - } -} - -// ════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - mock::{Test, acc, new_test_ext}, - types::{AssetMetadata, Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE, Nullifier}, - }; - use frame_support::{BoundedVec, pallet_prelude::ConstU32}; - use sp_runtime::AccountId32; - - // ── helpers ─────────────────────────────────────────────────────────────── - - fn test_commitment(seed: u8) -> Commitment { - Commitment::new([seed; 32]) - } - - fn test_nullifier(seed: u8) -> Nullifier { - Nullifier::new([seed; 32]) - } - - fn test_memo() -> EncryptedMemo { - EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap() - } - - fn make_asset_metadata(id: u32) -> AssetMetadata { - AssetMetadata::new( - id, - BoundedVec::try_from(b"Test".to_vec()).unwrap(), - BoundedVec::try_from(b"TST".to_vec()).unwrap(), - 18, - 0u64, - acc(1), - ) - } - - // ── AssetRepository ────────────────────────────────────────────────────── - - #[test] - fn asset_repo_store_and_get() { - new_test_ext().execute_with(|| { - let meta = make_asset_metadata(10); - AssetRepository::store_asset::(10, meta); - let got = AssetRepository::get_asset::(10).unwrap(); - assert_eq!(got.id, 10); - }); - } - - #[test] - fn asset_repo_exists_returns_correct() { - new_test_ext().execute_with(|| { - assert!(!AssetRepository::exists::(99)); - AssetRepository::store_asset::(99, make_asset_metadata(99)); - assert!(AssetRepository::exists::(99)); - }); - } - - #[test] - fn asset_repo_get_none_for_unknown() { - new_test_ext().execute_with(|| { - assert!(AssetRepository::get_asset::(999).is_none()); - }); - } - - #[test] - fn asset_repo_get_next_id_starts_at_1() { - new_test_ext().execute_with(|| { - // genesis sets NextAssetId = 1 - assert_eq!(AssetRepository::get_next_asset_id::(), 1); - }); - } - - #[test] - fn asset_repo_increment_returns_current_then_advances() { - new_test_ext().execute_with(|| { - let id0 = AssetRepository::increment_asset_id::(); // returns 1, stores 2 - assert_eq!(id0, 1); - let id1 = AssetRepository::increment_asset_id::(); // returns 2, stores 3 - assert_eq!(id1, 2); - assert_eq!(AssetRepository::get_next_asset_id::(), 3); - }); - } - - #[test] - fn asset_repo_set_verified_true_and_false() { - new_test_ext().execute_with(|| { - AssetRepository::store_asset::(5, make_asset_metadata(5)); - assert!(AssetRepository::set_verified::(5, true)); - assert!(AssetRepository::get_asset::(5).unwrap().is_verified); - assert!(AssetRepository::set_verified::(5, false)); - assert!(!AssetRepository::get_asset::(5).unwrap().is_verified); - }); - } - - #[test] - fn asset_repo_set_verified_not_found_returns_false() { - new_test_ext().execute_with(|| { - assert!(!AssetRepository::set_verified::(999, true)); - }); - } - - // ── CommitmentRepository ───────────────────────────────────────────────── - - #[test] - fn commitment_repo_store_and_get_memo() { - new_test_ext().execute_with(|| { - let c = test_commitment(0x01); - let memo = test_memo(); - CommitmentRepository::store_memo::(c, memo.clone()); - assert_eq!(CommitmentRepository::get_memo::(&c), Some(memo)); - }); - } - - #[test] - fn commitment_repo_exists_returns_correct() { - new_test_ext().execute_with(|| { - let c = test_commitment(0x02); - assert!(!CommitmentRepository::exists::(&c)); - CommitmentRepository::store_memo::(c, test_memo()); - assert!(CommitmentRepository::exists::(&c)); - }); - } - - #[test] - fn commitment_repo_get_memo_none_when_missing() { - new_test_ext().execute_with(|| { - assert!(CommitmentRepository::get_memo::(&test_commitment(0xAA)).is_none()); - }); - } - - // ── MerkleRepository ───────────────────────────────────────────────────── - - #[test] - fn merkle_repo_poseidon_root_get_and_set() { - new_test_ext().execute_with(|| { - let root = [0xBBu8; 32]; - MerkleRepository::set_poseidon_root::(root); - assert_eq!(MerkleRepository::get_poseidon_root::(), root); - }); - } - - #[test] - fn merkle_repo_tree_size_zero_on_start() { - new_test_ext().execute_with(|| { - assert_eq!(MerkleRepository::get_tree_size::(), 0); - }); - } - - #[test] - fn merkle_repo_tree_size_set() { - new_test_ext().execute_with(|| { - MerkleRepository::set_tree_size::(7); - assert_eq!(MerkleRepository::get_tree_size::(), 7); - }); - } - - #[test] - fn merkle_repo_insert_and_get_leaf() { - new_test_ext().execute_with(|| { - let c = test_commitment(0x05); - MerkleRepository::insert_leaf::(0, c); - assert_eq!(MerkleRepository::get_leaf::(0), Some(c)); - assert_eq!(MerkleRepository::get_leaf::(1), None); - }); - } - - #[test] - fn merkle_repo_is_known_root_after_add() { - new_test_ext().execute_with(|| { - let root = [0xCCu8; 32]; - assert!(!MerkleRepository::is_known_root::(&root)); - MerkleRepository::add_historic_poseidon_root::(root); - assert!(MerkleRepository::is_known_root::(&root)); - }); - } - - #[test] - fn merkle_repo_remove_historic_root() { - new_test_ext().execute_with(|| { - let root = [0xDDu8; 32]; - MerkleRepository::add_historic_poseidon_root::(root); - assert!(MerkleRepository::is_known_root::(&root)); - MerkleRepository::remove_poseidon_historic_root::(&root); - assert!(!MerkleRepository::is_known_root::(&root)); - }); - } - - #[test] - fn merkle_repo_commitment_to_leaf_index_get_set() { - new_test_ext().execute_with(|| { - let c = test_commitment(0xDE); - // Before insertion: returns None - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&c), - None - ); - // After set: returns the stored index - MerkleRepository::set_commitment_leaf_index::(c, 7); - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&c), - Some(7) - ); - // A different commitment is unaffected - assert_eq!( - MerkleRepository::get_commitment_leaf_index::(&test_commitment(0xAB)), - None - ); - }); - } - - #[test] - fn merkle_repo_find_leaf_index_returns_correct_positions() { - new_test_ext().execute_with(|| { - let c0 = test_commitment(0xA1); - let c1 = test_commitment(0xA2); - MerkleRepository::insert_leaf::(0, c0); - MerkleRepository::set_commitment_leaf_index::(c0, 0); - MerkleRepository::insert_leaf::(1, c1); - MerkleRepository::set_commitment_leaf_index::(c1, 1); - MerkleRepository::set_tree_size::(2); - assert_eq!(MerkleRepository::find_leaf_index::(&c0), Some(0)); - assert_eq!(MerkleRepository::find_leaf_index::(&c1), Some(1)); - assert_eq!( - MerkleRepository::find_leaf_index::(&test_commitment(0xFF)), - None - ); - }); - } - - #[test] - fn merkle_repo_node_get_set_and_missing() { - new_test_ext().execute_with(|| { - assert_eq!(MerkleRepository::get_node::(0, 1, 0), None); - let node = [0xB1u8; 32]; - MerkleRepository::set_node::(0, 1, 0, node); - assert_eq!(MerkleRepository::get_node::(0, 1, 0), Some(node)); - // Distinct coordinates are independent - assert_eq!(MerkleRepository::get_node::(0, 1, 1), None); - assert_eq!(MerkleRepository::get_node::(0, 2, 0), None); - assert_eq!(MerkleRepository::get_node::(1, 1, 0), None); - }); - } - - // ── NullifierRepository ────────────────────────────────────────────────── - - #[test] - fn nullifier_repo_not_used_by_default() { - new_test_ext().execute_with(|| { - assert!(!NullifierRepository::is_used::(&test_nullifier(0x01))); - }); - } - - #[test] - fn nullifier_repo_mark_and_check_used() { - new_test_ext().execute_with(|| { - let n = test_nullifier(0x02); - NullifierRepository::mark_as_used::(n, 42u64); - assert!(NullifierRepository::is_used::(&n)); - }); - } - - #[test] - fn nullifier_repo_get_usage_block() { - new_test_ext().execute_with(|| { - let n = test_nullifier(0x03); - assert!(NullifierRepository::get_usage_block::(&n).is_none()); - NullifierRepository::mark_as_used::(n, 100u64); - assert_eq!( - NullifierRepository::get_usage_block::(&n), - Some(100u64) - ); - }); - } - - // ── PoolStatsRepository ─────────────────────────────────────────────────── - - #[test] - fn pool_stats_commitments_zero_by_default() { - new_test_ext().execute_with(|| { - assert_eq!( - PoolStatsRepository::get_total_commitments_inserted::(), - 0 - ); - }); - } - - #[test] - fn pool_stats_commitments_increments_correctly() { - new_test_ext().execute_with(|| { - PoolStatsRepository::increment_commitments_inserted::(); - PoolStatsRepository::increment_commitments_inserted::(); - PoolStatsRepository::increment_commitments_inserted::(); - assert_eq!( - PoolStatsRepository::get_total_commitments_inserted::(), - 3 - ); - }); - } - - #[test] - fn pool_stats_nullifiers_zero_by_default() { - new_test_ext().execute_with(|| { - assert_eq!(PoolStatsRepository::get_total_nullifiers_spent::(), 0); - }); - } - - #[test] - fn pool_stats_nullifiers_incremented_by_mark_as_used() { - new_test_ext().execute_with(|| { - let n0 = test_nullifier(0xE0); - let n1 = test_nullifier(0xE1); - NullifierRepository::mark_as_used::(n0, 1u64); - NullifierRepository::mark_as_used::(n1, 2u64); - assert_eq!(PoolStatsRepository::get_total_nullifiers_spent::(), 2); - }); - } - - // ── PoolBalanceRepository ───────────────────────────────────────────────── - - #[test] - fn pool_balance_repo_zero_by_default() { - new_test_ext().execute_with(|| { - assert_eq!(PoolBalanceRepository::get_asset_balance::(0), 0u128); - }); - } - - #[test] - fn pool_balance_repo_set_and_get() { - new_test_ext().execute_with(|| { - PoolBalanceRepository::set_asset_balance::(1, 500u128); - assert_eq!(PoolBalanceRepository::get_asset_balance::(1), 500u128); - }); - } - - #[test] - fn pool_balance_repo_increase_adds_amount() { - new_test_ext().execute_with(|| { - PoolBalanceRepository::set_asset_balance::(2, 100u128); - PoolBalanceRepository::increase_balance::(2, 50u128); - assert_eq!(PoolBalanceRepository::get_asset_balance::(2), 150u128); - }); - } - - #[test] - fn pool_balance_repo_decrease_subtracts_amount() { - new_test_ext().execute_with(|| { - PoolBalanceRepository::set_asset_balance::(3, 200u128); - PoolBalanceRepository::decrease_balance::(3, 80u128); - assert_eq!(PoolBalanceRepository::get_asset_balance::(3), 120u128); - }); - } - - #[test] - fn pool_balance_repo_decrease_to_exact_zero() { - new_test_ext().execute_with(|| { - // Decrementing by the full balance reaches zero without tripping the - // `defensive_assert!(balance >= amount)` guard. - PoolBalanceRepository::set_asset_balance::(4, 100u128); - PoolBalanceRepository::decrease_balance::(4, 100u128); - assert_eq!(PoolBalanceRepository::get_asset_balance::(4), 0u128); - }); - } - - #[test] - #[cfg(debug_assertions)] - #[should_panic(expected = "Defensive")] - fn pool_balance_repo_decrease_below_balance_is_defensive() { - // An over-decrement (balance < amount) is an accounting bug: the defensive - // guard trips in test/debug. In production `saturating_sub` still floors at - // zero, but this path must never be reached under invariant (A). - new_test_ext().execute_with(|| { - PoolBalanceRepository::set_asset_balance::(4, 10u128); - PoolBalanceRepository::decrease_balance::(4, 100u128); - }); - } - - fn _suppress_unused(_: ConstU32<10>) {} -} diff --git a/frame/shielded-pool/src/storage/asset.rs b/frame/shielded-pool/src/storage/asset.rs new file mode 100644 index 00000000..5340b008 --- /dev/null +++ b/frame/shielded-pool/src/storage/asset.rs @@ -0,0 +1,50 @@ +//! Asset registry storage. +//! +//! Asset metadata plus the monotonic id counter. `increment_asset_id` hands out +//! the next id; the caller is responsible for rejecting a collision, since the +//! counter alone cannot tell a fresh slot from a reused one. + +use crate::{ + pallet::{Assets, Config, NextAssetId}, + types::AssetMetadata, +}; +use frame_system::pallet_prelude::BlockNumberFor; + +// AssetRepository + +pub struct AssetRepository; + +impl AssetRepository { + pub fn get_asset( + asset_id: u32, + ) -> Option>> { + Assets::::get(asset_id) + } + pub fn store_asset( + asset_id: u32, + metadata: AssetMetadata>, + ) { + Assets::::insert(asset_id, metadata); + } + pub fn exists(asset_id: u32) -> bool { + Assets::::contains_key(asset_id) + } + pub fn get_next_asset_id() -> u32 { + NextAssetId::::get() + } + pub fn increment_asset_id() -> u32 { + let current = Self::get_next_asset_id::(); + NextAssetId::::put(current.saturating_add(1)); + current + } + pub fn set_verified(asset_id: u32, is_verified: bool) -> bool { + Assets::::mutate(asset_id, |maybe_asset| { + if let Some(asset) = maybe_asset { + asset.is_verified = is_verified; + true + } else { + false + } + }) + } +} diff --git a/frame/shielded-pool/src/storage/balance.rs b/frame/shielded-pool/src/storage/balance.rs new file mode 100644 index 00000000..66de369d --- /dev/null +++ b/frame/shielded-pool/src/storage/balance.rs @@ -0,0 +1,35 @@ +//! Per-asset pool ledger. +//! +//! Tracks what the pool holds for each asset. Only the native asset is backed by +//! `Currency`; the ledger must stay equal to the pool account's physical balance, +//! an invariant the pallet asserts under `try_state`. + +use crate::pallet::{BalanceOf, Config, PoolBalancePerAsset}; +use sp_runtime::traits::Saturating; + +// PoolBalanceRepository + +pub struct PoolBalanceRepository; + +impl PoolBalanceRepository { + pub fn get_asset_balance(asset_id: u32) -> BalanceOf { + PoolBalancePerAsset::::get(asset_id) + } + pub fn set_asset_balance(asset_id: u32, balance: BalanceOf) { + PoolBalancePerAsset::::insert(asset_id, balance); + } + pub fn increase_balance(asset_id: u32, amount: BalanceOf) { + PoolBalancePerAsset::::mutate(asset_id, |balance| { + *balance = balance.saturating_add(amount); + }); + } + pub fn decrease_balance(asset_id: u32, amount: BalanceOf) { + PoolBalancePerAsset::::mutate(asset_id, |balance| { + // The unshield guard (`>= amount + fee`) makes `balance >= amount` always + // hold here; `defensive!` trips in tests/try-runtime if that invariant is + // ever broken, while `saturating_sub` keeps production safe. + frame_support::defensive_assert!(*balance >= amount); + *balance = balance.saturating_sub(amount); + }); + } +} diff --git a/frame/shielded-pool/src/storage/commitment.rs b/frame/shielded-pool/src/storage/commitment.rs new file mode 100644 index 00000000..c235e253 --- /dev/null +++ b/frame/shielded-pool/src/storage/commitment.rs @@ -0,0 +1,26 @@ +//! Commitment memo storage. +//! +//! Maps a note commitment to its encrypted memo. Membership here doubles as the +//! duplicate-commitment check, so `exists` is what keeps the same commitment +//! from being inserted into the tree twice. + +use crate::{ + pallet::{CommitmentMemos, Config}, + types::{Commitment, EncryptedMemo}, +}; + +// CommitmentRepository + +pub struct CommitmentRepository; + +impl CommitmentRepository { + pub fn get_memo(commitment: &Commitment) -> Option { + CommitmentMemos::::get(commitment) + } + pub fn store_memo(commitment: Commitment, memo: EncryptedMemo) { + CommitmentMemos::::insert(commitment, memo); + } + pub fn exists(commitment: &Commitment) -> bool { + CommitmentMemos::::contains_key(commitment) + } +} diff --git a/frame/shielded-pool/src/storage/merkle.rs b/frame/shielded-pool/src/storage/merkle.rs new file mode 100644 index 00000000..3530c281 --- /dev/null +++ b/frame/shielded-pool/src/storage/merkle.rs @@ -0,0 +1,149 @@ +//! Merkle tree and historic-root storage. +//! +//! Covers the live tree (root, size, leaves, frontier, internal nodes), the +//! sealed-tree anchors, and the historic-root window. +//! +//! The window is the subtle part: a root carries the block at which it stops +//! being accepted, and pruning is lazy, so an expired entry can outlive its +//! window in storage. Spendability is therefore decided by the stored expiry — +//! never by queue membership. + +use crate::{ + pallet::{ + CommitmentToLeafIndex, Config, HistoricPoseidonRoots, HistoricRootsHead, + HistoricRootsQueue, HistoricRootsTail, MerkleLeaves, MerkleNodes, MerkleTreeFrontier, + MerkleTreeSize, PoseidonRoot, SealedRootIndex, SealedTreeRoots, + }, + types::{Commitment, Hash}, +}; +use frame_support::traits::Get; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_runtime::traits::Saturating; + +// MerkleRepository + +pub struct MerkleRepository; + +impl MerkleRepository { + pub fn get_poseidon_root() -> Hash { + PoseidonRoot::::get() + } + pub fn set_poseidon_root(root: Hash) { + PoseidonRoot::::put(root); + } + pub fn get_tree_size() -> u32 { + MerkleTreeSize::::get() + } + pub fn set_tree_size(size: u32) { + MerkleTreeSize::::put(size); + } + pub fn get_leaf(index: u32) -> Option { + MerkleLeaves::::get(index) + } + pub fn insert_leaf(index: u32, commitment: Commitment) { + MerkleLeaves::::insert(index, commitment); + } + /// A historic root is spendable until its expiry block, inclusive. + /// + /// Checked against the expiry stored at insert time rather than pruned + /// eagerly: pruning is lazy (see `MerkleTreeService::add_poseidon_historic_root`), + /// so an expired entry can outlive its window in storage. Reading the + /// expiry here makes that harmless. + pub fn is_known_poseidon_root(root: &Hash) -> bool { + match HistoricPoseidonRoots::::get(root) { + Some(expires_at) => frame_system::Pallet::::block_number() <= expires_at, + None => false, + } + } + /// A root is spendable if it is the active root, is still inside its + /// retention window, or anchors a sealed tree. + /// + /// The active root is accepted unconditionally. Expiries are only refreshed + /// by a leaf insert, so on a chain that goes quiet for a full retention + /// window the current root would otherwise expire while still being the one + /// every wallet proves against — wedging the pool, since `private_transfer` + /// and `unshield` both need a known root and only a funded `shield` could + /// mint a new one. + pub fn is_known_root(root: &Hash) -> bool { + *root == PoseidonRoot::::get() + || Self::is_known_poseidon_root::(root) + || SealedRootIndex::::contains_key(root) + } + pub fn insert_sealed_root(tree_id: u32, root: Hash) { + SealedTreeRoots::::insert(tree_id, root); + SealedRootIndex::::insert(root, tree_id); + } + pub fn get_sealed_root(tree_id: u32) -> Option { + SealedTreeRoots::::get(tree_id) + } + /// Record `root` as spendable for one full retention window from now. + pub fn add_historic_poseidon_root(root: Hash) { + let expires_at = + frame_system::Pallet::::block_number().saturating_add(T::RootRetentionBlocks::get()); + Self::add_historic_poseidon_root_until::(root, expires_at); + } + + /// Record `root` as spendable until `expires_at` (inclusive). + /// + /// A root re-inserted at a later block extends its expiry; it never shortens + /// it, so a duplicate root cannot cut short the window of the earlier entry. + pub fn add_historic_poseidon_root_until(root: Hash, expires_at: BlockNumberFor) { + HistoricPoseidonRoots::::mutate(root, |slot| match slot { + Some(current) if *current >= expires_at => {} + _ => *slot = Some(expires_at), + }); + } + pub fn remove_poseidon_historic_root(root: &Hash) { + HistoricPoseidonRoots::::remove(root); + } + pub fn get_historic_root_expiry(root: &Hash) -> Option> { + HistoricPoseidonRoots::::get(root) + } + pub fn get_historic_root_slot(slot: u64) -> Option<(Hash, BlockNumberFor)> { + HistoricRootsQueue::::get(slot) + } + pub fn set_historic_root_slot(slot: u64, root: Hash, expires_at: BlockNumberFor) { + HistoricRootsQueue::::insert(slot, (root, expires_at)); + } + pub fn remove_historic_root_slot(slot: u64) { + HistoricRootsQueue::::remove(slot); + } + pub fn get_historic_roots_head() -> u64 { + HistoricRootsHead::::get() + } + pub fn set_historic_roots_head(head: u64) { + HistoricRootsHead::::put(head); + } + pub fn get_historic_roots_tail() -> u64 { + HistoricRootsTail::::get() + } + pub fn set_historic_roots_tail(tail: u64) { + HistoricRootsTail::::put(tail); + } + /// Number of slots still queued. Bounded by the retention window in practice + /// and hard-capped by `MaxHistoricRoots`. + pub fn historic_roots_queued() -> u64 { + HistoricRootsHead::::get().saturating_sub(HistoricRootsTail::::get()) + } + pub fn get_frontier() -> [[u8; 32]; 20] { + MerkleTreeFrontier::::get() + } + pub fn set_frontier(frontier: [[u8; 32]; 20]) { + MerkleTreeFrontier::::put(frontier); + } + pub fn get_commitment_leaf_index(commitment: &Commitment) -> Option { + CommitmentToLeafIndex::::get(commitment) + } + pub fn set_commitment_leaf_index(commitment: Commitment, index: u32) { + CommitmentToLeafIndex::::insert(commitment, index); + } + pub fn find_leaf_index(commitment: &Commitment) -> Option { + Self::get_commitment_leaf_index::(commitment) + } + pub fn get_node(tree_id: u32, level: u8, index: u32) -> Option { + MerkleNodes::::get((tree_id, level, index)) + } + pub fn set_node(tree_id: u32, level: u8, index: u32, node: Hash) { + MerkleNodes::::insert((tree_id, level, index), node); + } +} diff --git a/frame/shielded-pool/src/storage/mod.rs b/frame/shielded-pool/src/storage/mod.rs new file mode 100644 index 00000000..28374372 --- /dev/null +++ b/frame/shielded-pool/src/storage/mod.rs @@ -0,0 +1,398 @@ +//! Storage access — typed wrappers over every `StorageValue` / `StorageMap`. +//! +//! One module per logical domain, one repository struct in each. All functions +//! are static; no instance state. Keeping the raw storage items behind these +//! wrappers means the pallet body never names a `StorageMap` directly, so a +//! layout change stays contained here. + +pub mod asset; +pub mod balance; +pub mod commitment; +pub mod merkle; +pub mod nullifier; +pub mod stats; + +pub use asset::AssetRepository; +pub use balance::PoolBalanceRepository; +pub use commitment::CommitmentRepository; +pub use merkle::MerkleRepository; +pub use nullifier::NullifierRepository; +pub use stats::PoolStatsRepository; + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + mock::{Test, acc, new_test_ext}, + types::{AssetMetadata, Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE, Nullifier}, + }; + use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + use sp_runtime::AccountId32; + + // ── helpers ─────────────────────────────────────────────────────────────── + + fn test_commitment(seed: u8) -> Commitment { + Commitment::new([seed; 32]) + } + + fn test_nullifier(seed: u8) -> Nullifier { + Nullifier::new([seed; 32]) + } + + fn test_memo() -> EncryptedMemo { + EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap() + } + + fn make_asset_metadata(id: u32) -> AssetMetadata { + AssetMetadata::new( + id, + BoundedVec::try_from(b"Test".to_vec()).unwrap(), + BoundedVec::try_from(b"TST".to_vec()).unwrap(), + 18, + 0u64, + acc(1), + ) + } + + // ── AssetRepository ────────────────────────────────────────────────────── + + #[test] + fn asset_repo_store_and_get() { + new_test_ext().execute_with(|| { + let meta = make_asset_metadata(10); + AssetRepository::store_asset::(10, meta); + let got = AssetRepository::get_asset::(10).unwrap(); + assert_eq!(got.id, 10); + }); + } + + #[test] + fn asset_repo_exists_returns_correct() { + new_test_ext().execute_with(|| { + assert!(!AssetRepository::exists::(99)); + AssetRepository::store_asset::(99, make_asset_metadata(99)); + assert!(AssetRepository::exists::(99)); + }); + } + + #[test] + fn asset_repo_get_none_for_unknown() { + new_test_ext().execute_with(|| { + assert!(AssetRepository::get_asset::(999).is_none()); + }); + } + + #[test] + fn asset_repo_get_next_id_starts_at_1() { + new_test_ext().execute_with(|| { + // genesis sets NextAssetId = 1 + assert_eq!(AssetRepository::get_next_asset_id::(), 1); + }); + } + + #[test] + fn asset_repo_increment_returns_current_then_advances() { + new_test_ext().execute_with(|| { + let id0 = AssetRepository::increment_asset_id::(); // returns 1, stores 2 + assert_eq!(id0, 1); + let id1 = AssetRepository::increment_asset_id::(); // returns 2, stores 3 + assert_eq!(id1, 2); + assert_eq!(AssetRepository::get_next_asset_id::(), 3); + }); + } + + #[test] + fn asset_repo_set_verified_true_and_false() { + new_test_ext().execute_with(|| { + AssetRepository::store_asset::(5, make_asset_metadata(5)); + assert!(AssetRepository::set_verified::(5, true)); + assert!(AssetRepository::get_asset::(5).unwrap().is_verified); + assert!(AssetRepository::set_verified::(5, false)); + assert!(!AssetRepository::get_asset::(5).unwrap().is_verified); + }); + } + + #[test] + fn asset_repo_set_verified_not_found_returns_false() { + new_test_ext().execute_with(|| { + assert!(!AssetRepository::set_verified::(999, true)); + }); + } + + // ── CommitmentRepository ───────────────────────────────────────────────── + + #[test] + fn commitment_repo_store_and_get_memo() { + new_test_ext().execute_with(|| { + let c = test_commitment(0x01); + let memo = test_memo(); + CommitmentRepository::store_memo::(c, memo.clone()); + assert_eq!(CommitmentRepository::get_memo::(&c), Some(memo)); + }); + } + + #[test] + fn commitment_repo_exists_returns_correct() { + new_test_ext().execute_with(|| { + let c = test_commitment(0x02); + assert!(!CommitmentRepository::exists::(&c)); + CommitmentRepository::store_memo::(c, test_memo()); + assert!(CommitmentRepository::exists::(&c)); + }); + } + + #[test] + fn commitment_repo_get_memo_none_when_missing() { + new_test_ext().execute_with(|| { + assert!(CommitmentRepository::get_memo::(&test_commitment(0xAA)).is_none()); + }); + } + + // ── MerkleRepository ───────────────────────────────────────────────────── + + #[test] + fn merkle_repo_poseidon_root_get_and_set() { + new_test_ext().execute_with(|| { + let root = [0xBBu8; 32]; + MerkleRepository::set_poseidon_root::(root); + assert_eq!(MerkleRepository::get_poseidon_root::(), root); + }); + } + + #[test] + fn merkle_repo_tree_size_zero_on_start() { + new_test_ext().execute_with(|| { + assert_eq!(MerkleRepository::get_tree_size::(), 0); + }); + } + + #[test] + fn merkle_repo_tree_size_set() { + new_test_ext().execute_with(|| { + MerkleRepository::set_tree_size::(7); + assert_eq!(MerkleRepository::get_tree_size::(), 7); + }); + } + + #[test] + fn merkle_repo_insert_and_get_leaf() { + new_test_ext().execute_with(|| { + let c = test_commitment(0x05); + MerkleRepository::insert_leaf::(0, c); + assert_eq!(MerkleRepository::get_leaf::(0), Some(c)); + assert_eq!(MerkleRepository::get_leaf::(1), None); + }); + } + + #[test] + fn merkle_repo_is_known_root_after_add() { + new_test_ext().execute_with(|| { + let root = [0xCCu8; 32]; + assert!(!MerkleRepository::is_known_root::(&root)); + MerkleRepository::add_historic_poseidon_root::(root); + assert!(MerkleRepository::is_known_root::(&root)); + }); + } + + #[test] + fn merkle_repo_remove_historic_root() { + new_test_ext().execute_with(|| { + let root = [0xDDu8; 32]; + MerkleRepository::add_historic_poseidon_root::(root); + assert!(MerkleRepository::is_known_root::(&root)); + MerkleRepository::remove_poseidon_historic_root::(&root); + assert!(!MerkleRepository::is_known_root::(&root)); + }); + } + + #[test] + fn merkle_repo_commitment_to_leaf_index_get_set() { + new_test_ext().execute_with(|| { + let c = test_commitment(0xDE); + // Before insertion: returns None + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&c), + None + ); + // After set: returns the stored index + MerkleRepository::set_commitment_leaf_index::(c, 7); + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&c), + Some(7) + ); + // A different commitment is unaffected + assert_eq!( + MerkleRepository::get_commitment_leaf_index::(&test_commitment(0xAB)), + None + ); + }); + } + + #[test] + fn merkle_repo_find_leaf_index_returns_correct_positions() { + new_test_ext().execute_with(|| { + let c0 = test_commitment(0xA1); + let c1 = test_commitment(0xA2); + MerkleRepository::insert_leaf::(0, c0); + MerkleRepository::set_commitment_leaf_index::(c0, 0); + MerkleRepository::insert_leaf::(1, c1); + MerkleRepository::set_commitment_leaf_index::(c1, 1); + MerkleRepository::set_tree_size::(2); + assert_eq!(MerkleRepository::find_leaf_index::(&c0), Some(0)); + assert_eq!(MerkleRepository::find_leaf_index::(&c1), Some(1)); + assert_eq!( + MerkleRepository::find_leaf_index::(&test_commitment(0xFF)), + None + ); + }); + } + + #[test] + fn merkle_repo_node_get_set_and_missing() { + new_test_ext().execute_with(|| { + assert_eq!(MerkleRepository::get_node::(0, 1, 0), None); + let node = [0xB1u8; 32]; + MerkleRepository::set_node::(0, 1, 0, node); + assert_eq!(MerkleRepository::get_node::(0, 1, 0), Some(node)); + // Distinct coordinates are independent + assert_eq!(MerkleRepository::get_node::(0, 1, 1), None); + assert_eq!(MerkleRepository::get_node::(0, 2, 0), None); + assert_eq!(MerkleRepository::get_node::(1, 1, 0), None); + }); + } + + // ── NullifierRepository ────────────────────────────────────────────────── + + #[test] + fn nullifier_repo_not_used_by_default() { + new_test_ext().execute_with(|| { + assert!(!NullifierRepository::is_used::(&test_nullifier(0x01))); + }); + } + + #[test] + fn nullifier_repo_mark_and_check_used() { + new_test_ext().execute_with(|| { + let n = test_nullifier(0x02); + NullifierRepository::mark_as_used::(n, 42u64); + assert!(NullifierRepository::is_used::(&n)); + }); + } + + #[test] + fn nullifier_repo_get_usage_block() { + new_test_ext().execute_with(|| { + let n = test_nullifier(0x03); + assert!(NullifierRepository::get_usage_block::(&n).is_none()); + NullifierRepository::mark_as_used::(n, 100u64); + assert_eq!( + NullifierRepository::get_usage_block::(&n), + Some(100u64) + ); + }); + } + + // ── PoolStatsRepository ─────────────────────────────────────────────────── + + #[test] + fn pool_stats_commitments_zero_by_default() { + new_test_ext().execute_with(|| { + assert_eq!( + PoolStatsRepository::get_total_commitments_inserted::(), + 0 + ); + }); + } + + #[test] + fn pool_stats_commitments_increments_correctly() { + new_test_ext().execute_with(|| { + PoolStatsRepository::increment_commitments_inserted::(); + PoolStatsRepository::increment_commitments_inserted::(); + PoolStatsRepository::increment_commitments_inserted::(); + assert_eq!( + PoolStatsRepository::get_total_commitments_inserted::(), + 3 + ); + }); + } + + #[test] + fn pool_stats_nullifiers_zero_by_default() { + new_test_ext().execute_with(|| { + assert_eq!(PoolStatsRepository::get_total_nullifiers_spent::(), 0); + }); + } + + #[test] + fn pool_stats_nullifiers_incremented_by_mark_as_used() { + new_test_ext().execute_with(|| { + let n0 = test_nullifier(0xE0); + let n1 = test_nullifier(0xE1); + NullifierRepository::mark_as_used::(n0, 1u64); + NullifierRepository::mark_as_used::(n1, 2u64); + assert_eq!(PoolStatsRepository::get_total_nullifiers_spent::(), 2); + }); + } + + // ── PoolBalanceRepository ───────────────────────────────────────────────── + + #[test] + fn pool_balance_repo_zero_by_default() { + new_test_ext().execute_with(|| { + assert_eq!(PoolBalanceRepository::get_asset_balance::(0), 0u128); + }); + } + + #[test] + fn pool_balance_repo_set_and_get() { + new_test_ext().execute_with(|| { + PoolBalanceRepository::set_asset_balance::(1, 500u128); + assert_eq!(PoolBalanceRepository::get_asset_balance::(1), 500u128); + }); + } + + #[test] + fn pool_balance_repo_increase_adds_amount() { + new_test_ext().execute_with(|| { + PoolBalanceRepository::set_asset_balance::(2, 100u128); + PoolBalanceRepository::increase_balance::(2, 50u128); + assert_eq!(PoolBalanceRepository::get_asset_balance::(2), 150u128); + }); + } + + #[test] + fn pool_balance_repo_decrease_subtracts_amount() { + new_test_ext().execute_with(|| { + PoolBalanceRepository::set_asset_balance::(3, 200u128); + PoolBalanceRepository::decrease_balance::(3, 80u128); + assert_eq!(PoolBalanceRepository::get_asset_balance::(3), 120u128); + }); + } + + #[test] + fn pool_balance_repo_decrease_to_exact_zero() { + new_test_ext().execute_with(|| { + // Decrementing by the full balance reaches zero without tripping the + // `defensive_assert!(balance >= amount)` guard. + PoolBalanceRepository::set_asset_balance::(4, 100u128); + PoolBalanceRepository::decrease_balance::(4, 100u128); + assert_eq!(PoolBalanceRepository::get_asset_balance::(4), 0u128); + }); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "Defensive")] + fn pool_balance_repo_decrease_below_balance_is_defensive() { + // An over-decrement (balance < amount) is an accounting bug: the defensive + // guard trips in test/debug. In production `saturating_sub` still floors at + // zero, but this path must never be reached under invariant (A). + new_test_ext().execute_with(|| { + PoolBalanceRepository::set_asset_balance::(4, 10u128); + PoolBalanceRepository::decrease_balance::(4, 100u128); + }); + } + + fn _suppress_unused(_: ConstU32<10>) {} +} diff --git a/frame/shielded-pool/src/storage/nullifier.rs b/frame/shielded-pool/src/storage/nullifier.rs new file mode 100644 index 00000000..8cf392e6 --- /dev/null +++ b/frame/shielded-pool/src/storage/nullifier.rs @@ -0,0 +1,28 @@ +//! Nullifier set — the double-spend guard. +//! +//! Insert-only by design: there is no removal path, and a nullifier maps to the +//! block it was spent in. Every spend checks membership before taking effect and +//! inserts within the same dispatch, so a rollback undoes both together. + +use super::stats::PoolStatsRepository; +use crate::pallet::{Config, NullifierSet}; +use frame_system::pallet_prelude::BlockNumberFor; + +// NullifierRepository + +pub struct NullifierRepository; + +impl NullifierRepository { + pub fn is_used(nullifier: &crate::types::Nullifier) -> bool { + NullifierSet::::contains_key(nullifier) + } + pub fn mark_as_used(nullifier: crate::types::Nullifier, block: BlockNumberFor) { + NullifierSet::::insert(nullifier, block); + PoolStatsRepository::increment_nullifiers_spent::(); + } + pub fn get_usage_block( + nullifier: &crate::types::Nullifier, + ) -> Option> { + NullifierSet::::get(nullifier) + } +} diff --git a/frame/shielded-pool/src/storage/stats.rs b/frame/shielded-pool/src/storage/stats.rs new file mode 100644 index 00000000..124f7f33 --- /dev/null +++ b/frame/shielded-pool/src/storage/stats.rs @@ -0,0 +1,26 @@ +//! Pool counters. +//! +//! Monotonic totals for commitments inserted and nullifiers spent. Saturating +//! throughout — a stalled counter is preferable to a wrapped one, and neither is +//! reachable at any realistic chain lifetime. + +use crate::pallet::{Config, TotalCommitmentsInserted, TotalNullifiersSpent}; + +// PoolStatsRepository + +pub struct PoolStatsRepository; + +impl PoolStatsRepository { + pub fn increment_commitments_inserted() { + TotalCommitmentsInserted::::mutate(|n| *n = n.saturating_add(1)); + } + pub fn get_total_commitments_inserted() -> u64 { + TotalCommitmentsInserted::::get() + } + pub fn increment_nullifiers_spent() { + TotalNullifiersSpent::::mutate(|n| *n = n.saturating_add(1)); + } + pub fn get_total_nullifiers_spent() -> u64 { + TotalNullifiersSpent::::get() + } +} From 3a27cf4495780510511f43d28cd44ac71049074f Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 15:42:08 -0400 Subject: [PATCH 4/8] refactor: separate responsibilities --- .../src/validate_unsigned/codes.rs | 39 ++++ .../mod.rs} | 171 ++++-------------- .../src/validate_unsigned/transfer.rs | 86 +++++++++ .../src/validate_unsigned/unshield.rs | 79 ++++++++ 4 files changed, 239 insertions(+), 136 deletions(-) create mode 100644 frame/shielded-pool/src/validate_unsigned/codes.rs rename frame/shielded-pool/src/{validate_unsigned.rs => validate_unsigned/mod.rs} (76%) create mode 100644 frame/shielded-pool/src/validate_unsigned/transfer.rs create mode 100644 frame/shielded-pool/src/validate_unsigned/unshield.rs diff --git a/frame/shielded-pool/src/validate_unsigned/codes.rs b/frame/shielded-pool/src/validate_unsigned/codes.rs new file mode 100644 index 00000000..d5d20177 --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/codes.rs @@ -0,0 +1,39 @@ +//! Pool-rejection codes for unsigned transactions. +//! +//! `InvalidTransaction::Custom` carries a bare `u8`, which reaches operators as +//! `Custom error: N` with no name attached. Naming the codes here keeps the two +//! validators from drifting apart and makes a rejection diagnosable from a log +//! line alone. +//! +//! Codes are part of the observable interface: a wallet or relayer may branch on +//! them, so **never reuse a number for a different meaning** — retire it and add +//! a new one instead. + +use sp_runtime::transaction_validity::InvalidTransaction; + +/// The Merkle root is not in the historic window and does not anchor a sealed +/// tree, so no proof can be verified against it. +pub const UNKNOWN_ROOT: u8 = 1; + +/// Every input nullifier is the dummy sentinel, i.e. the transaction spends +/// nothing. Rejected as anti-spam: it would insert commitments for free. +pub const ALL_INPUTS_DUMMY: u8 = 2; + +/// `amount + fee` overflows the balance type. +/// +/// Deliberately distinct from [`ALL_INPUTS_DUMMY`]: both meanings once shared +/// code 2 across the two validators, which made a rejection ambiguous to anyone +/// reading a node log. +pub const AMOUNT_OVERFLOW: u8 = 4; + +/// The pool does not hold enough of the asset to cover `amount + fee`. +pub const INSUFFICIENT_POOL_BALANCE: u8 = 3; + +/// The circuit version is not registered in the verifier, so the proof could +/// never verify. Checked first — it is the cheapest gate of all. +pub const UNSUPPORTED_CIRCUIT_VERSION: u8 = 10; + +/// Build an `InvalidTransaction` from one of the codes above. +pub fn reject(code: u8) -> InvalidTransaction { + InvalidTransaction::Custom(code) +} diff --git a/frame/shielded-pool/src/validate_unsigned.rs b/frame/shielded-pool/src/validate_unsigned/mod.rs similarity index 76% rename from frame/shielded-pool/src/validate_unsigned.rs rename to frame/shielded-pool/src/validate_unsigned/mod.rs index 59a6524d..96417673 100644 --- a/frame/shielded-pool/src/validate_unsigned.rs +++ b/frame/shielded-pool/src/validate_unsigned/mod.rs @@ -1,156 +1,55 @@ //! Unsigned transaction validation for `private_transfer` and `unshield`. //! -//! These are lightweight anti-spam checks that run before the transaction -//! enters the pool. Full ZK proof verification happens inside each extrinsic. +//! These are lightweight anti-spam checks that run before a transaction enters +//! the pool. Full ZK proof verification happens inside each extrinsic — doing it +//! here would let anyone burn a node's CPU for free, since unsigned submissions +//! cost nothing to make. //! -//! Splitting this out of `lib.rs` makes the validation logic independently -//! testable without needing the full pallet mock environment. - -use crate::{ - pallet::{BalanceOf, Config, NullifierSet, PoolBalancePerAsset}, - storage::MerkleRepository, - types::{Hash, Nullifier}, -}; -use frame_support::pallet_prelude::*; -use pallet_relayer::RelayerInterface as _; -use pallet_zk_verifier::ZkVerifierPort as _; -use parity_scale_codec::Encode; -use sp_runtime::{ - SaturatedConversion, - transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, -}; - -/// On-chain circuit ids used for the version guard (mirror the zk-verifier's -/// `CircuitId` constants: TRANSFER = 1, UNSHIELD = 2). -const CIRCUIT_TRANSFER: u32 = 1; -const CIRCUIT_UNSHIELD: u32 = 2; +//! Every check here is also re-done in the dispatchable. That is deliberate: a +//! check performed only at admission could be skipped by a malicious block +//! author, so admission may reject more than execution but never less. +//! +//! - [`codes`] — named pool-rejection codes shared by both validators. +//! - [`transfer`] — admission for `private_transfer`. +//! - [`unshield`] — admission for `unshield`. + +pub mod codes; +pub mod transfer; +pub mod unshield; + +pub use transfer::validate_private_transfer; +pub use unshield::validate_unshield; /// How long an unsigned transaction stays valid in the pool, in blocks. Bounded /// so a transaction that never gets included does not linger indefinitely. /// /// `Config::RootRetentionBlocks` must exceed this (checked in `integrity_test`): -/// a root has to outlive every transaction admitted against it. +/// a root has to outlive every transaction admitted against it, or a spend can +/// pass admission, propagate, and only then revert with `UnknownMerkleRoot`. pub(crate) const TX_LONGEVITY: u64 = 64; -/// Validate an incoming `private_transfer` unsigned transaction. -pub fn validate_private_transfer( - merkle_root: &Hash, - nullifiers: &BoundedVec>, - fee: &BalanceOf, - relayer: &Option, - circuit_version: u32, -) -> TransactionValidity { - // Anti-spam: reject an unsupported circuit version before pool admission. - if !T::ZkVerifier::is_supported_version(CIRCUIT_TRANSFER, circuit_version) { - return InvalidTransaction::Custom(10).into(); - } - - // Anti-spam: fee must meet minimum relay fee - let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - if *fee < min_fee { - return InvalidTransaction::Payment.into(); - } - - // Reject unknown Merkle roots - if !MerkleRepository::is_known_root::(merkle_root) { - return InvalidTransaction::Custom(1).into(); - } - - // Reject already-spent nullifiers (skip dummy nullifiers — value zero, forced by circuit) - for nullifier in nullifiers.iter() { - if nullifier.0 == [0u8; 32] { - continue; // dummy input — never inserted in the set, cannot be stale - } - if NullifierSet::::contains_key(nullifier) { - return InvalidTransaction::Stale.into(); - } - } - - // Reject transactions where all nullifiers are dummy (both inputs value=0). - // This prevents free Merkle tree spam (2 commitments inserted at zero cost). - if nullifiers.iter().all(|n| n.0 == [0u8; 32]) { - return InvalidTransaction::Custom(2).into(); - } - - // Exclude dummy nullifiers (zero) from provides — they carry no identity. - // Bind the fee recipient (`relayer`) into the tag so a variant differing only - // in `relayer` is a distinct pool entry and cannot silently replace the honest - // tx. The shared nullifier tag already makes same-nullifier variants mutually - // exclusive (first-seen wins at equal fee); this hardens that boundary. - let mut provides: alloc::vec::Vec> = nullifiers - .iter() - .filter(|n| n.0 != [0u8; 32]) - .map(|n| n.encode()) - .collect(); - provides.push(relayer.encode()); - - ValidTransaction::with_tag_prefix("ShieldedPoolTransfer") - .priority((*fee).saturated_into()) - .longevity(TX_LONGEVITY) - .and_provides(provides) - .propagate(true) - .build() -} - -/// Validate an incoming `unshield` unsigned transaction. -pub fn validate_unshield( - merkle_root: &Hash, - nullifier: &Nullifier, - asset_id: &u32, - amount: &BalanceOf, - fee: &BalanceOf, - relayer: &Option, - circuit_version: u32, -) -> TransactionValidity { - // Anti-spam: reject an unsupported circuit version before pool admission. - if !T::ZkVerifier::is_supported_version(CIRCUIT_UNSHIELD, circuit_version) { - return InvalidTransaction::Custom(10).into(); - } - - // Anti-spam: fee must meet minimum relay fee - let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - if *fee < min_fee { - return InvalidTransaction::Payment.into(); - } - - // Reject unknown Merkle roots - if !MerkleRepository::is_known_root::(merkle_root) { - return InvalidTransaction::Custom(1).into(); - } - - // Reject already-spent nullifier - if NullifierSet::::contains_key(nullifier) { - return InvalidTransaction::Stale.into(); - } - - // Reject if pool balance is insufficient - let total = amount - .checked_add(fee) - .ok_or(InvalidTransaction::Custom(2))?; - if PoolBalancePerAsset::::get(asset_id) < total { - return InvalidTransaction::Custom(3).into(); - } - - // Bind `relayer` into the tag alongside the nullifier: a variant differing only - // in the fee recipient is a distinct pool entry, so it cannot silently replace - // the honest tx. Same-nullifier variants stay mutually exclusive (first-seen - // wins at equal fee). - ValidTransaction::with_tag_prefix("ShieldedPoolUnshield") - .priority((*fee).saturated_into()) - .longevity(TX_LONGEVITY) - .and_provides([nullifier.encode(), relayer.encode()]) - .propagate(true) - .build() -} - #[cfg(test)] mod tests { - use super::*; + use super::{TX_LONGEVITY, validate_private_transfer, validate_unshield}; use crate::{ mock::{Test, new_test_ext}, storage::{MerkleRepository, NullifierRepository, PoolBalanceRepository}, types::Nullifier, }; + use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + + /// Rejection codes reach wallets and relayers as bare `Custom(N)`, so they are + /// part of the observable interface: pin them here, and retire a number rather + /// than reuse it for a new meaning. + #[test] + fn rejection_codes_are_stable() { + use super::codes; + assert_eq!(codes::UNKNOWN_ROOT, 1); + assert_eq!(codes::ALL_INPUTS_DUMMY, 2); + assert_eq!(codes::INSUFFICIENT_POOL_BALANCE, 3); + assert_eq!(codes::AMOUNT_OVERFLOW, 4); + assert_eq!(codes::UNSUPPORTED_CIRCUIT_VERSION, 10); + } const KNOWN_ROOT: [u8; 32] = [0x11u8; 32]; diff --git a/frame/shielded-pool/src/validate_unsigned/transfer.rs b/frame/shielded-pool/src/validate_unsigned/transfer.rs new file mode 100644 index 00000000..400ae412 --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/transfer.rs @@ -0,0 +1,86 @@ +//! Pool admission for `private_transfer`. +//! +//! Checks run cheapest-first — a version lookup, a fee compare, then two point +//! reads — so flooding the pool with invalid transactions stays cheap to reject. +//! No ZK verification happens here; that is the extrinsic's job. + +use super::{ + TX_LONGEVITY, + codes::{self, reject}, +}; +use crate::{ + pallet::{BalanceOf, Config, NullifierSet}, + storage::MerkleRepository, + types::{Hash, Nullifier}, +}; +use frame_support::pallet_prelude::*; +use pallet_relayer::RelayerInterface as _; +use pallet_zk_verifier::ZkVerifierPort as _; +use parity_scale_codec::Encode; +use sp_runtime::{ + SaturatedConversion, + transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, +}; + +/// On-chain circuit id for the transfer/unshield version guard (mirrors the +/// zk-verifier's `CircuitId` constants). +const CIRCUIT_TRANSFER: u32 = 1; + +pub fn validate_private_transfer( + merkle_root: &Hash, + nullifiers: &BoundedVec>, + fee: &BalanceOf, + relayer: &Option, + circuit_version: u32, +) -> TransactionValidity { + // Anti-spam: reject an unsupported circuit version before pool admission. + if !T::ZkVerifier::is_supported_version(CIRCUIT_TRANSFER, circuit_version) { + return reject(codes::UNSUPPORTED_CIRCUIT_VERSION).into(); + } + + // Anti-spam: fee must meet minimum relay fee + let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + if *fee < min_fee { + return InvalidTransaction::Payment.into(); + } + + // Reject unknown Merkle roots + if !MerkleRepository::is_known_root::(merkle_root) { + return reject(codes::UNKNOWN_ROOT).into(); + } + + // Reject already-spent nullifiers (skip dummy nullifiers — value zero, forced by circuit) + for nullifier in nullifiers.iter() { + if nullifier.0 == [0u8; 32] { + continue; // dummy input — never inserted in the set, cannot be stale + } + if NullifierSet::::contains_key(nullifier) { + return InvalidTransaction::Stale.into(); + } + } + + // Reject transactions where all nullifiers are dummy (both inputs value=0). + // This prevents free Merkle tree spam (2 commitments inserted at zero cost). + if nullifiers.iter().all(|n| n.0 == [0u8; 32]) { + return reject(codes::ALL_INPUTS_DUMMY).into(); + } + + // Exclude dummy nullifiers (zero) from provides — they carry no identity. + // Bind the fee recipient (`relayer`) into the tag so a variant differing only + // in `relayer` is a distinct pool entry and cannot silently replace the honest + // tx. The shared nullifier tag already makes same-nullifier variants mutually + // exclusive (first-seen wins at equal fee); this hardens that boundary. + let mut provides: alloc::vec::Vec> = nullifiers + .iter() + .filter(|n| n.0 != [0u8; 32]) + .map(|n| n.encode()) + .collect(); + provides.push(relayer.encode()); + + ValidTransaction::with_tag_prefix("ShieldedPoolTransfer") + .priority((*fee).saturated_into()) + .longevity(TX_LONGEVITY) + .and_provides(provides) + .propagate(true) + .build() +} diff --git a/frame/shielded-pool/src/validate_unsigned/unshield.rs b/frame/shielded-pool/src/validate_unsigned/unshield.rs new file mode 100644 index 00000000..2cda0cce --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/unshield.rs @@ -0,0 +1,79 @@ +//! Pool admission for `unshield`. +//! +//! Mirrors [`super::transfer`], plus a pool-solvency check. That check is +//! advisory only: the balance can move between admission and execution, so the +//! extrinsic re-verifies it. Rejecting early just avoids gossiping a spend the +//! pool cannot cover. + +use super::{ + TX_LONGEVITY, + codes::{self, reject}, +}; +use crate::{ + pallet::{BalanceOf, Config, NullifierSet, PoolBalancePerAsset}, + storage::MerkleRepository, + types::{Hash, Nullifier}, +}; +use frame_support::pallet_prelude::*; +use pallet_relayer::RelayerInterface as _; +use pallet_zk_verifier::ZkVerifierPort as _; +use parity_scale_codec::Encode; +use sp_runtime::{ + SaturatedConversion, + transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, +}; + +/// On-chain circuit id for the transfer/unshield version guard (mirrors the +/// zk-verifier's `CircuitId` constants). +const CIRCUIT_UNSHIELD: u32 = 2; + +/// Validate an incoming `unshield` unsigned transaction. +pub fn validate_unshield( + merkle_root: &Hash, + nullifier: &Nullifier, + asset_id: &u32, + amount: &BalanceOf, + fee: &BalanceOf, + relayer: &Option, + circuit_version: u32, +) -> TransactionValidity { + // Anti-spam: reject an unsupported circuit version before pool admission. + if !T::ZkVerifier::is_supported_version(CIRCUIT_UNSHIELD, circuit_version) { + return reject(codes::UNSUPPORTED_CIRCUIT_VERSION).into(); + } + + // Anti-spam: fee must meet minimum relay fee + let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + if *fee < min_fee { + return InvalidTransaction::Payment.into(); + } + + // Reject unknown Merkle roots + if !MerkleRepository::is_known_root::(merkle_root) { + return reject(codes::UNKNOWN_ROOT).into(); + } + + // Reject already-spent nullifier + if NullifierSet::::contains_key(nullifier) { + return InvalidTransaction::Stale.into(); + } + + // Reject if pool balance is insufficient + let total = amount + .checked_add(fee) + .ok_or(reject(codes::AMOUNT_OVERFLOW))?; + if PoolBalancePerAsset::::get(asset_id) < total { + return reject(codes::INSUFFICIENT_POOL_BALANCE).into(); + } + + // Bind `relayer` into the tag alongside the nullifier: a variant differing only + // in the fee recipient is a distinct pool entry, so it cannot silently replace + // the honest tx. Same-nullifier variants stay mutually exclusive (first-seen + // wins at equal fee). + ValidTransaction::with_tag_prefix("ShieldedPoolUnshield") + .priority((*fee).saturated_into()) + .longevity(TX_LONGEVITY) + .and_provides([nullifier.encode(), relayer.encode()]) + .propagate(true) + .build() +} From d22c2043edde1b36b6a1970c36be992b980ea4a6 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 16:00:26 -0400 Subject: [PATCH 5/8] refactor: separate responsibilities --- frame/shielded-pool/src/merkle/mod.rs | 3 +- frame/shielded-pool/src/types.rs | 719 ------------------------ frame/shielded-pool/src/types/asset.rs | 68 +++ frame/shielded-pool/src/types/ids.rs | 179 ++++++ frame/shielded-pool/src/types/memo.rs | 120 ++++ frame/shielded-pool/src/types/merkle.rs | 31 + frame/shielded-pool/src/types/mod.rs | 294 ++++++++++ frame/shielded-pool/src/types/note.rs | 76 +++ 8 files changed, 769 insertions(+), 721 deletions(-) delete mode 100644 frame/shielded-pool/src/types.rs create mode 100644 frame/shielded-pool/src/types/asset.rs create mode 100644 frame/shielded-pool/src/types/ids.rs create mode 100644 frame/shielded-pool/src/types/memo.rs create mode 100644 frame/shielded-pool/src/types/merkle.rs create mode 100644 frame/shielded-pool/src/types/mod.rs create mode 100644 frame/shielded-pool/src/types/note.rs diff --git a/frame/shielded-pool/src/merkle/mod.rs b/frame/shielded-pool/src/merkle/mod.rs index e5ac1892..c357e568 100644 --- a/frame/shielded-pool/src/merkle/mod.rs +++ b/frame/shielded-pool/src/merkle/mod.rs @@ -37,7 +37,7 @@ pub(crate) const MAX_ROOTS_PRUNED_PER_INSERT: usize = 4; mod tests { use super::{ MAX_ROOTS_PRUNED_PER_INSERT, - batch::{compute_root_from_leaves, compute_root_from_leaves_poseidon}, + batch::compute_root_from_leaves_poseidon, hashing::{get_zero_hash_cached, hash_pair, hash_pair_poseidon, zero_hash_at_level}, service::MerkleTreeService, tree::IncrementalMerkleTree, @@ -48,7 +48,6 @@ mod tests { storage::MerkleRepository, types::{Commitment, Hash}, }; - use frame_support::traits::Hooks; // ── hash functions ────────────────────────────────────────────────────── diff --git a/frame/shielded-pool/src/types.rs b/frame/shielded-pool/src/types.rs deleted file mode 100644 index 9a6e32f7..00000000 --- a/frame/shielded-pool/src/types.rs +++ /dev/null @@ -1,719 +0,0 @@ -//! Pallet types — all structs, enums, newtypes and type aliases. -//! -//! Concentrates every type defined by `pallet-shielded-pool` in a single file -//! so that any consumer (operations, storage, tests) can reach them with -//! `use crate::types::*`. - -// ── Codec & FRAME imports ────────────────────────────────────────────────── -use frame_support::{BoundedVec, pallet_prelude::*}; -use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; -use scale_info::TypeInfo; -use sp_core::H256; -use sp_runtime::RuntimeDebug; -use sp_std::vec::Vec; - -// ════════════════════════════════════════════════════════════════════════════ -// Primitive value types -// ════════════════════════════════════════════════════════════════════════════ - -/// A 32-byte hash used for Merkle roots, cryptographic hashes and identifiers. -pub type Hash = [u8; 32]; - -// ════════════════════════════════════════════════════════════════════════════ -// Commitment -// ════════════════════════════════════════════════════════════════════════════ - -/// A commitment to a private note. -/// -/// Computed as: `Poseidon(value, asset_id, owner_pubkey, blinding)` -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct Commitment(pub [u8; 32]); - -impl Commitment { - pub fn new(bytes: [u8; 32]) -> Self { - Self(bytes) - } - pub fn is_valid(&self) -> bool { - self.0 != [0u8; 32] - } - pub fn is_zero(&self) -> bool { - self.0 == [0u8; 32] - } - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - pub fn into_bytes(self) -> [u8; 32] { - self.0 - } -} - -impl From<[u8; 32]> for Commitment { - fn from(b: [u8; 32]) -> Self { - Self::new(b) - } -} -impl From for Commitment { - fn from(h: H256) -> Self { - Self::new(h.0) - } -} -impl AsRef<[u8]> for Commitment { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Nullifier -// ════════════════════════════════════════════════════════════════════════════ - -/// A nullifier identifying a spent note. -/// -/// Computed as: `Poseidon(commitment, spending_key)` -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct Nullifier(pub [u8; 32]); - -impl Nullifier { - pub fn new(bytes: [u8; 32]) -> Self { - Self(bytes) - } - pub fn validate(&self) -> bool { - self.0 != [0u8; 32] - } - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - pub fn into_bytes(self) -> [u8; 32] { - self.0 - } -} - -impl From<[u8; 32]> for Nullifier { - fn from(b: [u8; 32]) -> Self { - Self::new(b) - } -} -impl From for Nullifier { - fn from(h: H256) -> Self { - Self::new(h.0) - } -} -impl AsRef<[u8]> for Nullifier { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// AssetId -// ════════════════════════════════════════════════════════════════════════════ - -/// Identifier for an asset in the shielded pool. -/// -/// `0` = native (ORB), `1+` = registered external assets. -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default, - PartialOrd, - Ord -)] -pub struct AssetId(pub u32); - -impl AssetId { - pub fn new(id: u32) -> Self { - Self(id) - } - pub fn native() -> Self { - Self(0) - } - pub fn is_native(&self) -> bool { - self.0 == 0 - } - pub fn inner(&self) -> u32 { - self.0 - } - pub fn is_valid(&self) -> bool { - true - } -} - -impl From for AssetId { - fn from(id: u32) -> Self { - Self(id) - } -} -impl From for u32 { - fn from(a: AssetId) -> Self { - a.0 - } -} - -impl core::fmt::Display for AssetId { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - if self.is_native() { - write!(f, "Native Asset (0)") - } else { - write!(f, "Asset {}", self.0) - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Note (off-chain type, used by wallets) -// ════════════════════════════════════════════════════════════════════════════ - -/// A private note in the shielded pool (UTXO). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Note { - value: u128, - owner_pubkey: Hash, - blinding: Hash, - asset_id: u32, -} - -impl Note { - pub fn new(value: u128, owner_pubkey: Hash, blinding: Hash) -> Result { - if value == 0 { - return Err("Note value cannot be zero"); - } - if owner_pubkey == [0u8; 32] { - return Err("Owner public key cannot be zero"); - } - if blinding == [0u8; 32] { - return Err("Blinding factor cannot be zero"); - } - Ok(Self { - value, - owner_pubkey, - blinding, - asset_id: 0, - }) - } - - pub fn new_with_asset( - value: u128, - owner_pubkey: Hash, - blinding: Hash, - asset_id: u32, - ) -> Result { - let mut note = Self::new(value, owner_pubkey, blinding)?; - note.asset_id = asset_id; - Ok(note) - } - - pub fn value(&self) -> u128 { - self.value - } - pub fn owner_pubkey(&self) -> &Hash { - &self.owner_pubkey - } - pub fn blinding(&self) -> &Hash { - &self.blinding - } - pub fn asset_id(&self) -> u32 { - self.asset_id - } - - pub fn to_bytes(&self) -> Vec { - let mut bytes = Vec::new(); - bytes.extend_from_slice(&self.value.to_le_bytes()); - bytes.extend_from_slice(&self.asset_id.to_le_bytes()); - bytes.extend_from_slice(&self.owner_pubkey); - bytes.extend_from_slice(&self.blinding); - bytes - } - - pub fn is_valid(&self) -> bool { - self.value > 0 && self.owner_pubkey != [0u8; 32] && self.blinding != [0u8; 32] - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerklePath -// ════════════════════════════════════════════════════════════════════════════ - -pub const DEFAULT_TREE_DEPTH: usize = 20; -pub const MAX_TREE_DEPTH: u32 = 20; - -/// A Merkle path (siblings from leaf to root). -#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug, PartialEq, Eq)] -pub struct MerklePath { - pub siblings: [[u8; 32]; DEPTH], - pub indices: [u8; DEPTH], -} - -impl Default for MerklePath { - fn default() -> Self { - Self { - siblings: [[0u8; 32]; DEPTH], - indices: [0u8; DEPTH], - } - } -} - -pub type DefaultMerklePath = MerklePath; - -// ════════════════════════════════════════════════════════════════════════════ -// EncryptedMemo (concrete, FRAME-compatible — used in storage & extrinsics) -// ════════════════════════════════════════════════════════════════════════════ - -/// Max encrypted memo size: `nonce(12) + ciphertext(120) + MAC(16) + ephPk(32) = 180`. -pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 180; - -/// Encrypted memo attached to a commitment (ChaCha20-Poly1305). -#[derive( - Clone, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct EncryptedMemo(pub BoundedVec>); - -impl EncryptedMemo { - pub fn new(data: Vec) -> Result { - BoundedVec::try_from(data) - .map(Self) - .map_err(|_| "Memo size exceeds maximum") - } - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - pub fn is_valid_size(&self) -> bool { - !self.0.is_empty() - } - pub fn len(&self) -> usize { - self.0.len() - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() != MAX_ENCRYPTED_MEMO_SIZE as usize { - return Err("Invalid memo size"); - } - Self::new(bytes.to_vec()) - } - pub fn nonce(&self) -> &[u8] { - // Invariant: EncryptedMemo is always exactly MAX_ENCRYPTED_MEMO_SIZE bytes after - // construction. from_bytes() enforces this; the else branch is unreachable in practice. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 12 { - &self.0[..12] - } else { - &[] - } - } - pub fn ciphertext(&self) -> &[u8] { - // Invariant: see nonce(). ciphertext occupies bytes 12..132. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 132 { - &self.0[12..132] - } else { - &[] - } - } - pub fn tag(&self) -> &[u8] { - // Layout: nonce(0..12) | ciphertext(12..132) | tag/MAC(132..148) | ephPk(148..180) - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 148 { - &self.0[132..148] - } else { - &[] - } - } - pub fn eph_pk(&self) -> &[u8] { - // Ephemeral BabyJubJub public key (packed, LE) occupies bytes 148..180. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 180 { - &self.0[148..180] - } else { - &[] - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// AssetMetadata -// ════════════════════════════════════════════════════════════════════════════ - -/// Asset metadata for multi-asset shielded pool. -#[derive( - Clone, - PartialEq, - Eq, - Encode, - Decode, - MaxEncodedLen, - TypeInfo, - RuntimeDebug -)] -pub struct AssetMetadata { - pub id: u32, - pub name: BoundedVec>, - pub symbol: BoundedVec>, - pub decimals: u8, - pub is_verified: bool, - pub contract_address: Option<[u8; 20]>, - pub created_at: BlockNumber, - pub creator: AccountId, -} - -impl AssetMetadata { - pub fn new( - id: u32, - name: BoundedVec>, - symbol: BoundedVec>, - decimals: u8, - created_at: BlockNumber, - creator: AccountId, - ) -> Self { - Self { - id, - name, - symbol, - decimals, - is_verified: false, - contract_address: None, - created_at, - creator, - } - } - pub fn verify(&mut self) { - self.is_verified = true; - } - pub fn unverify(&mut self) { - self.is_verified = false; - } - pub fn is_verified(&self) -> bool { - self.is_verified - } - pub fn set_contract_address(&mut self, address: [u8; 20]) { - self.contract_address = Some(address); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Commitment ────────────────────────────────────────────────────────── - - #[test] - fn commitment_new_stores_bytes() { - let c = Commitment::new([0x01u8; 32]); - assert_eq!(c.0, [0x01u8; 32]); - } - - #[test] - fn commitment_is_valid_non_zero_only() { - assert!(Commitment::new([0x01u8; 32]).is_valid()); - assert!(!Commitment::new([0x00u8; 32]).is_valid()); - } - - #[test] - fn commitment_is_zero_checks_all_zero_bytes() { - assert!(Commitment::new([0x00u8; 32]).is_zero()); - assert!(!Commitment::new([0x01u8; 32]).is_zero()); - } - - #[test] - fn commitment_as_bytes_and_into_bytes() { - let bytes = [0x42u8; 32]; - let c = Commitment::new(bytes); - assert_eq!(c.as_bytes(), &bytes); - assert_eq!(c.into_bytes(), bytes); - } - - #[test] - fn commitment_from_array() { - let bytes = [0x99u8; 32]; - let c: Commitment = bytes.into(); - assert_eq!(c.0, bytes); - } - - #[test] - fn commitment_default_is_zero() { - assert_eq!(Commitment::default(), Commitment::new([0u8; 32])); - } - - // ── Nullifier ─────────────────────────────────────────────────────────── - - #[test] - fn nullifier_new_and_accessors() { - let bytes = [0x10u8; 32]; - let n = Nullifier::new(bytes); - assert_eq!(n.as_bytes(), &bytes); - assert_eq!(n.into_bytes(), bytes); - } - - #[test] - fn nullifier_validate_non_zero_only() { - assert!(Nullifier::new([0x01u8; 32]).validate()); - assert!(!Nullifier::new([0x00u8; 32]).validate()); - } - - #[test] - fn nullifier_from_array() { - let bytes = [0xAAu8; 32]; - let n: Nullifier = bytes.into(); - assert_eq!(n.0, bytes); - } - - #[test] - fn nullifier_default_is_zero() { - assert_eq!(Nullifier::default(), Nullifier::new([0u8; 32])); - } - - // ── AssetId ───────────────────────────────────────────────────────────── - - #[test] - fn asset_id_new_and_inner() { - let a = AssetId::new(42); - assert_eq!(a.inner(), 42); - assert_eq!(a.0, 42); - } - - #[test] - fn asset_id_native_is_zero() { - let n = AssetId::native(); - assert!(n.is_native()); - assert_eq!(n.inner(), 0); - } - - #[test] - fn asset_id_not_native_when_nonzero() { - assert!(!AssetId::new(1).is_native()); - assert!(!AssetId::new(99).is_native()); - } - - #[test] - fn asset_id_from_and_into_u32() { - let a: AssetId = 7u32.into(); - assert_eq!(a.inner(), 7); - let v: u32 = AssetId::new(13).into(); - assert_eq!(v, 13); - } - - #[test] - fn asset_id_is_valid_always_true() { - assert!(AssetId::new(0).is_valid()); - assert!(AssetId::new(u32::MAX).is_valid()); - } - - // ── Note ──────────────────────────────────────────────────────────────── - - #[test] - fn note_new_valid_sets_all_fields() { - let pk = [0x01u8; 32]; - let bl = [0x02u8; 32]; - let note = Note::new(100, pk, bl).unwrap(); - assert_eq!(note.value(), 100); - assert_eq!(note.owner_pubkey(), &pk); - assert_eq!(note.blinding(), &bl); - assert_eq!(note.asset_id(), 0); - assert!(note.is_valid()); - } - - #[test] - fn note_new_zero_value_fails() { - assert!(Note::new(0, [0x01u8; 32], [0x02u8; 32]).is_err()); - } - - #[test] - fn note_new_zero_pubkey_fails() { - assert!(Note::new(100, [0u8; 32], [0x02u8; 32]).is_err()); - } - - #[test] - fn note_new_zero_blinding_fails() { - assert!(Note::new(100, [0x01u8; 32], [0u8; 32]).is_err()); - } - - #[test] - fn note_new_with_asset_sets_asset_id() { - let note = Note::new_with_asset(50, [0x01u8; 32], [0x02u8; 32], 5).unwrap(); - assert_eq!(note.asset_id(), 5); - assert_eq!(note.value(), 50); - } - - #[test] - fn note_to_bytes_has_correct_length() { - let note = Note::new(100, [0x01u8; 32], [0x02u8; 32]).unwrap(); - // 16 (value u128) + 4 (asset_id u32) + 32 (pubkey) + 32 (blinding) = 84 - assert_eq!(note.to_bytes().len(), 84); - } - - #[test] - fn note_asset_id_serializes_as_4_bytes() { - // asset_id must be 4 LE bytes to match the circuit's public signal - // (commitment[0..32] | value[32..40] | asset_id[40..44] | ...). - let note = Note::new_with_asset(100, [0x01u8; 32], [0x02u8; 32], 0x01020304).unwrap(); - let bytes = note.to_bytes(); - assert_eq!(&bytes[16..20], &0x01020304u32.to_le_bytes()); - } - - // ── MerklePath ────────────────────────────────────────────────────────── - - #[test] - fn merkle_path_default_all_zeros() { - let path = DefaultMerklePath::default(); - assert_eq!(path.siblings, [[0u8; 32]; DEFAULT_TREE_DEPTH]); - assert_eq!(path.indices, [0u8; DEFAULT_TREE_DEPTH]); - } - - #[test] - fn merkle_path_generic_depth() { - let path = MerklePath::<5>::default(); - assert_eq!(path.siblings.len(), 5); - assert_eq!(path.indices.len(), 5); - } - - // ── EncryptedMemo ──────────────────────────────────────────────────────── - - #[test] - fn encrypted_memo_new_valid_size() { - let data = vec![0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::new(data).unwrap(); - assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); - } - - #[test] - fn encrypted_memo_new_exceeds_max_fails() { - let data = vec![0x01u8; (MAX_ENCRYPTED_MEMO_SIZE + 1) as usize]; - assert!(EncryptedMemo::new(data).is_err()); - } - - #[test] - fn encrypted_memo_from_bytes_exact_size() { - let bytes = [0x03u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); - assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); - } - - #[test] - fn encrypted_memo_from_bytes_wrong_size_fails() { - assert!(EncryptedMemo::from_bytes(&[0x01u8; 32]).is_err()); - assert!(EncryptedMemo::from_bytes(&[0x01u8; 50]).is_err()); - } - - #[test] - fn encrypted_memo_nonce_ciphertext_tag_slices() { - let bytes = [0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); - assert_eq!(memo.nonce().len(), 12); - assert_eq!(memo.ciphertext().len(), 120); - assert_eq!(memo.tag().len(), 16); - assert_eq!(memo.eph_pk().len(), 32); - } - - #[test] - fn encrypted_memo_default_is_empty() { - let empty = EncryptedMemo::default(); - assert!(empty.is_empty()); - assert!(!empty.is_valid_size()); - } - - #[test] - fn encrypted_memo_full_is_not_empty() { - let full = EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(); - assert!(!full.is_empty()); - assert!(full.is_valid_size()); - } - - // ── AssetMetadata ─────────────────────────────────────────────────────── - - #[test] - fn asset_metadata_new_not_verified_by_default() { - let name: BoundedVec> = BoundedVec::try_from(b"Test".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"TST".to_vec()).unwrap(); - let meta: AssetMetadata = AssetMetadata::new(1, name, sym, 18, 0u64, 99u64); - assert!(!meta.is_verified()); - assert_eq!(meta.id, 1); - assert_eq!(meta.decimals, 18); - assert_eq!(meta.contract_address, None); - } - - #[test] - fn asset_metadata_verify_unverify() { - let name: BoundedVec> = BoundedVec::try_from(b"Token".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"TKN".to_vec()).unwrap(); - let mut meta: AssetMetadata = AssetMetadata::new(2, name, sym, 8, 10u64, 1u64); - assert!(!meta.is_verified()); - meta.verify(); - assert!(meta.is_verified()); - meta.unverify(); - assert!(!meta.is_verified()); - } - - #[test] - fn asset_metadata_set_contract_address() { - let name: BoundedVec> = BoundedVec::try_from(b"ERC20".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"ERC".to_vec()).unwrap(); - let mut meta: AssetMetadata = AssetMetadata::new(3, name, sym, 18, 0u64, 1u64); - let addr = [0xABu8; 20]; - meta.set_contract_address(addr); - assert_eq!(meta.contract_address, Some(addr)); - } -} diff --git a/frame/shielded-pool/src/types/asset.rs b/frame/shielded-pool/src/types/asset.rs new file mode 100644 index 00000000..779bbd85 --- /dev/null +++ b/frame/shielded-pool/src/types/asset.rs @@ -0,0 +1,68 @@ +//! Registered asset metadata. +//! +//! An asset must be both registered and verified before it can be shielded or +//! unshielded; `is_verified` doubles as a governance kill-switch for an asset +//! found to be compromised. + +use frame_support::{BoundedVec, pallet_prelude::*}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; + +// AssetMetadata + +/// Asset metadata for multi-asset shielded pool. +#[derive( + Clone, + PartialEq, + Eq, + Encode, + Decode, + MaxEncodedLen, + TypeInfo, + RuntimeDebug +)] +pub struct AssetMetadata { + pub id: u32, + pub name: BoundedVec>, + pub symbol: BoundedVec>, + pub decimals: u8, + pub is_verified: bool, + pub contract_address: Option<[u8; 20]>, + pub created_at: BlockNumber, + pub creator: AccountId, +} + +impl AssetMetadata { + pub fn new( + id: u32, + name: BoundedVec>, + symbol: BoundedVec>, + decimals: u8, + created_at: BlockNumber, + creator: AccountId, + ) -> Self { + Self { + id, + name, + symbol, + decimals, + is_verified: false, + contract_address: None, + created_at, + creator, + } + } + pub fn verify(&mut self) { + self.is_verified = true; + } + pub fn unverify(&mut self) { + self.is_verified = false; + } + pub fn is_verified(&self) -> bool { + self.is_verified + } + pub fn set_contract_address(&mut self, address: [u8; 20]) { + self.contract_address = Some(address); + } +} diff --git a/frame/shielded-pool/src/types/ids.rs b/frame/shielded-pool/src/types/ids.rs new file mode 100644 index 00000000..957de0ca --- /dev/null +++ b/frame/shielded-pool/src/types/ids.rs @@ -0,0 +1,179 @@ +//! Newtypes over the 32-byte field elements and the asset id. +//! +//! `Commitment` and `Nullifier` are both `[u8; 32]` underneath, and confusing +//! one for the other would be a silent correctness bug — the newtypes make that +//! a compile error instead. +//! +//! Both are stored as **raw bytes**, so byte equality is identity. Callers +//! feeding untrusted input must reject non-canonical encodings before they reach +//! storage; the ZK verifier does this today for anything backed by a proof. + +use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::H256; +use sp_runtime::RuntimeDebug; + +// Commitment + +/// A commitment to a private note. +/// +/// Computed as: `Poseidon(value, asset_id, owner_pubkey, blinding)` +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct Commitment(pub [u8; 32]); + +impl Commitment { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub fn is_valid(&self) -> bool { + self.0 != [0u8; 32] + } + pub fn is_zero(&self) -> bool { + self.0 == [0u8; 32] + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl From<[u8; 32]> for Commitment { + fn from(b: [u8; 32]) -> Self { + Self::new(b) + } +} +impl From for Commitment { + fn from(h: H256) -> Self { + Self::new(h.0) + } +} +impl AsRef<[u8]> for Commitment { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +// Nullifier + +/// A nullifier identifying a spent note. +/// +/// Computed as: `Poseidon(commitment, spending_key)` +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct Nullifier(pub [u8; 32]); + +impl Nullifier { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub fn validate(&self) -> bool { + self.0 != [0u8; 32] + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl From<[u8; 32]> for Nullifier { + fn from(b: [u8; 32]) -> Self { + Self::new(b) + } +} +impl From for Nullifier { + fn from(h: H256) -> Self { + Self::new(h.0) + } +} +impl AsRef<[u8]> for Nullifier { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +// AssetId + +/// Identifier for an asset in the shielded pool. +/// +/// `0` = native (ORB), `1+` = registered external assets. +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default, + PartialOrd, + Ord +)] +pub struct AssetId(pub u32); + +impl AssetId { + pub fn new(id: u32) -> Self { + Self(id) + } + pub fn native() -> Self { + Self(0) + } + pub fn is_native(&self) -> bool { + self.0 == 0 + } + pub fn inner(&self) -> u32 { + self.0 + } + pub fn is_valid(&self) -> bool { + true + } +} + +impl From for AssetId { + fn from(id: u32) -> Self { + Self(id) + } +} +impl From for u32 { + fn from(a: AssetId) -> Self { + a.0 + } +} + +impl core::fmt::Display for AssetId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.is_native() { + write!(f, "Native Asset (0)") + } else { + write!(f, "Asset {}", self.0) + } + } +} diff --git a/frame/shielded-pool/src/types/memo.rs b/frame/shielded-pool/src/types/memo.rs new file mode 100644 index 00000000..60b97e9f --- /dev/null +++ b/frame/shielded-pool/src/types/memo.rs @@ -0,0 +1,120 @@ +//! Encrypted memo attached to a commitment. +//! +//! Fixed at exactly `MAX_ENCRYPTED_MEMO_SIZE` bytes with a fixed field layout, +//! which is what lets the accessors slice it by offset. Those accessors are +//! defensive anyway — they return an empty slice rather than panicking if the +//! invariant is ever violated, since a panic here would be reachable from a +//! dispatchable. + +use frame_support::{BoundedVec, pallet_prelude::*}; +use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; +use sp_std::vec::Vec; + +// EncryptedMemo (concrete, FRAME-compatible — used in storage & extrinsics) + +/// Max encrypted memo size: `nonce(12) + ciphertext(120) + MAC(16) + ephPk(32) = 180`. +pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 180; + +/// Encrypted memo attached to a commitment (ChaCha20-Poly1305). +#[derive( + Clone, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct EncryptedMemo(pub BoundedVec>); + +impl EncryptedMemo { + pub fn new(data: Vec) -> Result { + BoundedVec::try_from(data) + .map(Self) + .map_err(|_| "Memo size exceeds maximum") + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn is_valid_size(&self) -> bool { + !self.0.is_empty() + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != MAX_ENCRYPTED_MEMO_SIZE as usize { + return Err("Invalid memo size"); + } + Self::new(bytes.to_vec()) + } + pub fn nonce(&self) -> &[u8] { + // Invariant: EncryptedMemo is always exactly MAX_ENCRYPTED_MEMO_SIZE bytes after + // construction. from_bytes() enforces this; the else branch is unreachable in practice. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 12 { + &self.0[..12] + } else { + &[] + } + } + pub fn ciphertext(&self) -> &[u8] { + // Invariant: see nonce(). ciphertext occupies bytes 12..132. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 132 { + &self.0[12..132] + } else { + &[] + } + } + pub fn tag(&self) -> &[u8] { + // Layout: nonce(0..12) | ciphertext(12..132) | tag/MAC(132..148) | ephPk(148..180) + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 148 { + &self.0[132..148] + } else { + &[] + } + } + pub fn eph_pk(&self) -> &[u8] { + // Ephemeral BabyJubJub public key (packed, LE) occupies bytes 148..180. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 180 { + &self.0[148..180] + } else { + &[] + } + } +} diff --git a/frame/shielded-pool/src/types/merkle.rs b/frame/shielded-pool/src/types/merkle.rs new file mode 100644 index 00000000..aaad4afc --- /dev/null +++ b/frame/shielded-pool/src/types/merkle.rs @@ -0,0 +1,31 @@ +//! Merkle path type and the tree-depth constants. +//! +//! `DEFAULT_TREE_DEPTH` is fixed at 20 and pinned by `integrity_test`: clients +//! derive a note's `tree_id` from it, so changing it on a live chain would +//! re-map every existing note. + +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +// MerklePath + +pub const DEFAULT_TREE_DEPTH: usize = 20; +pub const MAX_TREE_DEPTH: u32 = 20; + +/// A Merkle path (siblings from leaf to root). +#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug, PartialEq, Eq)] +pub struct MerklePath { + pub siblings: [[u8; 32]; DEPTH], + pub indices: [u8; DEPTH], +} + +impl Default for MerklePath { + fn default() -> Self { + Self { + siblings: [[0u8; 32]; DEPTH], + indices: [0u8; DEPTH], + } + } +} + +pub type DefaultMerklePath = MerklePath; diff --git a/frame/shielded-pool/src/types/mod.rs b/frame/shielded-pool/src/types/mod.rs new file mode 100644 index 00000000..553b8199 --- /dev/null +++ b/frame/shielded-pool/src/types/mod.rs @@ -0,0 +1,294 @@ +//! Pallet types — every struct, newtype and alias defined by the pallet. +//! +//! Split by domain, but re-exported flat so consumers keep reaching them through +//! `crate::types::*` regardless of which module they live in: +//! +//! - [`ids`] — `Commitment`, `Nullifier`, `AssetId`. +//! - [`note`] — the off-chain shielded note. +//! - [`merkle`] — Merkle path plus the tree-depth constants. +//! - [`memo`] — the fixed-size encrypted memo. +//! - [`asset`] — registered asset metadata. + +pub mod asset; +pub mod ids; +pub mod memo; +pub mod merkle; +pub mod note; + +pub use asset::AssetMetadata; +pub use ids::{AssetId, Commitment, Nullifier}; +pub use memo::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; +pub use merkle::{DEFAULT_TREE_DEPTH, DefaultMerklePath, MAX_TREE_DEPTH, MerklePath}; +pub use note::Note; + +/// A 32-byte hash used for Merkle roots, cryptographic hashes and identifiers. +/// +/// Deliberately a bare alias, not a newtype: it names a shape, not a role. The +/// roles that must not be interchanged — commitments and nullifiers — get real +/// newtypes in [`ids`]. +pub type Hash = [u8; 32]; + +#[cfg(test)] +mod tests { + use super::*; + use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + + // ── Commitment ────────────────────────────────────────────────────────── + + #[test] + fn commitment_new_stores_bytes() { + let c = Commitment::new([0x01u8; 32]); + assert_eq!(c.0, [0x01u8; 32]); + } + + #[test] + fn commitment_is_valid_non_zero_only() { + assert!(Commitment::new([0x01u8; 32]).is_valid()); + assert!(!Commitment::new([0x00u8; 32]).is_valid()); + } + + #[test] + fn commitment_is_zero_checks_all_zero_bytes() { + assert!(Commitment::new([0x00u8; 32]).is_zero()); + assert!(!Commitment::new([0x01u8; 32]).is_zero()); + } + + #[test] + fn commitment_as_bytes_and_into_bytes() { + let bytes = [0x42u8; 32]; + let c = Commitment::new(bytes); + assert_eq!(c.as_bytes(), &bytes); + assert_eq!(c.into_bytes(), bytes); + } + + #[test] + fn commitment_from_array() { + let bytes = [0x99u8; 32]; + let c: Commitment = bytes.into(); + assert_eq!(c.0, bytes); + } + + #[test] + fn commitment_default_is_zero() { + assert_eq!(Commitment::default(), Commitment::new([0u8; 32])); + } + + // ── Nullifier ─────────────────────────────────────────────────────────── + + #[test] + fn nullifier_new_and_accessors() { + let bytes = [0x10u8; 32]; + let n = Nullifier::new(bytes); + assert_eq!(n.as_bytes(), &bytes); + assert_eq!(n.into_bytes(), bytes); + } + + #[test] + fn nullifier_validate_non_zero_only() { + assert!(Nullifier::new([0x01u8; 32]).validate()); + assert!(!Nullifier::new([0x00u8; 32]).validate()); + } + + #[test] + fn nullifier_from_array() { + let bytes = [0xAAu8; 32]; + let n: Nullifier = bytes.into(); + assert_eq!(n.0, bytes); + } + + #[test] + fn nullifier_default_is_zero() { + assert_eq!(Nullifier::default(), Nullifier::new([0u8; 32])); + } + + // ── AssetId ───────────────────────────────────────────────────────────── + + #[test] + fn asset_id_new_and_inner() { + let a = AssetId::new(42); + assert_eq!(a.inner(), 42); + assert_eq!(a.0, 42); + } + + #[test] + fn asset_id_native_is_zero() { + let n = AssetId::native(); + assert!(n.is_native()); + assert_eq!(n.inner(), 0); + } + + #[test] + fn asset_id_not_native_when_nonzero() { + assert!(!AssetId::new(1).is_native()); + assert!(!AssetId::new(99).is_native()); + } + + #[test] + fn asset_id_from_and_into_u32() { + let a: AssetId = 7u32.into(); + assert_eq!(a.inner(), 7); + let v: u32 = AssetId::new(13).into(); + assert_eq!(v, 13); + } + + #[test] + fn asset_id_is_valid_always_true() { + assert!(AssetId::new(0).is_valid()); + assert!(AssetId::new(u32::MAX).is_valid()); + } + + // ── Note ──────────────────────────────────────────────────────────────── + + #[test] + fn note_new_valid_sets_all_fields() { + let pk = [0x01u8; 32]; + let bl = [0x02u8; 32]; + let note = Note::new(100, pk, bl).unwrap(); + assert_eq!(note.value(), 100); + assert_eq!(note.owner_pubkey(), &pk); + assert_eq!(note.blinding(), &bl); + assert_eq!(note.asset_id(), 0); + assert!(note.is_valid()); + } + + #[test] + fn note_new_zero_value_fails() { + assert!(Note::new(0, [0x01u8; 32], [0x02u8; 32]).is_err()); + } + + #[test] + fn note_new_zero_pubkey_fails() { + assert!(Note::new(100, [0u8; 32], [0x02u8; 32]).is_err()); + } + + #[test] + fn note_new_zero_blinding_fails() { + assert!(Note::new(100, [0x01u8; 32], [0u8; 32]).is_err()); + } + + #[test] + fn note_new_with_asset_sets_asset_id() { + let note = Note::new_with_asset(50, [0x01u8; 32], [0x02u8; 32], 5).unwrap(); + assert_eq!(note.asset_id(), 5); + assert_eq!(note.value(), 50); + } + + #[test] + fn note_to_bytes_has_correct_length() { + let note = Note::new(100, [0x01u8; 32], [0x02u8; 32]).unwrap(); + // 16 (value u128) + 4 (asset_id u32) + 32 (pubkey) + 32 (blinding) = 84 + assert_eq!(note.to_bytes().len(), 84); + } + + #[test] + fn note_asset_id_serializes_as_4_bytes() { + // asset_id must be 4 LE bytes to match the circuit's public signal + // (commitment[0..32] | value[32..40] | asset_id[40..44] | ...). + let note = Note::new_with_asset(100, [0x01u8; 32], [0x02u8; 32], 0x01020304).unwrap(); + let bytes = note.to_bytes(); + assert_eq!(&bytes[16..20], &0x01020304u32.to_le_bytes()); + } + + // ── MerklePath ────────────────────────────────────────────────────────── + + #[test] + fn merkle_path_default_all_zeros() { + let path = DefaultMerklePath::default(); + assert_eq!(path.siblings, [[0u8; 32]; DEFAULT_TREE_DEPTH]); + assert_eq!(path.indices, [0u8; DEFAULT_TREE_DEPTH]); + } + + #[test] + fn merkle_path_generic_depth() { + let path = MerklePath::<5>::default(); + assert_eq!(path.siblings.len(), 5); + assert_eq!(path.indices.len(), 5); + } + + // ── EncryptedMemo ──────────────────────────────────────────────────────── + + #[test] + fn encrypted_memo_new_valid_size() { + let data = vec![0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::new(data).unwrap(); + assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); + } + + #[test] + fn encrypted_memo_new_exceeds_max_fails() { + let data = vec![0x01u8; (MAX_ENCRYPTED_MEMO_SIZE + 1) as usize]; + assert!(EncryptedMemo::new(data).is_err()); + } + + #[test] + fn encrypted_memo_from_bytes_exact_size() { + let bytes = [0x03u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); + assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); + } + + #[test] + fn encrypted_memo_from_bytes_wrong_size_fails() { + assert!(EncryptedMemo::from_bytes(&[0x01u8; 32]).is_err()); + assert!(EncryptedMemo::from_bytes(&[0x01u8; 50]).is_err()); + } + + #[test] + fn encrypted_memo_nonce_ciphertext_tag_slices() { + let bytes = [0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); + assert_eq!(memo.nonce().len(), 12); + assert_eq!(memo.ciphertext().len(), 120); + assert_eq!(memo.tag().len(), 16); + assert_eq!(memo.eph_pk().len(), 32); + } + + #[test] + fn encrypted_memo_default_is_empty() { + let empty = EncryptedMemo::default(); + assert!(empty.is_empty()); + assert!(!empty.is_valid_size()); + } + + #[test] + fn encrypted_memo_full_is_not_empty() { + let full = EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(); + assert!(!full.is_empty()); + assert!(full.is_valid_size()); + } + + // ── AssetMetadata ─────────────────────────────────────────────────────── + + #[test] + fn asset_metadata_new_not_verified_by_default() { + let name: BoundedVec> = BoundedVec::try_from(b"Test".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"TST".to_vec()).unwrap(); + let meta: AssetMetadata = AssetMetadata::new(1, name, sym, 18, 0u64, 99u64); + assert!(!meta.is_verified()); + assert_eq!(meta.id, 1); + assert_eq!(meta.decimals, 18); + assert_eq!(meta.contract_address, None); + } + + #[test] + fn asset_metadata_verify_unverify() { + let name: BoundedVec> = BoundedVec::try_from(b"Token".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"TKN".to_vec()).unwrap(); + let mut meta: AssetMetadata = AssetMetadata::new(2, name, sym, 8, 10u64, 1u64); + assert!(!meta.is_verified()); + meta.verify(); + assert!(meta.is_verified()); + meta.unverify(); + assert!(!meta.is_verified()); + } + + #[test] + fn asset_metadata_set_contract_address() { + let name: BoundedVec> = BoundedVec::try_from(b"ERC20".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"ERC".to_vec()).unwrap(); + let mut meta: AssetMetadata = AssetMetadata::new(3, name, sym, 18, 0u64, 1u64); + let addr = [0xABu8; 20]; + meta.set_contract_address(addr); + assert_eq!(meta.contract_address, Some(addr)); + } +} diff --git a/frame/shielded-pool/src/types/note.rs b/frame/shielded-pool/src/types/note.rs new file mode 100644 index 00000000..01dab3e4 --- /dev/null +++ b/frame/shielded-pool/src/types/note.rs @@ -0,0 +1,76 @@ +//! The shielded note — the private-side unit of value. +//! +//! Only ever built and inspected off-chain: the chain sees its commitment, never +//! the note itself. Field widths must match the circuit exactly, since the +//! commitment is computed over this layout on both sides. + +use super::Hash; +use sp_std::vec::Vec; + +// Note (off-chain type, used by wallets) + +/// A private note in the shielded pool (UTXO). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + value: u128, + owner_pubkey: Hash, + blinding: Hash, + asset_id: u32, +} + +impl Note { + pub fn new(value: u128, owner_pubkey: Hash, blinding: Hash) -> Result { + if value == 0 { + return Err("Note value cannot be zero"); + } + if owner_pubkey == [0u8; 32] { + return Err("Owner public key cannot be zero"); + } + if blinding == [0u8; 32] { + return Err("Blinding factor cannot be zero"); + } + Ok(Self { + value, + owner_pubkey, + blinding, + asset_id: 0, + }) + } + + pub fn new_with_asset( + value: u128, + owner_pubkey: Hash, + blinding: Hash, + asset_id: u32, + ) -> Result { + let mut note = Self::new(value, owner_pubkey, blinding)?; + note.asset_id = asset_id; + Ok(note) + } + + pub fn value(&self) -> u128 { + self.value + } + pub fn owner_pubkey(&self) -> &Hash { + &self.owner_pubkey + } + pub fn blinding(&self) -> &Hash { + &self.blinding + } + pub fn asset_id(&self) -> u32 { + self.asset_id + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&self.value.to_le_bytes()); + bytes.extend_from_slice(&self.asset_id.to_le_bytes()); + bytes.extend_from_slice(&self.owner_pubkey); + bytes.extend_from_slice(&self.blinding); + bytes + } + + pub fn is_valid(&self) -> bool { + self.value > 0 && self.owner_pubkey != [0u8; 32] && self.blinding != [0u8; 32] + } +} From 44d27ef2aef1f276f673b773941345b652dc80ed Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 16:37:09 -0400 Subject: [PATCH 6/8] chore(shielded-pool): re-benchmark weights against the v3 root window --- frame/shielded-pool/src/merkle/service.rs | 6 - frame/shielded-pool/src/weights.rs | 312 ++++++++++++---------- 2 files changed, 174 insertions(+), 144 deletions(-) diff --git a/frame/shielded-pool/src/merkle/service.rs b/frame/shielded-pool/src/merkle/service.rs index 681ea8bb..5a8327d7 100644 --- a/frame/shielded-pool/src/merkle/service.rs +++ b/frame/shielded-pool/src/merkle/service.rs @@ -120,12 +120,6 @@ impl MerkleTreeService { /// Retention is measured in blocks (`RootRetentionBlocks`), so the window /// always outlives the mempool longevity a transaction was admitted with. /// `MaxHistoricRoots` is only a safety cap on queue length. - /// - /// TODO(weights): `weights.rs` still records the pre-v3 window — it declares - /// `HistoricRootsOrder`, which no longer exists, and omits - /// `HistoricRootsQueue` / `HistoricRootsHead` / `HistoricRootsTail`. The - /// per-insert pruning below is therefore uncharged. Re-benchmark - /// `shield` / `shield_batch` / `private_transfer` / `unshield` before deploying. pub(crate) fn add_poseidon_historic_root(poseidon_root: Hash) { let now = frame_system::Pallet::::block_number(); let expires_at = now.saturating_add(T::RootRetentionBlocks::get()); diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index f8704906..78ba658c 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -2,9 +2,9 @@ //! Autogenerated weights for pallet_shielded_pool //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-07-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-08-05, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `ubuntu-32gb-hel1-1`, CPU: `AMD EPYC-Genoa Processor` +//! HOSTNAME: `ubuntu-32gb-nbg1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 // Executed Command: @@ -76,26 +76,30 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn shield() -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687` - // Minimum execution time: 1_014_974_000 picoseconds. - Weight::from_parts(1_024_433_000, 4687) - .saturating_add(T::DbWeight::get().reads(14_u64)) - .saturating_add(T::DbWeight::get().writes(32_u64)) + // Measured: `1242` + // Estimated: `3695` + // Minimum execution time: 861_716_000 picoseconds. + Weight::from_parts(878_209_000, 3695) + .saturating_add(T::DbWeight::get().reads(17_u64)) + .saturating_add(T::DbWeight::get().writes(34_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -121,14 +125,18 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:20) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:20 w:20) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:35) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:20) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:20) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:20) @@ -136,28 +144,30 @@ impl WeightInfo for SubstrateWeight { /// The range of component `n` is `[1, 20]`. fn shield_batch(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 1_007_933_000 picoseconds. - Weight::from_parts(63_329_265, 4687) - // Standard Error: 303_679 - .saturating_add(Weight::from_parts(966_398_277, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(13_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(26_u64)) - .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1242` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 853_004_000 picoseconds. + Weight::from_parts(77_406_360, 3631) + // Standard Error: 2_015_265 + .saturating_add(Weight::from_parts(851_634_513, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(27_u64)) + .saturating_add(T::DbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:2) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:2 w:2) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `Relayer::MinRelayFee` (r:1 w:0) /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) @@ -166,12 +176,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::CommitmentMemos` (`max_values`: None, `max_size`: Some(230), added: 2705, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeFrontier` (r:1 w:1) /// Proof: `ShieldedPool::MerkleTreeFrontier` (`max_values`: Some(1), `max_size`: Some(640), added: 1135, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) - /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:2) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -191,22 +203,26 @@ impl WeightInfo for SubstrateWeight { /// The range of component `n` is `[1, 2]`. fn private_transfer(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1182` - // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 969_972_000 picoseconds. - Weight::from_parts(53_550_853, 4687) - // Standard Error: 4_924_563 - .saturating_add(Weight::from_parts(956_310_573, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(15_u64)) - .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(28_u64)) - .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1260` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 829_842_000 picoseconds. + Weight::from_parts(58_727_597, 3631) + // Standard Error: 3_711_290 + .saturating_add(Weight::from_parts(813_265_051, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(17_u64)) + .saturating_add(T::DbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(29_u64)) + .saturating_add(T::DbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:1 w:1) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) @@ -217,8 +233,6 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -233,11 +247,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) fn unshield() -> Weight { // Proof Size summary in bytes: - // Measured: `903` + // Measured: `981` // Estimated: `6196` - // Minimum execution time: 113_240_000 picoseconds. - Weight::from_parts(118_970_000, 6196) - .saturating_add(T::DbWeight::get().reads(15_u64)) + // Minimum execution time: 108_500_000 picoseconds. + Weight::from_parts(112_414_000, 6196) + .saturating_add(T::DbWeight::get().reads(16_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) @@ -254,10 +268,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn register_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `239` + // Measured: `260` // Estimated: `3631` - // Minimum execution time: 18_560_000 picoseconds. - Weight::from_parts(20_220_000, 3631) + // Minimum execution time: 16_953_000 picoseconds. + Weight::from_parts(17_543_000, 3631) .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } @@ -273,10 +287,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn verify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 21_810_000 picoseconds. - Weight::from_parts(24_540_000, 3631) + // Minimum execution time: 17_044_000 picoseconds. + Weight::from_parts(17_754_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -292,10 +306,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn unverify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_850_000 picoseconds. - Weight::from_parts(19_700_000, 3631) + // Minimum execution time: 16_913_000 picoseconds. + Weight::from_parts(17_645_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -321,24 +335,28 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn claim_shielded_fees() -> Weight { // Proof Size summary in bytes: - // Measured: `1085` - // Estimated: `4687` - // Minimum execution time: 963_314_000 picoseconds. - Weight::from_parts(1_003_013_000, 4687) - .saturating_add(T::DbWeight::get().reads(12_u64)) - .saturating_add(T::DbWeight::get().writes(31_u64)) + // Measured: `1200` + // Estimated: `3695` + // Minimum execution time: 841_328_000 picoseconds. + Weight::from_parts(851_813_000, 3695) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().writes(33_u64)) } } @@ -368,26 +386,30 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn shield() -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687` - // Minimum execution time: 1_014_974_000 picoseconds. - Weight::from_parts(1_024_433_000, 4687) - .saturating_add(RocksDbWeight::get().reads(14_u64)) - .saturating_add(RocksDbWeight::get().writes(32_u64)) + // Measured: `1242` + // Estimated: `3695` + // Minimum execution time: 861_716_000 picoseconds. + Weight::from_parts(878_209_000, 3695) + .saturating_add(RocksDbWeight::get().reads(17_u64)) + .saturating_add(RocksDbWeight::get().writes(34_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -413,14 +435,18 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:20) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:20 w:20) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:35) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:20) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:20) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:20) @@ -428,28 +454,30 @@ impl WeightInfo for () { /// The range of component `n` is `[1, 20]`. fn shield_batch(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 1_007_933_000 picoseconds. - Weight::from_parts(63_329_265, 4687) - // Standard Error: 303_679 - .saturating_add(Weight::from_parts(966_398_277, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(13_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(26_u64)) - .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1242` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 853_004_000 picoseconds. + Weight::from_parts(77_406_360, 3631) + // Standard Error: 2_015_265 + .saturating_add(Weight::from_parts(851_634_513, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(27_u64)) + .saturating_add(RocksDbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:2) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:2 w:2) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `Relayer::MinRelayFee` (r:1 w:0) /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) @@ -458,12 +486,14 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::CommitmentMemos` (`max_values`: None, `max_size`: Some(230), added: 2705, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeFrontier` (r:1 w:1) /// Proof: `ShieldedPool::MerkleTreeFrontier` (`max_values`: Some(1), `max_size`: Some(640), added: 1135, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) - /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:2) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -483,22 +513,26 @@ impl WeightInfo for () { /// The range of component `n` is `[1, 2]`. fn private_transfer(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1182` - // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 969_972_000 picoseconds. - Weight::from_parts(53_550_853, 4687) - // Standard Error: 4_924_563 - .saturating_add(Weight::from_parts(956_310_573, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(15_u64)) - .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(28_u64)) - .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1260` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 829_842_000 picoseconds. + Weight::from_parts(58_727_597, 3631) + // Standard Error: 3_711_290 + .saturating_add(Weight::from_parts(813_265_051, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(17_u64)) + .saturating_add(RocksDbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(29_u64)) + .saturating_add(RocksDbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:1 w:1) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) @@ -509,8 +543,6 @@ impl WeightInfo for () { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -525,11 +557,11 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) fn unshield() -> Weight { // Proof Size summary in bytes: - // Measured: `903` + // Measured: `981` // Estimated: `6196` - // Minimum execution time: 113_240_000 picoseconds. - Weight::from_parts(118_970_000, 6196) - .saturating_add(RocksDbWeight::get().reads(15_u64)) + // Minimum execution time: 108_500_000 picoseconds. + Weight::from_parts(112_414_000, 6196) + .saturating_add(RocksDbWeight::get().reads(16_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) @@ -546,10 +578,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn register_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `239` + // Measured: `260` // Estimated: `3631` - // Minimum execution time: 18_560_000 picoseconds. - Weight::from_parts(20_220_000, 3631) + // Minimum execution time: 16_953_000 picoseconds. + Weight::from_parts(17_543_000, 3631) .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } @@ -565,10 +597,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn verify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 21_810_000 picoseconds. - Weight::from_parts(24_540_000, 3631) + // Minimum execution time: 17_044_000 picoseconds. + Weight::from_parts(17_754_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -584,10 +616,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn unverify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_850_000 picoseconds. - Weight::from_parts(19_700_000, 3631) + // Minimum execution time: 16_913_000 picoseconds. + Weight::from_parts(17_645_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -613,23 +645,27 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) - /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn claim_shielded_fees() -> Weight { // Proof Size summary in bytes: - // Measured: `1085` - // Estimated: `4687` - // Minimum execution time: 963_314_000 picoseconds. - Weight::from_parts(1_003_013_000, 4687) - .saturating_add(RocksDbWeight::get().reads(12_u64)) - .saturating_add(RocksDbWeight::get().writes(31_u64)) + // Measured: `1200` + // Estimated: `3695` + // Minimum execution time: 841_328_000 picoseconds. + Weight::from_parts(851_813_000, 3695) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().writes(33_u64)) } } From 9b1a384ba235326a73cbb5adf46336d998ea2401 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 16:41:26 -0400 Subject: [PATCH 7/8] chore(shielded-pool): bump to 0.14.0 --- Cargo.lock | 2 +- frame/shielded-pool/CHANGELOG.md | 95 ++++++++++++++++++++++++++++++++ frame/shielded-pool/Cargo.toml | 2 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48533ebc..68f0973c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8104,7 +8104,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 42f9eeed..6ee2eef5 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -2,6 +2,101 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. +## [0.14.0] - 2026-08-05 + +### Changed +- **Historic-root window is now measured in blocks, not in inserts.** The window + was bounded by `MaxHistoricRoots` (100) counted in *leaf insertions*, while an + unsigned transaction stays valid in the pool for `TX_LONGEVITY` (64) *blocks*. + A `private_transfer` inserts up to 2 commitments, so ~50 transfers rotated the + whole window: under load an honest spend passed `validate_unsigned`, gossiped + across the network, and only then reverted with `UnknownMerkleRoot`, with its + nullifier still unspent. No attacker was required — ordinary throughput did it, + and an attacker could force it cheaply with valid transfers. +- New Config constant **`RootRetentionBlocks`** (production 300 blocks, ~30 min at + 6s) sets the window. `integrity_test` asserts it exceeds `TX_LONGEVITY`, so a + runtime that misaligns the two units refuses to build. +- **`MaxHistoricRoots` changed meaning**: it is no longer the window but a safety + cap on queue length (production 16384, sized for ~54 sustained inserts/block). + Reaching it logs a warning rather than silently shortening the window. +- `HistoricPoseidonRoots` value type `bool` → `BlockNumberFor` (the block at + which a root stops being accepted), `OptionQuery`. +- `HistoricRootsOrder` (a single `BoundedVec`) replaced by a slot-indexed queue: + `HistoricRootsQueue: StorageMap` plus `HistoricRootsHead` + and `HistoricRootsTail`. A `StorageValue` would be read and rewritten in full on + every leaf insert — hundreds of KiB on the hottest path once the window holds + thousands of roots. The map touches one entry plus the few it prunes. +- Pruning is lazy and capped at `MAX_ROOTS_PRUNED_PER_INSERT` (4) per insert, so + one extrinsic never pays for a backlog it did not create. Leftovers drain on + subsequent inserts; a stale queue slot is harmless because spendability is + decided by the expiry stored per root, never by queue membership. +- **Weights re-benchmarked against the v3 layout.** The recorded storage access + now covers `HistoricRootsQueue` / `HistoricRootsHead` / `HistoricRootsTail` and + drops the `HistoricRootsOrder` entry, which no longer exists. The measurement + exercises a queue with expired entries, so the per-insert pruning is charged + rather than free: `shield` 17r/34w, `unshield` 16r/8w, and per-`n` components + on `shield_batch` (2r/6w) and `private_transfer` (3r/6w). +- **Pool-rejection code for `amount + fee` overflow moved from `Custom(2)` to + `Custom(4)`.** Code 2 previously meant two different things depending on which + validator rejected the transaction — "all inputs are dummy" in + `private_transfer`, "amount overflow" in `unshield` — so a `Custom error: 2` in + a node log was ambiguous. The other codes keep their values. Codes are part of + the observable interface, so a test now pins them. +- `is_known_root` now accepts the **active root unconditionally**. Expiries are + only refreshed by a leaf insert and the pallet has no hooks, so on a chain idle + for a full window the current root — the one every wallet proves against — + would expire and wedge the pool: `private_transfer` and `unshield` both need a + known root, and only a funded `shield` could mint a new one. + +### Added +- `migrations::v3::MigrateToV3` (`STORAGE_VERSION` 2 → 3). Both value types + changed, so v2 entries cannot be decoded by the new definitions and are + rewritten here. Every existing root is kept and granted a full window from the + upgrade block rather than dropped: a root on chain backs proofs wallets may be + about to submit. The old map is enumerated by **raw key** rather than + `translate`, which does not delete an entry whose value fails to decode — it + logs, skips, and leaves the bytes in place, which for a 1-byte `bool` under a + 4-byte slot would leave an unreadable, unprunable residue. Map and queue are + written in the same loop so the two can never diverge. + +### Fixed +- Honest spends no longer revert with `UnknownMerkleRoot` after passing pool + admission: a root now outlives every transaction admitted against it. +- Map and queue can no longer diverge, which previously left roots reachable by + neither the pruner nor an eviction path — permanently spendable and impossible + to remove. +- The cap path no longer drops a root whose window has not elapsed; a live root + is re-queued instead of being forgotten. +- Reaching the queue cap logs a warning instead of using `defensive!`, which + expands to `debug_assert!(false)` and would halt a validator running a + debug-assertions build on a reachable operational state. + +### Refactored +- The four largest modules were split into directories, one file per + responsibility. No behaviour change: every type and function keeps its path + through re-exports, so no caller outside the pallet was touched. + - `merkle.rs` (1642 lines) → `merkle/` — `hashing`, `tree`, `batch`, `service`. + The split isolates the only storage-touching layer (`service`) from the pure + maths; `batch` now states in its own docs that it is O(n) and used off-chain + only, which the single file left implicit. + - `storage.rs` (658 lines) → `storage/` — one module per repository: `asset`, + `commitment`, `merkle`, `nullifier`, `stats`, `balance`. + - `validate_unsigned.rs` (683 lines) → `validate_unsigned/` — `transfer`, + `unshield`, and a new `codes` module holding the named rejection codes that + were previously bare literals duplicated across both validators. + - `types.rs` (719 lines) → `types/` — `ids`, `note`, `merkle`, `memo`, `asset`. + +### Verification +298 pallet tests, 65 precompile tests, and 78 end-to-end checks against a running +dev node covering `shield_batch`, same-block bursts, idle chains, the steady-state +plateau (299 → 301 slots after +30 inserts), orphan detection in both directions, +and a spend against a root the chain has churned past. + +### Notes +- `spec_version` moves 6 → 7 in this release: the storage layout changes and a + migration is back in the tuple. `transaction_version` stays — no call + signature changed. + ## [0.13.0] - 2026-08-04 ### Removed diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index b4e5a80b..fc7f1756 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.13.0" +version = "0.14.0" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" From aa1cec5ffbef0379f9320be891b183324f172115 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 5 Aug 2026 16:43:39 -0400 Subject: [PATCH 8/8] chore(runtime): bump spec_version to 7 --- template/runtime/RUNTIME_VERSIONS.md | 1 + template/runtime/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 14d2298e..cb104864 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -22,6 +22,7 @@ The genesis reset (`69d1b837`) set `spec_version` back to 1 and | spec | tx | Date | Commit | Change | |------|----|------|--------|--------| +| 7 | 2 | 2026-08-05 | — | Two pallet changes shipping in one upgrade (neither 7 nor 8 was ever deployed, so they collapse into a single bump). **shielded-pool 0.14.0:** historic-root window re-anchored from insert counts to block numbers. New Config `RootRetentionBlocks` (300 blocks); `MaxHistoricRoots` (raised to 16384) becomes a queue-length cap rather than the window. `HistoricPoseidonRoots` now stores an expiry block instead of a bool; `HistoricRootsOrder` replaced by the slot-indexed `HistoricRootsQueue` + `Head`/`Tail`. `STORAGE_VERSION` 2 → 3 with `MigrateToV3` in the tuple. Weights re-benchmarked against the v3 layout. The amount-overflow pool rejection moves from `Custom(2)` to `Custom(4)`, which had two meanings. **Applied migrations removed (`72ff7b88`):** the v1/v2 modules are gone from shielded-pool and zk-verifier 0.11.0 — testnet was already past both, so they were no-ops, and `MigrateToV1` rebuilt the whole Merkle tree in one block. `spec_version` moves because storage layout and `on_runtime_upgrade` both change; `transaction_version` stays — no call signature changed. | | 6 | 2 | 2026-08-03 | — | `pallet-account-mapping` and its precompile (index 14, address 0x0800) removed, along with the `private_link` circuit (id 5) and its verification key. Index 14 is retired and must not be reassigned. zk-verifier 0.10.0 gains `purge_circuit` (call index 7, Root) plus `STORAGE_VERSION` 1 and `MigrateToV1`, which drops the stranded circuit-5 key that no extrinsic could reach. `transaction_version` moves because the new call index changes extrinsic encoding. | | 5 | 1 | 2026-07-30 | — | shielded-pool 0.12.0: multi-tree forest. Full trees seal (`TreeSealed`, permanent `SealedTreeRoots` anchors) and inserts roll over to a fresh tree — the 2^20-note network ceiling is gone. New Config `MaxLeavesPerTree` (2^20), `STORAGE_VERSION` 2 (`MigrateToV2`, version-only), runtime API v2 (`get_forest_info`, `get_root_for_leaf`). No circuit/extrinsic/ABI changes. | | 4 | 1 | 2026-07-30 | `63caafca` | shielded-pool 0.11.0: `MerkleNodes` storage (internal nodes written on every insert) + `MigrateToV1` migration (backfill, `STORAGE_VERSION` 1). O(depth) Merkle proofs. Weights re-benchmarked. The upgrade block runs the one-shot migration (~3s at ~90k leaves). | diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 98d7a7e9..1722be13 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -201,7 +201,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 6, + spec_version: 7, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 2,