From 14cd19c6744f8362e23bf91b1afd46891a789f06 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 18:16:34 +0300 Subject: [PATCH 01/11] reduce balancer exp precision from 1024 to 512 --- pallets/swap/src/pallet/balancer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 2ffd04fdba..5230612ec6 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -149,7 +149,7 @@ impl Balancer { let w1: u128 = self.get_base_weight().deconstruct() as u128; let w2: u128 = self.get_quote_weight().deconstruct() as u128; - let precision = 1024; + let precision = 512; let x_safe = SafeInt::from(x); let w1_safe = SafeInt::from(w1); let w2_safe = SafeInt::from(w2); From bc5c0246bdd1927abbc38ca838547c1409f3af54 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 19:00:58 +0300 Subject: [PATCH 02/11] reduce balancer exp precision from 512 to 128 --- pallets/swap/src/pallet/balancer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 5230612ec6..0b248a63f5 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -149,7 +149,7 @@ impl Balancer { let w1: u128 = self.get_base_weight().deconstruct() as u128; let w2: u128 = self.get_quote_weight().deconstruct() as u128; - let precision = 512; + let precision = 128; let x_safe = SafeInt::from(x); let w1_safe = SafeInt::from(w1); let w2_safe = SafeInt::from(w2); @@ -928,6 +928,7 @@ mod tests { } } + // cargo test --package pallet-subtensor-swap --lib -- pallet::balancer::tests::test_exp_quote_fuzzy --include-ignored --exact --nocapture #[ignore] #[test] fn test_exp_quote_fuzzy() { From 503170f0126b29fec26ff4364570235c5afdf3dc Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 19:01:53 +0300 Subject: [PATCH 03/11] Increase fuzzy test output frequency --- pallets/swap/src/pallet/balancer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 0b248a63f5..b04532eb00 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -994,7 +994,7 @@ mod tests { // Print progress let done = counter.fetch_add(1, Ordering::Relaxed) + 1; - if done % 100_000_000 == 0 { + if done % 10_000_000 == 0 { let progress = done as f64 / ITERATIONS as f64 * 100.0; // Replace with println for real-time progress log::debug!("progress = {progress:.4}%"); From 98151ecd2eaea92b5966e81aca7fcc1897495076 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 19:11:16 +0300 Subject: [PATCH 04/11] cap exp_scaled at 1, set balancer exp precision to 256 --- pallets/swap/src/pallet/balancer.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index b04532eb00..85676db7db 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -149,7 +149,7 @@ impl Balancer { let w1: u128 = self.get_base_weight().deconstruct() as u128; let w2: u128 = self.get_quote_weight().deconstruct() as u128; - let precision = 128; + let precision = 256; let x_safe = SafeInt::from(x); let w1_safe = SafeInt::from(w1); let w2_safe = SafeInt::from(w2); @@ -187,8 +187,9 @@ impl Balancer { if let Some(result_safe_int) = maybe_result_safe_int && let Some(result_u64) = result_safe_int.to_u64() { - return U64F64::saturating_from_num(result_u64) + let result = U64F64::saturating_from_num(result_u64) .safe_div(U64F64::saturating_from_num(ACCURACY)); + return result.min(U64F64::from_num(1)); } U64F64::saturating_from_num(0) } From 593de8a177ddc00c3202705ff6c90e923a431241 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 19:26:28 +0300 Subject: [PATCH 05/11] Forbid swaps that are too large compared to available liauidity --- pallets/swap/src/pallet/impls.rs | 2 +- pallets/swap/src/pallet/mod.rs | 3 ++ pallets/swap/src/pallet/swap_step.rs | 28 +++++++++++++-- pallets/swap/src/pallet/tests.rs | 54 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index c3e0b2f1d3..df56f29aa2 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -210,7 +210,7 @@ impl Pallet { amount_to_swap, limit_price, drop_fees, - ); + )?; let swap_result = swap_step.execute()?; diff --git a/pallets/swap/src/pallet/mod.rs b/pallets/swap/src/pallet/mod.rs index 1d2fd07c59..b9044d4e82 100644 --- a/pallets/swap/src/pallet/mod.rs +++ b/pallets/swap/src/pallet/mod.rs @@ -165,6 +165,9 @@ mod pallet { /// Swap reserves are too imbalanced ReservesOutOfBalance, + /// Swap input is too large relative to input-side liquidity + SwapInputTooLarge, + /// The extrinsic is deprecated Deprecated, } diff --git a/pallets/swap/src/pallet/swap_step.rs b/pallets/swap/src/pallet/swap_step.rs index 7f10bff65a..9b98259a6f 100644 --- a/pallets/swap/src/pallet/swap_step.rs +++ b/pallets/swap/src/pallet/swap_step.rs @@ -7,6 +7,8 @@ use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token, TokenRes use super::pallet::*; +const MAX_SWAP_INPUT_RESERVE_MULTIPLIER: u64 = 1_000; + /// A struct representing a single swap step with all its parameters and state pub(crate) struct BasicSwapStep where @@ -45,15 +47,16 @@ where amount_remaining: PaidIn, limit_price: U64F64, drop_fees: bool, - ) -> Self { + ) -> Result> { let fee = Pallet::::calculate_fee_amount(netuid, amount_remaining, drop_fees); let requested_delta_in = amount_remaining.saturating_sub(fee); + Self::ensure_input_within_reserve_limit(netuid, requested_delta_in)?; // Target and current prices let target_price = Self::price_target(netuid, requested_delta_in); let current_price = Pallet::::current_price(netuid); - Self { + Ok(Self { netuid, drop_fees, requested_delta_in, @@ -64,15 +67,23 @@ where final_price: target_price, fee, _phantom: PhantomData, - } + }) } /// Execute the swap step and return the result pub(crate) fn execute(&mut self) -> Result, Error> { self.determine_action(); + Self::ensure_input_within_reserve_limit(self.netuid, self.delta_in)?; self.process_swap() } + fn ensure_input_within_reserve_limit(netuid: NetUid, delta_in: PaidIn) -> Result<(), Error> { + let input_reserve = Self::input_reserve(netuid); + let max_delta_in = input_reserve.saturating_mul(MAX_SWAP_INPUT_RESERVE_MULTIPLIER.into()); + ensure!(delta_in <= max_delta_in, Error::::SwapInputTooLarge); + Ok(()) + } + /// Determine the appropriate action for this swap step fn determine_action(&mut self) { let mut recalculate_fee = false; @@ -176,6 +187,10 @@ impl SwapStep .saturating_to_num::(), ) } + + fn input_reserve(netuid: NetUid) -> TaoBalance { + T::TaoReserve::reserve(netuid.into()) + } } impl SwapStep @@ -220,6 +235,10 @@ impl SwapStep .saturating_to_num::(), ) } + + fn input_reserve(netuid: NetUid) -> AlphaBalance { + T::AlphaReserve::reserve(netuid.into()) + } } pub(crate) trait SwapStep @@ -244,6 +263,9 @@ where /// This is the core method of the swap that tells how much output token is given for an /// amount of input token within one price tick. fn convert_deltas(netuid: NetUid, delta_in: PaidIn) -> PaidOut; + + /// Return the reserve for the token being paid into this swap step. + fn input_reserve(netuid: NetUid) -> PaidIn; } #[derive(Debug, PartialEq)] diff --git a/pallets/swap/src/pallet/tests.rs b/pallets/swap/src/pallet/tests.rs index b1071294d3..833dc8d9c1 100644 --- a/pallets/swap/src/pallet/tests.rs +++ b/pallets/swap/src/pallet/tests.rs @@ -721,6 +721,60 @@ fn test_rollback_works() { }) } +#[test] +fn test_swap_rejects_input_over_1000x_input_reserve() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + TaoReserve::set_mock_reserve(netuid, TaoBalance::from(1_000)); + AlphaReserve::set_mock_reserve(netuid, AlphaBalance::from(1_000)); + + assert_noop!( + Pallet::::do_swap( + netuid, + GetTaoForAlpha::with_amount(1_000_001), + get_min_price(), + true, + false, + ), + Error::::SwapInputTooLarge + ); + assert_noop!( + Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(1_000_001), + get_max_price(), + true, + false, + ), + Error::::SwapInputTooLarge + ); + }); +} + +#[test] +fn test_swap_allows_input_at_1000x_input_reserve() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + TaoReserve::set_mock_reserve(netuid, TaoBalance::from(1_000)); + AlphaReserve::set_mock_reserve(netuid, AlphaBalance::from(1_000)); + + assert_ok!(Pallet::::do_swap( + netuid, + GetTaoForAlpha::with_amount(1_000_000), + get_min_price(), + true, + true, + )); + assert_ok!(Pallet::::do_swap( + netuid, + GetAlphaForTao::with_amount(1_000_000), + get_max_price(), + true, + true, + )); + }); +} + #[allow(dead_code)] fn bbox(t: U64F64, a: U64F64, b: U64F64) -> U64F64 { if t < a { From 536c8938d7ff114508bcf6adaaa7290d22e73b75 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 20:16:22 +0300 Subject: [PATCH 06/11] spec bump --- runtime/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index a39c473880..52a517873a 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -234,7 +234,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 423, + spec_version: 424, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, From c4c77a270208e97243e48621adc5bde9aadc0c39 Mon Sep 17 00:00:00 2001 From: "subtensor-ai-review[bot]" Date: Fri, 26 Jun 2026 17:17:36 +0000 Subject: [PATCH 07/11] chore: auditor auto-fix --- runtime/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index a39c473880..52a517873a 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -234,7 +234,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 423, + spec_version: 424, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, From 566fb8c5afd9a8fe323cde1224b19a98d75a9ad3 Mon Sep 17 00:00:00 2001 From: gztensor <166415444+gztensor@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:21:51 -0700 Subject: [PATCH 08/11] Update pallets/swap/src/pallet/balancer.rs Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- pallets/swap/src/pallet/balancer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 85676db7db..5c51ee08ca 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -189,7 +189,11 @@ impl Balancer { { let result = U64F64::saturating_from_num(result_u64) .safe_div(U64F64::saturating_from_num(ACCURACY)); - return result.min(U64F64::from_num(1)); + return if dx >= 0 { + result.min(U64F64::from_num(1)) + } else { + result + }; } U64F64::saturating_from_num(0) } From d005de37e16fda9d4f2dac6d55604511ad4820de Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 20:59:08 +0300 Subject: [PATCH 09/11] fix tests --- pallets/subtensor/src/tests/staking.rs | 28 ++++++++++++--------- pallets/swap/src/pallet/balancer.rs | 6 +++++ pallets/swap/src/pallet/impls.rs | 34 +++++++++++++++++++++++--- pallets/swap/src/pallet/swap_step.rs | 28 +++------------------ 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/pallets/subtensor/src/tests/staking.rs b/pallets/subtensor/src/tests/staking.rs index 660b6957d7..ee36fe8e4e 100644 --- a/pallets/subtensor/src/tests/staking.rs +++ b/pallets/subtensor/src/tests/staking.rs @@ -776,9 +776,9 @@ fn test_add_stake_insufficient_liquidity() { }); } -/// cargo test --package pallet-subtensor --lib -- tests::staking::test_add_stake_insufficient_liquidity_one_side_ok --exact --show-output +/// cargo test --package pallet-subtensor --lib -- tests::staking::test_add_stake_input_reserve_too_low_fails --exact --show-output #[test] -fn test_add_stake_insufficient_liquidity_one_side_ok() { +fn test_add_stake_input_reserve_too_low_fails() { new_test_ext(1).execute_with(|| { let subnet_owner_coldkey = U256::from(1001); let subnet_owner_hotkey = U256::from(1002); @@ -795,13 +795,17 @@ fn test_add_stake_insufficient_liquidity_one_side_ok() { let reserve_tao = u64::from(mock::SwapMinimumReserve::get()) - 1; mock::setup_reserves(netuid, reserve_tao.into(), reserve_alpha.into()); - // Check the error - assert_ok!(SubtensorModule::add_stake( - RuntimeOrigin::signed(coldkey), - hotkey, - netuid, - amount_staked.into() - )); + // The output-side reserve is sufficient, but the input-side reserve is too small for the + // requested swap under the 1000x input-reserve cap. + assert_noop!( + SubtensorModule::add_stake( + RuntimeOrigin::signed(coldkey), + hotkey, + netuid, + amount_staked.into() + ), + pallet_subtensor_swap::Error::::SwapInputTooLarge + ); }); } @@ -876,7 +880,7 @@ fn test_remove_stake_insufficient_liquidity() { // Mock more liquidity - remove becomes successful SubnetTAO::::insert(netuid, TaoBalance::from(amount_staked + 1)); - SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(alpha.to_u64() / 1000 + 1)); assert_ok!(SubtensorModule::remove_stake( RuntimeOrigin::signed(coldkey), hotkey, @@ -5248,7 +5252,8 @@ fn test_large_swap() { // add network let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); add_balance_to_coldkey_account(&coldkey, 1_000_000_000_000_000_u64.into()); - let tao = TaoBalance::from(100_000_000u64); + let swap_amount = TaoBalance::from(100_000_000_000_000_u64); + let tao = TaoBalance::from(swap_amount.to_u64() / 1000); let alpha = AlphaBalance::from(1_000_000_000_000_000_u64); SubnetTAO::::insert(netuid, tao); SubnetAlphaIn::::insert(netuid, alpha); @@ -5256,7 +5261,6 @@ fn test_large_swap() { // Force the swap to initialize ::SwapInterface::init_swap(netuid, None); - let swap_amount = TaoBalance::from(100_000_000_000_000_u64); assert_ok!(SubtensorModule::add_stake( RuntimeOrigin::signed(coldkey), owner_hotkey, diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 5c51ee08ca..244e4ff9eb 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -796,6 +796,12 @@ mod tests { let dy1 = y_fixed * (one - e1); let dy2 = y_fixed * (one - e2); + if dx > x.saturating_mul(1_000) { + assert!(e1 <= one); + assert!(e2 <= one); + return; + } + let w1 = perquintill_to_f64(bal.get_base_weight()); let w2 = perquintill_to_f64(bal.get_quote_weight()); let e1_expected = (x as f64 / (x as f64 + dx as f64)).powf(w1 / w2); diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index df56f29aa2..7edbf2bed4 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -14,7 +14,7 @@ use subtensor_swap_interface::{ }; use super::pallet::*; -use super::swap_step::{BasicSwapStep, SwapStep}; +use super::swap_step::{BasicSwapStep, MAX_SWAP_INPUT_RESERVE_MULTIPLIER, SwapStep}; use crate::{pallet::Balancer, pallet::balancer::BalancerError}; impl Pallet { @@ -154,8 +154,17 @@ impl Pallet { transactional::with_transaction(|| { let reserve = Order::ReserveOut::reserve(netuid.into()); - let result = Self::swap_inner::(netuid, order, limit_price, drop_fees) - .map_err(Into::into); + let result = if simulate { + Ok(()) + } else { + Self::ensure_swap_input_within_reserve_limit::( + netuid, + order.amount(), + drop_fees, + ) + } + .and_then(|_| Self::swap_inner::(netuid, order, limit_price, drop_fees)) + .map_err(Into::into); if simulate || result.is_err() { // Simulation only @@ -177,6 +186,23 @@ impl Pallet { }) } + fn ensure_swap_input_within_reserve_limit( + netuid: NetUid, + amount: Order::PaidIn, + drop_fees: bool, + ) -> Result<(), Error> + where + Order: OrderT, + { + let fee = Self::calculate_fee_amount(netuid, amount, drop_fees); + let net_amount = amount.saturating_sub(fee); + let input_reserve = Order::ReserveIn::reserve(netuid); + let max_amount = input_reserve.saturating_mul(MAX_SWAP_INPUT_RESERVE_MULTIPLIER.into()); + + ensure!(net_amount <= max_amount, Error::::SwapInputTooLarge); + Ok(()) + } + fn swap_inner( netuid: NetUid, order: Order, @@ -210,7 +236,7 @@ impl Pallet { amount_to_swap, limit_price, drop_fees, - )?; + ); let swap_result = swap_step.execute()?; diff --git a/pallets/swap/src/pallet/swap_step.rs b/pallets/swap/src/pallet/swap_step.rs index 9b98259a6f..3d4d516d1f 100644 --- a/pallets/swap/src/pallet/swap_step.rs +++ b/pallets/swap/src/pallet/swap_step.rs @@ -7,7 +7,7 @@ use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token, TokenRes use super::pallet::*; -const MAX_SWAP_INPUT_RESERVE_MULTIPLIER: u64 = 1_000; +pub(crate) const MAX_SWAP_INPUT_RESERVE_MULTIPLIER: u64 = 1_000; /// A struct representing a single swap step with all its parameters and state pub(crate) struct BasicSwapStep @@ -47,16 +47,15 @@ where amount_remaining: PaidIn, limit_price: U64F64, drop_fees: bool, - ) -> Result> { + ) -> Self { let fee = Pallet::::calculate_fee_amount(netuid, amount_remaining, drop_fees); let requested_delta_in = amount_remaining.saturating_sub(fee); - Self::ensure_input_within_reserve_limit(netuid, requested_delta_in)?; // Target and current prices let target_price = Self::price_target(netuid, requested_delta_in); let current_price = Pallet::::current_price(netuid); - Ok(Self { + Self { netuid, drop_fees, requested_delta_in, @@ -67,23 +66,15 @@ where final_price: target_price, fee, _phantom: PhantomData, - }) + } } /// Execute the swap step and return the result pub(crate) fn execute(&mut self) -> Result, Error> { self.determine_action(); - Self::ensure_input_within_reserve_limit(self.netuid, self.delta_in)?; self.process_swap() } - fn ensure_input_within_reserve_limit(netuid: NetUid, delta_in: PaidIn) -> Result<(), Error> { - let input_reserve = Self::input_reserve(netuid); - let max_delta_in = input_reserve.saturating_mul(MAX_SWAP_INPUT_RESERVE_MULTIPLIER.into()); - ensure!(delta_in <= max_delta_in, Error::::SwapInputTooLarge); - Ok(()) - } - /// Determine the appropriate action for this swap step fn determine_action(&mut self) { let mut recalculate_fee = false; @@ -187,10 +178,6 @@ impl SwapStep .saturating_to_num::(), ) } - - fn input_reserve(netuid: NetUid) -> TaoBalance { - T::TaoReserve::reserve(netuid.into()) - } } impl SwapStep @@ -235,10 +222,6 @@ impl SwapStep .saturating_to_num::(), ) } - - fn input_reserve(netuid: NetUid) -> AlphaBalance { - T::AlphaReserve::reserve(netuid.into()) - } } pub(crate) trait SwapStep @@ -263,9 +246,6 @@ where /// This is the core method of the swap that tells how much output token is given for an /// amount of input token within one price tick. fn convert_deltas(netuid: NetUid, delta_in: PaidIn) -> PaidOut; - - /// Return the reserve for the token being paid into this swap step. - fn input_reserve(netuid: NetUid) -> PaidIn; } #[derive(Debug, PartialEq)] From 132b9cca3b0d8aa4cbadf54b402c2bde12bf8050 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 21:15:43 +0300 Subject: [PATCH 10/11] Fix tests, enforce atomic add-stake recycle/burn operations by rolling back the initial stake when the recycle or burn leg fails. --- chain-extensions/src/tests.rs | 38 ++++++++-- .../subtensor/src/staking/recycle_alpha.rs | 75 ++++++++++++++----- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/chain-extensions/src/tests.rs b/chain-extensions/src/tests.rs index 16619389bf..886f722aa7 100644 --- a/chain-extensions/src/tests.rs +++ b/chain-extensions/src/tests.rs @@ -1297,15 +1297,26 @@ fn add_stake_recycle_rollback_on_recycle_failure() { let netuid = mock::add_dynamic_network(&owner_hotkey, &owner_coldkey); - // Set up very low reserves so recycle will fail with InsufficientLiquidity + mock::register_ok_neuron(netuid, hotkey, coldkey, 0); + pallet_subtensor::Pallet::::insert_lock_state( + &coldkey, + netuid, + &hotkey, + pallet_subtensor::staking::lock::LockState { + locked_mass: AlphaBalance::from(u64::MAX / 4), + conviction: U64F64::saturating_from_num(0), + last_update: pallet_subtensor::Pallet::::get_current_block_as_u64(), + }, + ); + + // Leave enough input-side liquidity for add_stake to pass the 1000x swap input cap. + // The lock above makes the recycle leg fail, exercising atomic rollback. mock::setup_reserves( netuid, - TaoBalance::from(1_000_u64), + TaoBalance::from(tao_amount_raw / 1000 + 1), AlphaBalance::from(1_000_u64), ); - mock::register_ok_neuron(netuid, hotkey, coldkey, 0); - add_balance_to_coldkey_account( &coldkey, TaoBalance::from(tao_amount_raw.saturating_add(1_000_000_000)), @@ -1368,15 +1379,26 @@ fn add_stake_burn_rollback_on_burn_failure() { let netuid = mock::add_dynamic_network(&owner_hotkey, &owner_coldkey); - // Set up very low reserves so burn will fail with InsufficientLiquidity + mock::register_ok_neuron(netuid, hotkey, coldkey, 0); + pallet_subtensor::Pallet::::insert_lock_state( + &coldkey, + netuid, + &hotkey, + pallet_subtensor::staking::lock::LockState { + locked_mass: AlphaBalance::from(u64::MAX / 4), + conviction: U64F64::saturating_from_num(0), + last_update: pallet_subtensor::Pallet::::get_current_block_as_u64(), + }, + ); + + // Leave enough input-side liquidity for add_stake to pass the 1000x swap input cap. + // The lock above makes the burn leg fail, exercising atomic rollback. mock::setup_reserves( netuid, - TaoBalance::from(1_000_u64), + TaoBalance::from(tao_amount_raw / 1000 + 1), AlphaBalance::from(1_000_u64), ); - mock::register_ok_neuron(netuid, hotkey, coldkey, 0); - add_balance_to_coldkey_account( &coldkey, TaoBalance::from(tao_amount_raw.saturating_add(1_000_000_000)), diff --git a/pallets/subtensor/src/staking/recycle_alpha.rs b/pallets/subtensor/src/staking/recycle_alpha.rs index 7152c48cdb..aa0eced405 100644 --- a/pallets/subtensor/src/staking/recycle_alpha.rs +++ b/pallets/subtensor/src/staking/recycle_alpha.rs @@ -1,5 +1,6 @@ use super::*; use crate::{Error, system::ensure_signed}; +use frame_support::storage::{TransactionOutcome, with_transaction}; use subtensor_runtime_common::{AlphaBalance, NetUid}; impl Pallet { @@ -132,22 +133,38 @@ impl Pallet { amount: TaoBalance, limit: Option, ) -> DispatchResult { - let alpha = if let Some(limit) = limit { - Self::do_add_stake_limit(origin.clone(), hotkey.clone(), netuid, amount, limit, false)? - } else { - Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)? - }; - - Self::do_burn_alpha(origin, hotkey.clone(), alpha, netuid)?; - - Self::deposit_event(Event::AddStakeBurn { - netuid, - hotkey, - amount, - alpha, - }); - - Ok(()) + with_transaction(|| { + let result = (|| { + let alpha = if let Some(limit) = limit { + Self::do_add_stake_limit( + origin.clone(), + hotkey.clone(), + netuid, + amount, + limit, + false, + )? + } else { + Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)? + }; + + Self::do_burn_alpha(origin, hotkey.clone(), alpha, netuid)?; + + Self::deposit_event(Event::AddStakeBurn { + netuid, + hotkey, + amount, + alpha, + }); + + Ok(()) + })(); + + match result { + Ok(()) => TransactionOutcome::Commit(Ok(())), + Err(err) => TransactionOutcome::Rollback(Err(err)), + } + }) } /// Atomically stakes TAO and recycles the resulting alpha. @@ -160,8 +177,17 @@ impl Pallet { netuid: NetUid, amount: TaoBalance, ) -> Result { - let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?; - Self::do_recycle_alpha(origin, hotkey, alpha, netuid) + with_transaction(|| { + let result = (|| { + let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?; + Self::do_recycle_alpha(origin, hotkey, alpha, netuid) + })(); + + match result { + Ok(alpha) => TransactionOutcome::Commit(Ok(alpha)), + Err(err) => TransactionOutcome::Rollback(Err(err)), + } + }) } /// Atomically stakes TAO and burns the resulting alpha. Permissionless @@ -173,7 +199,16 @@ impl Pallet { netuid: NetUid, amount: TaoBalance, ) -> Result { - let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?; - Self::do_burn_alpha(origin, hotkey, alpha, netuid) + with_transaction(|| { + let result = (|| { + let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?; + Self::do_burn_alpha(origin, hotkey, alpha, netuid) + })(); + + match result { + Ok(alpha) => TransactionOutcome::Commit(Ok(alpha)), + Err(err) => TransactionOutcome::Rollback(Err(err)), + } + }) } } From 1ad16880f9a66f9b4b8506c470d3fb6468db457e Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 26 Jun 2026 21:30:30 +0300 Subject: [PATCH 11/11] Apply the 1000x swap input cap to simulations and limit-path max-amount probes. --- pallets/subtensor/src/staking/add_stake.rs | 24 +++++++- pallets/subtensor/src/staking/remove_stake.rs | 23 +++++++- pallets/subtensor/src/tests/staking.rs | 58 ++++++++++++++----- pallets/swap/src/pallet/impls.rs | 14 ++--- pallets/swap/src/pallet/tests.rs | 20 ++++++- 5 files changed, 111 insertions(+), 28 deletions(-) diff --git a/pallets/subtensor/src/staking/add_stake.rs b/pallets/subtensor/src/staking/add_stake.rs index 33cadf241b..d2bad04787 100644 --- a/pallets/subtensor/src/staking/add_stake.rs +++ b/pallets/subtensor/src/staking/add_stake.rs @@ -48,6 +48,8 @@ impl Pallet { "do_add_stake( origin:{coldkey:?} hotkey:{hotkey:?}, netuid:{netuid:?}, stake_to_be_added:{stake_to_be_added:?} )" ); + Self::ensure_add_stake_input_within_swap_limit(netuid, stake_to_be_added)?; + // 2. Validate user input Self::validate_add_stake( &coldkey, @@ -124,6 +126,8 @@ impl Pallet { "do_add_stake( origin:{coldkey:?} hotkey:{hotkey:?}, netuid:{netuid:?}, stake_to_be_added:{stake_to_be_added:?} )" ); + Self::ensure_add_stake_input_within_swap_limit(netuid, stake_to_be_added)?; + // 2. Calculate the maximum amount that can be executed with price limit let max_amount: TaoBalance = Self::get_max_amount_add(netuid, limit_price)?.into(); let mut possible_stake = stake_to_be_added; @@ -175,11 +179,27 @@ impl Pallet { } } - // Use reverting swap to estimate max limit amount - let order = GetAlphaForTao::::with_amount(u64::MAX); + // Use the largest supported input instead of probing the swap path with u64::MAX. + let max_supported_input = SubnetTAO::::get(netuid).saturating_mul(1_000.into()); + let order = GetAlphaForTao::::with_amount(max_supported_input); let result = T::SwapInterface::swap(netuid.into(), order, limit_price, false, true) .map(|r| r.amount_paid_in.saturating_add(r.fee_paid))?; Ok(result.into()) } + + fn ensure_add_stake_input_within_swap_limit( + netuid: NetUid, + amount: TaoBalance, + ) -> Result<(), Error> { + if !netuid.is_root() && SubnetMechanism::::get(netuid) == 1 { + let max_supported_input = SubnetTAO::::get(netuid).saturating_mul(1_000.into()); + ensure!( + amount <= max_supported_input, + Error::::InsufficientLiquidity + ); + } + + Ok(()) + } } diff --git a/pallets/subtensor/src/staking/remove_stake.rs b/pallets/subtensor/src/staking/remove_stake.rs index cf640dc661..0b460612b9 100644 --- a/pallets/subtensor/src/staking/remove_stake.rs +++ b/pallets/subtensor/src/staking/remove_stake.rs @@ -55,6 +55,7 @@ impl Pallet { let alpha_available = Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); let alpha_unstaked = alpha_unstaked.min(alpha_available); + Self::ensure_remove_stake_input_within_swap_limit(netuid, alpha_unstaked)?; // 2. Validate the user input Self::validate_remove_stake( @@ -336,6 +337,8 @@ impl Pallet { "do_remove_stake( origin:{coldkey:?} hotkey:{hotkey:?}, netuid: {netuid:?}, alpha_unstaked:{alpha_unstaked:?} )" ); + Self::ensure_remove_stake_input_within_swap_limit(netuid, alpha_unstaked)?; + // 2. Calculate the maximum amount that can be executed with price limit let max_amount = Self::get_max_amount_remove(netuid, limit_price)?; let mut possible_alpha = alpha_unstaked; @@ -394,14 +397,30 @@ impl Pallet { } } - // Use reverting swap to estimate max limit amount - let order = GetTaoForAlpha::::with_amount(u64::MAX); + // Use the largest supported input instead of probing the swap path with u64::MAX. + let max_supported_input = SubnetAlphaIn::::get(netuid).saturating_mul(1_000.into()); + let order = GetTaoForAlpha::::with_amount(max_supported_input); let result = T::SwapInterface::swap(netuid.into(), order, limit_price.into(), false, true) .map(|r| r.amount_paid_in.saturating_add(r.fee_paid))?; Ok(result) } + fn ensure_remove_stake_input_within_swap_limit( + netuid: NetUid, + amount: AlphaBalance, + ) -> Result<(), Error> { + if !netuid.is_root() && SubnetMechanism::::get(netuid) == 1 { + let max_supported_input = SubnetAlphaIn::::get(netuid).saturating_mul(1_000.into()); + ensure!( + amount <= max_supported_input, + Error::::InsufficientLiquidity + ); + } + + Ok(()) + } + pub fn do_remove_stake_full_limit( origin: OriginFor, hotkey: T::AccountId, diff --git a/pallets/subtensor/src/tests/staking.rs b/pallets/subtensor/src/tests/staking.rs index ee36fe8e4e..3d759e200c 100644 --- a/pallets/subtensor/src/tests/staking.rs +++ b/pallets/subtensor/src/tests/staking.rs @@ -804,7 +804,7 @@ fn test_add_stake_input_reserve_too_low_fails() { netuid, amount_staked.into() ), - pallet_subtensor_swap::Error::::SwapInputTooLarge + Error::::InsufficientLiquidity ); }); } @@ -3046,14 +3046,14 @@ fn test_max_amount_remove_dynamic() { pallet_subtensor_swap::Error::::PriceLimitExceeded, )), ), - (10_000_000_000, 10_000_000_000, 0, Ok(u64::MAX)), + (10_000_000_000, 10_000_000_000, 0, Ok(10_000_000_000_000)), // Low bounds (numbers are empirical, it is only important that result // is sharply decreasing when limit price increases) - (1_000, 1_000, 0, Ok(u64::MAX)), - (1_001, 1_001, 0, Ok(u64::MAX)), - (1_001, 1_001, 1, Ok(17_472)), - (1_001, 1_001, 2, Ok(17_472)), - (1_001, 1_001, 1_001, Ok(17_472)), + (1_000, 1_000, 0, Ok(1_000_000)), + (1_001, 1_001, 0, Ok(1_001_000)), + (1_001, 1_001, 1, Ok(1_001_000)), + (1_001, 1_001, 2, Ok(1_001_000)), + (1_001, 1_001, 1_001, Ok(1_001_000)), (1_001, 1_001, 10_000, Ok(17_472)), (1_001, 1_001, 100_000, Ok(17_472)), (1_001, 1_001, 1_000_000, Ok(17_472)), @@ -3069,7 +3069,7 @@ fn test_max_amount_remove_dynamic() { Ok(3_030_000_000_000), ), // Normal range values with edge cases and sanity checks - (200_000_000_000, 100_000_000_000, 0, Ok(u64::MAX)), + (200_000_000_000, 100_000_000_000, 0, Ok(100_000_000_000_000)), ( 200_000_000_000, 100_000_000_000, @@ -3157,10 +3157,13 @@ fn test_max_amount_remove_dynamic() { ), Ok(v) => { let v = AlphaBalance::from(v); - assert_abs_diff_eq!( - SubtensorModule::get_max_amount_remove(netuid, limit_price.into()).unwrap(), - v, - epsilon = v / 100.into() + let actual = + SubtensorModule::get_max_amount_remove(netuid, limit_price.into()).unwrap(); + let epsilon = v / 100.into(); + let diff = actual.max(v).saturating_sub(actual.min(v)); + assert!( + diff <= epsilon, + "max remove mismatch: tao_in={tao_in}, alpha_in={alpha_in:?}, limit_price={limit_price}, actual={actual:?}, expected={v:?}, epsilon={epsilon:?}", ); } } @@ -3417,10 +3420,10 @@ fn test_max_amount_move_dynamic_stable() { // The tests below just mimic the remove_stake_limit tests - // 0 price => max is u64::MAX + // 0 price => max is capped at 1000x input reserve assert_eq!( SubtensorModule::get_max_amount_move(dynamic_netuid, stable_netuid, TaoBalance::ZERO), - Ok(AlphaBalance::MAX) + Ok(alpha_in.saturating_mul(1_000.into())) ); // Low price values don't blow things up @@ -3876,6 +3879,33 @@ fn test_add_stake_limit_fill_or_kill() { }); } +#[test] +fn test_add_stake_limit_rejects_input_over_swap_reserve_cap() { + new_test_ext(1).execute_with(|| { + let hotkey_account_id = U256::from(533454); + let coldkey_account_id = U256::from(55454); + + let netuid = add_dynamic_network(&hotkey_account_id, &coldkey_account_id); + let tao_reserve = TaoBalance::from(1_000_u64); + mock::setup_reserves(netuid, tao_reserve, AlphaBalance::from(1_000_000_000_u64)); + + let amount = tao_reserve.saturating_mul(1_000.into()) + TaoBalance::from(1_u64); + add_balance_to_coldkey_account(&coldkey_account_id, amount); + + assert_noop!( + SubtensorModule::add_stake_limit( + RuntimeOrigin::signed(coldkey_account_id), + hotkey_account_id, + netuid, + amount, + ::SwapInterface::max_price(), + true + ), + Error::::InsufficientLiquidity + ); + }); +} + #[test] fn test_add_stake_limit_partial_zero_max_stake_amount_error() { new_test_ext(1).execute_with(|| { diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index 7edbf2bed4..c5b9b11a29 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -154,15 +154,11 @@ impl Pallet { transactional::with_transaction(|| { let reserve = Order::ReserveOut::reserve(netuid.into()); - let result = if simulate { - Ok(()) - } else { - Self::ensure_swap_input_within_reserve_limit::( - netuid, - order.amount(), - drop_fees, - ) - } + let result = Self::ensure_swap_input_within_reserve_limit::( + netuid, + order.amount(), + drop_fees, + ) .and_then(|_| Self::swap_inner::(netuid, order, limit_price, drop_fees)) .map_err(Into::into); diff --git a/pallets/swap/src/pallet/tests.rs b/pallets/swap/src/pallet/tests.rs index 833dc8d9c1..5e9712d63d 100644 --- a/pallets/swap/src/pallet/tests.rs +++ b/pallets/swap/src/pallet/tests.rs @@ -11,7 +11,7 @@ use sp_arithmetic::Perquintill; use sp_runtime::DispatchError; use substrate_fixed::types::U64F64; use subtensor_runtime_common::{NetUid, Token}; -use subtensor_swap_interface::Order as OrderT; +use subtensor_swap_interface::{Order as OrderT, SwapHandler}; use super::*; use crate::mock::*; @@ -751,6 +751,24 @@ fn test_swap_rejects_input_over_1000x_input_reserve() { }); } +#[test] +fn test_sim_swap_rejects_input_over_1000x_input_reserve() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1); + TaoReserve::set_mock_reserve(netuid, TaoBalance::from(1_000)); + AlphaReserve::set_mock_reserve(netuid, AlphaBalance::from(1_000)); + + assert_noop!( + Pallet::::sim_swap(netuid, GetTaoForAlpha::with_amount(1_001_000)), + Error::::SwapInputTooLarge + ); + assert_noop!( + Pallet::::sim_swap(netuid, GetAlphaForTao::with_amount(1_001_000)), + Error::::SwapInputTooLarge + ); + }); +} + #[test] fn test_swap_allows_input_at_1000x_input_reserve() { new_test_ext().execute_with(|| {