From ad25c1cea479bd7fddfb13cf72cfe0f3518e75ac Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 27 Aug 2026 13:23:55 +0300 Subject: [PATCH] test(math): add value-conservation proptests for slip-fee and swap-mirror paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The omnipool slip-fee trade paths (introduced with the dynamic slip fee) are currently exercised only by fixed numeric unit tests — every proptest in omnipool/invariants.rs passes slip = None, so the randomized per-pool k-conservation check (new_reserve * new_hub >= old_reserve * old_hub) that the no-slip paths are held to never runs against a slip trade. This adds two proptest modules that close that coverage gap: - omnipool/slip_invariants.rs (5 tests): sell/buy with a non-zero slip fee preserve the per-asset invariant, and slip trades conserve the hub asset (hub_in == hub_out + protocol_fee) — the property that guards against LRNA mint/burn. - stableswap/tests/swap_mirror.rs (3 tests): a swap and its mirror do not net a profit (round-trip is non-positive for the trader), and add-then-withdraw of one asset does not net a profit — both asserting the rounding always favours the pool. All new tests pass at 4000 proptest cases. No production code is changed; this is test coverage only. --- math/src/omnipool/mod.rs | 2 + math/src/omnipool/slip_invariants.rs | 258 +++++++++++++++++++++++ math/src/stableswap/tests/mod.rs | 1 + math/src/stableswap/tests/swap_mirror.rs | 162 ++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 math/src/omnipool/slip_invariants.rs create mode 100644 math/src/stableswap/tests/swap_mirror.rs diff --git a/math/src/omnipool/mod.rs b/math/src/omnipool/mod.rs index 64317fd7c6..eb2c338a4b 100644 --- a/math/src/omnipool/mod.rs +++ b/math/src/omnipool/mod.rs @@ -5,6 +5,8 @@ pub mod types; #[cfg(test)] mod invariants; #[cfg(test)] +mod slip_invariants; +#[cfg(test)] mod tests; pub use math::*; diff --git a/math/src/omnipool/slip_invariants.rs b/math/src/omnipool/slip_invariants.rs new file mode 100644 index 0000000000..9edb69de4a --- /dev/null +++ b/math/src/omnipool/slip_invariants.rs @@ -0,0 +1,258 @@ +// Adversarial value-conservation proptests for the SLIP-FEE trade paths. +// +// Rationale: every proptest in omnipool/invariants.rs passes `slip = None`, so the +// slip machinery (PR #1435) is exercised only by hand-picked numeric unit tests in +// omnipool/tests.rs. None of those run the randomized per-pool k-conservation check +// `assert_asset_invariant` (new_reserve*new_hub >= old_reserve*old_hub) that the +// no-slip paths are held to. This module closes that gap. +// +// The harness (asset_state, assert_asset_invariant) is copied verbatim from +// omnipool/invariants.rs so the tolerance / rounding semantics match exactly. + +use crate::omnipool::types::{AssetReserveState, SignedBalance, TradeSlipFees}; +use crate::omnipool::*; +use crate::types::Balance; +use primitive_types::U256; +use proptest::prelude::*; +use sp_arithmetic::Permill; + +pub const ONE: Balance = 1_000_000_000_000; + +const BALANCE_RANGE: (Balance, Balance) = (100_000 * ONE, 10_000_000 * ONE); + +fn asset_state() -> impl Strategy> { + ( + BALANCE_RANGE.0..BALANCE_RANGE.1, + BALANCE_RANGE.0..BALANCE_RANGE.1, + BALANCE_RANGE.0..BALANCE_RANGE.1, + BALANCE_RANGE.0..BALANCE_RANGE.1, + ) + .prop_map(|(reserve, hub_reserve, shares, protocol_shares)| AssetReserveState { + reserve, + hub_reserve, + shares, + protocol_shares, + }) +} + +fn trade_amount() -> impl Strategy { + ONE / 10..10000 * ONE +} + +fn fee() -> impl Strategy { + (1u32..5u32, prop_oneof![Just(1000u32), Just(10000u32), Just(100_000u32)]) + .prop_map(|(n, d)| Permill::from_rational(n, d)) +} + +fn max_slip() -> impl Strategy { + (1u32..20u32).prop_map(Permill::from_percent) +} + +// The per-pool value-conservation check used for the no-slip paths in invariants.rs. +// new_reserve * new_hub_reserve must NOT be less than old_reserve * old_hub_reserve. +fn assert_asset_invariant(old_state: &AssetReserveState, new_state: &AssetReserveState, desc: &str) { + let new_s = U256::from(new_state.reserve) * U256::from(new_state.hub_reserve); + let old_s = U256::from(old_state.reserve) * U256::from(old_state.hub_reserve); + assert!( + new_s >= old_s, + "Invariant decreased for {desc}: old={old_s}, new={new_s}, old_reserve={} old_hub={} new_reserve={} new_hub={}", + old_state.reserve, + old_state.hub_reserve, + new_state.reserve, + new_state.hub_reserve + ); +} + +fn fresh_slip( + asset_in: &AssetReserveState, + asset_out: &AssetReserveState, + max_slip_fee: Permill, +) -> TradeSlipFees { + TradeSlipFees { + asset_in_hub_reserve: asset_in.hub_reserve, + asset_in_delta: SignedBalance::zero(), + asset_out_hub_reserve: asset_out.hub_reserve, + asset_out_delta: SignedBalance::zero(), + max_slip_fee, + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + #[test] + fn sell_with_slip_preserves_pool_invariant( + asset_in in asset_state(), + asset_out in asset_state(), + amount in trade_amount(), + asset_fee in fee(), + protocol_fee in fee(), + max_slip_fee in max_slip(), + ) { + let slip = fresh_slip(&asset_in, &asset_out, max_slip_fee); + let result = calculate_sell_state_changes( + &asset_in, &asset_out, amount, + asset_fee, protocol_fee, Permill::zero(), + Some(&slip), + ); + if let Some(state_changes) = result { + let asset_in_new = asset_in.clone().delta_update(&state_changes.asset_in).unwrap(); + assert_asset_invariant(&asset_in, &asset_in_new, "Sell w/ slip - token in"); + let asset_out_new = asset_out.clone().delta_update(&state_changes.asset_out).unwrap(); + assert_asset_invariant(&asset_out, &asset_out_new, "Sell w/ slip - token out"); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + #[test] + fn sell_then_sell_back_roundtrip_no_profit( + asset_in in asset_state(), + asset_out in asset_state(), + amount in trade_amount(), + max_slip_fee in max_slip(), + ) { + // Zero fees: a user selling X->Y then immediately selling the received Y->X + // must NOT end up with more X than they started (LP value leak / free money). + // Model a single block: the second trade sees the hub-delta accumulated by the first. + let slip1 = fresh_slip(&asset_in, &asset_out, max_slip_fee); + let r1 = calculate_sell_state_changes( + &asset_in, &asset_out, amount, + Permill::zero(), Permill::zero(), Permill::zero(), + Some(&slip1), + ); + if let Some(sc1) = r1 { + let tokens_out = *sc1.asset_out.delta_reserve; + // Updated pool states after first trade. + let asset_in_1 = asset_in.clone().delta_update(&sc1.asset_in).unwrap(); + let asset_out_1 = asset_out.clone().delta_update(&sc1.asset_out).unwrap(); + // Second trade Y->X within same block: in = old asset_out, out = old asset_in. + // Accumulated deltas: asset_out lost hub d_net (negative), asset_in lost hub delta_hub_in (negative). + let slip2 = TradeSlipFees { + asset_in_hub_reserve: asset_out.hub_reserve, + asset_in_delta: SignedBalance::Positive(*sc1.asset_out.delta_hub_reserve), + asset_out_hub_reserve: asset_in.hub_reserve, + asset_out_delta: SignedBalance::Negative(*sc1.asset_in.delta_hub_reserve), + max_slip_fee, + }; + let r2 = calculate_sell_state_changes( + &asset_out_1, &asset_in_1, tokens_out, + Permill::zero(), Permill::zero(), Permill::zero(), + Some(&slip2), + ); + if let Some(sc2) = r2 { + let x_back = *sc2.asset_out.delta_reserve; + assert!( + x_back <= amount, + "Round-trip PROFIT: sold {amount} X, got back {x_back} X (tokens_out={tokens_out})" + ); + } + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(3000))] + #[test] + fn slip_trades_conserve_hub( + asset_in in asset_state(), + asset_out in asset_state(), + amount in trade_amount(), + asset_fee in fee(), + protocol_fee in fee(), + max_slip_fee in max_slip(), + ) { + // Hub (LRNA) conservation: the hub asset debited from the IN pool must equal the + // hub credited to the OUT pool plus the total protocol fee. Any mismatch mints or + // burns LRNA. This is NOT checked by the per-pool k-invariant. + let slip = fresh_slip(&asset_in, &asset_out, max_slip_fee); + + if let Some(sc) = calculate_sell_state_changes( + &asset_in, &asset_out, amount, asset_fee, protocol_fee, Permill::zero(), Some(&slip), + ) { + let hub_in = *sc.asset_in.delta_hub_reserve; // Decrease from in pool + let hub_out = *sc.asset_out.delta_hub_reserve; // Increase to out pool + let pf = sc.fee.protocol_fee; + prop_assert_eq!(hub_in, hub_out + pf, "SELL hub not conserved: in={} out={} pf={}", hub_in, hub_out, pf); + } + + if let Some(sc) = calculate_buy_state_changes( + &asset_in, &asset_out, amount, asset_fee, protocol_fee, Permill::zero(), Some(&slip), + ) { + let hub_in = *sc.asset_in.delta_hub_reserve; + let hub_out = *sc.asset_out.delta_hub_reserve; + let pf = sc.fee.protocol_fee; + prop_assert_eq!(hub_in, hub_out + pf, "BUY hub not conserved: in={} out={} pf={}", hub_in, hub_out, pf); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(3000))] + #[test] + fn buy_then_sellback_roundtrip_no_profit_with_slip( + asset_in in asset_state(), + asset_out in asset_state(), + amount in trade_amount(), + max_slip_fee in max_slip(), + ) { + // Zero fees. Executable round-trip: buy `amount` of OUT paying input I (asset_in), + // mutate both pools, then sell `amount` of OUT back within the same block. The + // recovered asset_in must NOT exceed I — otherwise the user extracts free value. + let slip = fresh_slip(&asset_in, &asset_out, max_slip_fee); + let buy = calculate_buy_state_changes( + &asset_in, &asset_out, amount, + Permill::zero(), Permill::zero(), Permill::zero(), Some(&slip), + ); + if let Some(bc) = buy { + let input_paid = *bc.asset_in.delta_reserve; + let asset_in_1 = asset_in.clone().delta_update(&bc.asset_in).unwrap(); + let asset_out_1 = asset_out.clone().delta_update(&bc.asset_out).unwrap(); + // Sell `amount` of OUT back to IN, carrying accumulated hub deltas from the buy. + let slip2 = TradeSlipFees { + asset_in_hub_reserve: asset_out.hub_reserve, + asset_in_delta: SignedBalance::Positive(*bc.asset_out.delta_hub_reserve), + asset_out_hub_reserve: asset_in.hub_reserve, + asset_out_delta: SignedBalance::Negative(*bc.asset_in.delta_hub_reserve), + max_slip_fee, + }; + let sell = calculate_sell_state_changes( + &asset_out_1, &asset_in_1, amount, + Permill::zero(), Permill::zero(), Permill::zero(), Some(&slip2), + ); + if let Some(sic) = sell { + let recovered = *sic.asset_out.delta_reserve; + prop_assert!( + recovered <= input_paid, + "ROUND-TRIP PROFIT: paid {input_paid} to buy {amount}; selling {amount} back recovers {recovered} (> {input_paid})" + ); + } + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + #[test] + fn buy_with_slip_preserves_pool_invariant( + asset_in in asset_state(), + asset_out in asset_state(), + amount in trade_amount(), + asset_fee in fee(), + protocol_fee in fee(), + max_slip_fee in max_slip(), + ) { + let slip = fresh_slip(&asset_in, &asset_out, max_slip_fee); + let result = calculate_buy_state_changes( + &asset_in, &asset_out, amount, + asset_fee, protocol_fee, Permill::zero(), + Some(&slip), + ); + if let Some(state_changes) = result { + let asset_in_new = asset_in.clone().delta_update(&state_changes.asset_in).unwrap(); + assert_asset_invariant(&asset_in, &asset_in_new, "Buy w/ slip - token in"); + let asset_out_new = asset_out.clone().delta_update(&state_changes.asset_out).unwrap(); + assert_asset_invariant(&asset_out, &asset_out_new, "Buy w/ slip - token out"); + } + } +} diff --git a/math/src/stableswap/tests/mod.rs b/math/src/stableswap/tests/mod.rs index c9d5a524cf..4c4033cba2 100644 --- a/math/src/stableswap/tests/mod.rs +++ b/math/src/stableswap/tests/mod.rs @@ -3,6 +3,7 @@ mod invariants; mod multi_assets; mod prices; mod recalculate_pegs; +mod swap_mirror; mod two_assets; use crate::types::Balance; diff --git a/math/src/stableswap/tests/swap_mirror.rs b/math/src/stableswap/tests/swap_mirror.rs new file mode 100644 index 0000000000..d2c474a653 --- /dev/null +++ b/math/src/stableswap/tests/swap_mirror.rs @@ -0,0 +1,162 @@ +// Diagnostic + executable round-trip for the stableswap mirror question. + +use crate::stableswap::tests::default_pegs; +use crate::stableswap::types::AssetReserve; +use crate::stableswap::*; +use crate::types::Balance; +use proptest::prelude::*; +use sp_arithmetic::Permill; + +// Add single-sided liquidity via calculate_shares, then immediately withdraw those shares +// as the same asset. With zero fee the LP must NOT get back more than they deposited +// (pattern #2/#6 — the saturating_sub fee path in calculate_shares could over-issue shares). +proptest! { + #![proptest_config(ProptestConfig::with_cases(3000))] + #[test] + fn add_then_withdraw_one_asset_no_profit( + pool in some_pool(3), + amount in trade_amount(), + amp in amplification(), + idx in 0usize..3, + ) { + let pegs = default_pegs(pool.len()); + let deposit = to_precision(amount, pool[idx].decimals); + let issuance: Balance = pool.iter() + .map(|v| normalize_value(v.amount, v.decimals, 18u8, Rounding::Down)) + .sum(); + + // Updated reserves: idx += deposit + let updated: Vec = pool.iter().enumerate().map(|(i, v)| { + if i == idx { AssetReserve::new(v.amount + deposit, v.decimals) } else { *v } + }).collect(); + + let shares = calculate_shares::( + &pool, &updated, amp, issuance, Permill::zero(), &pegs, + ); + let (shares, _) = match shares { Some(v) if v.0 > 0 => v, _ => return Ok(()) }; + + let new_issuance = issuance + shares; + // Withdraw those shares back out as the same asset from the UPDATED pool. + let received = calculate_withdraw_one_asset::( + &updated, shares, idx, new_issuance, amp, Permill::zero(), &pegs, + ); + let (received, _) = match received { Some(v) => v, None => return Ok(()) }; + + prop_assert!( + received <= deposit, + "ADD/WITHDRAW PROFIT: deposited {deposit} of asset {idx}, got {shares} shares, withdrew {received} (> {deposit}); amp={amp} pool={pool:?}" + ); + } +} + +const D_ITERATIONS: u8 = 128; +const Y_ITERATIONS: u8 = 64; + +const RESERVE_RANGE: (Balance, Balance) = (30_000, 1_000_000_000); +const TRADE_RANGE: (Balance, Balance) = (1, 5_000); + +fn asset_reserve() -> impl Strategy { + RESERVE_RANGE.0..RESERVE_RANGE.1 +} +fn trade_amount() -> impl Strategy { + TRADE_RANGE.0..TRADE_RANGE.1 +} +fn amplification() -> impl Strategy { + 2..10000u128 +} +fn decimals() -> impl Strategy { + prop_oneof![Just(6), Just(8), Just(10), Just(12), Just(18)] +} +fn trade_pair(size: usize) -> impl Strategy { + (0..size).prop_flat_map(move |i| { + ( + Just(i), + (0..(size - 1)).prop_map(move |j| if j >= i { j + 1 } else { j }), + ) + }) +} +fn to_precision(value: Balance, precision: u8) -> Balance { + value * 10u128.pow(precision as u32) +} +fn some_pool(size: usize) -> impl Strategy> { + prop::collection::vec( + (asset_reserve(), decimals()).prop_map(|(v, dec)| AssetReserve::new(to_precision(v, dec), dec)), + size, + ) +} + +// Exact failing input from the same-state mirror test — measure the magnitude. +#[test] +fn diagnostic_same_state_mirror_magnitude() { + let pool = vec![ + AssetReserve::new(3407360000000000, 10), + AssetReserve::new(180751568000000, 6), + AssetReserve::new(30000000000, 6), + ]; + let amp = 2u128; + let (idx_in, idx_out) = (1usize, 0usize); + let amount_in = to_precision(1, pool[idx_in].decimals); // 1 * 10^6 + let amount_out = calculate_out_given_in::( + &pool, + idx_in, + idx_out, + amount_in, + amp, + &default_pegs(pool.len()), + ) + .unwrap(); + let required_in = calculate_in_given_out::( + &pool, + idx_in, + idx_out, + amount_out, + amp, + &default_pegs(pool.len()), + ) + .unwrap(); + println!("amount_in={amount_in} amount_out={amount_out} required_in={required_in}"); + println!( + "shortfall (amount_in - required_in) = {}", + amount_in as i128 - required_in as i128 + ); + // amount_out is denominated in idx_out (10 decimals). amount_in in idx_out-value terms: +} + +// HONEST executable self-round-trip: sell then sell-back at the mutated pool. +proptest! { + #![proptest_config(ProptestConfig::with_cases(4000))] + #[test] + fn executable_roundtrip_no_profit( + pool in some_pool(3), + amount in trade_amount(), + amp in amplification(), + (idx_in, idx_out) in trade_pair(3), + ) { + let amount_in = to_precision(amount, pool[idx_in].decimals); + let pegs = default_pegs(pool.len()); + + let amount_out = match calculate_out_given_in::( + &pool, idx_in, idx_out, amount_in, amp, &pegs) { + Some(v) if v > 0 => v, _ => return Ok(()), + }; + + // Update pool: idx_in += amount_in, idx_out -= amount_out + let pool2: Vec = pool.iter().enumerate().map(|(i, v)| { + if i == idx_in { AssetReserve::new(v.amount + amount_in, v.decimals) } + else if i == idx_out { AssetReserve::new(v.amount - amount_out, v.decimals) } + else { *v } + }).collect(); + + // Sell the received amount_out back (idx_out -> idx_in) at the mutated pool. + let x_back = match calculate_out_given_in::( + &pool2, idx_out, idx_in, amount_out, amp, &pegs) { + Some(v) => v, None => return Ok(()), + }; + + // A self-round-trip must never return more of asset_in than was put in. + prop_assert!( + x_back <= amount_in, + "ROUND-TRIP PROFIT: put {amount_in}, got back {x_back} (amount_out={amount_out}); idx_in={idx_in} idx_out={idx_out} amp={amp} pool={pool:?}" + ); + } +}