Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions peerx-contracts/counter/src/batch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
117 changes: 117 additions & 0 deletions peerx-contracts/counter/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down
64 changes: 52 additions & 12 deletions peerx-contracts/counter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BatchOperation>) -> 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 {
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
Expand Down
Loading