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/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" 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..830a284c 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)) + } 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"); + }); + } }