From f68095b0d22da1e35f23d40342de8fb45a0ab10c Mon Sep 17 00:00:00 2001 From: Bigg770 Date: Sat, 18 Jul 2026 10:07:55 +0000 Subject: [PATCH 1/3] feat: multi-pool per pair (Issue #1) Change pair_to_pool from Map<(Symbol,Symbol), u64> to Map<(Symbol,Symbol), Vec> so multiple pools (e.g. different fee tiers or depths) can be registered for the same token pair without reverting. find_best_route now iterates every pool in the list for both direct and two-hop legs, selecting the one that produces the highest output for the given amount_in. Tests (lp_tests.rs): - test_two_pools_same_pair_route_chooses_optimal: registers a shallow (1k) and a deep (100k) XLM/USDC pool; asserts the deep pool is chosen. - test_register_second_pool_same_pair_succeeds: verifies that a second register_pool call for the same pair no longer reverts. --- peerx-contracts/counter/src/liquidity_pool.rs | 154 +++++++++++------- peerx-contracts/counter/src/lp_tests.rs | 58 +++++++ 2 files changed, 157 insertions(+), 55 deletions(-) diff --git a/peerx-contracts/counter/src/liquidity_pool.rs b/peerx-contracts/counter/src/liquidity_pool.rs index d8993d7..2b4c3fc 100644 --- a/peerx-contracts/counter/src/liquidity_pool.rs +++ b/peerx-contracts/counter/src/liquidity_pool.rs @@ -30,7 +30,11 @@ pub struct Route { #[contracttype] pub struct PoolRegistry { pools: Map, - pair_to_pool: Map<(Symbol, Symbol), u64>, + /// Maps a normalised token pair to the list of pool IDs registered for + /// that pair. Multiple pools may exist for the same pair (e.g. different + /// fee tiers or depths) — `find_best_route` picks the one with the highest + /// expected output. + pair_to_pool: Map<(Symbol, Symbol), Vec>, next_pool_id: u64, lp_balances: Map<(u64, Address), i128>, } @@ -73,12 +77,6 @@ impl PoolRegistry { } let (norm_a, norm_b) = Self::normalize_pair(token_a.clone(), token_b.clone()); - if self - .pair_to_pool - .contains_key((norm_a.clone(), norm_b.clone())) - { - return Err(ContractError::InvalidSwapPair); - } let pool_id = self.next_pool_id; let (reserve_a, reserve_b) = if token_a == norm_a { @@ -104,7 +102,16 @@ impl PoolRegistry { fee_tier, }, ); - self.pair_to_pool.set((norm_a, norm_b), pool_id); + + // Append pool_id to the list for this pair (creating the list if absent). + let pair_key = (norm_a, norm_b); + let mut pool_list = self + .pair_to_pool + .get(pair_key.clone()) + .unwrap_or_else(|| Vec::new(env)); + pool_list.push_back(pool_id); + self.pair_to_pool.set(pair_key, pool_list); + self.next_pool_id += 1; Ok(pool_id) } @@ -290,27 +297,44 @@ impl PoolRegistry { token_out: Symbol, amount_in: i128, ) -> Option { + let mut best_route: Option = None; + let mut best_output = 0i128; + + // ── Direct routes: consider ALL pools registered for this pair ────────── let (norm_in, norm_out) = Self::normalize_pair(token_in.clone(), token_out.clone()); - if let Some(pool_id) = self.pair_to_pool.get((norm_in, norm_out)) { - if let Some(pool) = self.pools.get(pool_id) { - let output = self.calculate_output(&pool, token_in.clone(), amount_in)?; - let impact = self.calculate_price_impact(&pool, token_in.clone(), amount_in); - let mut pools = Vec::new(env); - pools.push_back(pool_id); - let mut tokens = Vec::new(env); - tokens.push_back(token_in); - tokens.push_back(token_out); - return Some(Route { - pools, - tokens, - expected_output: output, - total_price_impact_bps: impact, - }); + if let Some(pool_ids) = self.pair_to_pool.get((norm_in, norm_out)) { + for j in 0..pool_ids.len() { + if let Some(pool_id) = pool_ids.get(j) { + if let Some(pool) = self.pools.get(pool_id) { + if let Ok(output) = + self.calculate_output(&pool, token_in.clone(), amount_in) + { + if output > best_output { + best_output = output; + let impact = self.calculate_price_impact( + &pool, + token_in.clone(), + amount_in, + ); + let mut pools = Vec::new(env); + pools.push_back(pool_id); + let mut tokens = Vec::new(env); + tokens.push_back(token_in.clone()); + tokens.push_back(token_out.clone()); + best_route = Some(Route { + pools, + tokens, + expected_output: output, + total_price_impact_bps: impact, + }); + } + } + } + } } } - let mut best_route: Option = None; - let mut best_output = 0i128; + // ── Two-hop routes ─────────────────────────────────────────────────────── for i in 0..self.next_pool_id { if let Some(pool1) = self.pools.get(i) { if pool1.token_a == token_in || pool1.token_b == token_in { @@ -320,37 +344,57 @@ impl PoolRegistry { pool1.token_a.clone() }; if intermediate != token_out { - let (norm_int, norm_out) = + let (norm_int, norm_out2) = Self::normalize_pair(intermediate.clone(), token_out.clone()); - if let Some(pool2_id) = self.pair_to_pool.get((norm_int, norm_out)) { - if let Some(pool2) = self.pools.get(pool2_id) { - let out1 = - self.calculate_output(&pool1, token_in.clone(), amount_in)?; - let out2 = - self.calculate_output(&pool2, intermediate.clone(), out1)?; - let impact1 = self.calculate_price_impact( - &pool1, - token_in.clone(), - amount_in, - ); - let impact2 = - self.calculate_price_impact(&pool2, intermediate.clone(), out1); - let total_impact = impact1.saturating_add(impact2); - if out2 > best_output { - best_output = out2; - let mut pools = Vec::new(env); - pools.push_back(i); - pools.push_back(pool2_id); - let mut tokens = Vec::new(env); - tokens.push_back(token_in.clone()); - tokens.push_back(intermediate); - tokens.push_back(token_out.clone()); - best_route = Some(Route { - pools, - tokens, - expected_output: out2, - total_price_impact_bps: total_impact, - }); + if let Some(pool2_ids) = self.pair_to_pool.get((norm_int, norm_out2)) { + // Consider every pool registered for the second leg. + for k in 0..pool2_ids.len() { + if let Some(pool2_id) = pool2_ids.get(k) { + if let Some(pool2) = self.pools.get(pool2_id) { + let out1 = match self.calculate_output( + &pool1, + token_in.clone(), + amount_in, + ) { + Ok(v) => v, + Err(_) => continue, + }; + let out2 = match self.calculate_output( + &pool2, + intermediate.clone(), + out1, + ) { + Ok(v) => v, + Err(_) => continue, + }; + let impact1 = self.calculate_price_impact( + &pool1, + token_in.clone(), + amount_in, + ); + let impact2 = self.calculate_price_impact( + &pool2, + intermediate.clone(), + out1, + ); + let total_impact = impact1.saturating_add(impact2); + if out2 > best_output { + best_output = out2; + let mut pools = Vec::new(env); + pools.push_back(i); + pools.push_back(pool2_id); + let mut tokens = Vec::new(env); + tokens.push_back(token_in.clone()); + tokens.push_back(intermediate.clone()); + tokens.push_back(token_out.clone()); + best_route = Some(Route { + pools, + tokens, + expected_output: out2, + total_price_impact_bps: total_impact, + }); + } + } } } } diff --git a/peerx-contracts/counter/src/lp_tests.rs b/peerx-contracts/counter/src/lp_tests.rs index e1077fc..2e9f30b 100644 --- a/peerx-contracts/counter/src/lp_tests.rs +++ b/peerx-contracts/counter/src/lp_tests.rs @@ -230,3 +230,61 @@ fn test_invalid_fee_tier() { client.register_pool(&admin, &token_a, &token_b, &1000, &1000, &100); } + +// ===== MULTI-POOL PER PAIR TESTS (Issue #1) ===== + +/// Register two XLM/USDC pools at different depths; `find_best_route` +/// should choose the pool that returns a higher output for the given +/// `amount_in`. +#[test] +fn test_two_pools_same_pair_route_chooses_optimal() { + let env = Env::default(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + let xlm = symbol_short!("XLM"); + let usdc = symbol_short!("USDC"); + + // Pool A: shallow — 1 000 XLM / 1 000 USDC (fee tier 1 bps) + let pool_a = client.register_pool(&admin, &xlm, &usdc, &1000, &1000, &1); + // Pool B: deep — 100 000 XLM / 100 000 USDC (fee tier 30 bps) + // A deep pool produces more output per unit because price impact is lower. + let pool_b = client.register_pool(&admin, &xlm, &usdc, &100000, &100000, &30); + + assert_ne!(pool_a, pool_b, "two distinct pool IDs must be assigned"); + + let amount_in: i128 = 100; + let route = client.find_best_route(&xlm, &usdc, &amount_in); + assert!(route.is_some(), "a direct route must be found"); + + let r = route.unwrap(); + assert_eq!(r.pools.len(), 1, "direct route uses exactly one pool"); + + // The optimal pool for 100 XLM is the deep pool (pool_b) because the + // shallow pool suffers a much larger price impact. + let chosen_pool = r.pools.get(0).unwrap(); + assert_eq!( + chosen_pool, pool_b, + "find_best_route should pick the deep pool (pool_b) for lower price impact" + ); +} + +/// Registering a second pool for the same pair must succeed (no duplicate rejection). +#[test] +fn test_register_second_pool_same_pair_succeeds() { + let env = Env::default(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + let tok_a = symbol_short!("TOKA"); + let tok_b = symbol_short!("TOKB"); + + let id1 = client.register_pool(&admin, &tok_a, &tok_b, &500, &500, &1); + let id2 = client.register_pool(&admin, &tok_a, &tok_b, &5000, &5000, &5); + + assert_ne!(id1, id2); + assert!(client.get_pool(&id1).is_some()); + assert!(client.get_pool(&id2).is_some()); +} From 1db97afb057159bd264419877260818bc6486081 Mon Sep 17 00:00:00 2001 From: Bigg770 Date: Sat, 18 Jul 2026 10:11:38 +0000 Subject: [PATCH 2/3] feat: swap distribution metrics (Issue #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add on-chain swap-size histogram published as a SwapDistribution event. events.rs: - SwapDistribution contracttype struct with four buckets: size_0_100 / size_100_1k / size_1k_10k / size_10k_plus + total_swaps. - SWAP_COUNT_KEY and SWAP_BUCKETS_KEY instance-storage constants. - Events::record_swap() increments counter and bucket, auto-emits the SwapDistribution event every SWAP_DISTRIBUTION_CADENCE (1 000) swaps. - Events::emit_swap_distribution() for admin-triggered flush + bucket reset. - Events::swap_count() helper for tests and analytics. lib.rs: - CounterContract::emit_swap_distribution() entry point (admin-gated). - swap() now calls Events::record_swap() after every successful swap. Also includes BatchShape optimisation (Issue #3) and its tests — both land in the same lib.rs file. Tests (batch_tests.rs): - test_swap_count_increments: asserts counter is 1 after one swap, 2 after two. - test_admin_trigger_emit_swap_distribution: admin can trigger flush without panic. - test_batch_shape_ten_op_batch_correct_counts: 10-op batch executes correctly. - test_batch_shape_mixed_operations: mixed swap/mint batch executes correctly. --- peerx-contracts/counter/src/batch_tests.rs | 131 +++++++++++++++++++++ peerx-contracts/counter/src/events.rs | 117 ++++++++++++++++++ peerx-contracts/counter/src/lib.rs | 80 +++++++++++-- 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/peerx-contracts/counter/src/batch_tests.rs b/peerx-contracts/counter/src/batch_tests.rs index 86305dc..e6374b1 100644 --- a/peerx-contracts/counter/src/batch_tests.rs +++ b/peerx-contracts/counter/src/batch_tests.rs @@ -583,3 +583,134 @@ fn test_clear_error_messages() { assert!(!err_sym.to_string().is_empty()); } } + +// ===== SWAP DISTRIBUTION METRICS TESTS (Issue #2) ===== + +/// Verify that the swap counter increments on each swap call. +#[test] +fn test_swap_count_increments() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let xlm = symbol_short!("XLM"); + let usdc = symbol_short!("USDCSIM"); + + client.mint(&xlm, &user, &5000); + + // Swap once — counter should be 1. + client.swap(&xlm, &usdc, &100, &user); + assert_eq!( + crate::events::Events::swap_count(&env), + 1, + "swap counter should be 1 after one swap" + ); + + // Swap again — counter should be 2. + client.swap(&xlm, &usdc, &100, &user); + assert_eq!( + crate::events::Events::swap_count(&env), + 2, + "swap counter should be 2 after two swaps" + ); +} + +/// Verify that `emit_swap_distribution` can be called by admin and resets +/// the bucket counters for the next window. +#[test] +fn test_admin_trigger_emit_swap_distribution() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + // Set admin in persistent storage (matches how set_admin stores it). + env.storage() + .persistent() + .set(&crate::storage::ADMIN_KEY, &admin); + + let user = Address::generate(&env); + let xlm = symbol_short!("XLM"); + let usdc = symbol_short!("USDCSIM"); + client.mint(&xlm, &user, &5000); + + // Perform a few swaps to populate buckets. + client.swap(&xlm, &usdc, &50, &user); // bucket 0-100 + client.swap(&xlm, &usdc, &500, &user); // bucket 100-1k + client.swap(&xlm, &usdc, &50, &user); // bucket 0-100 + + // Admin triggers distribution — should not panic. + client.emit_swap_distribution(&admin); +} + +// ===== BATCH SHAPE OPTIMIZATION TESTS (Issue #3) ===== + +/// A 10-operation batch must complete without double-counting swap operations. +/// This test verifies correctness of the BatchShape single-pass approach: the +/// same number of operations_executed should be returned as in the old path. +#[test] +fn test_batch_shape_ten_op_batch_correct_counts() { + let env = Env::default(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let xlm = symbol_short!("XLM"); + let usdc = symbol_short!("USDCSIM"); + + // Give the user enough balance for 10 swaps of 100 each. + client.mint(&xlm, &user, &5000); + + let mut batch_ops = Vec::new(&env); + for _ in 0..10 { + batch_ops.push_back(BatchOperation::Swap( + xlm.clone(), + usdc.clone(), + 100, + user.clone(), + )); + } + + let result = client.execute_batch_atomic(&batch_ops); + + // All 10 operations should have executed successfully. + assert_eq!(result.operations_executed, 10); + assert_eq!(result.operations_failed, 0); +} + +/// Verify that a mixed batch (swaps + mints) counts each category correctly +/// by checking the overall result is consistent. +#[test] +fn test_batch_shape_mixed_operations() { + let env = Env::default(); + let contract_id = env.register(CounterContract, ()); + let client = CounterContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let xlm = symbol_short!("XLM"); + let usdc = symbol_short!("USDCSIM"); + + client.mint(&xlm, &user, &5000); + + let mut batch_ops = Vec::new(&env); + // 3 swaps + for _ in 0..3 { + batch_ops.push_back(BatchOperation::Swap( + xlm.clone(), + usdc.clone(), + 100, + user.clone(), + )); + } + // 2 mints + batch_ops.push_back(BatchOperation::MintToken(xlm.clone(), user.clone(), 100)); + batch_ops.push_back(BatchOperation::MintToken(usdc.clone(), user.clone(), 100)); + + let result = client.execute_batch_best_effort(&batch_ops); + + assert_eq!(result.operations_executed, 5); + assert_eq!(result.operations_failed, 0); +} diff --git a/peerx-contracts/counter/src/events.rs b/peerx-contracts/counter/src/events.rs index 7313be8..af99e4a 100644 --- a/peerx-contracts/counter/src/events.rs +++ b/peerx-contracts/counter/src/events.rs @@ -8,7 +8,37 @@ pub struct BadgeEvent { pub timestamp: i64, } +/// On-chain histogram of swap sizes. Published as a `SwapDistribution` event +/// every `SWAP_DISTRIBUTION_CADENCE` swaps (default 1 000) or on admin +/// trigger via `emit_swap_distribution`. +/// +/// Buckets (inclusive lower, exclusive upper): +/// • `size_0_100` — [0, 100) +/// • `size_100_1k` — [100, 1 000) +/// • `size_1k_10k` — [1 000, 10 000) +/// • `size_10k_plus` — [10 000, ∞) +#[contracttype] +#[derive(Clone)] +pub struct SwapDistribution { + pub size_0_100: u64, + pub size_100_1k: u64, + pub size_1k_10k: u64, + pub size_10k_plus: u64, + /// Total swap count captured in this snapshot. + pub total_swaps: u64, + pub timestamp: i64, +} + +/// Number of swaps between automatic `SwapDistribution` emissions. +pub const SWAP_DISTRIBUTION_CADENCE: u64 = 1_000; + const EVENT_BUFFER_KEY: Symbol = Symbol::short("evt_buf"); +/// Persistent key for the global swap counter. +const SWAP_COUNT_KEY: Symbol = Symbol::short("sw_cnt"); +/// Persistent key for the per-bucket histogram counters (stored as a 4-element +/// tuple to keep a single storage round-trip). +/// Layout: (size_0_100, size_100_1k, size_1k_10k, size_10k_plus) +const SWAP_BUCKETS_KEY: Symbol = Symbol::short("sw_bkt"); pub struct Events; @@ -28,6 +58,93 @@ impl Events { ); } + /// Increment the global swap counter and update the size histogram. + /// Automatically emits a `SwapDistribution` event when the counter + /// crosses a `SWAP_DISTRIBUTION_CADENCE` boundary. + pub fn record_swap(env: &Env, amount: i128, timestamp: i64) { + // --- update counter --- + let prev_count: u64 = env + .storage() + .instance() + .get(&SWAP_COUNT_KEY) + .unwrap_or(0u64); + let new_count = prev_count.saturating_add(1); + env.storage().instance().set(&SWAP_COUNT_KEY, &new_count); + + // --- update bucket histogram --- + let (mut b0, mut b1, mut b2, mut b3): (u64, u64, u64, u64) = env + .storage() + .instance() + .get(&SWAP_BUCKETS_KEY) + .unwrap_or((0u64, 0u64, 0u64, 0u64)); + match amount { + a if a < 100 => b0 = b0.saturating_add(1), + a if a < 1_000 => b1 = b1.saturating_add(1), + a if a < 10_000 => b2 = b2.saturating_add(1), + _ => b3 = b3.saturating_add(1), + } + env.storage() + .instance() + .set(&SWAP_BUCKETS_KEY, &(b0, b1, b2, b3)); + + // --- auto-emit on cadence boundary --- + if new_count % SWAP_DISTRIBUTION_CADENCE == 0 { + Self::emit_swap_distribution_inner(env, b0, b1, b2, b3, new_count, timestamp); + } + } + + /// Admin-triggered emission of the current swap distribution histogram. + /// Resets buckets after emission so each window is independent. + /// Caller must have already been authorized before calling this function. + pub fn emit_swap_distribution(env: &Env, _admin: Address, timestamp: i64) { + let total_swaps: u64 = env + .storage() + .instance() + .get(&SWAP_COUNT_KEY) + .unwrap_or(0u64); + let (b0, b1, b2, b3): (u64, u64, u64, u64) = env + .storage() + .instance() + .get(&SWAP_BUCKETS_KEY) + .unwrap_or((0u64, 0u64, 0u64, 0u64)); + Self::emit_swap_distribution_inner(env, b0, b1, b2, b3, total_swaps, timestamp); + // Reset buckets after manual flush so the next window starts fresh. + env.storage() + .instance() + .set(&SWAP_BUCKETS_KEY, &(0u64, 0u64, 0u64, 0u64)); + } + + /// Returns the current swap counter (useful for tests / analytics). + pub fn swap_count(env: &Env) -> u64 { + env.storage() + .instance() + .get(&SWAP_COUNT_KEY) + .unwrap_or(0u64) + } + + fn emit_swap_distribution_inner( + env: &Env, + b0: u64, + b1: u64, + b2: u64, + b3: u64, + total_swaps: u64, + timestamp: i64, + ) { + let distribution = SwapDistribution { + size_0_100: b0, + size_100_1k: b1, + size_1k_10k: b2, + size_10k_plus: b3, + total_swaps, + timestamp, + }; + env.events().publish( + (Symbol::new(env, "SwapDistribution"),), + distribution, + ); + } + pub fn liquidity_added( env: &Env, xlm_amount: i128, diff --git a/peerx-contracts/counter/src/lib.rs b/peerx-contracts/counter/src/lib.rs index a5a7008..5c1bd2e 100644 --- a/peerx-contracts/counter/src/lib.rs +++ b/peerx-contracts/counter/src/lib.rs @@ -226,6 +226,41 @@ fn save_pool_registry(env: &Env, registry: &PoolRegistry) { env.storage().instance().set(&POOL_REGISTRY_KEY, registry); } +/// Pre-computed shape of a batch — produced in a single pass over the +/// operation vec so no part of the hot path needs to iterate it twice. +struct BatchShape { + swap_count: u32, + mint_count: u32, + lp_add_count: u32, + lp_remove_count: u32, +} + +impl BatchShape { + /// Build a `BatchShape` in one O(n) pass over `operations`. + fn compute(operations: &Vec) -> Self { + let mut swap_count = 0u32; + let mut mint_count = 0u32; + let mut lp_add_count = 0u32; + let mut lp_remove_count = 0u32; + for i in 0..operations.len() { + if let Some(op) = operations.get(i) { + match op { + BatchOperation::Swap(_, _, _, _) => swap_count += 1, + BatchOperation::MintToken(_, _, _) => mint_count += 1, + BatchOperation::AddLiquidity(_, _, _) => lp_add_count += 1, + BatchOperation::RemoveLiquidity(_, _, _) => lp_remove_count += 1, + } + } + } + Self { + swap_count, + mint_count, + lp_add_count, + lp_remove_count, + } + } +} + #[derive(Clone)] #[contracttype] struct CacheHitMetrics { @@ -451,6 +486,9 @@ impl CounterContract { portfolio.record_trade(&env, user.clone()); + // Update swap distribution metrics (auto-emits event every 1 000 swaps). + crate::events::Events::record_swap(&env, amount, env.ledger().timestamp() as i64); + // Record daily portfolio value for analytics portfolio.record_daily_portfolio_value(&env, user.clone(), env.ledger().timestamp()); @@ -729,6 +767,22 @@ impl CounterContract { RateLimiter::cleanup_rate_limits(&env, &user) } + // ===== OBSERVABILITY ===== + + /// Admin-triggered emission of the current swap-size histogram. + /// Useful when the automatic 1 000-swap cadence has not been reached yet + /// but fresh metrics are needed (e.g., before a protocol upgrade). + pub fn emit_swap_distribution(env: Env, admin: Address) { + admin.require_auth(); + crate::admin::require_admin(&env, &admin) + .expect("only admin may trigger swap distribution"); + crate::events::Events::emit_swap_distribution( + &env, + admin, + env.ledger().timestamp() as i64, + ); + } + // ===== BATCH OPERATIONS ===== pub fn execute_batch_atomic(env: Env, operations: Vec) -> BatchResult { @@ -764,12 +818,14 @@ impl CounterContract { .get(&()) .unwrap_or_else(|| Portfolio::new(&env)); + // Pre-compute batch shape in a single pass — reused below for both the + // rate-limit check and the rate-limit recording step (Issue #3). + let shape = BatchShape::compute(&operations); + // Check rate limiting for batch operations with swaps if let Some(caller_addr) = &caller { let user_tier = portfolio.get_user_tier(&env, caller_addr.clone()); - // Count swap operations in batch - let swap_count = operations.iter().filter(|op| matches!(op, BatchOperation::Swap(_, _, _, _))).count(); - if swap_count > 0 { + if shape.swap_count > 0 { // Apply rate limit check for batch swaps if RateLimiter::check_swap_limit(&env, caller_addr, &user_tier).is_err() { let mut result = BatchResult::new(&env); @@ -785,10 +841,9 @@ impl CounterContract { Ok(res) => { env.storage().instance().set(&(), &portfolio); - // Record rate limit usage for executed swaps + // Record rate limit usage for executed swaps — reuse pre-computed shape. if let Some(caller_addr) = &caller { - let swap_count = operations.iter().filter(|op| matches!(op, BatchOperation::Swap(_, _, _, _))).count(); - if swap_count > 0 && res.operations_executed > 0 { + if shape.swap_count > 0 && res.operations_executed > 0 { for _ in 0..res.operations_executed { RateLimiter::record_swap_op(&env, caller_addr, env.ledger().timestamp()); } @@ -840,12 +895,14 @@ impl CounterContract { .get(&()) .unwrap_or_else(|| Portfolio::new(&env)); + // Pre-compute batch shape in a single pass — reused below for both the + // rate-limit check and the rate-limit recording step (Issue #3). + let shape = BatchShape::compute(&operations); + // Check rate limiting for batch operations with swaps if let Some(caller_addr) = &caller { let user_tier = portfolio.get_user_tier(&env, caller_addr.clone()); - // Count swap operations in batch - let swap_count = operations.iter().filter(|op| matches!(op, BatchOperation::Swap(_, _, _, _))).count(); - if swap_count > 0 { + if shape.swap_count > 0 { // Apply rate limit check for batch swaps if RateLimiter::check_swap_limit(&env, caller_addr, &user_tier).is_err() { let mut result = BatchResult::new(&env); @@ -861,10 +918,9 @@ impl CounterContract { Ok(res) => { env.storage().instance().set(&(), &portfolio); - // Record rate limit usage for executed swaps + // Record rate limit usage for executed swaps — reuse pre-computed shape. if let Some(caller_addr) = &caller { - let swap_count = operations.iter().filter(|op| matches!(op, BatchOperation::Swap(_, _, _, _))).count(); - if swap_count > 0 && res.operations_executed > 0 { + if shape.swap_count > 0 && res.operations_executed > 0 { for _ in 0..res.operations_executed { RateLimiter::record_swap_op(&env, caller_addr, env.ledger().timestamp()); } From c18a911f4aab96a0f02333739741ee70eb918fed Mon Sep 17 00:00:00 2001 From: Bigg770 Date: Sat, 18 Jul 2026 10:12:16 +0000 Subject: [PATCH 3/3] perf: single-pass BatchShape pre-computation (Issue #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the double filter-iteration pattern in execute_batch_atomic and execute_batch_best_effort with a pre-computed BatchShape. Problem: both functions called operations.iter().filter(|op| matches!(op, BatchOperation::Swap(…))).count() twice — once for the rate-limit check and once for recording usage. Fix: introduce BatchShape { swap_count, mint_count, lp_add_count, lp_remove_count } computed in a single O(n) pass via BatchShape::compute(). The result is stored before the portfolio load and reused for both the rate-limit gate and the record step, eliminating the redundant iteration. Tests (batch_tests.rs): - test_batch_shape_ten_op_batch_correct_counts: 10-op all-swap batch completes with operations_executed == 10. - test_batch_shape_mixed_operations: 3 swaps + 2 mints returns operations_executed == 5 with no failures. Note: BatchShape code lands in the same lib.rs commit as Issue #2 because both touch the same file. This commit records the intent and acceptance criteria.