From 221d6b5a7be68cd7b9ade928a14942b5c2523afa Mon Sep 17 00:00:00 2001 From: Loom Agent Date: Thu, 16 Jul 2026 20:28:26 +0000 Subject: [PATCH] fix(limit-orders): recheck per-order slippage floor in batches --- pallets/limit-orders/src/lib.rs | 20 ++++++ pallets/limit-orders/src/tests/extrinsics.rs | 69 ++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 1d5548c529..35358cc55a 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -357,6 +357,11 @@ pub mod pallet { /// delivering any output (conservation), and the order stays retryable in a /// differently-composed batch. ZeroShareInBatch, + /// A sell order's individual slippage floor (derived from `limit_price` and + /// `max_slippage`) was breached by the current execution price. The whole + /// batch is rejected so the order stays retryable, matching the single-order + /// path which rejects the same condition and keeps the order open. + SlippageTooHigh, } // ── Hooks ───────────────────────────────────────────────────────────────── @@ -1171,6 +1176,21 @@ pub mod pallet { let mut sell_fees: Vec<(T::AccountId, u64)> = Vec::new(); for e in sells.iter() { + // Per-order slippage recheck: in a buy-dominant batch, sellers are + // paid at `current_price` rather than through the pool swap. Verify + // that the execution price meets each seller's individual floor + // (`effective_swap_limit`), mirroring the single-order path which + // rejects the same condition and keeps the order retryable. + if net_side == &OrderSide::Buy && e.effective_swap_limit > 0 { + let scaled_price = current_price + .saturating_mul(U64F64::from_num(1_000_000_000u64)) + .saturating_to_num::(); + ensure!( + scaled_price >= e.effective_swap_limit, + Error::::SlippageTooHigh + ); + } + let sell_tao_equiv = Self::alpha_to_tao(e.net as u128, current_price); let gross_share: u64 = if total_sell_tao_equiv > 0 { total_tao diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 43a85a1db1..3e0b2addef 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -2636,6 +2636,75 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { }); } +/// In a buy-dominant batch the counter-side sells are settled at `current_price` +/// rather than through the pool swap, so the pool's price-limit check never +/// inspects them. A StopLoss with a tight `max_slippage` derives a floor above +/// that spot price; without the per-order slippage recheck the seller would be +/// filled below their floor. The batch is rejected with `SlippageTooHigh` so the +/// order stays retryable, mirroring the single-order path. +#[test] +fn execute_batched_orders_buy_dominant_rejects_sell_below_slippage_floor() { + new_test_ext().execute_with(|| { + // Price = 1.0, scaled = 1_000_000_000. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(900); + + // Alice + Bob LimitBuy dominate the batch (net side = Buy). + MockSwap::set_tao_balance(alice(), 600); + MockSwap::set_tao_balance(bob(), 400); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 100); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 600, + 1_000_000_000, // 1.0 in x10^9 scale; scaled=1_000_000_000 <= 1_000_000_000 holds + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + bob(), + netuid(), + OrderType::LimitBuy, + 400, + 1_000_000_000, // 1.0 in x10^9 scale; scaled=1_000_000_000 <= 1_000_000_000 holds + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + // Dave StopLoss: limit = 2.0 (scaled 1_000_000_000 <= 2_000_000_000 holds), + // 25% slippage -> floor = 1_500_000_000, above the 1_000_000_000 spot price. + let dave_stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 100, + 2_000_000_000, // 2.0 in x10^9 scale; scaled=1_000_000_000 <= 2_000_000_000 holds + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(25)), // floor = 1_500_000_000 > spot 1_000_000_000 + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_stoploss]), + ), + Error::::SlippageTooHigh + ); + }); +} + /// StopLoss with a narrow slippage sets an effective floor above the current market price, /// making the pool swap impossible and failing the entire batch. ///