diff --git a/pallets/subtensor/src/staking/helpers.rs b/pallets/subtensor/src/staking/helpers.rs index 5c785f199b..5c2bc09663 100644 --- a/pallets/subtensor/src/staking/helpers.rs +++ b/pallets/subtensor/src/staking/helpers.rs @@ -76,7 +76,7 @@ impl Pallet { hotkey, coldkey, netuid, ); let order = GetTaoForAlpha::::with_amount(alpha_stake); - T::SwapInterface::sim_swap(netuid.into(), order) + T::SwapInterface::sim_swap_pure(netuid.into(), order) .map(|r| { let fee: u64 = U96F32::saturating_from_num(r.fee_paid) .saturating_mul(T::SwapInterface::current_alpha_price( @@ -110,7 +110,7 @@ impl Pallet { hotkey, coldkey, netuid, ); let order = GetTaoForAlpha::::with_amount(alpha_stake); - T::SwapInterface::sim_swap(netuid.into(), order) + T::SwapInterface::sim_swap_pure(netuid.into(), order) .map(|r| { let fee: u64 = U96F32::saturating_from_num(r.fee_paid) .saturating_mul(T::SwapInterface::current_alpha_price( diff --git a/pallets/subtensor/src/staking/stake_utils.rs b/pallets/subtensor/src/staking/stake_utils.rs index 5e22cc09ac..b2c065da4e 100644 --- a/pallets/subtensor/src/staking/stake_utils.rs +++ b/pallets/subtensor/src/staking/stake_utils.rs @@ -1049,7 +1049,7 @@ impl Pallet { let min_stake = DefaultMinStake::::get(); let min_amount = { let order = GetAlphaForTao::::with_amount(min_stake); - let fee = T::SwapInterface::sim_swap(netuid.into(), order) + let fee = T::SwapInterface::sim_swap_pure(netuid.into(), order) .map(|res| res.fee_paid) .unwrap_or(T::SwapInterface::approx_fee_amount( netuid.into(), @@ -1082,7 +1082,7 @@ impl Pallet { ); let order = GetAlphaForTao::::with_amount(stake_to_be_added); - let swap_result = T::SwapInterface::sim_swap(netuid.into(), order) + let swap_result = T::SwapInterface::sim_swap_pure(netuid.into(), order) .map_err(|_| Error::::InsufficientLiquidity)?; // Check that actual withdrawn TAO amount is not lower than the minimum stake @@ -1133,7 +1133,7 @@ impl Pallet { let remaining_alpha_stake = Self::calculate_reduced_stake_on_subnet(hotkey, coldkey, netuid, alpha_unstaked)?; let order = GetTaoForAlpha::::with_amount(alpha_unstaked); - match T::SwapInterface::sim_swap(netuid.into(), order) { + match T::SwapInterface::sim_swap_pure(netuid.into(), order) { Ok(res) => { if !remaining_alpha_stake.is_zero() { ensure!( diff --git a/pallets/subtensor/src/tests/staking.rs b/pallets/subtensor/src/tests/staking.rs index 7a7c5b69ac..db244b8e5a 100644 --- a/pallets/subtensor/src/tests/staking.rs +++ b/pallets/subtensor/src/tests/staking.rs @@ -6146,3 +6146,206 @@ fn test_sharepool_dataops_try_get_value_returns_err_on_non_existing_v2() { assert!(maybe_actual_value.is_err()); }); } + +// ============================================================ +// Tests for sim_swap_pure migration in validate_remove_stake +// and stake-querying helpers +// ============================================================ + +/// Verify that `validate_remove_stake` (which internally calls `sim_swap_pure`) does not write +/// any AMM state as a side effect — the whole point of "pure" simulation. +#[test] +fn test_remove_stake_pure_swap_no_storage_mutation_during_validation() { + new_test_ext(1).execute_with(|| { + let subnet_owner_coldkey = U256::from(1); + let subnet_owner_hotkey = U256::from(2); + let coldkey = U256::from(4343); + let hotkey = U256::from(4968585); + + let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + register_ok_neuron(netuid, hotkey, coldkey, 192213123); + + // Clear any implicit existing stake to start deterministically. + let existing = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); + if !existing.is_zero() { + SubtensorModule::decrease_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, &coldkey, netuid, existing, + ); + } + + // Give the hotkey a meaningful amount of alpha stake well above the minimum. + let min_stake = DefaultMinStake::::get(); + let alpha_amount = AlphaBalance::from(min_stake.to_u64() * 10); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, &coldkey, netuid, alpha_amount, + ); + + // Capture AMM state before the validation call. + let price_before = pallet_subtensor_swap::AlphaSqrtPrice::::get(netuid); + let tick_before = pallet_subtensor_swap::CurrentTick::::get(netuid); + let liquidity_before = pallet_subtensor_swap::CurrentLiquidity::::get(netuid); + + // `assert_storage_noop!` panics if any storage item is written inside the closure. + // This confirms sim_swap_pure does not mutate storage. + frame_support::assert_storage_noop!(SubtensorModule::validate_remove_stake( + &coldkey, + &hotkey, + netuid, + alpha_amount, + alpha_amount, + false, + ) + .expect("validate_remove_stake must succeed for a valid unstake above min")); + + // Explicitly assert the individual AMM items are unchanged for readable failure messages. + assert_eq!( + pallet_subtensor_swap::AlphaSqrtPrice::::get(netuid), + price_before, + "AlphaSqrtPrice must not change during sim_swap_pure validation" + ); + assert_eq!( + pallet_subtensor_swap::CurrentTick::::get(netuid), + tick_before, + "CurrentTick must not change during sim_swap_pure validation" + ); + assert_eq!( + pallet_subtensor_swap::CurrentLiquidity::::get(netuid), + liquidity_before, + "CurrentLiquidity must not change during sim_swap_pure validation" + ); + }); +} + +/// Verify that `get_total_stake_for_coldkey` returns a non-zero value after a real `add_stake`, +/// exercising the `sim_swap_pure` code path inside that helper. +#[test] +fn test_get_total_stake_for_coldkey_is_nonzero_after_staking() { + new_test_ext(1).execute_with(|| { + let subnet_owner_coldkey = U256::from(1); + let subnet_owner_hotkey = U256::from(2); + let coldkey = U256::from(4343); + let hotkey = U256::from(4968585); + + let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + register_ok_neuron(netuid, hotkey, coldkey, 192213123); + + // Fund the coldkey so it can stake. + let stake_tao: u64 = 500_000_000; + SubtensorModule::add_balance_to_coldkey_account(&coldkey, TaoBalance::from(stake_tao)); + + assert_ok!(SubtensorModule::add_stake( + RuntimeOrigin::signed(coldkey), + hotkey, + netuid, + TaoBalance::from(stake_tao), + )); + + // The helper converts held alpha to tao via sim_swap_pure. + let total = SubtensorModule::get_total_stake_for_coldkey(&coldkey); + assert!( + total > TaoBalance::ZERO, + "get_total_stake_for_coldkey must be non-zero after staking" + ); + }); +} + +/// Verify that `get_total_stake_for_coldkey_on_subnet` and `get_total_stake_for_coldkey` agree +/// when there is only one subnet with stake, exercising both sim_swap_pure code paths. +#[test] +fn test_get_total_stake_for_coldkey_on_subnet_matches_total() { + new_test_ext(1).execute_with(|| { + let subnet_owner_coldkey = U256::from(1); + let subnet_owner_hotkey = U256::from(2); + let coldkey = U256::from(4343); + let hotkey = U256::from(4968585); + + let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + register_ok_neuron(netuid, hotkey, coldkey, 192213123); + + // Fund and stake. + let stake_tao: u64 = 500_000_000; + SubtensorModule::add_balance_to_coldkey_account(&coldkey, TaoBalance::from(stake_tao)); + + assert_ok!(SubtensorModule::add_stake( + RuntimeOrigin::signed(coldkey), + hotkey, + netuid, + TaoBalance::from(stake_tao), + )); + + let total_all_subnets = SubtensorModule::get_total_stake_for_coldkey(&coldkey); + let total_on_netuid = + SubtensorModule::get_total_stake_for_coldkey_on_subnet(&coldkey, netuid); + + // With only one subnet holding stake both helpers must return the same value. + assert_eq!( + total_on_netuid, + total_all_subnets, + "get_total_stake_for_coldkey_on_subnet must equal get_total_stake_for_coldkey when \ + there is a single subnet with stake" + ); + assert!( + total_all_subnets > TaoBalance::ZERO, + "stake must be non-zero after staking" + ); + }); +} + +/// Verify that `validate_remove_stake` correctly succeeds via the `sim_swap_pure` path when the +/// unstake amount is comfortably above the minimum stake threshold, and correctly rejects a +/// removal that is far below the minimum while leaving a non-zero remainder. +#[test] +fn test_remove_stake_pure_swap_validates_minimum_correctly() { + new_test_ext(1).execute_with(|| { + let subnet_owner_coldkey = U256::from(1); + let subnet_owner_hotkey = U256::from(2); + let coldkey = U256::from(4343); + let hotkey = U256::from(4968585); + + let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + register_ok_neuron(netuid, hotkey, coldkey, 192213123); + + // Clear any implicit stake from registration. + let existing = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); + if !existing.is_zero() { + SubtensorModule::decrease_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, &coldkey, netuid, existing, + ); + } + + // Provide stake that is double the minimum so the simulation produces a payout >= min. + let min_stake = DefaultMinStake::::get(); + let alpha_amount = AlphaBalance::from(min_stake.to_u64() * 2); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, &coldkey, netuid, alpha_amount, + ); + + // Unstake the entire amount — remaining stake is zero, so the min-stake check is + // bypassed and the call must succeed regardless of price. + assert_ok!(SubtensorModule::validate_remove_stake( + &coldkey, + &hotkey, + netuid, + alpha_amount, + alpha_amount, + false, + )); + + // Now verify the minimum-enforcement path: leave some stake behind with a tiny removal. + // Give additional stake so removal of just 1 alpha leaves a non-zero remainder. + let extra_alpha = AlphaBalance::from(min_stake.to_u64() * 10); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, &coldkey, netuid, extra_alpha, + ); + + // Removing a single alpha unit while leaving a remainder should fail the min-stake check + // because 1 alpha sim-swaps to far less than the DefaultMinStake in TAO. + let tiny = AlphaBalance::from(1u64); + assert_err!( + SubtensorModule::validate_remove_stake(&coldkey, &hotkey, netuid, tiny, tiny, false,), + Error::::AmountTooLow + ); + }); +} diff --git a/pallets/subtensor/src/tests/staking2.rs b/pallets/subtensor/src/tests/staking2.rs index 536a14579a..22ac84cd25 100644 --- a/pallets/subtensor/src/tests/staking2.rs +++ b/pallets/subtensor/src/tests/staking2.rs @@ -6,8 +6,9 @@ use frame_support::{ weights::Weight, }; use sp_core::U256; +use frame_system::RawOrigin; use subtensor_runtime_common::{AlphaBalance, TaoBalance, Token}; -use subtensor_swap_interface::SwapHandler; +use subtensor_swap_interface::{Order, SwapHandler}; use super::mock; use super::mock::*; @@ -892,3 +893,49 @@ fn test_stake_fee_calculation() { assert_ne!(stake_fee, default_fee); }); } + +// Verifies that validate_add_stake uses sim_swap_pure correctly: the alpha received after +// do_add_stake on a V3 subnet matches what sim_swap_pure predicted before the tx. +#[test] +fn test_validate_add_stake_v3_matches_pure_swap() { + new_test_ext(1).execute_with(|| { + let hotkey = U256::from(1); + let coldkey = U256::from(2); + let netuid = NetUid::from(1); + let stake_amount = TaoBalance::from(1_000_000_000_u64); // 1 TAO + + // Set up a V3 subnet with liquidity + mock::add_network(netuid, 1, 0); + SubnetMechanism::::insert(netuid, 1); + let tao_reserve = TaoBalance::from(10_000_000_000_u64); + let alpha_reserve = AlphaBalance::from(10_000_000_000_u64); + mock::setup_reserves(netuid, tao_reserve, alpha_reserve); + SubnetAlphaOut::::insert(netuid, alpha_reserve); + mock::register_ok_neuron(netuid, hotkey, coldkey, 0); + + // Fund coldkey + SubtensorModule::add_balance_to_coldkey_account(&coldkey, (stake_amount.to_u64() * 2).into()); + + // Predict alpha out using sim_swap_pure before the tx + let order = GetAlphaForTao::::with_amount(stake_amount); + let predicted = ::SwapInterface::sim_swap_pure(netuid.into(), order) + .expect("sim_swap_pure must succeed"); + + // Execute the actual stake + assert_ok!(SubtensorModule::do_add_stake( + RawOrigin::Signed(coldkey).into(), + hotkey, + netuid, + stake_amount, + )); + + // Alpha on-chain must match the pure-swap prediction + let alpha_staked = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); + assert_eq!( + alpha_staked, + predicted.amount_paid_out, + "alpha staked must match sim_swap_pure prediction" + ); + }); +} diff --git a/pallets/swap-interface/src/lib.rs b/pallets/swap-interface/src/lib.rs index a37e9e49ad..a2edac43d5 100644 --- a/pallets/swap-interface/src/lib.rs +++ b/pallets/swap-interface/src/lib.rs @@ -10,7 +10,30 @@ pub use order::*; mod order; -pub trait SwapEngine: DefaultPriceLimit { +/// Direction-specific dispatch for pure (no-storage-write) swap simulation. +/// +/// Implemented by the swap pallet for each concrete token-pair direction +/// (`TaoBalance → AlphaBalance` and `AlphaBalance → TaoBalance`). +/// `SwapEngine` requires this trait for the matching pair, so callers with +/// a `SwapEngine` bound automatically have access to `sim_run`. +pub trait PureSwapDispatch +where + PaidIn: Token, + PaidOut: Token, +{ + /// Run a pure swap simulation for `amount` without writing to storage. + /// + /// The limit price is derived from the direction's default price limit. + fn sim_run( + netuid: NetUid, + amount: PaidIn, + drop_fees: bool, + ) -> Result, DispatchError>; +} + +pub trait SwapEngine: + DefaultPriceLimit + PureSwapDispatch +{ fn swap( netuid: NetUid, order: O, @@ -37,6 +60,13 @@ pub trait SwapHandler { where Self: SwapEngine; + fn sim_swap_pure( + netuid: NetUid, + order: O, + ) -> Result, DispatchError> + where + Self: SwapEngine; + fn approx_fee_amount(netuid: NetUid, amount: T) -> T; fn current_alpha_price(netuid: NetUid) -> U96F32; fn get_protocol_tao(netuid: NetUid) -> TaoBalance; diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index 4386eae4bb..3da02dea00 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -12,6 +12,7 @@ use subtensor_runtime_common::{ }; use super::pallet::*; +use super::sim; use super::swap_step::{BasicSwapStep, SwapStep, SwapStepAction}; use crate::{ SqrtPrice, @@ -19,7 +20,7 @@ use crate::{ tick::{ActiveTickIndexManager, Tick, TickIndex}, }; use subtensor_swap_interface::{ - DefaultPriceLimit, Order as OrderT, SwapEngine, SwapHandler, SwapResult, + DefaultPriceLimit, Order as OrderT, PureSwapDispatch, SwapEngine, SwapHandler, SwapResult, }; const MAX_SWAP_ITERATIONS: u16 = 1000; @@ -1030,6 +1031,52 @@ impl Pallet { Ok(()) } + + /// Pure-read swap simulation. + /// + /// Functionally equivalent to `sim_swap` but never writes to storage at + /// all — AMM state is carried as local variables through the swap loop. + /// Callers must also satisfy `Self: PureSimDispatch`, + /// which is implemented for the two concrete token-pair directions. + pub fn sim_swap_pure( + netuid: NetUid, + order: O, + ) -> Result, DispatchError> + where + O: OrderT, + Self: SwapEngine, + { + match T::SubnetInfo::mechanism(netuid) { + 1 => { + // Mirror swap_inner's MinimumReserve guard so the pure path + // returns the same error as the stateful path when the output + // reserve is below the minimum. + ensure!( + O::ReserveOut::reserve(netuid).to_u64() + >= T::MinimumReserve::get().get(), + Error::::ReservesTooLow + ); + >::sim_run( + netuid, + order.amount(), + false, + ) + } + _ => { + let actual_amount = if T::SubnetInfo::exists(netuid) { + order.amount() + } else { + O::PaidIn::ZERO + }; + Ok(SwapResult { + amount_paid_in: actual_amount, + amount_paid_out: actual_amount.to_u64().into(), + fee_paid: 0.into(), + fee_to_block_author: 0.into(), + }) + } + } + } } impl DefaultPriceLimit for Pallet { @@ -1044,12 +1091,74 @@ impl DefaultPriceLimit for Pallet { } } +/// `PureSwapDispatch` impl for the Tao→Alpha (buy) direction. +/// +/// Uses `BuyStep` to run the pure simulation loop. +impl PureSwapDispatch for Pallet { + fn sim_run( + netuid: NetUid, + amount: TaoBalance, + drop_fees: bool, + ) -> Result, DispatchError> { + let limit_sqrt_price = + >::default_price_limit::() + .to_u64(); + let limit_sqrt_price = SqrtPrice::saturating_from_num(limit_sqrt_price) + .safe_div(SqrtPrice::saturating_from_num(1_000_000_000)) + .checked_sqrt(SqrtPrice::saturating_from_num(0.0000000001)) + .unwrap_or_else(|| { + // max_price_inner is guaranteed to be a well-formed positive value; + // this branch is unreachable in production. + SqrtPrice::saturating_from_num(u64::MAX) + }); + + sim::sim_swap_inner_pure::>( + netuid, + amount, + limit_sqrt_price, + drop_fees, + ) + .map_err(Into::into) + } +} + +/// `PureSwapDispatch` impl for the Alpha→Tao (sell) direction. +/// +/// Uses `SellStep` to run the pure simulation loop. +impl PureSwapDispatch for Pallet { + fn sim_run( + netuid: NetUid, + amount: AlphaBalance, + drop_fees: bool, + ) -> Result, DispatchError> { + let limit_sqrt_price = + >::default_price_limit::() + .to_u64(); + let limit_sqrt_price = SqrtPrice::saturating_from_num(limit_sqrt_price) + .safe_div(SqrtPrice::saturating_from_num(1_000_000_000)) + .checked_sqrt(SqrtPrice::saturating_from_num(0.0000000001)) + .unwrap_or_else(|| { + // min_price_inner returns a very small value; if sqrt fails, use zero. + SqrtPrice::saturating_from_num(0u64) + }); + + sim::sim_swap_inner_pure::>( + netuid, + amount, + limit_sqrt_price, + drop_fees, + ) + .map_err(Into::into) + } +} + impl SwapEngine for Pallet where T: Config, O: OrderT, BasicSwapStep: SwapStep, Self: DefaultPriceLimit, + Self: PureSwapDispatch, { fn swap( netuid: NetUid, @@ -1119,6 +1228,19 @@ impl SwapHandler for Pallet { } } + fn sim_swap_pure( + netuid: NetUid, + order: O, + ) -> Result, DispatchError> + where + O: OrderT, + Self: SwapEngine, + { + // `SwapEngine` requires `PureSwapDispatch` as a + // supertrait, so this call is always valid. + Pallet::::sim_swap_pure(netuid, order) + } + fn approx_fee_amount(netuid: NetUid, amount: C) -> C { Self::calculate_fee_amount(netuid, amount, false) } diff --git a/pallets/swap/src/pallet/mod.rs b/pallets/swap/src/pallet/mod.rs index 95a82a1c08..f9a6e2c65d 100644 --- a/pallets/swap/src/pallet/mod.rs +++ b/pallets/swap/src/pallet/mod.rs @@ -18,6 +18,7 @@ use crate::{ pub use pallet::*; mod impls; +mod sim; mod swap_step; #[cfg(test)] mod tests; diff --git a/pallets/swap/src/pallet/sim.rs b/pallets/swap/src/pallet/sim.rs new file mode 100644 index 0000000000..40836c7a6c --- /dev/null +++ b/pallets/swap/src/pallet/sim.rs @@ -0,0 +1,567 @@ +use core::marker::PhantomData; + +use frame_support::{ensure, pallet_prelude::Zero, traits::Get}; +use safe_math::*; +use sp_arithmetic::helpers_128bit; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token, TokenReserve}; + +use super::pallet::*; +use super::swap_step::SwapStepAction; +use crate::{ + SqrtPrice, + tick::{ActiveTickIndexManager, TickIndex}, +}; +use subtensor_swap_interface::SwapResult; + +const MAX_SIM_ITERATIONS: u16 = 1000; + +/// Mutable AMM state carried through a pure simulation loop. +/// Values are read once from storage before the loop and updated locally +/// on each iteration — no storage writes occur. +pub(crate) struct SimState { + pub sqrt_price: SqrtPrice, + pub current_tick: TickIndex, + pub current_liquidity: u64, + /// True when pure simulation bootstraps an uninitialized V3 pool in memory only. + pub virtual_full_range_liquidity: bool, +} + +/// Output of one pure simulation step. +/// Mirrors `SwapStepResult` but also carries the updated AMM state so the +/// caller can thread it through the loop without touching storage. +pub(crate) struct PureStepResult +where + PaidIn: Token, + PaidOut: Token, +{ + pub amount_to_take: PaidIn, + pub fee_paid: PaidIn, + pub delta_in: PaidIn, + pub delta_out: PaidOut, + pub fee_to_block_author: PaidIn, + pub action: SwapStepAction, + pub new_sqrt_price: SqrtPrice, + pub new_tick: TickIndex, + pub new_liquidity: u64, +} + +/// Direction-specific pure methods. +/// +/// This mirrors `SwapStep` but every method that previously read from storage +/// now takes an explicit `state: &SimState` argument instead. The provided +/// `execute` method runs the full step (determine action + process swap) +/// returning a `PureStepResult` with updated state values instead of writing +/// them back to storage. +pub(crate) trait PureStep +where + T: Config, + PaidIn: Token, + PaidOut: Token, +{ + /// Get the input amount needed to reach the target price. + fn delta_in( + liquidity: U64F64, + sqrt_price_curr: SqrtPrice, + sqrt_price_target: SqrtPrice, + ) -> PaidIn; + + /// Get the tick at the current tick edge (direction-specific). + fn tick_edge(netuid: NetUid, state: &SimState) -> TickIndex; + + /// Get the target sqrt price based on the available input amount. + fn sqrt_price_target( + liquidity: U64F64, + sqrt_price_curr: SqrtPrice, + delta_in: PaidIn, + ) -> SqrtPrice; + + /// Returns true if p1 is closer to the current price than p2 + /// in the direction of this step. + fn price_is_closer(p1: &SqrtPrice, p2: &SqrtPrice) -> bool; + + /// The action to take when we land exactly on the edge price. + fn action_on_edge_sqrt_price() -> SwapStepAction; + + /// Convert delta_in (input token) to delta_out (output token) using state values. + fn convert_deltas(state: &SimState, delta_in: PaidIn) -> PaidOut; + + /// Compute the new liquidity after crossing a tick. + /// Returns the updated liquidity value (does NOT write to storage). + fn update_liquidity_at_crossing( + netuid: NetUid, + state: &SimState, + ) -> Result>; + + /// Execute a single pure simulation step. + /// + /// Mirrors `BasicSwapStep::new` + `determine_action` + `process_swap` but + /// reads AMM state from `state` instead of storage, and returns updated + /// state values in `PureStepResult` rather than writing them. + fn execute( + netuid: NetUid, + amount_remaining: PaidIn, + limit_sqrt_price: SqrtPrice, + drop_fees: bool, + state: &SimState, + ) -> Result, Error> { + let current_sqrt_price = state.sqrt_price; + let edge_tick = Self::tick_edge(netuid, state); + let edge_sqrt_price = edge_tick.as_sqrt_price_bounded(); + + let mut fee = + Pallet::::calculate_fee_amount(netuid, amount_remaining, drop_fees); + let possible_delta_in = amount_remaining.saturating_sub(fee); + + let current_liquidity = + U64F64::saturating_from_num(state.current_liquidity); + let target_sqrt_price = + Self::sqrt_price_target(current_liquidity, current_sqrt_price, possible_delta_in); + + let (mut action, delta_in, final_price) = + if Self::price_is_closer(&target_sqrt_price, &limit_sqrt_price) + && Self::price_is_closer(&target_sqrt_price, &edge_sqrt_price) + { + // Case 1: stop within tick, use full possible_delta_in + (SwapStepAction::Stop, possible_delta_in, target_sqrt_price) + } else if Self::price_is_closer(&limit_sqrt_price, &target_sqrt_price) + && Self::price_is_closer(&limit_sqrt_price, &edge_sqrt_price) + { + // Case 2: limit price is closest — recalculate fee against actual delta_in + let delta_in = + Self::delta_in(current_liquidity, current_sqrt_price, limit_sqrt_price); + let u16_max = U64F64::saturating_from_num(u16::MAX); + let fee_rate = if drop_fees { + U64F64::saturating_from_num(0) + } else { + U64F64::saturating_from_num(FeeRate::::get(netuid)) + }; + fee = U64F64::saturating_from_num(delta_in) + .saturating_mul(fee_rate.safe_div(u16_max.saturating_sub(fee_rate))) + .saturating_to_num::() + .into(); + (SwapStepAction::Stop, delta_in, limit_sqrt_price) + } else { + // Case 3: edge price is closest — tick crossing likely, recalculate fee + let delta_in = + Self::delta_in(current_liquidity, current_sqrt_price, edge_sqrt_price); + let u16_max = U64F64::saturating_from_num(u16::MAX); + let fee_rate = if drop_fees { + U64F64::saturating_from_num(0) + } else { + U64F64::saturating_from_num(FeeRate::::get(netuid)) + }; + fee = U64F64::saturating_from_num(delta_in) + .saturating_mul(fee_rate.safe_div(u16_max.saturating_sub(fee_rate))) + .saturating_to_num::() + .into(); + (SwapStepAction::Crossing, delta_in, edge_sqrt_price) + }; + + // Correct action when stopped exactly at the edge price + if action == SwapStepAction::Stop && final_price == edge_sqrt_price { + action = Self::action_on_edge_sqrt_price(); + } + + let delta_out = Self::convert_deltas(state, delta_in); + + let mut fee_to_block_author = PaidIn::ZERO; + if delta_in > PaidIn::ZERO { + ensure!(delta_out > PaidOut::ZERO, Error::::ReservesTooLow); + + let fee_split = DefaultFeeSplit::get(); + let lp_fee: PaidIn = fee_split.mul_floor(fee.to_u64()).into(); + // lp_fee would be added to fee globals (skipped — pure simulation) + fee_to_block_author = fee.saturating_sub(lp_fee); + } + + let new_tick = TickIndex::from_sqrt_price_bounded(final_price); + let new_liquidity = if action == SwapStepAction::Crossing { + // Tick crossing: read liquidity_net from storage (read-only) to + // compute the new liquidity, but do not write it. + Self::update_liquidity_at_crossing(netuid, state)? + } else { + state.current_liquidity + }; + + Ok(PureStepResult { + amount_to_take: delta_in.saturating_add(fee), + fee_paid: fee, + delta_in, + delta_out, + fee_to_block_author, + action, + new_sqrt_price: final_price, + new_tick, + new_liquidity, + }) + } +} + +// --------------------------------------------------------------------------- +// BuyStep: Tao → Alpha +// --------------------------------------------------------------------------- + +pub(crate) struct BuyStep(PhantomData); + +impl PureStep for BuyStep { + fn delta_in( + liquidity_curr: U64F64, + sqrt_price_curr: SqrtPrice, + sqrt_price_target: SqrtPrice, + ) -> TaoBalance { + liquidity_curr + .saturating_mul(sqrt_price_target.saturating_sub(sqrt_price_curr)) + .saturating_to_num::() + .into() + } + + fn tick_edge(netuid: NetUid, state: &SimState) -> TickIndex { + if state.virtual_full_range_liquidity { + return TickIndex::MAX; + } + + ActiveTickIndexManager::::find_closest_higher( + netuid, + state.current_tick.next().unwrap_or(TickIndex::MAX), + ) + .unwrap_or(TickIndex::MAX) + } + + fn sqrt_price_target( + liquidity_curr: U64F64, + sqrt_price_curr: SqrtPrice, + delta_in: TaoBalance, + ) -> SqrtPrice { + let delta_fixed = U64F64::saturating_from_num(delta_in); + + if liquidity_curr == 0 { + return SqrtPrice::saturating_from_num( + Pallet::::max_price_inner::().to_u64(), + ); + } + + delta_fixed + .safe_div(liquidity_curr) + .saturating_add(sqrt_price_curr) + } + + fn price_is_closer(sq_price1: &SqrtPrice, sq_price2: &SqrtPrice) -> bool { + sq_price1 <= sq_price2 + } + + fn action_on_edge_sqrt_price() -> SwapStepAction { + SwapStepAction::Crossing + } + + fn convert_deltas(state: &SimState, delta_in: TaoBalance) -> AlphaBalance { + if delta_in.is_zero() { + return AlphaBalance::ZERO; + } + + let liquidity_curr = SqrtPrice::saturating_from_num(state.current_liquidity); + let sqrt_price_curr = state.sqrt_price; + let delta_fixed = SqrtPrice::saturating_from_num(delta_in.to_u64()); + + let result = { + let a = liquidity_curr + .saturating_mul(sqrt_price_curr) + .saturating_add(delta_fixed) + .saturating_mul(sqrt_price_curr); + let b = liquidity_curr.safe_div(a); + b.saturating_mul(delta_fixed) + }; + + result.saturating_to_num::().into() + } + + fn update_liquidity_at_crossing( + netuid: NetUid, + state: &SimState, + ) -> Result> { + if state.virtual_full_range_liquidity { + // The only upper crossing in virtual bootstrap mode is TickIndex::MAX, + // where protocol full-range position contributes negative liquidity_net. + return Ok(0); + } + + let mut liquidity_curr = state.current_liquidity; + + // For BuyStep, find the next active tick above current_tick + let upper_tick = ActiveTickIndexManager::::find_closest_higher( + netuid, + state.current_tick.next().unwrap_or(TickIndex::MAX), + ) + .unwrap_or(TickIndex::MAX); + + let tick = + Ticks::::get(netuid, upper_tick).ok_or(Error::::InsufficientLiquidity)?; + + let liquidity_update_abs_u64 = tick.liquidity_net_as_u64(); + + liquidity_curr = if tick.liquidity_net >= 0 { + liquidity_curr.saturating_add(liquidity_update_abs_u64) + } else { + liquidity_curr.saturating_sub(liquidity_update_abs_u64) + }; + + Ok(liquidity_curr) + } +} + +// --------------------------------------------------------------------------- +// SellStep: Alpha → Tao +// --------------------------------------------------------------------------- + +pub(crate) struct SellStep(PhantomData); + +impl PureStep for SellStep { + fn delta_in( + liquidity_curr: U64F64, + sqrt_price_curr: SqrtPrice, + sqrt_price_target: SqrtPrice, + ) -> AlphaBalance { + let one = U64F64::saturating_from_num(1); + + liquidity_curr + .saturating_mul( + one.safe_div(sqrt_price_target.into()) + .saturating_sub(one.safe_div(sqrt_price_curr)), + ) + .saturating_to_num::() + .into() + } + + fn tick_edge(netuid: NetUid, state: &SimState) -> TickIndex { + if state.virtual_full_range_liquidity { + return TickIndex::MIN; + } + + let current_tick = state.current_tick; + let current_price: SqrtPrice = state.sqrt_price; + let current_tick_price = current_tick.as_sqrt_price_bounded(); + let is_active = ActiveTickIndexManager::::tick_is_active(netuid, current_tick); + + if is_active && current_price > current_tick_price { + return ActiveTickIndexManager::::find_closest_lower(netuid, current_tick) + .unwrap_or(TickIndex::MIN); + } + + ActiveTickIndexManager::::find_closest_lower( + netuid, + current_tick.prev().unwrap_or(TickIndex::MIN), + ) + .unwrap_or(TickIndex::MIN) + } + + fn sqrt_price_target( + liquidity_curr: U64F64, + sqrt_price_curr: SqrtPrice, + delta_in: AlphaBalance, + ) -> SqrtPrice { + let delta_fixed = U64F64::saturating_from_num(delta_in); + let one = U64F64::saturating_from_num(1); + + if liquidity_curr == 0 { + return SqrtPrice::saturating_from_num( + Pallet::::min_price_inner::().to_u64(), + ); + } + + one.safe_div( + delta_fixed + .safe_div(liquidity_curr) + .saturating_add(one.safe_div(sqrt_price_curr)), + ) + } + + fn price_is_closer(sq_price1: &SqrtPrice, sq_price2: &SqrtPrice) -> bool { + sq_price1 >= sq_price2 + } + + fn action_on_edge_sqrt_price() -> SwapStepAction { + SwapStepAction::Stop + } + + fn convert_deltas(state: &SimState, delta_in: AlphaBalance) -> TaoBalance { + if delta_in.is_zero() { + return TaoBalance::ZERO; + } + + let liquidity_curr = SqrtPrice::saturating_from_num(state.current_liquidity); + let sqrt_price_curr = state.sqrt_price; + let delta_fixed = SqrtPrice::saturating_from_num(delta_in.to_u64()); + + let result = { + let denom = liquidity_curr + .safe_div(sqrt_price_curr) + .saturating_add(delta_fixed); + let a = liquidity_curr.safe_div(denom); + let b = a.saturating_mul(sqrt_price_curr); + delta_fixed.saturating_mul(b) + }; + + result.saturating_to_num::().into() + } + + fn update_liquidity_at_crossing( + netuid: NetUid, + state: &SimState, + ) -> Result> { + if state.virtual_full_range_liquidity { + // The only lower crossing in virtual bootstrap mode is TickIndex::MIN, + // where protocol full-range position contributes positive liquidity_net. + return Ok(0); + } + + let mut liquidity_curr = state.current_liquidity; + + // For SellStep, find the next active tick below current_tick + // (mirrors the logic in BasicSwapStep::update_liquidity_at_crossing) + let current_tick_index = state.current_tick; + let current_price: SqrtPrice = state.sqrt_price; + let current_tick_price = current_tick_index.as_sqrt_price_bounded(); + let is_active = + ActiveTickIndexManager::::tick_is_active(netuid, current_tick_index); + + let lower_tick = if is_active && current_price > current_tick_price { + ActiveTickIndexManager::::find_closest_lower(netuid, current_tick_index) + .unwrap_or(TickIndex::MIN) + } else { + ActiveTickIndexManager::::find_closest_lower( + netuid, + current_tick_index.prev().unwrap_or(TickIndex::MIN), + ) + .unwrap_or(TickIndex::MIN) + }; + + let tick = + Ticks::::get(netuid, lower_tick).ok_or(Error::::InsufficientLiquidity)?; + + let liquidity_update_abs_u64 = tick.liquidity_net_as_u64(); + + liquidity_curr = if tick.liquidity_net >= 0 { + liquidity_curr.saturating_sub(liquidity_update_abs_u64) + } else { + liquidity_curr.saturating_add(liquidity_update_abs_u64) + }; + + Ok(liquidity_curr) + } +} + +// --------------------------------------------------------------------------- +// Core pure simulation loop +// --------------------------------------------------------------------------- + +/// Pure swap simulation loop. +/// +/// Functionally equivalent to `swap_inner` but never writes to storage. +/// AMM state (`sqrt_price`, `current_tick`, `current_liquidity`) is read once +/// before the loop and carried as mutable local variables through each +/// iteration. +pub(crate) fn sim_swap_inner_pure( + netuid: NetUid, + amount: PaidIn, + limit_sqrt_price: SqrtPrice, + drop_fees: bool, +) -> Result, Error> +where + T: Config, + PaidIn: Token, + PaidOut: Token, + Step: PureStep, +{ + // Read initial AMM state once. If V3 isn't initialized yet, bootstrap the + // same effective state that `maybe_initialize_v3()` would create, but keep it + // local (no storage writes). + let mut state = if SwapV3Initialized::::get(netuid) { + SimState { + sqrt_price: AlphaSqrtPrice::::get(netuid), + current_tick: CurrentTick::::get(netuid), + current_liquidity: CurrentLiquidity::::get(netuid), + virtual_full_range_liquidity: false, + } + } else { + let tao_reserve = T::TaoReserve::reserve(netuid.into()); + let alpha_reserve = T::AlphaReserve::reserve(netuid.into()); + + let price = U64F64::saturating_from_num(tao_reserve) + .safe_div(U64F64::saturating_from_num(alpha_reserve)); + let epsilon = U64F64::saturating_from_num(0.000000000001); + let sqrt_price = price.checked_sqrt(epsilon).unwrap_or(U64F64::from_num(0)); + let current_tick = TickIndex::from_sqrt_price_bounded(sqrt_price); + // Mirror clamp_sqrt_price: if the tick landed at a boundary, snap the + // sqrt_price to the tick's canonical value, matching maybe_initialize_v3. + let sqrt_price = if current_tick >= TickIndex::MAX || current_tick <= TickIndex::MIN { + current_tick.as_sqrt_price_bounded() + } else { + sqrt_price + }; + // Mirror update_liquidity_if_needed: the full-range protocol position + // [MIN, MAX) only contributes liquidity when current_tick < MAX. + // At TickIndex::MAX the condition (current_tick < tick_high) fails, so + // CurrentLiquidity stays 0 in the stateful path — match that here. + let current_liquidity = if current_tick >= TickIndex::MAX { + 0u64 + } else { + helpers_128bit::sqrt( + (tao_reserve.to_u64() as u128).saturating_mul(alpha_reserve.to_u64() as u128), + ) as u64 + }; + + SimState { + sqrt_price, + current_tick, + current_liquidity, + virtual_full_range_liquidity: true, + } + }; + + let mut amount_remaining = amount; + let mut amount_paid_out = PaidOut::ZERO; + let mut iteration_counter: u16 = 0; + let mut in_acc = PaidIn::ZERO; + let mut fee_acc = PaidIn::ZERO; + let mut fee_to_block_author_acc = PaidIn::ZERO; + + while !amount_remaining.is_zero() { + let step_result = + Step::execute(netuid, amount_remaining, limit_sqrt_price, drop_fees, &state)?; + + // Thread updated state through the loop + state.sqrt_price = step_result.new_sqrt_price; + state.current_tick = step_result.new_tick; + state.current_liquidity = step_result.new_liquidity; + + in_acc = in_acc.saturating_add(step_result.delta_in); + fee_acc = fee_acc.saturating_add(step_result.fee_paid); + fee_to_block_author_acc = + fee_to_block_author_acc.saturating_add(step_result.fee_to_block_author); + amount_remaining = + amount_remaining.saturating_sub(step_result.amount_to_take); + amount_paid_out = amount_paid_out.saturating_add(step_result.delta_out); + + if step_result.action == SwapStepAction::Stop { + amount_remaining = PaidIn::ZERO; + } + + if step_result.amount_to_take.is_zero() { + amount_remaining = PaidIn::ZERO; + } + + iteration_counter = iteration_counter.saturating_add(1); + + ensure!( + iteration_counter <= MAX_SIM_ITERATIONS, + Error::::TooManySwapSteps + ); + } + + Ok(SwapResult { + amount_paid_in: in_acc, + amount_paid_out, + fee_paid: fee_acc, + fee_to_block_author: fee_to_block_author_acc, + }) +} diff --git a/pallets/swap/src/pallet/tests.rs b/pallets/swap/src/pallet/tests.rs index e2b53c5142..28bd502c90 100644 --- a/pallets/swap/src/pallet/tests.rs +++ b/pallets/swap/src/pallet/tests.rs @@ -6,7 +6,7 @@ )] use approx::assert_abs_diff_eq; -use frame_support::{assert_err, assert_noop, assert_ok}; +use frame_support::{assert_err, assert_noop, assert_ok, assert_storage_noop}; use sp_arithmetic::helpers_128bit; use sp_runtime::DispatchError; use substrate_fixed::types::U96F32; @@ -2866,3 +2866,1243 @@ fn adjust_protocol_liquidity_uses_and_sets_scrap_reservoirs() { ); }); } + +// --- sim_swap_pure parity tests --- + +/// Buy direction (Tao→Alpha): sim_swap_pure and sim_swap must return identical SwapResult. +#[test] +fn sim_swap_pure_buy_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(1_000_000); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (buy) must succeed"), + "sim_swap and sim_swap_pure must agree for buy" + ); + }); +} + +/// Sell direction (Alpha→Tao): sim_swap_pure and sim_swap must return identical SwapResult. +#[test] +fn sim_swap_pure_sell_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetTaoForAlpha::with_amount(4_000_000); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (sell) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (sell) must succeed"), + "sim_swap and sim_swap_pure must agree for sell" + ); + }); +} + +/// Key property: sim_swap_pure must never mutate any storage item (buy direction). +#[test] +fn sim_swap_pure_buy_no_storage_mutation() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(500_000_000); + assert_storage_noop!(Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure must succeed")); + }); +} + +/// Multi-tick: large enough swap to stress the full tick range; both functions must agree. +/// Adds extra liquidity positions across several price ranges so tick crossings occur, +/// then uses a large swap amount that exhausts multiple tick bands. +#[test] +fn sim_swap_pure_multi_tick_buy_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + // Add a cluster of tightly spaced liquidity positions so the large swap + // must cross several tick boundaries. + let offsets: &[f64] = &[-0.05, -0.02, 0.0, 0.02, 0.05]; + for &offset in offsets { + let price_low = (current_price + offset).max(0.0001); + let price_high = price_low + 0.02; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 100_000_000_000_u64, + ); + } + + // A swap large enough to cross multiple tick boundaries. + let large_amount = 2_000_000_000_u64; + let order = GetAlphaForTao::with_amount(large_amount); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (multi-tick buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (multi-tick buy) must succeed"), + "sim_swap and sim_swap_pure must agree for multi-tick buy" + ); + }); +} + +/// Non-V3 subnet: both functions must return the same pass-through SwapResult +/// (mechanism != 1 takes the fallback path that echoes the input amount). +#[test] +fn sim_swap_pure_non_v3_subnet_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + // NetUid 0 has mechanism 0 in the mock (non-V3). + let netuid = NetUid::from(0); + + let order = GetAlphaForTao::with_amount(1_234_567); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (non-V3) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (non-V3) must succeed"), + "sim_swap and sim_swap_pure must agree for non-V3 subnet" + ); + }); +} + +/// Minimum non-zero buy amount (1 TAO unit): both functions must agree on rounding behavior. +#[test] +fn sim_swap_pure_min_amount_buy() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(1); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (min buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (min buy) must succeed"), + "sim_swap and sim_swap_pure must agree for min buy amount" + ); + }); +} + +/// Minimum non-zero sell amount (1 Alpha unit): both functions must agree on rounding behavior. +#[test] +fn sim_swap_pure_min_amount_sell() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetTaoForAlpha::with_amount(1); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "sim_swap and sim_swap_pure must agree for min sell amount" + ); + }); +} + +/// Key property: sim_swap_pure must never mutate any storage item (sell direction). +#[test] +fn sim_swap_pure_sell_no_storage_mutation() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetTaoForAlpha::with_amount(500_000_000); + assert_storage_noop!(Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (sell) must succeed")); + }); +} + +/// Fee accumulators must not be written by sim_swap_pure. +#[test] +fn sim_swap_pure_fee_globals_not_mutated() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(1_000_000); + assert_storage_noop!(Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (fee globals) must succeed")); + }); +} + +/// At maximum fee rate both sim functions must still agree for buy and sell orders. +#[test] +fn sim_swap_pure_high_fee_rate_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + // Set fee rate to the maximum allowed value. + FeeRate::::insert(netuid, MaxFeeRate::get()); + + // --- Buy direction --- + let buy_order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, buy_order.clone()).expect("sim_swap (high-fee buy) must succeed"), + Pallet::::sim_swap_pure(netuid, buy_order).expect("sim_swap_pure (high-fee buy) must succeed"), + "sim_swap and sim_swap_pure must agree for high-fee buy" + ); + + // --- Sell direction --- + let sell_order = GetTaoForAlpha::with_amount(4_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, sell_order.clone()).expect("sim_swap (high-fee sell) must succeed"), + Pallet::::sim_swap_pure(netuid, sell_order).expect("sim_swap_pure (high-fee sell) must succeed"), + "sim_swap and sim_swap_pure must agree for high-fee sell" + ); + }); +} + +/// After a real swap that moves the pool state, both sim functions must still agree +/// when querying the updated pool. +#[test] +fn sim_swap_pure_after_real_swap_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + // Perform a real swap to move the pool to a different price state. + let real_order = GetAlphaForTao::with_amount(50_000_000); + let limit_sqrt_price = SqrtPrice::from_num( + (Pallet::::current_price(netuid).to_num::() * 10.0_f64).sqrt(), + ); + assert_ok!(Pallet::::do_swap( + netuid, + real_order, + limit_sqrt_price, + false, + false + )); + + // Now both sim functions must agree on the post-swap state. + let order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (post-real-swap) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (post-real-swap) must succeed"), + "sim_swap and sim_swap_pure must agree after real swap" + ); + }); +} + +/// Multi-tick sell: large enough sell order to cross multiple tick boundaries; +/// both functions must agree on all SwapResult fields. +#[test] +fn sim_swap_pure_multi_tick_sell_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + // Add a cluster of tightly spaced liquidity positions so the large sell swap + // must cross several tick boundaries. + let offsets: &[f64] = &[-0.05, -0.02, 0.0, 0.02, 0.05]; + for &offset in offsets { + let price_low = (current_price + offset).max(0.0001); + let price_high = price_low + 0.02; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 100_000_000_000_u64, + ); + } + + // A sell large enough to cross multiple tick boundaries. + let large_amount = 8_000_000_000_u64; + let order = GetTaoForAlpha::with_amount(large_amount); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()).expect("sim_swap (multi-tick sell) must succeed"), + Pallet::::sim_swap_pure(netuid, order).expect("sim_swap_pure (multi-tick sell) must succeed"), + "sim_swap and sim_swap_pure must agree for multi-tick sell" + ); + }); +} + +/// On an uninitialized pool (SwapV3Initialized is false) both sim functions must behave +/// identically — either both return an error or both return the same Ok result. +#[test] +fn sim_swap_pure_uninitialized_pool_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!( + !SwapV3Initialized::::get(netuid), + "pool must be uninitialized for this test" + ); + + let order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "sim_swap and sim_swap_pure must agree for uninitialized pool" + ); + }); +} + +/// Both sim functions must behave identically when the subnet does not exist. +#[test] +fn sim_swap_pure_non_existent_subnet_equals_sim_swap() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(NON_EXISTENT_NETUID); + let order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "sim_swap and sim_swap_pure must agree for non-existent subnet" + ); + }); +} + +/// Calling sim_swap_pure three times in a row must produce identical results each time, +/// and those results must match a single call to sim_swap. This verifies the absence of +/// side effects across multiple pure calls. +#[test] +fn sim_swap_pure_repeated_calls_are_idempotent() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(2_000_000); + + let result_sim = Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap must succeed"); + let result_pure_1 = Pallet::::sim_swap_pure(netuid, order.clone()) + .expect("sim_swap_pure call 1 must succeed"); + let result_pure_2 = Pallet::::sim_swap_pure(netuid, order.clone()) + .expect("sim_swap_pure call 2 must succeed"); + let result_pure_3 = Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure call 3 must succeed"); + + assert_eq!(result_pure_1, result_pure_2, "pure calls must be idempotent (1 vs 2)"); + assert_eq!(result_pure_2, result_pure_3, "pure calls must be idempotent (2 vs 3)"); + assert_eq!(result_sim, result_pure_1, "sim_swap and sim_swap_pure must agree"); + }); +} + +// --- sim_swap_pure edge case tests --- + +/// Zero-amount buy: both functions must agree and return an all-zero SwapResult +/// without executing any swap step. +#[test] +fn sim_swap_pure_zero_amount_buy_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetAlphaForTao::with_amount(0); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree on zero-amount buy" + ); + }); +} + +/// Zero-amount sell: both functions must agree and return an all-zero SwapResult. +#[test] +fn sim_swap_pure_zero_amount_sell_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let order = GetTaoForAlpha::with_amount(0); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree on zero-amount sell" + ); + }); +} + +/// Degenerate pool: alpha_reserve = 0. +/// Both functions derive zero liquidity and must agree on the result. +#[test] +fn sim_swap_pure_zero_alpha_reserve_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!(!SwapV3Initialized::::get(netuid), "pool must be uninitialized"); + + AlphaReserve::set_mock_reserve(netuid, 0.into()); + + let order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree for zero alpha reserve" + ); + }); +} + +/// Degenerate pool: tao_reserve = 0. +/// Both functions derive zero liquidity and must agree on the result. +#[test] +fn sim_swap_pure_zero_tao_reserve_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!(!SwapV3Initialized::::get(netuid), "pool must be uninitialized"); + + TaoReserve::set_mock_reserve(netuid, 0.into()); + + let order = GetAlphaForTao::with_amount(1_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree for zero tao reserve" + ); + }); +} + +/// Large buy on a tiny uninitialized pool: the swap amount is large enough to +/// push price beyond TickIndex::MAX, exercising the virtual boundary-crossing +/// path in `sim_swap_inner_pure`. Both functions must agree on the partial fill. +#[test] +fn sim_swap_pure_large_buy_exhausts_virtual_liquidity_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!(!SwapV3Initialized::::get(netuid), "pool must be uninitialized"); + + // Tiny pool: liquidity = sqrt(100 * 100) = 100, price = 1. + // A buy of 1e10 TAO dwarfs the pool capacity, forcing price to TickIndex::MAX. + TaoReserve::set_mock_reserve(netuid, 100.into()); + AlphaReserve::set_mock_reserve(netuid, 100.into()); + + let order = GetAlphaForTao::with_amount(10_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree when large buy exhausts virtual liquidity" + ); + }); +} + +/// Large sell on a tiny uninitialized pool: exercises the virtual TickIndex::MIN +/// boundary crossing in the sell direction. Both functions must agree. +#[test] +fn sim_swap_pure_large_sell_exhausts_virtual_liquidity_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!(!SwapV3Initialized::::get(netuid), "pool must be uninitialized"); + + TaoReserve::set_mock_reserve(netuid, 100.into()); + AlphaReserve::set_mock_reserve(netuid, 100.into()); + + let order = GetTaoForAlpha::with_amount(10_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "both must agree when large sell exhausts virtual liquidity" + ); + }); +} + +/// When `drop_fees=true`, `sim_run` must produce fee_paid=0 and +/// fee_to_block_author=0, and must yield at least as much output as the +/// equivalent call with fees enabled. +#[test] +fn sim_swap_pure_drop_fees_produces_zero_fee() { + use subtensor_swap_interface::PureSwapDispatch; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + // Non-zero fee rate so the two calls produce measurably different results. + FeeRate::::insert(netuid, 1000u16); + + let amount: TaoBalance = 1_000_000.into(); + + let with_fees = + as PureSwapDispatch>::sim_run( + netuid, amount, false, + ) + .expect("sim_run with fees must succeed"); + + let no_fees = + as PureSwapDispatch>::sim_run( + netuid, amount, true, + ) + .expect("sim_run drop_fees must succeed"); + + assert_eq!(no_fees.fee_paid, 0.into(), "fee_paid must be zero when drop_fees=true"); + assert_eq!( + no_fees.fee_to_block_author, + 0.into(), + "fee_to_block_author must be zero when drop_fees=true" + ); + assert!( + no_fees.amount_paid_out >= with_fees.amount_paid_out, + "dropping fees must yield at least as much output" + ); + }); +} + +/// sim_swap_pure and sim_swap must agree when the pool price is so extreme that +/// the initial tick lands exactly at TickIndex::MAX (buy direction). This exercises +/// the clamp_sqrt_price parity fix in sim_swap_inner_pure. +#[test] +fn sim_swap_pure_extreme_price_at_max_tick_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + // Set reserves so tao/alpha ratio is astronomically high → tick clamps to MAX. + TaoReserve::set_mock_reserve(netuid, TaoBalance::from(u64::MAX)); + AlphaReserve::set_mock_reserve(netuid, AlphaBalance::from(1u64)); + + let order = GetTaoForAlpha::with_amount(AlphaBalance::from(1u64)); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "sim_swap and sim_swap_pure must agree at TickIndex::MAX boundary" + ); + }); +} + +/// sim_swap_pure and sim_swap must agree when the pool price is so extreme that +/// the initial tick lands exactly at TickIndex::MIN (sell direction). +#[test] +fn sim_swap_pure_extreme_price_at_min_tick_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + // Set reserves so tao/alpha ratio is astronomically low → tick clamps to MIN. + TaoReserve::set_mock_reserve(netuid, TaoBalance::from(1u64)); + AlphaReserve::set_mock_reserve(netuid, AlphaBalance::from(u64::MAX)); + + let order = GetAlphaForTao::with_amount(TaoBalance::from(1u64)); + + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "sim_swap and sim_swap_pure must agree at TickIndex::MIN boundary" + ); + }); +} + +// --- sim_swap_pure expanded parity tests --- + +/// Parametric buy: loops over 20+ (amount, fee_rate) pairs spanning small, medium, and +/// large amounts with a variety of fee rates. Both functions must agree on every case. +#[test] +fn sim_swap_pure_parametric_buy_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let cases: &[(u64, u16)] = &[ + (1, 0), + (1, 1000), + (1, 10000), + (100, 0), + (100, 100), + (100, 5000), + (1_000, 0), + (1_000, 1000), + (1_000, 30000), + (10_000, 0), + (10_000, 5000), + (10_000, u16::MAX / 4), + (100_000, 0), + (100_000, 1000), + (100_000, 10000), + (1_000_000, 0), + (1_000_000, 100), + (1_000_000, 5000), + (50_000_000, 0), + (50_000_000, 1000), + (500_000_000, 0), + (500_000_000, 10000), + (2_000_000_000, 0), + (2_000_000_000, 5000), + (5_000_000_000, 0), + (5_000_000_000, 1000), + (5_000_000_000, u16::MAX / 4), + ]; + + for &(amount, fee_rate) in cases { + FeeRate::::insert(netuid, fee_rate); + let order = GetAlphaForTao::with_amount(amount); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (parametric buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (parametric buy) must succeed"), + "parametric case amount={amount} fee_rate={fee_rate}" + ); + } + }); +} + +/// Parametric sell: loops over 20+ (amount, fee_rate) pairs spanning small, medium, and +/// large amounts with a variety of fee rates. Both functions must agree on every case. +#[test] +fn sim_swap_pure_parametric_sell_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let cases: &[(u64, u16)] = &[ + (1, 0), + (1, 1000), + (1, 10000), + (100, 0), + (100, 100), + (100, 5000), + (1_000, 0), + (1_000, 1000), + (1_000, 30000), + (10_000, 0), + (10_000, 5000), + (10_000, u16::MAX / 4), + (100_000, 0), + (100_000, 1000), + (100_000, 10000), + (1_000_000, 0), + (1_000_000, 100), + (1_000_000, 5000), + (50_000_000, 0), + (50_000_000, 1000), + (500_000_000, 0), + (500_000_000, 10000), + (2_000_000_000, 0), + (2_000_000_000, 5000), + (5_000_000_000, 0), + (5_000_000_000, 1000), + (5_000_000_000, u16::MAX / 4), + ]; + + for &(amount, fee_rate) in cases { + FeeRate::::insert(netuid, fee_rate); + let order = GetTaoForAlpha::with_amount(amount); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (parametric sell) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (parametric sell) must succeed"), + "parametric case amount={amount} fee_rate={fee_rate}" + ); + } + }); +} + +/// Many tick crossings (buy): 15 evenly-spread liquidity positions are added around the +/// current price; a large buy crosses all of them. Both functions must agree. +#[test] +fn sim_swap_pure_many_tick_crossings_buy_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + let offsets: &[f64] = &[ + -0.20, -0.17, -0.14, -0.11, -0.08, -0.05, -0.02, 0.01, 0.04, 0.07, 0.10, 0.13, + 0.16, 0.19, 0.22, + ]; + + for &offset in offsets { + let price_low = (current_price + offset).max(0.0001); + let price_high = price_low + 0.02; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 50_000_000_000_u64, + ); + } + + let order = GetAlphaForTao::with_amount(20_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (many tick crossings buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (many tick crossings buy) must succeed"), + "sim_swap and sim_swap_pure must agree for many tick crossings buy" + ); + }); +} + +/// Many tick crossings (sell): 15 evenly-spread liquidity positions are added around the +/// current price; a large sell crosses all of them. Both functions must agree. +#[test] +fn sim_swap_pure_many_tick_crossings_sell_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + let offsets: &[f64] = &[ + -0.20, -0.17, -0.14, -0.11, -0.08, -0.05, -0.02, 0.01, 0.04, 0.07, 0.10, 0.13, + 0.16, 0.19, 0.22, + ]; + + for &offset in offsets { + let price_low = (current_price + offset).max(0.0001); + let price_high = price_low + 0.02; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 50_000_000_000_u64, + ); + } + + let order = GetTaoForAlpha::with_amount(80_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (many tick crossings sell) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (many tick crossings sell) must succeed"), + "sim_swap and sim_swap_pure must agree for many tick crossings sell" + ); + }); +} + +/// After 5 alternating real swaps (buy/sell/buy/sell/buy) that push pool state through +/// multiple transitions, both sim functions must still agree for a subsequent buy and sell. +#[test] +fn sim_swap_pure_after_alternating_real_swaps_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let buy_limit = SqrtPrice::from_num(1000.0_f64); + let sell_limit = SqrtPrice::from_num(0.001_f64); + + // buy + assert_ok!(Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(30_000_000), + buy_limit, + false, + false, + )); + // sell + assert_ok!(Pallet::::do_swap( + netuid, + GetTaoForAlpha::with_amount(40_000_000), + sell_limit, + false, + false, + )); + // buy + assert_ok!(Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(25_000_000), + buy_limit, + false, + false, + )); + // sell + assert_ok!(Pallet::::do_swap( + netuid, + GetTaoForAlpha::with_amount(35_000_000), + sell_limit, + false, + false, + )); + // buy + assert_ok!(Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(20_000_000), + buy_limit, + false, + false, + )); + + let buy_order = GetAlphaForTao::with_amount(5_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, buy_order.clone()) + .expect("sim_swap (post-alternating buy) must succeed"), + Pallet::::sim_swap_pure(netuid, buy_order) + .expect("sim_swap_pure (post-alternating buy) must succeed"), + "sim_swap and sim_swap_pure must agree for buy after alternating real swaps" + ); + + let sell_order = GetTaoForAlpha::with_amount(5_000_000); + assert_eq!( + Pallet::::sim_swap(netuid, sell_order.clone()) + .expect("sim_swap (post-alternating sell) must succeed"), + Pallet::::sim_swap_pure(netuid, sell_order) + .expect("sim_swap_pure (post-alternating sell) must succeed"), + "sim_swap and sim_swap_pure must agree for sell after alternating real swaps" + ); + }); +} + +/// Partial fill within a single tick (buy): a narrow liquidity position sits just above the +/// current price; a small buy that does not cross the tick. Both functions must agree. +#[test] +fn sim_swap_pure_buy_partial_fill_within_tick_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + let price_low = (current_price + 0.001).max(0.0001); + let price_high = price_low + 0.002; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + + if tick_low < tick_high { + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 500_000_000_000_u64, + ); + } + + let order = GetAlphaForTao::with_amount(500_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (buy partial fill) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (buy partial fill) must succeed"), + "sim_swap and sim_swap_pure must agree for buy partial fill within tick" + ); + }); +} + +/// Partial fill within a single tick (sell): a narrow liquidity position sits just below +/// the current price; a small sell that does not cross the tick. Both functions must agree. +#[test] +fn sim_swap_pure_sell_partial_fill_within_tick_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + let price_low = (current_price - 0.003).max(0.0001); + let price_high = price_low + 0.002; + let tick_low = price_to_tick(price_low); + let tick_high = price_to_tick(price_high); + + if tick_low < tick_high { + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + 500_000_000_000_u64, + ); + } + + let order = GetTaoForAlpha::with_amount(500_000); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (sell partial fill) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (sell partial fill) must succeed"), + "sim_swap and sim_swap_pure must agree for sell partial fill within tick" + ); + }); +} + +/// Asymmetric liquidity (buy): four non-overlapping positions with very different +/// liquidity amounts spread around the current price; a large buy traverses several +/// of them. Both functions must agree. +#[test] +fn sim_swap_pure_asymmetric_liquidity_ranges_buy_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + // Four non-overlapping price bands spread around the current price. + let ranges: &[(f64, f64, u64)] = &[ + (current_price * 0.7, current_price * 0.8, 10_000_000_000), + (current_price * 0.85, current_price * 0.95, 25_000_000_000), + (current_price * 1.05, current_price * 1.15, 8_000_000_000), + (current_price * 1.2, current_price * 1.3, 40_000_000_000), + ]; + + for &(price_low, price_high, liquidity) in ranges { + let tick_low = price_to_tick(price_low.max(0.0001)); + let tick_high = price_to_tick(price_high.max(0.0001)); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + liquidity, + ); + } + + let order = GetAlphaForTao::with_amount(15_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (asymmetric ranges buy) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (asymmetric ranges buy) must succeed"), + "sim_swap and sim_swap_pure must agree for asymmetric liquidity ranges buy" + ); + }); +} + +/// Asymmetric liquidity (sell): four non-overlapping positions with very different +/// liquidity amounts spread around the current price; a large sell traverses several +/// of them. Both functions must agree. +#[test] +fn sim_swap_pure_asymmetric_liquidity_ranges_sell_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + let ranges: &[(f64, f64, u64)] = &[ + (current_price * 0.7, current_price * 0.8, 10_000_000_000), + (current_price * 0.85, current_price * 0.95, 25_000_000_000), + (current_price * 1.05, current_price * 1.15, 8_000_000_000), + (current_price * 1.2, current_price * 1.3, 40_000_000_000), + ]; + + for &(price_low, price_high, liquidity) in ranges { + let tick_low = price_to_tick(price_low.max(0.0001)); + let tick_high = price_to_tick(price_high.max(0.0001)); + if tick_low >= tick_high { + continue; + } + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_low, + tick_high, + liquidity, + ); + } + + let order = GetTaoForAlpha::with_amount(50_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (asymmetric ranges sell) must succeed"), + Pallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (asymmetric ranges sell) must succeed"), + "sim_swap and sim_swap_pure must agree for asymmetric liquidity ranges sell" + ); + }); +} + +/// Uninitialized pool with varied reserve ratios: the pure path bootstraps +/// sqrt_price, current_tick, and current_liquidity from raw reserves. Parametric +/// coverage over a wide range of tao/alpha ratios, amounts, and directions. +#[test] +fn sim_swap_pure_uninitialized_pool_parametric_agrees() { + use subtensor_swap_interface::SwapHandler; + + struct Case { + tao: u64, + alpha: u64, + amount: u64, + is_buy: bool, + } + + let cases = [ + Case { tao: 1_000, alpha: 1_000_000, amount: 1, is_buy: true }, + Case { tao: 1_000, alpha: 1_000_000, amount: 1, is_buy: false }, + Case { tao: 1_000_000, alpha: 1_000, amount: 1, is_buy: true }, + Case { tao: 1_000_000, alpha: 1_000, amount: 1, is_buy: false }, + Case { tao: 1_000_000, alpha: 1_000_000, amount: 1_000_000, is_buy: true }, + Case { tao: 1_000_000, alpha: 1_000_000, amount: 1_000_000, is_buy: false }, + Case { tao: 500_000, alpha: 500_000, amount: 1_000_000_000, is_buy: true }, + Case { tao: 500_000, alpha: 500_000, amount: 1_000_000_000, is_buy: false }, + Case { tao: 100, alpha: 100, amount: 1, is_buy: true }, + Case { tao: 100, alpha: 100, amount: 1, is_buy: false }, + Case { + tao: u32::MAX as u64, + alpha: u32::MAX as u64, + amount: 1_000_000, + is_buy: true, + }, + Case { + tao: u32::MAX as u64, + alpha: u32::MAX as u64, + amount: 1_000_000, + is_buy: false, + }, + Case { tao: 1, alpha: 1_000_000_000, amount: 1, is_buy: true }, + Case { tao: 1, alpha: 1_000_000_000, amount: 1, is_buy: false }, + Case { tao: 1_000_000_000, alpha: 1, amount: 1, is_buy: true }, + Case { tao: 1_000_000_000, alpha: 1, amount: 1, is_buy: false }, + Case { tao: 3_000_000, alpha: 7_000_000, amount: 1_000_000, is_buy: true }, + Case { tao: 3_000_000, alpha: 7_000_000, amount: 1_000_000, is_buy: false }, + // Large reserves: values above u32::MAX stress the bootstrap arithmetic + Case { tao: 1_000_000_000_000_u64, alpha: 1_000_000_000_000_u64, amount: 1_000_000, is_buy: true }, + Case { tao: 1_000_000_000_000_u64, alpha: 1_000_000_000_000_u64, amount: 1_000_000, is_buy: false }, + Case { tao: 10_000_000_000_000_u64, alpha: 1_000_000_000_u64, amount: 1_000_000, is_buy: true }, + Case { tao: 1_000_000_000_u64, alpha: 10_000_000_000_000_u64, amount: 1_000_000, is_buy: false }, + Case { tao: u64::MAX / 2, alpha: u64::MAX / 2, amount: 1_000_000_000, is_buy: true }, + Case { tao: u64::MAX / 2, alpha: u64::MAX / 2, amount: 1_000_000_000, is_buy: false }, + Case { tao: u64::MAX / 2, alpha: 1_000, amount: 1, is_buy: true }, + Case { tao: 1_000, alpha: u64::MAX / 2, amount: 1, is_buy: false }, + ]; + + for case in &cases { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(50); + assert!(!SwapV3Initialized::::get(netuid)); + TaoReserve::set_mock_reserve(netuid, case.tao.into()); + AlphaReserve::set_mock_reserve(netuid, case.alpha.into()); + if case.is_buy { + let order = GetAlphaForTao::with_amount(case.amount); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "uninitialized pool parity: tao={} alpha={} amount={} buy", + case.tao, + case.alpha, + case.amount + ); + } else { + let order = GetTaoForAlpha::with_amount(case.amount); + assert_eq!( + Pallet::::sim_swap(netuid, order.clone()), + Pallet::::sim_swap_pure(netuid, order), + "uninitialized pool parity: tao={} alpha={} amount={} sell", + case.tao, + case.alpha, + case.amount + ); + } + }); + } +} + +/// Shared tick boundary: two liquidity positions share a boundary tick so that +/// liquidity_net at that tick is the sum of both positions' contributions. A swap +/// that crosses the shared tick exercises the pure path's single Ticks::get read. +#[test] +fn sim_swap_pure_shared_tick_boundary_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + let price_low_a = current_price * 1.02; + let price_shared = current_price * 1.06; + let price_high_c = current_price * 1.10; + + let tick_a = price_to_tick(price_low_a); + let tick_b = price_to_tick(price_shared); + let tick_c = price_to_tick(price_high_c); + + if tick_a >= tick_b || tick_b >= tick_c { + return; + } + + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_a, + tick_b, + 20_000_000_000, + ); + + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_b, + tick_c, + 15_000_000_000, + ); + + let buy_order = GetAlphaForTao::with_amount(5_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, buy_order.clone()) + .expect("sim_swap (shared tick boundary buy) must succeed"), + Pallet::::sim_swap_pure(netuid, buy_order) + .expect("sim_swap_pure (shared tick boundary buy) must succeed"), + "shared tick boundary: buy must agree" + ); + + let sell_order = GetTaoForAlpha::with_amount(5_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, sell_order.clone()) + .expect("sim_swap (shared tick boundary sell) must succeed"), + Pallet::::sim_swap_pure(netuid, sell_order) + .expect("sim_swap_pure (shared tick boundary sell) must succeed"), + "shared tick boundary: sell must agree" + ); + }); +} + +/// Exercises the `action_on_edge_sqrt_price` branch by landing `current_tick` exactly on a shared boundary tick. +#[test] +fn sim_swap_pure_current_tick_exactly_on_shared_boundary_agrees() { + use subtensor_swap_interface::SwapHandler; + + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + assert_ok!(Pallet::::maybe_initialize_v3(netuid)); + + let current_price = Pallet::::current_price(netuid).to_num::(); + + let price_a = current_price * 1.03; + let price_shared = current_price * 1.07; + let price_c = current_price * 1.11; + + let tick_a = price_to_tick(price_a); + let tick_b = price_to_tick(price_shared); + let tick_c = price_to_tick(price_c); + + if tick_a >= tick_b || tick_b >= tick_c { + return; + } + + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_a, + tick_b, + 30_000_000_000, + ); + + let _ = Pallet::::do_add_liquidity( + netuid, + &OK_COLDKEY_ACCOUNT_ID, + &OK_HOTKEY_ACCOUNT_ID, + tick_b, + tick_c, + 20_000_000_000, + ); + + let limit_sqrt_price = tick_b.as_sqrt_price_bounded(); + + assert_ok!(Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(50_000_000_000_u64), + limit_sqrt_price, + false, + false, + )); + + if CurrentTick::::get(netuid) != tick_b { + return; + } + + let buy_order = GetAlphaForTao::with_amount(1_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, buy_order.clone()) + .expect("sim_swap buy at boundary must succeed"), + Pallet::::sim_swap_pure(netuid, buy_order) + .expect("sim_swap_pure buy at boundary must succeed"), + "sim_swap and sim_swap_pure must agree when current_tick is exactly on shared boundary (buy)" + ); + + let sell_order = GetTaoForAlpha::with_amount(1_000_000_000_u64); + assert_eq!( + Pallet::::sim_swap(netuid, sell_order.clone()) + .expect("sim_swap sell at boundary must succeed"), + Pallet::::sim_swap_pure(netuid, sell_order) + .expect("sim_swap_pure sell at boundary must succeed"), + "sim_swap and sim_swap_pure must agree when current_tick is exactly on shared boundary (sell)" + ); + }); +} diff --git a/runtime/tests/pallet_swap_sim.rs b/runtime/tests/pallet_swap_sim.rs new file mode 100644 index 0000000000..c1769f96c2 --- /dev/null +++ b/runtime/tests/pallet_swap_sim.rs @@ -0,0 +1,173 @@ +#![allow(clippy::unwrap_used)] + +use frame_support::assert_ok; +use node_subtensor_runtime::{BuildStorage, Runtime, RuntimeGenesisConfig, System}; +use pallet_subtensor::{ + SubnetAlphaIn, SubnetAlphaInProvided, SubnetMechanism, SubnetTAO, SubnetTaoProvided, +}; +use pallet_subtensor_swap::{Pallet as SwapPallet, SwapV3Initialized}; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use subtensor_swap_interface::SwapHandler; + +fn new_test_ext() -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() + .build_storage() + .unwrap() + .into(); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +/// Set storage so the real `TaoReserve` / `AlphaReserve` implementations return +/// the requested values. Both implementations sum the base map and the +/// "provided" map, so writing to the base map alone is sufficient when +/// `*Provided` stays zero (the default). +fn set_reserves(netuid: NetUid, tao: u64, alpha: u64) { + SubnetTAO::::insert(netuid, TaoBalance::from(tao)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(alpha)); +} + +/// Mark the subnet as having mechanism 1 (dynamic / V3) so `sim_swap_pure` +/// and `sim_swap` both take the V3 code-path. +fn set_dynamic_mechanism(netuid: NetUid) { + SubnetMechanism::::insert(netuid, 1u16); +} + +#[test] +fn sim_swap_pure_real_runtime_basic_buy_agrees() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + set_reserves(netuid, 1_000_000_000_000, 4_000_000_000_000); + set_dynamic_mechanism(netuid); + assert_ok!(SwapPallet::::maybe_initialize_v3(netuid)); + + let order = pallet_subtensor::GetAlphaForTao::::with_amount(1_000_000_u64); + + let result_sim = SwapPallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (buy) must succeed"); + let result_pure = SwapPallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (buy) must succeed"); + + assert_eq!( + result_sim, result_pure, + "sim_swap and sim_swap_pure must agree for buy with real runtime reserves" + ); + }); +} + +#[test] +fn sim_swap_pure_real_runtime_basic_sell_agrees() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + set_reserves(netuid, 1_000_000_000_000, 4_000_000_000_000); + set_dynamic_mechanism(netuid); + assert_ok!(SwapPallet::::maybe_initialize_v3(netuid)); + + let order = pallet_subtensor::GetTaoForAlpha::::with_amount(4_000_000_u64); + + let result_sim = SwapPallet::::sim_swap(netuid, order.clone()) + .expect("sim_swap (sell) must succeed"); + let result_pure = SwapPallet::::sim_swap_pure(netuid, order) + .expect("sim_swap_pure (sell) must succeed"); + + assert_eq!( + result_sim, result_pure, + "sim_swap and sim_swap_pure must agree for sell with real runtime reserves" + ); + }); +} + +#[test] +fn sim_swap_pure_real_runtime_uninitialized_with_provided_reserve_agrees() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + // Write split reserves: TaoReserve::reserve() = 600T + 400T = 1T + // AlphaReserve::reserve() = 2T + 2T = 4T + SubnetTAO::::insert(netuid, TaoBalance::from(600_000_000_000_u64)); + SubnetTaoProvided::::insert(netuid, TaoBalance::from(400_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(2_000_000_000_000_u64)); + SubnetAlphaInProvided::::insert(netuid, AlphaBalance::from(2_000_000_000_000_u64)); + set_dynamic_mechanism(netuid); + + // Pool must remain uninitialized for this test. + assert!( + !SwapV3Initialized::::get(netuid), + "pool must be uninitialized" + ); + + // Buy direction. + let buy_order = + pallet_subtensor::GetAlphaForTao::::with_amount(1_000_000_u64); + + let result_sim_buy = SwapPallet::::sim_swap(netuid, buy_order.clone()) + .expect("sim_swap (buy, uninitialized) must succeed"); + let result_pure_buy = SwapPallet::::sim_swap_pure(netuid, buy_order) + .expect("sim_swap_pure (buy, uninitialized) must succeed"); + + assert_eq!( + result_sim_buy, result_pure_buy, + "sim_swap and sim_swap_pure must agree for buy with split provided reserves (uninitialized pool)" + ); + + // Sell direction. + let sell_order = + pallet_subtensor::GetTaoForAlpha::::with_amount(1_000_000_u64); + + let result_sim_sell = SwapPallet::::sim_swap(netuid, sell_order.clone()) + .expect("sim_swap (sell, uninitialized) must succeed"); + let result_pure_sell = SwapPallet::::sim_swap_pure(netuid, sell_order) + .expect("sim_swap_pure (sell, uninitialized) must succeed"); + + assert_eq!( + result_sim_sell, result_pure_sell, + "sim_swap and sim_swap_pure must agree for sell with split provided reserves (uninitialized pool)" + ); + }); +} + +#[test] +fn sim_swap_pure_real_runtime_large_reserves_agrees() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + + set_reserves(netuid, u64::MAX / 4, u64::MAX / 4); + set_dynamic_mechanism(netuid); + + // Pool is intentionally left uninitialized — exercise the bootstrap path. + assert!( + !SwapV3Initialized::::get(netuid), + "pool must be uninitialized" + ); + + // Buy direction. + let buy_order = + pallet_subtensor::GetAlphaForTao::::with_amount(1_000_000_u64); + + let result_sim_buy = SwapPallet::::sim_swap(netuid, buy_order.clone()) + .expect("sim_swap (buy, large reserves) must succeed"); + let result_pure_buy = SwapPallet::::sim_swap_pure(netuid, buy_order) + .expect("sim_swap_pure (buy, large reserves) must succeed"); + + assert_eq!( + result_sim_buy, result_pure_buy, + "sim_swap and sim_swap_pure must agree for buy with large reserves" + ); + + // Sell direction. + let sell_order = + pallet_subtensor::GetTaoForAlpha::::with_amount(1_000_000_u64); + + let result_sim_sell = SwapPallet::::sim_swap(netuid, sell_order.clone()) + .expect("sim_swap (sell, large reserves) must succeed"); + let result_pure_sell = SwapPallet::::sim_swap_pure(netuid, sell_order) + .expect("sim_swap_pure (sell, large reserves) must succeed"); + + assert_eq!( + result_sim_sell, result_pure_sell, + "sim_swap and sim_swap_pure must agree for sell with large reserves" + ); + }); +}