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 e91cd1a..99fed99 100644 --- a/peerx-contracts/counter/src/events.rs +++ b/peerx-contracts/counter/src/events.rs @@ -10,7 +10,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; @@ -32,6 +62,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 7018af1..6471313 100644 --- a/peerx-contracts/counter/src/lib.rs +++ b/peerx-contracts/counter/src/lib.rs @@ -286,6 +286,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 { @@ -531,6 +566,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()); @@ -1185,12 +1223,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); @@ -1206,10 +1246,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()); } @@ -1261,12 +1300,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); @@ -1282,10 +1323,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()); }