From dabc4a173d545a4cf6e2e3340063a33d40a36057 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 29 Apr 2026 16:25:49 -0400 Subject: [PATCH 1/4] perf(shielded-pool): replace O(n) root recomputation with incremental frontier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insert_leaf formerly called get_all_leaves() and recomputed the full Poseidon Merkle root from scratch on every insert (O(n) reads, O(n log n) hashes). At 10k commitments this meant ~10k storage reads per shield or private_transfer extrinsic. Replace with an incremental frontier algorithm: persist a 20-slot array (MerkleTreeFrontier storage item) that stores the last left-sibling at each tree level. Each insert now takes exactly 20 hashes and 1 storage read/write, regardless of tree size. Complexity is O(depth) = O(20) — constant at any scale. Changes: - lib.rs: add MerkleTreeFrontier StorageValue<_, [[u8;32]; 20]> - storage.rs: add get_frontier / set_frontier to MerkleRepository - merkle.rs: rewrite MerkleTreeService::insert_leaf to use frontier; remove compute_poseidon_merkle_root (now unused); fix old_root in MerkleRootUpdated event (was always [0u8;32], now carries real value) - genesis.rs: initialize MerkleTreeFrontier to all-zeros at genesis Add 5 tests: - incremental_root_matches_batch_root_after_single_insert - incremental_root_matches_batch_root_after_multiple_inserts - incremental_proof_verifies_against_incremental_root - merkle_root_updated_event_carries_correct_old_root - frontier_survives_storage_round_trip_across_separate_calls --- frame/shielded-pool/Cargo.toml | 2 +- frame/shielded-pool/src/genesis.rs | 8 +- frame/shielded-pool/src/lib.rs | 9 ++ frame/shielded-pool/src/merkle.rs | 166 +++++++++++++++++++++++++++-- frame/shielded-pool/src/storage.rs | 11 +- 5 files changed, 181 insertions(+), 15 deletions(-) diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 75c41cb0..dd83c99a 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.5.0" +version = "0.5.1" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/shielded-pool/src/genesis.rs b/frame/shielded-pool/src/genesis.rs index cedcbb20..81c6ebfb 100644 --- a/frame/shielded-pool/src/genesis.rs +++ b/frame/shielded-pool/src/genesis.rs @@ -2,7 +2,8 @@ use crate::{ pallet::{ - Assets, Config, HistoricPoseidonRoots, HistoricRootsOrder, NextAssetId, PoseidonRoot, + Assets, Config, HistoricPoseidonRoots, HistoricRootsOrder, MerkleTreeFrontier, NextAssetId, + PoseidonRoot, }, types::AssetMetadata, types::Hash, @@ -16,6 +17,11 @@ pub fn initialize_genesis(initial_root: Hash) { // Initialize Poseidon Merkle tree with genesis root PoseidonRoot::::put(initial_root); + // Initialize incremental frontier to all zeros (empty tree state). + // Must be set before any insert_leaf call so the O(depth) algorithm + // starts from the correct baseline. + MerkleTreeFrontier::::put([[0u8; 32]; 20]); + // Add genesis root to historic roots HistoricPoseidonRoots::::insert(initial_root, true); diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 0c9b768c..27210034 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -181,6 +181,15 @@ pub mod pallet { #[pallet::getter(fn merkle_tree_size)] pub type MerkleTreeSize = StorageValue<_, u32, ValueQuery>; + /// Incremental Merkle tree frontier for O(depth) root updates. + /// + /// Stores the last left-sibling at each of the 20 levels of the tree. + /// Updated in O(depth) on every `insert_leaf`, replacing the former + /// O(n) full recomputation from all leaves. + /// Depth is fixed at `DEFAULT_TREE_DEPTH = 20`. + #[pallet::storage] + pub type MerkleTreeFrontier = StorageValue<_, [[u8; 32]; 20], ValueQuery>; + /// Merkle tree leaves (index -> commitment) #[pallet::storage] pub type MerkleLeaves = StorageMap<_, Blake2_128Concat, u32, Commitment, OptionQuery>; diff --git a/frame/shielded-pool/src/merkle.rs b/frame/shielded-pool/src/merkle.rs index 124b6cf6..da4ebdb6 100644 --- a/frame/shielded-pool/src/merkle.rs +++ b/frame/shielded-pool/src/merkle.rs @@ -309,6 +309,9 @@ 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::(); let max_leaves = 2u32.saturating_pow(T::MaxTreeDepth::get()); @@ -318,29 +321,42 @@ impl MerkleTreeService { Error::::CommitmentAlreadyExists ); + // 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 = index; + + 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; + } + + let new_poseidon_root = current_hash; + let old_poseidon_root = MerkleRepository::get_poseidon_root::(); + MerkleRepository::insert_leaf::(index, commitment); MerkleRepository::set_tree_size::(index.saturating_add(1)); - - let new_poseidon_root = Self::compute_poseidon_merkle_root::(); + MerkleRepository::set_frontier::(frontier); MerkleRepository::set_poseidon_root::(new_poseidon_root); Self::add_poseidon_historic_root::(new_poseidon_root); Pallet::::deposit_event(Event::MerkleRootUpdated { - old_root: [0u8; 32], + old_root: old_poseidon_root, new_root: new_poseidon_root, tree_size: index.saturating_add(1), }); Ok(index) } - fn compute_poseidon_merkle_root() -> Hash { - let leaves = MerkleRepository::get_all_leaves::(); - if leaves.is_empty() { - return [0u8; 32]; - } - compute_root_from_leaves_poseidon::<20>(&leaves) - } - fn add_poseidon_historic_root(poseidon_root: Hash) { let mut order = MerkleRepository::get_historic_roots_order::(); if order.len() >= T::MaxHistoricRoots::get() as usize { @@ -717,4 +733,132 @@ mod tests { assert_eq!(MerkleTreeService::find_leaf_index::(&c1), Some(1)); }); } + + // ── 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" + ); + } } diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage.rs index 197ae726..54e062ee 100644 --- a/frame/shielded-pool/src/storage.rs +++ b/frame/shielded-pool/src/storage.rs @@ -7,8 +7,9 @@ use crate::{ pallet::{ Assets, AuditPolicies, AuditTrailStorage, BalanceOf, CommitmentMemos, Config, DisclosureCounters, DisclosureRecords, DisclosureRequests, Error, HistoricPoseidonRoots, - HistoricRootsOrder, LastDisclosureTimestamp, MerkleLeaves, MerkleTreeSize, NextAssetId, - NextAuditTrailId, NullifierSet, PoolBalancePerAsset, PoseidonRoot, + HistoricRootsOrder, LastDisclosureTimestamp, MerkleLeaves, MerkleTreeFrontier, + MerkleTreeSize, NextAssetId, NextAuditTrailId, NullifierSet, PoolBalancePerAsset, + PoseidonRoot, }, types::{ AssetMetadata, AuditPolicy, AuditTrail, Commitment, DisclosureRecord, DisclosureRequest, @@ -122,6 +123,12 @@ impl MerkleRepository { pub fn set_historic_roots_order(order: BoundedVec) { HistoricRootsOrder::::put(order); } + pub fn get_frontier() -> [[u8; 32]; 20] { + MerkleTreeFrontier::::get() + } + pub fn set_frontier(frontier: [[u8; 32]; 20]) { + MerkleTreeFrontier::::put(frontier); + } pub fn find_leaf_index(commitment: &Commitment) -> Option { let size = Self::get_tree_size::(); for i in 0..size { From 3a03a5e1c745c16a9e7f04ec800b25ead270e23e Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 29 Apr 2026 19:06:16 -0400 Subject: [PATCH 2/4] feat(shielded-pool): incremental Merkle frontier, O(1) counters, RPC hardening and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merkle tree performance: - Replace full O(n) root recomputation with an incremental frontier; insertion is now O(depth) instead of O(n leaves). - Add CommitmentToLeafIndex reverse storage map; commitment → leaf position lookup is now O(1) instead of a linear scan. Pool statistics: - Track total commitments inserted and total nullifiers spent in dedicated storage items updated on every operation. - Expose nullifier_count in fc-rpc-v2 PoolStatsResponse as an O(1) read from the new counter, replacing the previous O(n) key-page scan. RPC cleanup: - Remove shieldedPool_scanEvents from the legacy RPC trait and server; the endpoint always returned an empty list and has been superseded by the privacy_* v2 endpoints. Type safety: - Add debug_assert_eq in EncryptedMemo::nonce(), ciphertext(), and tag() to catch buffer-size invariant violations at test time. Testing: - Add integration tests for Flujo A + Flujo B mixed batch disclosure, covering both successful mixing and policy-missing failure paths. - Add integration tests for the claim_shielded ZK verification path; extend MockZkVerifier with a sentinel byte (0x00) to simulate Ok(false). - Add unit tests for the validate_unsigned fee floor anti-spam guard; introduce mock_set_min_relay_fee to control the minimum per test. --- Cargo.lock | 3 +- client/rpc-v2/src/privacy.rs | 32 +++++ frame/shielded-pool/rpc/Cargo.toml | 1 - frame/shielded-pool/rpc/src/lib.rs | 47 ------- frame/shielded-pool/src/lib.rs | 25 ++++ frame/shielded-pool/src/merkle.rs | 57 ++++++++- frame/shielded-pool/src/mock.rs | 24 +++- .../src/operations/disclosure/batch_submit.rs | 121 ++++++++++++++++++ frame/shielded-pool/src/operations/fees.rs | 67 ++++++++++ frame/shielded-pool/src/storage.rs | 117 ++++++++++++++--- frame/shielded-pool/src/types.rs | 25 ++++ frame/shielded-pool/src/validate_unsigned.rs | 79 ++++++++++++ 12 files changed, 529 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 701e3746..f9fca373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8107,7 +8107,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.5.0" +version = "0.5.2" dependencies = [ "ark-bn254", "ark-ff 0.5.0", @@ -8134,7 +8134,6 @@ version = "0.1.0" dependencies = [ "hex", "jsonrpsee", - "log", "pallet-shielded-pool", "pallet-shielded-pool-runtime-api", "parity-scale-codec", diff --git a/client/rpc-v2/src/privacy.rs b/client/rpc-v2/src/privacy.rs index 673f7523..48a3ba1a 100644 --- a/client/rpc-v2/src/privacy.rs +++ b/client/rpc-v2/src/privacy.rs @@ -86,6 +86,7 @@ pub struct AssetBalanceResponse { pub struct PoolStatsResponse { pub merkle_root: String, pub commitment_count: u32, + pub nullifier_count: u64, pub total_balance: u128, pub asset_balances: Vec, pub tree_depth: u32, @@ -429,9 +430,18 @@ where } } + // Nullifier count — O(1) read of TotalNullifiersSpent counter. + // Defaults to 0 if the storage item is absent (pool with no spent notes yet). + let nullifier_count = + match read_storage(&*self.client, best_hash, value_key(b"TotalNullifiersSpent"))? { + Some(raw) => u64::decode(&mut &raw[..]).unwrap_or(0), + None => 0, + }; + Ok(PoolStatsResponse { merkle_root: format!("0x{}", hex::encode(root.as_bytes())), commitment_count, + nullifier_count, total_balance, asset_balances, tree_depth: DEFAULT_TREE_DEPTH as u32, @@ -671,6 +681,7 @@ mod tests { let resp = PoolStatsResponse { merkle_root: "0x01".to_string(), commitment_count: 5, + nullifier_count: 3, total_balance: 1_000, asset_balances: vec![ AssetBalanceResponse { @@ -686,6 +697,7 @@ mod tests { }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["commitment_count"], 5u64); + assert_eq!(json["nullifier_count"], 3u64); assert_eq!(json["total_balance"].as_u64().unwrap(), 1_000u64); let ab = json["asset_balances"].as_array().unwrap(); assert_eq!(ab.len(), 2); @@ -698,6 +710,7 @@ mod tests { let orig = PoolStatsResponse { merkle_root: "0xff".to_string(), commitment_count: 1, + nullifier_count: 0, total_balance: 42, asset_balances: vec![AssetBalanceResponse { asset_id: 0, @@ -709,6 +722,25 @@ mod tests { serde_json::from_str(&serde_json::to_string(&orig).unwrap()).unwrap(); assert_eq!(orig, back); } + + #[test] + fn pool_stats_response_nullifier_count_field_present() { + let resp = PoolStatsResponse { + merkle_root: "0xab".to_string(), + commitment_count: 10, + nullifier_count: 4, + total_balance: 0, + asset_balances: vec![], + tree_depth: 20, + }; + let json = serde_json::to_value(&resp).unwrap(); + // nullifier_count must be present and correctly serialised + assert_eq!(json["nullifier_count"], 4u64); + // active notes estimate: commitment_count - nullifier_count + let active = json["commitment_count"].as_u64().unwrap() + - json["nullifier_count"].as_u64().unwrap(); + assert_eq!(active, 6); + } } // ------------------------------------------------------------------------- diff --git a/frame/shielded-pool/rpc/Cargo.toml b/frame/shielded-pool/rpc/Cargo.toml index 3a59e503..1e864ae3 100644 --- a/frame/shielded-pool/rpc/Cargo.toml +++ b/frame/shielded-pool/rpc/Cargo.toml @@ -8,7 +8,6 @@ license = "GPL-3.0-or-later" [dependencies] hex = "0.4" jsonrpsee = { version = "0.24.9", features = ["server", "macros", "client"] } -log = "0.4" pallet-shielded-pool = { path = ".." } pallet-shielded-pool-runtime-api = { path = "../runtime-api" } parity-scale-codec = { version = "3.6", features = ["derive"] } diff --git a/frame/shielded-pool/rpc/src/lib.rs b/frame/shielded-pool/rpc/src/lib.rs index bc5e5e70..528f5774 100644 --- a/frame/shielded-pool/rpc/src/lib.rs +++ b/frame/shielded-pool/rpc/src/lib.rs @@ -20,36 +20,6 @@ pub struct MerkleProof { pub siblings: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ShieldedEvent { - pub block_number: u64, - pub extrinsic_index: u32, - pub event_type: ShieldedEventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(tag = "type")] -pub enum ShieldedEventType { - Shield { - depositor: String, - amount: u128, - commitment: String, - leaf_index: u32, - encrypted_memo: Option, - }, - PrivateTransfer { - nullifiers: Vec, - commitments: Vec, - leaf_indices: Vec, - encrypted_memos: Option>, - }, - Unshield { - nullifier: String, - amount: u128, - recipient: String, - }, -} - #[rpc(client, server)] pub trait ShieldedPoolApi { #[method(name = "shieldedPool_getMerkleTreeInfo")] @@ -57,9 +27,6 @@ pub trait ShieldedPoolApi { #[method(name = "shieldedPool_getMerkleProof")] fn get_merkle_proof(&self, commitment: String) -> RpcResult; - - #[method(name = "shieldedPool_scanEvents")] - fn scan_events(&self, from_block: u64, to_block: u64) -> RpcResult>; } pub struct ShieldedPool { @@ -137,18 +104,4 @@ where .collect(), }) } - - fn scan_events(&self, _from_block: u64, _to_block: u64) -> RpcResult> { - // Event scanning is not implemented via runtime API - // This functionality should be implemented by: - // 1. Indexing events in an off-chain database (recommended) - // 2. Using Substrate's archive node with state queries - // 3. Implementing a custom indexer service - // - // Returning empty list as placeholder. - // TODO: Implement proper event indexing strategy - - log::warn!("scan_events called but not implemented - use event indexer instead"); - Ok(Vec::new()) - } } diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 27210034..aa941aaa 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -194,6 +194,15 @@ pub mod pallet { #[pallet::storage] pub type MerkleLeaves = StorageMap<_, Blake2_128Concat, u32, Commitment, OptionQuery>; + /// Reverse index: commitment -> leaf index. + /// + /// Populated on every `insert_leaf`. Enables O(1) lookup for Merkle proof + /// generation and duplicate-commitment checks, replacing the former O(n) + /// linear scan over `MerkleLeaves`. + #[pallet::storage] + pub type CommitmentToLeafIndex = + StorageMap<_, Blake2_128Concat, Commitment, u32, OptionQuery>; + /// Set of used nullifiers (nullifier -> block number when used) #[pallet::storage] pub type NullifierSet = @@ -348,6 +357,22 @@ pub mod pallet { ValueQuery, >; + /// Total number of commitments ever inserted into the Merkle tree. + /// + /// Monotonically increasing counter. Incremented once per successful + /// `insert_leaf` (shield, private_transfer output, claim_shielded_fees). + /// Enables O(1) pool stats without scanning `MerkleLeaves` key prefixes. + #[pallet::storage] + pub type TotalCommitmentsInserted = StorageValue<_, u64, ValueQuery>; + + /// Total number of nullifiers ever spent (notes consumed). + /// + /// Monotonically increasing counter. Incremented once per + /// `NullifierRepository::mark_as_used` (unshield, private_transfer input). + /// Enables O(1) pool stats without scanning `NullifierSet` key prefixes. + #[pallet::storage] + pub type TotalNullifiersSpent = StorageValue<_, u64, ValueQuery>; + // ======================================================================== // Genesis Config // ======================================================================== diff --git a/frame/shielded-pool/src/merkle.rs b/frame/shielded-pool/src/merkle.rs index da4ebdb6..aada9676 100644 --- a/frame/shielded-pool/src/merkle.rs +++ b/frame/shielded-pool/src/merkle.rs @@ -6,7 +6,7 @@ use crate::{ pallet::{CommitmentMemos, Config, Error, Event, Pallet}, - storage::MerkleRepository, + storage::{MerkleRepository, PoolStatsRepository}, types::{Commitment, DefaultMerklePath, Hash, MerklePath}, }; use alloc::boxed::Box; @@ -344,7 +344,9 @@ impl MerkleTreeService { 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); @@ -734,6 +736,59 @@ mod tests { }); } + #[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 + ); + }); + } + // ── Incremental frontier vs batch consistency ──────────────────────────── #[test] diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index a75eb363..d69dfb95 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -103,7 +103,11 @@ impl ZkVerifierPort for MockZkVerifier { "Invalid public signals length", )); } - // Always return true for testing (bypass ZK verification) + // Sentinel: a proof whose first byte is 0x00 is treated as cryptographically + // rejected (simulates Groth16 returning false). All other non-empty proofs pass. + if proof[0] == 0x00 { + return Ok(false); + } Ok(true) } @@ -116,7 +120,11 @@ impl ZkVerifierPort for MockZkVerifier { if proofs.len() != public_signals.len() { return Err(sp_runtime::DispatchError::Other("Mismatched array lengths")); } - // Always return true for testing (bypass ZK verification) + // Sentinel: any proof in the batch starting with 0x00 causes the whole batch + // to return Ok(false), simulating a failed cryptographic batch verification. + if proofs.iter().any(|p| p.first() == Some(&0x00)) { + return Ok(false); + } Ok(true) } @@ -197,6 +205,13 @@ pub fn mock_evm_address_set(who: u64, addr: sp_core::H160) { sp_io::storage::set(&key, &addr.as_fixed_bytes().encode()); } +/// Write a minimum relay fee to raw test storage. +/// By default `MockRelayer::min_relay_fee()` returns 0; call this to raise the floor. +pub fn mock_set_min_relay_fee(fee: u128) { + use parity_scale_codec::Encode; + sp_io::storage::set(b"mock:min_relay_fee", &fee.encode()); +} + impl pallet_relayer::RelayerInterface for MockRelayer { type AccountId = u64; @@ -206,7 +221,10 @@ impl pallet_relayer::RelayerInterface for MockRelayer { } fn min_relay_fee() -> u128 { - 0 + use parity_scale_codec::Decode; + sp_io::storage::get(b"mock:min_relay_fee") + .and_then(|v| u128::decode(&mut &v[..]).ok()) + .unwrap_or(0) } fn allowed_selectors() -> sp_std::vec::Vec<[u8; 4]> { diff --git a/frame/shielded-pool/src/operations/disclosure/batch_submit.rs b/frame/shielded-pool/src/operations/disclosure/batch_submit.rs index 6452dbe0..fa68a092 100644 --- a/frame/shielded-pool/src/operations/disclosure/batch_submit.rs +++ b/frame/shielded-pool/src/operations/disclosure/batch_submit.rs @@ -286,4 +286,125 @@ mod tests { assert_eq!(ts, Some(1u64)); }); } + + // ── Flujo A + Flujo B mixed batch ───────────────────────────────────────── + // + // Mixing self-disclosure (auditor: None) and audited disclosure + // (auditor: Some(...)) in the same batch is intentional: each submission is + // processed independently inside `batch_submit_proofs`. Mixing is valid as + // long as every individual entry satisfies its own access policy. + // These tests document and pin that behaviour. + + #[test] + fn mixed_batch_flujo_a_and_flujo_b_both_succeed() { + new_test_ext().execute_with(|| { + let owner: u64 = 1; + let auditor: u64 = 2; + + // Flujo A commitment — no auditor required + let c_a = commitment(0x70); + register_commitment(c_a); + + // Flujo B commitment — requires policy + pending request + let c_b = commitment(0x71); + register_commitment(c_b); + set_policy_simple(owner, auditor); + make_request(owner, auditor); + + let submissions = BoundedVec::try_from(vec![ + submission(c_a), // Flujo A + submission_with_auditor(c_b, auditor), // Flujo B + ]) + .unwrap(); + + assert_ok!(batch_submit_proofs::(&owner, submissions)); + + // Both records stored under the owner key + assert!(AuditRepository::has_disclosure_record::(c_a, &owner)); + assert!(AuditRepository::has_disclosure_record::(c_b, &owner)); + }); + } + + #[test] + fn mixed_batch_emits_correct_events_per_entry() { + new_test_ext().execute_with(|| { + let owner: u64 = 1; + let auditor: u64 = 2; + + let c_a = commitment(0x72); + register_commitment(c_a); + + let c_b = commitment(0x73); + register_commitment(c_b); + set_policy_simple(owner, auditor); + make_request(owner, auditor); + + let submissions = + BoundedVec::try_from(vec![submission(c_a), submission_with_auditor(c_b, auditor)]) + .unwrap(); + assert_ok!(batch_submit_proofs::(&owner, submissions)); + + let events = frame_system::Pallet::::events(); + let disclosed: Vec<_> = events + .iter() + .filter_map(|r| { + if let crate::mock::RuntimeEvent::ShieldedPool(PalletEvent::Disclosed { + who, + commitment, + auditor: aud, + }) = &r.event + { + Some((*who, *commitment, aud.clone())) + } else { + None + } + }) + .collect(); + + assert_eq!(disclosed.len(), 2); + + // Flujo A: auditor field must be None + assert!( + disclosed + .iter() + .any(|(who, c, aud)| *who == owner && *c == c_a && aud.is_none()), + "Expected Disclosed event with auditor=None for Flujo A" + ); + // Flujo B: auditor field must be Some(auditor) + assert!( + disclosed + .iter() + .any(|(who, c, aud)| *who == owner && *c == c_b && *aud == Some(auditor)), + "Expected Disclosed event with auditor=Some({auditor}) for Flujo B" + ); + }); + } + + #[test] + fn mixed_batch_fails_when_flujo_b_entry_has_no_policy() { + new_test_ext().execute_with(|| { + let owner: u64 = 1; + let auditor: u64 = 3; // no policy set for this auditor + + let c_a = commitment(0x74); + register_commitment(c_a); + let c_b = commitment(0x75); + register_commitment(c_b); + // Deliberately NOT calling set_policy_simple / make_request + + let submissions = + BoundedVec::try_from(vec![submission(c_a), submission_with_auditor(c_b, auditor)]) + .unwrap(); + + // The whole batch must be rejected — atomicity guarantees no partial state. + assert_noop!( + batch_submit_proofs::(&owner, submissions), + crate::pallet::Error::::UnauthorizedAuditor + ); + + // Neither record should have been stored + assert!(!AuditRepository::has_disclosure_record::(c_a, &owner)); + assert!(!AuditRepository::has_disclosure_record::(c_b, &owner)); + }); + } } diff --git a/frame/shielded-pool/src/operations/fees.rs b/frame/shielded-pool/src/operations/fees.rs index f1d83fcc..ea4b6b17 100644 --- a/frame/shielded-pool/src/operations/fees.rs +++ b/frame/shielded-pool/src/operations/fees.rs @@ -413,6 +413,73 @@ mod tests { // ── ZK proof / public_signals validation ───────────────────────────────── + /// A proof of 128 zero bytes — the MockZkVerifier sentinel for "cryptographically + /// rejected" (returns Ok(false)). Distinct from `make_proof()` (first byte 0x01) + /// which the mock accepts. + fn make_rejected_proof() -> Vec { + vec![0x00u8; 128] + } + + // T1 — Integration tests for claim_shielded ZK path + // + // These tests exercise the `ensure!(is_valid, Error::InvalidProof)` guard that + // sits after the ZK verifier call — the path that fires when the verifier itself + // returns Ok(false) (cryptographically invalid proof, correct format). + // The MockZkVerifier returns Ok(false) for any proof whose first byte is 0x00. + + #[test] + fn claim_shielded_with_cryptographically_invalid_proof_returns_invalid_proof_error() { + new_test_ext().execute_with(|| { + let validator: u64 = 1; + let asset_id = setup_asset(); + let amount = 100u128; + let commitment = make_commitment(); + mock_pending_fees_set(validator, asset_id, amount); + + // Proof has correct length (128) and correct signals, but the mock + // verifier returns Ok(false) for proofs starting with 0x00. + assert_noop!( + FeeOperation::claim_shielded::( + validator, + commitment, + amount, + asset_id, + make_memo(), + make_rejected_proof(), // Ok(false) from verifier + make_signals(&commitment, amount, asset_id), + ), + crate::pallet::Error::::InvalidProof + ); + }); + } + + #[test] + fn claim_shielded_rejected_proof_leaves_no_state_changes() { + // A rejected proof must not insert the commitment or consume fees. + new_test_ext().execute_with(|| { + let validator: u64 = 1; + let asset_id = setup_asset(); + let amount = 200u128; + let commitment = make_commitment(); + mock_pending_fees_set(validator, asset_id, amount); + + let _ = FeeOperation::claim_shielded::( + validator, + commitment, + amount, + asset_id, + make_memo(), + make_rejected_proof(), + make_signals(&commitment, amount, asset_id), + ); + + // Commitment must NOT be in the tree + assert!(!CommitmentRepository::exists::(&commitment)); + // Pending fees must NOT have been consumed + assert_eq!(mock_pending_fees_get(validator, asset_id), amount); + }); + } + #[test] fn claim_shielded_empty_proof_fails() { new_test_ext().execute_with(|| { diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage.rs index 54e062ee..96c6389d 100644 --- a/frame/shielded-pool/src/storage.rs +++ b/frame/shielded-pool/src/storage.rs @@ -5,11 +5,11 @@ use crate::{ pallet::{ - Assets, AuditPolicies, AuditTrailStorage, BalanceOf, CommitmentMemos, Config, - DisclosureCounters, DisclosureRecords, DisclosureRequests, Error, HistoricPoseidonRoots, - HistoricRootsOrder, LastDisclosureTimestamp, MerkleLeaves, MerkleTreeFrontier, - MerkleTreeSize, NextAssetId, NextAuditTrailId, NullifierSet, PoolBalancePerAsset, - PoseidonRoot, + Assets, AuditPolicies, AuditTrailStorage, BalanceOf, CommitmentMemos, + CommitmentToLeafIndex, Config, DisclosureCounters, DisclosureRecords, DisclosureRequests, + Error, HistoricPoseidonRoots, HistoricRootsOrder, LastDisclosureTimestamp, MerkleLeaves, + MerkleTreeFrontier, MerkleTreeSize, NextAssetId, NextAuditTrailId, NullifierSet, + PoolBalancePerAsset, PoseidonRoot, TotalCommitmentsInserted, TotalNullifiersSpent, }, types::{ AssetMetadata, AuditPolicy, AuditTrail, Commitment, DisclosureRecord, DisclosureRequest, @@ -129,17 +129,14 @@ impl MerkleRepository { 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 { - let size = Self::get_tree_size::(); - for i in 0..size { - #[allow(clippy::collapsible_if)] - if let Some(c) = Self::get_leaf::(i) { - if c == *commitment { - return Some(i); - } - } - } - None + Self::get_commitment_leaf_index::(commitment) } pub fn get_all_leaves() -> sp_std::vec::Vec { let size = Self::get_tree_size::(); @@ -161,6 +158,7 @@ impl NullifierRepository { } 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, @@ -169,6 +167,27 @@ impl NullifierRepository { } } +// ════════════════════════════════════════════════════════════════════════════ +// 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 // ════════════════════════════════════════════════════════════════════════════ @@ -547,13 +566,38 @@ mod tests { }); } + #[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)); @@ -610,6 +654,49 @@ mod tests { }); } + // ── 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] diff --git a/frame/shielded-pool/src/types.rs b/frame/shielded-pool/src/types.rs index 64a91fa4..764d0280 100644 --- a/frame/shielded-pool/src/types.rs +++ b/frame/shielded-pool/src/types.rs @@ -332,6 +332,15 @@ impl EncryptedMemo { 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 { @@ -339,6 +348,14 @@ impl EncryptedMemo { } } pub fn ciphertext(&self) -> &[u8] { + // Invariant: see nonce(). ciphertext occupies bytes 12..120. + 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() >= 120 { &self.0[12..120] } else { @@ -346,6 +363,14 @@ impl EncryptedMemo { } } pub fn tag(&self) -> &[u8] { + // Invariant: see nonce(). tag (MAC) occupies bytes 120..136. + 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() >= 136 { &self.0[120..136] } else { diff --git a/frame/shielded-pool/src/validate_unsigned.rs b/frame/shielded-pool/src/validate_unsigned.rs index 14f74b35..ae3df0bf 100644 --- a/frame/shielded-pool/src/validate_unsigned.rs +++ b/frame/shielded-pool/src/validate_unsigned.rs @@ -341,4 +341,83 @@ mod tests { assert!(result.is_ok()); }); } + + // ── fee floor (anti-spam) ───────────────────────────────────────────────── + // + // T2: Verify that both validate_private_transfer and validate_unshield + // enforce the minimum relay fee set by T::Relayer::min_relay_fee(). + // The mock returns 0 by default; mock_set_min_relay_fee lets individual + // tests raise the floor to exercise the Payment rejection path. + + #[test] + fn private_transfer_fee_below_minimum_rejected() { + new_test_ext().execute_with(|| { + crate::mock::mock_set_min_relay_fee(100); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + // fee=50 < min_relay_fee=100 → InvalidTransaction::Payment + let result = + validate_private_transfer::(&KNOWN_ROOT, &nullifiers_of(&[0x01]), &50u128); + assert!(result.is_err(), "fee below minimum must be rejected"); + assert_eq!( + result.unwrap_err(), + sp_runtime::transaction_validity::TransactionValidityError::Invalid( + sp_runtime::transaction_validity::InvalidTransaction::Payment + ), + ); + }); + } + + #[test] + fn private_transfer_fee_at_minimum_accepted() { + new_test_ext().execute_with(|| { + crate::mock::mock_set_min_relay_fee(100); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + // fee == min_relay_fee → accept + let result = + validate_private_transfer::(&KNOWN_ROOT, &nullifiers_of(&[0x01]), &100u128); + assert!(result.is_ok(), "fee equal to minimum must be accepted"); + }); + } + + #[test] + fn unshield_fee_below_minimum_rejected() { + new_test_ext().execute_with(|| { + crate::mock::mock_set_min_relay_fee(200); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + PoolBalanceRepository::set_asset_balance::(0, 10_000u128); + // fee=50 < min_relay_fee=200 → InvalidTransaction::Payment + let result = validate_unshield::( + &KNOWN_ROOT, + &make_nullifier(0x60), + &0u32, + &100u128, + &50u128, + ); + assert!(result.is_err(), "fee below minimum must be rejected"); + assert_eq!( + result.unwrap_err(), + sp_runtime::transaction_validity::TransactionValidityError::Invalid( + sp_runtime::transaction_validity::InvalidTransaction::Payment + ), + ); + }); + } + + #[test] + fn unshield_fee_at_minimum_accepted() { + new_test_ext().execute_with(|| { + crate::mock::mock_set_min_relay_fee(200); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + PoolBalanceRepository::set_asset_balance::(0, 10_000u128); + // fee == min_relay_fee → accept (pool has enough for amount+fee) + let result = validate_unshield::( + &KNOWN_ROOT, + &make_nullifier(0x60), + &0u32, + &100u128, + &200u128, + ); + assert!(result.is_ok(), "fee equal to minimum must be accepted"); + }); + } } From 64b55fadbb1f4526fb87810a9b0324d54ccf5c38 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 29 Apr 2026 19:06:32 -0400 Subject: [PATCH 3/4] chore(package): bump version to 0.5.2 --- frame/shielded-pool/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index dd83c99a..0ce86ac7 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.5.1" +version = "0.5.2" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" From d3992edeb3a8dd171f93d50735b90eb275a17f09 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Wed, 29 Apr 2026 20:03:25 -0400 Subject: [PATCH 4/4] fix(tests): dereference auditor in event handling for clarity --- frame/shielded-pool/src/operations/disclosure/batch_submit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frame/shielded-pool/src/operations/disclosure/batch_submit.rs b/frame/shielded-pool/src/operations/disclosure/batch_submit.rs index fa68a092..830a284c 100644 --- a/frame/shielded-pool/src/operations/disclosure/batch_submit.rs +++ b/frame/shielded-pool/src/operations/disclosure/batch_submit.rs @@ -354,7 +354,7 @@ mod tests { auditor: aud, }) = &r.event { - Some((*who, *commitment, aud.clone())) + Some((*who, *commitment, *aud)) } else { None }