From 73da72ada1f4cc3d074e66b72649fa180d79e3ae Mon Sep 17 00:00:00 2001 From: gorka-i Date: Mon, 23 Mar 2026 16:57:56 +0100 Subject: [PATCH 01/85] move swap interface to primitives since it's not a pllet --- Cargo.toml | 2 +- {pallets => primitives}/swap-interface/Cargo.toml | 0 {pallets => primitives}/swap-interface/src/lib.rs | 0 {pallets => primitives}/swap-interface/src/order.rs | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename {pallets => primitives}/swap-interface/Cargo.toml (100%) rename {pallets => primitives}/swap-interface/src/lib.rs (100%) rename {pallets => primitives}/swap-interface/src/order.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 2a76ef639d..466c6153f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ subtensor-custom-rpc = { default-features = false, path = "pallets/subtensor/rpc subtensor-custom-rpc-runtime-api = { default-features = false, path = "pallets/subtensor/runtime-api" } subtensor-precompiles = { default-features = false, path = "precompiles" } subtensor-runtime-common = { default-features = false, path = "common" } -subtensor-swap-interface = { default-features = false, path = "pallets/swap-interface" } +subtensor-swap-interface = { default-features = false, path = "primitives/swap-interface" } subtensor-transaction-fee = { default-features = false, path = "pallets/transaction-fee" } subtensor-chain-extensions = { default-features = false, path = "chain-extensions" } stp-shield = { git = "https://github.com/opentensor/polkadot-sdk.git", rev = "fb1dd20df37710800aa284ac49bb26193d5539ee", default-features = false } diff --git a/pallets/swap-interface/Cargo.toml b/primitives/swap-interface/Cargo.toml similarity index 100% rename from pallets/swap-interface/Cargo.toml rename to primitives/swap-interface/Cargo.toml diff --git a/pallets/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs similarity index 100% rename from pallets/swap-interface/src/lib.rs rename to primitives/swap-interface/src/lib.rs diff --git a/pallets/swap-interface/src/order.rs b/primitives/swap-interface/src/order.rs similarity index 100% rename from pallets/swap-interface/src/order.rs rename to primitives/swap-interface/src/order.rs From e80c0b3b58809ec6bc70bb88bfa3892f9b62ef0f Mon Sep 17 00:00:00 2001 From: gorka-i Date: Mon, 23 Mar 2026 17:42:56 +0100 Subject: [PATCH 02/85] pallet advancing --- Cargo.lock | 15 + Cargo.toml | 1 + pallets/limit-orders/Cargo.toml | 32 ++ pallets/limit-orders/src/lib.rs | 439 ++++++++++++++++++++ pallets/subtensor/src/staking/mod.rs | 1 + pallets/subtensor/src/staking/order_swap.rs | 30 ++ primitives/swap-interface/src/lib.rs | 32 ++ 7 files changed, 550 insertions(+) create mode 100644 pallets/limit-orders/Cargo.toml create mode 100644 pallets/limit-orders/src/lib.rs create mode 100644 pallets/subtensor/src/staking/order_swap.rs diff --git a/Cargo.lock b/Cargo.lock index ef764199aa..88996c5897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9965,6 +9965,21 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-limit-orders" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-runtime", + "substrate-fixed", + "subtensor-runtime-common", + "subtensor-swap-interface", +] + [[package]] name = "pallet-lottery" version = "41.0.0" diff --git a/Cargo.toml b/Cargo.toml index 466c6153f6..63ecf39a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ useless_conversion = "allow" # until polkadot is patched [workspace.dependencies] node-subtensor-runtime = { path = "runtime", default-features = false } pallet-admin-utils = { path = "pallets/admin-utils", default-features = false } +pallet-limit-orders = { path = "pallets/limit-orders", default-features = false } pallet-commitments = { path = "pallets/commitments", default-features = false } pallet-registry = { path = "pallets/registry", default-features = false } pallet-crowdloan = { path = "pallets/crowdloan", default-features = false } diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml new file mode 100644 index 0000000000..809659369f --- /dev/null +++ b/pallets/limit-orders/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pallet-limit-orders" +version = "0.1.0" +edition.workspace = true + +[dependencies] +codec = { workspace = true, features = ["derive"] } +frame-support.workspace = true +frame-system.workspace = true +scale-info.workspace = true +sp-core.workspace = true +sp-runtime.workspace = true +substrate-fixed.workspace = true +subtensor-runtime-common.workspace = true +subtensor-swap-interface.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "scale-info/std", + "sp-core/std", + "sp-runtime/std", + "substrate-fixed/std", + "subtensor-runtime-common/std", + "subtensor-swap-interface/std", +] diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs new file mode 100644 index 0000000000..4a161e7ec9 --- /dev/null +++ b/pallets/limit-orders/src/lib.rs @@ -0,0 +1,439 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::traits::{IdentifyAccount, Verify}; +use substrate_fixed::types::U96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_swap_interface::OrderSwapInterface; + +// ── Data structures ────────────────────────────────────────────────────────── + +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen, + Clone, + PartialEq, + Eq, + Debug, +)] +pub enum OrderSide { + Buy, + Sell, +} + +/// The canonical order payload that users sign off-chain. +/// Only its H256 hash is stored on-chain; the full struct is submitted by the +/// admin at execution time (or by the user at cancellation time). +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen, + Clone, + PartialEq, + Eq, + Debug, +)] +pub struct Order { + /// The coldkey that authorised this order (pays TAO for buys; owns the + /// staked alpha for sells). + pub signer: AccountId, + /// The hotkey to stake to (buy) or unstake from (sell). + pub hotkey: AccountId, + /// Target subnet. + pub netuid: NetUid, + /// Buy or Sell. + pub side: OrderSide, + /// Input amount: TAO (raw) for Buy, alpha (raw) for Sell. + pub amount: u64, + /// Price threshold in TAO/alpha (raw units, same scale as + /// `OrderSwapInterface::current_alpha_price`). + /// Buy: maximum acceptable price. Sell: minimum acceptable price. + pub limit_price: u64, + /// Unix timestamp in milliseconds after which this order must not be executed. + pub expiry: u64, +} + +/// The envelope the admin submits on-chain: the order payload plus the user's +/// signature over the SCALE-encoded `Order`. +/// +/// Signature verification is performed against `order.signer` (the AccountId) +/// directly, which works because in Substrate sr25519/ed25519 AccountIds are +/// the raw public keys. +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen, + Clone, + PartialEq, + Eq, + Debug, +)] +pub struct SignedOrder< + AccountId: Encode + Decode + TypeInfo + MaxEncodedLen + Clone, + Signature: Encode + Decode + TypeInfo + MaxEncodedLen + Clone, +> { + pub order: Order, + /// Signature over `SCALE_ENCODE(order)`. + pub signature: Signature, +} + +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen, + Clone, + PartialEq, + Eq, + Debug, +)] +pub enum OrderStatus { + /// The order was successfully executed. + Fulfilled, + /// The user registered a cancellation intent before execution. + Cancelled, +} + +// ── Pallet ─────────────────────────────────────────────────────────────────── + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::{ + pallet_prelude::*, + traits::{Get, UnixTime}, + }; + use frame_system::pallet_prelude::*; + use sp_core::H256; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Signature type used to verify off-chain order authorisations. + /// + /// The `Verify::verify` method is called with the order's `signer` + /// (`T::AccountId`) as the expected signer, which works for + /// sr25519/ed25519 where AccountId == public key. + /// + /// For the subtensor runtime, set this to `sp_runtime::MultiSignature`. + type Signature: Verify> + + Encode + + Decode + + DecodeWithMemTracking + + TypeInfo + + MaxEncodedLen + + Clone + + PartialEq + + core::fmt::Debug; + + /// Full swap + balance execution interface (see [`OrderSwapInterface`]). + type SwapInterface: OrderSwapInterface; + + /// Time provider for expiry checks. + type TimeProvider: UnixTime; + + /// Account that collects protocol fees. + #[pallet::constant] + type FeeCollector: Get; + + /// Maximum number of orders in a single `execute_orders` call. + /// Should equal `floor(max_block_weight / per_order_weight)`. + #[pallet::constant] + type MaxOrdersPerBatch: Get; + } + + // ── Storage ─────────────────────────────────────────────────────────────── + + /// Protocol fee in parts-per-billion (PPB). e.g. 1_000_000 PPB = 0.1%. + #[pallet::storage] + pub type ProtocolFee = StorageValue<_, u32, ValueQuery>; + + /// Tracks the on-chain status of a known `OrderId`. + /// Absent ⇒ never seen (still executable if valid). + /// Present ⇒ Fulfilled or Cancelled (both are terminal). + #[pallet::storage] + pub type Orders = StorageMap<_, Blake2_128Concat, H256, OrderStatus, OptionQuery>; + + /// The privileged account allowed to call `execute_orders` and `set_protocol_fee`. + #[pallet::storage] + pub type Admin = StorageValue<_, T::AccountId, OptionQuery>; + + // ── Events ──────────────────────────────────────────────────────────────── + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A limit order was successfully executed. + OrderExecuted { + order_id: H256, + signer: T::AccountId, + netuid: NetUid, + side: OrderSide, + }, + /// A user registered a cancellation intent for their order. + OrderCancelled { + order_id: H256, + signer: T::AccountId, + }, + /// The admin account was updated. + AdminSet { admin: T::AccountId }, + /// The protocol fee was updated. + ProtocolFeeSet { fee: u32 }, + } + + // ── Errors ──────────────────────────────────────────────────────────────── + + #[pallet::error] + pub enum Error { + /// The provided signature does not match the order payload and signer. + InvalidSignature, + /// The order has already been Fulfilled or Cancelled. + OrderAlreadyProcessed, + /// The order's expiry timestamp is in the past. + OrderExpired, + /// The current market price does not satisfy the order's limit price. + PriceConditionNotMet, + /// Caller is not the configured admin. + NotAdmin, + /// Caller is not the order signer (required for cancellation). + Unauthorized, + } + + // ── Extrinsics ──────────────────────────────────────────────────────────── + + #[pallet::call] + impl Pallet { + /// Execute a batch of signed limit orders. Admin-gated. + /// + /// Orders whose price condition is not yet met are silently skipped so + /// that a single stale order cannot block the rest of the batch. + /// Orders that fail for any other reason (expired, bad signature, etc.) + /// are also skipped; the admin is expected to filter these off-chain. + #[pallet::call_index(0)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add( + T::DbWeight::get().reads_writes(2, 1).saturating_mul(orders.len() as u64) + ))] + pub fn execute_orders( + origin: OriginFor, + orders: BoundedVec, T::MaxOrdersPerBatch>, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + + for signed_order in orders { + // Best-effort: individual order failures do not revert the batch. + let _ = Self::try_execute_order(signed_order); + } + + Ok(()) + } + + /// Register a cancellation intent for an order. + /// + /// Must be called by the order's signer. The full `Order` payload is + /// provided so the pallet can derive the `OrderId`. Once marked + /// Cancelled, the order can never be executed. + #[pallet::call_index(1)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + pub fn cancel_order( + origin: OriginFor, + order: Order, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(order.signer == who, Error::::Unauthorized); + + let order_id = Self::derive_order_id(&order); + + ensure!( + Orders::::get(order_id).is_none(), + Error::::OrderAlreadyProcessed + ); + + Orders::::insert(order_id, OrderStatus::Cancelled); + Self::deposit_event(Event::OrderCancelled { + order_id, + signer: who, + }); + + Ok(()) + } + + /// Set the admin account. Requires root. + #[pallet::call_index(2)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + pub fn set_admin(origin: OriginFor, new_admin: T::AccountId) -> DispatchResult { + ensure_root(origin)?; + Admin::::put(&new_admin); + Self::deposit_event(Event::AdminSet { admin: new_admin }); + Ok(()) + } + + /// Set the protocol fee in parts-per-billion. Admin-gated. + #[pallet::call_index(3)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + pub fn set_protocol_fee(origin: OriginFor, fee: u32) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + ProtocolFee::::put(fee); + Self::deposit_event(Event::ProtocolFeeSet { fee }); + Ok(()) + } + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + impl Pallet { + /// Derive the on-chain `OrderId` as blake2_256 over the SCALE-encoded order. + pub fn derive_order_id(order: &Order) -> H256 { + H256(sp_core::hashing::blake2_256(&order.encode())) + } + + /// Attempt to execute one signed order. Returns an error on any + /// validation or execution failure without panicking. + fn try_execute_order( + signed_order: SignedOrder, + ) -> DispatchResult { + let order = &signed_order.order; + let order_id = Self::derive_order_id(order); + + // 1. Verify the signature over the SCALE-encoded order. + let message = order.encode(); + ensure!( + signed_order + .signature + .verify(message.as_slice(), &order.signer), + Error::::InvalidSignature + ); + + // 2. Check the order has not already been processed. + ensure!( + Orders::::get(order_id).is_none(), + Error::::OrderAlreadyProcessed + ); + + // 3. Check expiry. + let now_ms = T::TimeProvider::now().as_millis() as u64; + ensure!(now_ms <= order.expiry, Error::::OrderExpired); + + // 4. Check price condition. + let current_price = T::SwapInterface::current_alpha_price(order.netuid); + let limit_price = U96F32::saturating_from_num(order.limit_price); + match order.side { + // Buy: only execute if alpha is at or below the limit price. + OrderSide::Buy => ensure!( + current_price <= limit_price, + Error::::PriceConditionNotMet + ), + // Sell: only execute if alpha is at or above the limit price. + OrderSide::Sell => ensure!( + current_price >= limit_price, + Error::::PriceConditionNotMet + ), + } + + // 5. Execute the swap, taking protocol fee from the input. + let fee_ppb = ProtocolFee::::get(); + match order.side { + OrderSide::Buy => { + let tao_in = TaoBalance::from(order.amount); + // Deduct protocol fee from TAO input before swapping. + let fee_tao = Self::ppb_of_tao(tao_in, fee_ppb); + let tao_after_fee = tao_in.saturating_sub(fee_tao); + + T::SwapInterface::buy_alpha( + &order.signer, + &order.hotkey, + order.netuid, + tao_after_fee, + TaoBalance::from(order.limit_price), + )?; + + // Route the fee TAO to the fee collector as staked alpha. + if !fee_tao.is_zero() { + T::SwapInterface::buy_alpha( + &order.signer, + &T::FeeCollector::get(), + order.netuid, + fee_tao, + T::SwapInterface::current_alpha_price(order.netuid) + .saturating_to_num::() + .into(), + ) + .ok(); + } + } + OrderSide::Sell => { + let alpha_in = AlphaBalance::from(order.amount); + let fee_alpha = Self::ppb_of_alpha(alpha_in, fee_ppb); + let alpha_after_fee = alpha_in.saturating_sub(fee_alpha); + + T::SwapInterface::sell_alpha( + &order.signer, + &order.hotkey, + order.netuid, + alpha_after_fee, + TaoBalance::from(order.limit_price), + )?; + + // Sell fee alpha separately; TAO proceeds go to fee collector. + if !fee_alpha.is_zero() { + let fee_tao = T::SwapInterface::sell_alpha( + &order.signer, + &order.hotkey, + order.netuid, + fee_alpha, + TaoBalance::ZERO, + ) + .unwrap_or(TaoBalance::ZERO); + + if !fee_tao.is_zero() { + // The sell_alpha implementation is expected to credit TAO to + // the signer; transferring to fee collector requires a + // runtime-level BalanceOps call outside this pallet's scope. + // TODO: integrate BalanceOps to move fee TAO to FeeCollector. + let _ = fee_tao; + } + } + } + } + + // 6. Mark as fulfilled and emit event. + Orders::::insert(order_id, OrderStatus::Fulfilled); + Self::deposit_event(Event::OrderExecuted { + order_id, + signer: order.signer.clone(), + netuid: order.netuid, + side: order.side.clone(), + }); + + Ok(()) + } + + fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { + let result = (amount.to_u64() as u128) + .saturating_mul(ppb as u128) + .saturating_div(1_000_000_000); + TaoBalance::from(result as u64) + } + + fn ppb_of_alpha(amount: AlphaBalance, ppb: u32) -> AlphaBalance { + let result = (amount.to_u64() as u128) + .saturating_mul(ppb as u128) + .saturating_div(1_000_000_000); + AlphaBalance::from(result as u64) + } + } +} diff --git a/pallets/subtensor/src/staking/mod.rs b/pallets/subtensor/src/staking/mod.rs index ad2b66189f..83edf45244 100644 --- a/pallets/subtensor/src/staking/mod.rs +++ b/pallets/subtensor/src/staking/mod.rs @@ -9,4 +9,5 @@ pub mod move_stake; pub mod recycle_alpha; pub mod remove_stake; pub mod set_children; +pub mod order_swap; pub mod stake_utils; diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs new file mode 100644 index 0000000000..15b9c86e65 --- /dev/null +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -0,0 +1,30 @@ +use super::*; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; +use substrate_fixed::types::U96F32; + +impl OrderSwapInterface for Pallet { + fn buy_alpha( + coldkey: &T::AccountId, + hotkey: &T::AccountId, + netuid: NetUid, + tao_amount: TaoBalance, + limit_price: TaoBalance, + ) -> Result { + Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, limit_price, false, false) + } + + fn sell_alpha( + coldkey: &T::AccountId, + hotkey: &T::AccountId, + netuid: NetUid, + alpha_amount: AlphaBalance, + limit_price: TaoBalance, + ) -> Result { + Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, limit_price, false) + } + + fn current_alpha_price(netuid: NetUid) -> U96F32 { + T::SwapInterface::current_alpha_price(netuid) + } +} diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 1a1cd0156e..73d774c410 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -50,6 +50,38 @@ pub trait SwapHandler { fn get_alpha_amount_for_tao(netuid: NetUid, tao_amount: TaoBalance) -> AlphaBalance; } +/// Combined swap + balance execution interface for limit orders. +/// +/// Wraps the complete buy/sell operation: AMM state update (via `SwapHandler`), +/// pool reserve accounting, and user balance changes (TAO free balance / +/// alpha staking). Implemented by `pallet_subtensor::Pallet` using +/// `stake_into_subnet` / `unstake_from_subnet`. +pub trait OrderSwapInterface { + /// Buy alpha with TAO: debit `tao_amount` from `coldkey`'s free balance, + /// credit resulting alpha as stake at `hotkey` on `netuid`. + fn buy_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + netuid: NetUid, + tao_amount: TaoBalance, + limit_price: TaoBalance, + ) -> Result; + + /// Sell alpha for TAO: remove `alpha_amount` from `coldkey`'s stake at + /// `hotkey` on `netuid`, credit resulting TAO to `coldkey`'s free balance. + fn sell_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + netuid: NetUid, + alpha_amount: AlphaBalance, + limit_price: TaoBalance, + ) -> Result; + + /// Current spot price: TAO per alpha, same scale as + /// `SwapHandler::current_alpha_price`. + fn current_alpha_price(netuid: NetUid) -> U96F32; +} + pub trait DefaultPriceLimit where PaidIn: Token, From 4f6b85c5d9d6e16032dd12c07235dd13dc6b47b9 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 10:05:54 +0100 Subject: [PATCH 03/85] pallet execute_orders_batch and also pallet account --- pallets/limit-orders/src/lib.rs | 531 ++++++++++++++++++++++++--- primitives/swap-interface/src/lib.rs | 27 ++ 2 files changed, 504 insertions(+), 54 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 4a161e7ec9..f18bf6c775 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -113,9 +113,11 @@ pub mod pallet { use frame_support::{ pallet_prelude::*, traits::{Get, UnixTime}, + PalletId, }; use frame_system::pallet_prelude::*; use sp_core::H256; + use sp_runtime::traits::AccountIdConversion; #[pallet::pallet] pub struct Pallet(_); @@ -153,6 +155,22 @@ pub mod pallet { /// Should equal `floor(max_block_weight / per_order_weight)`. #[pallet::constant] type MaxOrdersPerBatch: Get; + + /// PalletId used to derive the intermediary account for batch execution. + /// + /// The derived account temporarily holds pooled TAO and staked alpha + /// during `execute_batched_orders` before distributing to order signers. + #[pallet::constant] + type PalletId: Get; + + /// Hotkey registered in each subnet that the pallet's intermediary + /// account stakes to/from during batch execution. + /// + /// This must be a hotkey registered on every subnet the pallet may + /// operate on. Operators should register a dedicated hotkey and set + /// this in the runtime configuration. + #[pallet::constant] + type PalletHotkey: Get; } // ── Storage ─────────────────────────────────────────────────────────────── @@ -167,10 +185,6 @@ pub mod pallet { #[pallet::storage] pub type Orders = StorageMap<_, Blake2_128Concat, H256, OrderStatus, OptionQuery>; - /// The privileged account allowed to call `execute_orders` and `set_protocol_fee`. - #[pallet::storage] - pub type Admin = StorageValue<_, T::AccountId, OptionQuery>; - // ── Events ──────────────────────────────────────────────────────────────── #[pallet::event] @@ -183,15 +197,31 @@ pub mod pallet { netuid: NetUid, side: OrderSide, }, + /// An order was skipped during batch execution (invalid signature, + /// expired, already processed, wrong netuid, or price not met). + OrderSkipped { order_id: H256 }, /// A user registered a cancellation intent for their order. OrderCancelled { order_id: H256, signer: T::AccountId, }, - /// The admin account was updated. - AdminSet { admin: T::AccountId }, /// The protocol fee was updated. ProtocolFeeSet { fee: u32 }, + /// Summary emitted once per `execute_batched_orders` call. + GroupExecutionSummary { + /// The subnet all orders in this batch belong to. + netuid: NetUid, + /// Direction of the net pool trade (Buy = net TAO into pool). + net_side: OrderSide, + /// Net amount sent to the pool (TAO for Buy, alpha for Sell). + /// Zero when buys and sells perfectly offset each other. + net_amount: u64, + /// Tokens received back from the pool. + /// Zero when `net_amount` is zero. + actual_out: u64, + /// Number of orders that were successfully executed. + executed_count: u32, + }, } // ── Errors ──────────────────────────────────────────────────────────────── @@ -206,8 +236,6 @@ pub mod pallet { OrderExpired, /// The current market price does not satisfy the order's limit price. PriceConditionNotMet, - /// Caller is not the configured admin. - NotAdmin, /// Caller is not the order signer (required for cancellation). Unauthorized, } @@ -230,17 +258,53 @@ pub mod pallet { origin: OriginFor, orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { - let who = ensure_signed(origin)?; - ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + ensure_signed(origin)?; for signed_order in orders { // Best-effort: individual order failures do not revert the batch. + // TODO: VERIFY IF PRICE IS CHECK AFTER EACH ORDER let _ = Self::try_execute_order(signed_order); } Ok(()) } + /// Execute a batch of signed limit orders for a single subnet using + /// aggregated (netted) pool interaction. + /// + /// Unlike `execute_orders`, which hits the pool once per order, this + /// extrinsic: + /// + /// 1. Validates all orders (bad signature / expired / already processed / + /// price-not-met orders are skipped and emit `OrderSkipped`). + /// 2. Fetches the current price once. + /// 3. Aggregates all valid buy inputs (TAO) and sell inputs (alpha). + /// 4. Nets the two sides: only the residual amount touches the pool in + /// a single swap, minimising price impact. + /// 5. Distributes outputs pro-rata: + /// - Dominant-side orders split the pool output proportionally to + /// their individual net amounts. + /// - Offset-side orders are filled internally at the current price + /// (no pool interaction for them). + /// 6. Collects protocol fees (TAO for buy orders, alpha → TAO for sell + /// orders) and routes them to `FeeCollector`. + /// + /// All orders in the batch must target `netuid`. Orders for a different + /// subnet are skipped. + #[pallet::call_index(4)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add( + T::DbWeight::get().reads_writes(3, 2).saturating_mul(orders.len() as u64) + ))] + pub fn execute_batched_orders( + origin: OriginFor, + netuid: NetUid, + orders: BoundedVec, T::MaxOrdersPerBatch>, + ) -> DispatchResult { + ensure_signed(origin)?; + + Self::do_execute_batched_orders(netuid, orders) + } + /// Register a cancellation intent for an order. /// /// Must be called by the order's signer. The full `Order` payload is @@ -271,22 +335,11 @@ pub mod pallet { Ok(()) } - /// Set the admin account. Requires root. - #[pallet::call_index(2)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] - pub fn set_admin(origin: OriginFor, new_admin: T::AccountId) -> DispatchResult { - ensure_root(origin)?; - Admin::::put(&new_admin); - Self::deposit_event(Event::AdminSet { admin: new_admin }); - Ok(()) - } - - /// Set the protocol fee in parts-per-billion. Admin-gated. + /// Set the protocol fee in parts-per-billion. Requires root. #[pallet::call_index(3)] #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] pub fn set_protocol_fee(origin: OriginFor, fee: u32) -> DispatchResult { - let who = ensure_signed(origin)?; - ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + ensure_root(origin)?; ProtocolFee::::put(fee); Self::deposit_event(Event::ProtocolFeeSet { fee }); Ok(()) @@ -301,6 +354,34 @@ pub mod pallet { H256(sp_core::hashing::blake2_256(&order.encode())) } + /// Account derived from the pallet's `PalletId`. + fn pallet_account() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Returns `true` if `signed_order` passes all execution preconditions: + /// valid signature, not yet processed, not expired, and price condition met. + /// Netuid is intentionally not checked here; callers handle that separately. + fn is_order_valid( + signed_order: &SignedOrder, + order_id: H256, + now_ms: u64, + current_price: U96F32, + ) -> bool { + let order = &signed_order.order; + signed_order.signature.verify(order.encode().as_slice(), &order.signer) + && Orders::::get(order_id).is_none() + && now_ms <= order.expiry + && match order.side { + OrderSide::Buy => { + current_price <= U96F32::saturating_from_num(order.limit_price) + } + OrderSide::Sell => { + current_price >= U96F32::saturating_from_num(order.limit_price) + } + } + } + /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. fn try_execute_order( @@ -308,42 +389,14 @@ pub mod pallet { ) -> DispatchResult { let order = &signed_order.order; let order_id = Self::derive_order_id(order); + let now_ms = T::TimeProvider::now().as_millis() as u64; + let current_price = T::SwapInterface::current_alpha_price(order.netuid); - // 1. Verify the signature over the SCALE-encoded order. - let message = order.encode(); ensure!( - signed_order - .signature - .verify(message.as_slice(), &order.signer), + Self::is_order_valid(&signed_order, order_id, now_ms, current_price), Error::::InvalidSignature ); - // 2. Check the order has not already been processed. - ensure!( - Orders::::get(order_id).is_none(), - Error::::OrderAlreadyProcessed - ); - - // 3. Check expiry. - let now_ms = T::TimeProvider::now().as_millis() as u64; - ensure!(now_ms <= order.expiry, Error::::OrderExpired); - - // 4. Check price condition. - let current_price = T::SwapInterface::current_alpha_price(order.netuid); - let limit_price = U96F32::saturating_from_num(order.limit_price); - match order.side { - // Buy: only execute if alpha is at or below the limit price. - OrderSide::Buy => ensure!( - current_price <= limit_price, - Error::::PriceConditionNotMet - ), - // Sell: only execute if alpha is at or above the limit price. - OrderSide::Sell => ensure!( - current_price >= limit_price, - Error::::PriceConditionNotMet - ), - } - // 5. Execute the swap, taking protocol fee from the input. let fee_ppb = ProtocolFee::::get(); match order.side { @@ -422,6 +475,376 @@ pub mod pallet { Ok(()) } + /// Thin orchestrator for `execute_batched_orders`. + fn do_execute_batched_orders( + netuid: NetUid, + orders: BoundedVec, T::MaxOrdersPerBatch>, + ) -> DispatchResult { + let now_ms = T::TimeProvider::now().as_millis() as u64; + let fee_ppb = ProtocolFee::::get(); + let current_price = T::SwapInterface::current_alpha_price(netuid); + + // Filter invalid/expired/price-missed orders; classify the rest into buys and sells. + let (valid_buys, valid_sells) = + Self::validate_and_classify(netuid, &orders, now_ms, fee_ppb, current_price); + + let executed_count = (valid_buys.len() + valid_sells.len()) as u32; + if executed_count == 0 { + return Ok(()); + } + + let total_buy_net: u128 = + valid_buys.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); + let total_sell_net: u128 = + valid_sells.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); + let total_sell_tao_equiv: u128 = current_price + .saturating_mul(U96F32::from_num(total_sell_net)) + .saturating_to_num::(); + + let pallet_acct = Self::pallet_account(); + let pallet_hotkey = T::PalletHotkey::get(); + + // Pull all input assets into the pallet intermediary before touching the pool. + Self::collect_assets(&valid_buys, &valid_sells, &pallet_acct, &pallet_hotkey, netuid)?; + + // Execute a single pool swap for the residual (buy TAO minus sell TAO-equiv, or vice versa). + let (net_side, actual_out) = Self::net_pool_swap( + total_buy_net, + total_sell_net, + total_sell_tao_equiv, + current_price, + &pallet_acct, + &pallet_hotkey, + netuid, + ); + + // Give every buyer their pro-rata share of (pool alpha output + offset sell alpha). + Self::distribute_alpha_pro_rata( + &valid_buys, + actual_out, + total_buy_net, + total_sell_net, + &net_side, + current_price, + &pallet_acct, + &pallet_hotkey, + netuid, + )?; + + // Give every seller their pro-rata share of (pool TAO output + offset buy TAO), + // deducting the fee from each payout; returns the total sell-side fee in TAO. + let sell_fee_tao = Self::distribute_tao_pro_rata( + &valid_sells, + actual_out, + total_buy_net, + total_sell_tao_equiv, + &net_side, + current_price, + fee_ppb, + &pallet_acct, + netuid, + )?; + + // Forward all accumulated TAO fees (buy input fees + sell output fees) to FeeCollector. + Self::collect_fees(&valid_buys, sell_fee_tao, &pallet_acct); + + let net_amount = Self::net_amount_for_event( + &net_side, + total_buy_net, + total_sell_net, + total_sell_tao_equiv, + current_price, + ); + Self::deposit_event(Event::GroupExecutionSummary { + netuid, + net_side, + net_amount, + actual_out: actual_out as u64, + executed_count, + }); + + Ok(()) + } + + /// Validate every order against `netuid`, signature, expiry, and price. + /// Valid orders are split into two BoundedVecs by side. + /// Each entry is `(order_id, signer, hotkey, gross, net, fee)`. + fn validate_and_classify( + netuid: NetUid, + orders: &BoundedVec, T::MaxOrdersPerBatch>, + now_ms: u64, + fee_ppb: u32, + current_price: U96F32, + ) -> ( + BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + ) { + let mut buys = BoundedVec::new(); + let mut sells = BoundedVec::new(); + + orders + .iter() + .filter_map(|signed_order| { + let order = &signed_order.order; + let order_id = Self::derive_order_id(order); + + let valid = order.netuid == netuid + && Self::is_order_valid(signed_order, order_id, now_ms, current_price); + + if !valid { + Self::deposit_event(Event::OrderSkipped { order_id }); + return None; + } + + let (net, fee) = match order.side { + // Buy: fee on TAO input — buyer contributes less TAO to the pool. + OrderSide::Buy => { + let f = Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb) + .to_u64(); + (order.amount.saturating_sub(f), f) + } + // Sell: fee on TAO output — seller contributes full alpha; the fee + // is deducted from their TAO payout in `distribute_tao_pro_rata`. + // No alpha is withheld here, so fee is recorded as 0 in the entry. + OrderSide::Sell => (order.amount, 0u64), + }; + + Some(( + order.side.clone(), + (order_id, order.signer.clone(), order.hotkey.clone(), order.amount, net, fee), + )) + }) + .for_each(|(side, entry)| { + // try_push cannot fail: both vecs share the same bound as `orders`. + match side { + OrderSide::Buy => { let _ = buys.try_push(entry); } + OrderSide::Sell => { let _ = sells.try_push(entry); } + } + }); + + (buys, sells) + } + + /// Pull gross TAO from each buyer and gross staked alpha from each seller + /// into the pallet intermediary account, bypassing the pool. + fn collect_assets( + buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + sells: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + pallet_acct: &T::AccountId, + pallet_hotkey: &T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + for (_, signer, _, gross, _, _) in buys.iter() { + T::SwapInterface::transfer_tao(signer, pallet_acct, TaoBalance::from(*gross))?; + } + for (_, signer, hotkey, gross, _, _) in sells.iter() { + T::SwapInterface::transfer_staked_alpha( + signer, hotkey, pallet_acct, pallet_hotkey, netuid, AlphaBalance::from(*gross), + )?; + } + Ok(()) + } + + /// Execute a single pool swap for the net (residual) amount. + /// Returns `(net_side, actual_out)` where `actual_out` is in the output + /// token units (alpha for Buy, TAO for Sell). + fn net_pool_swap( + total_buy_net: u128, + total_sell_net: u128, + total_sell_tao_equiv: u128, + current_price: U96F32, + pallet_acct: &T::AccountId, + pallet_hotkey: &T::AccountId, + netuid: NetUid, + ) -> (OrderSide, u128) { + if total_buy_net >= total_sell_tao_equiv { + let net_tao = (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64; + let actual_alpha = if net_tao > 0 { + T::SwapInterface::buy_alpha( + pallet_acct, pallet_hotkey, netuid, + TaoBalance::from(net_tao), TaoBalance::ZERO, + ) + .unwrap_or(AlphaBalance::ZERO) + .to_u64() as u128 + } else { + 0u128 + }; + (OrderSide::Buy, actual_alpha) + } else { + let total_buy_alpha_equiv: u128 = if current_price > U96F32::from_num(0u32) { + (U96F32::from_num(total_buy_net) / current_price).saturating_to_num::() + } else { + 0u128 + }; + let net_alpha = (total_sell_net.saturating_sub(total_buy_alpha_equiv)) as u64; + let actual_tao = if net_alpha > 0 { + T::SwapInterface::sell_alpha( + pallet_acct, pallet_hotkey, netuid, + AlphaBalance::from(net_alpha), TaoBalance::ZERO, + ) + .unwrap_or(TaoBalance::ZERO) + .to_u64() as u128 + } else { + 0u128 + }; + (OrderSide::Sell, actual_tao) + } + } + + /// Distribute alpha pro-rata to ALL buyers and mark their orders fulfilled. + /// + /// - Buy-dominant: total alpha = pool output + sell-side alpha (passed through). + /// - Sell-dominant: total alpha = buy-side TAO converted at `current_price`. + fn distribute_alpha_pro_rata( + buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + actual_out: u128, + total_buy_net: u128, + total_sell_net: u128, + net_side: &OrderSide, + current_price: U96F32, + pallet_acct: &T::AccountId, + pallet_hotkey: &T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + let total_alpha: u128 = match net_side { + OrderSide::Buy => actual_out.saturating_add(total_sell_net), + OrderSide::Sell => { + if current_price > U96F32::from_num(0u32) { + (U96F32::from_num(total_buy_net) / current_price) + .saturating_to_num::() + } else { + 0u128 + } + } + }; + + for (order_id, signer, hotkey, _, net, _) in buys.iter() { + let share: u64 = if total_buy_net > 0 { + (total_alpha.saturating_mul(*net as u128) / total_buy_net) as u64 + } else { + 0u64 + }; + if share > 0 { + T::SwapInterface::transfer_staked_alpha( + pallet_acct, pallet_hotkey, signer, hotkey, netuid, + AlphaBalance::from(share), + )?; + } + Orders::::insert(order_id, OrderStatus::Fulfilled); + Self::deposit_event(Event::OrderExecuted { + order_id: *order_id, + signer: signer.clone(), + netuid, + side: OrderSide::Buy, + }); + } + Ok(()) + } + + /// Distribute TAO pro-rata to ALL sellers and mark their orders fulfilled. + /// + /// - Sell-dominant: total TAO = pool output + buy-side TAO (passed through). + /// - Buy-dominant: each seller receives their alpha valued at `current_price`. + /// + /// Fee on TAO output: `ppb(share)` is withheld from each seller's payout and + /// left in the pallet account. Returns the total sell-side fee TAO accumulated. + fn distribute_tao_pro_rata( + sells: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + actual_out: u128, + total_buy_net: u128, + total_sell_tao_equiv: u128, + net_side: &OrderSide, + current_price: U96F32, + fee_ppb: u32, + pallet_acct: &T::AccountId, + netuid: NetUid, + ) -> Result { + let total_tao: u128 = match net_side { + OrderSide::Sell => actual_out.saturating_add(total_buy_net), + OrderSide::Buy => total_sell_tao_equiv, + }; + + let mut total_sell_fee_tao: u64 = 0; + + for (order_id, signer, _, _, net, _) in sells.iter() { + let sell_tao_equiv: u128 = current_price + .saturating_mul(U96F32::from_num(*net)) + .saturating_to_num::(); + let gross_share: u64 = if total_sell_tao_equiv > 0 { + (total_tao.saturating_mul(sell_tao_equiv) / total_sell_tao_equiv) as u64 + } else { + 0u64 + }; + let fee = Self::ppb_of_tao(TaoBalance::from(gross_share), fee_ppb).to_u64(); + let net_share = gross_share.saturating_sub(fee); + total_sell_fee_tao = total_sell_fee_tao.saturating_add(fee); + + T::SwapInterface::transfer_tao(pallet_acct, signer, TaoBalance::from(net_share))?; + Orders::::insert(order_id, OrderStatus::Fulfilled); + Self::deposit_event(Event::OrderExecuted { + order_id: *order_id, + signer: signer.clone(), + netuid, + side: OrderSide::Sell, + }); + } + Ok(total_sell_fee_tao) + } + + /// Route accumulated protocol fees to `FeeCollector`. + /// + /// Both buy and sell fees are always in TAO by this point: + /// - Buy fees: withheld from TAO input in `validate_and_classify`. + /// - Sell fees: withheld from TAO output in `distribute_tao_pro_rata` + /// (passed in as `sell_fee_tao`). + /// + /// Both transfers are best-effort and do not revert the batch on failure. + fn collect_fees( + buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + sell_fee_tao: u64, + pallet_acct: &T::AccountId, + ) { + let fee_collector = T::FeeCollector::get(); + + let total_buy_fee: u64 = buys.iter().map(|(_, _, _, _, _, f)| *f).sum(); + let total_fee = total_buy_fee.saturating_add(sell_fee_tao); + if total_fee > 0 { + T::SwapInterface::transfer_tao( + pallet_acct, &fee_collector, TaoBalance::from(total_fee), + ).ok(); + } + + // TODO: sweep rounding dust and any emissions accrued on the pallet account. + // Pro-rata integer division leaves small alpha residuals in (pallet_account, + // pallet_hotkey) after each batch. Over time these accumulate and, if an + // emission epoch fires while the dust is present, the pallet earns emissions + // it never distributes. Fix: add `staked_alpha(coldkey, hotkey, netuid) -> + // AlphaBalance` to `OrderSwapInterface`, then sell the full remaining balance + // here and forward the TAO to `FeeCollector`. + } + + /// Compute the net amount field for the `GroupExecutionSummary` event. + fn net_amount_for_event( + net_side: &OrderSide, + total_buy_net: u128, + total_sell_net: u128, + total_sell_tao_equiv: u128, + current_price: U96F32, + ) -> u64 { + match net_side { + OrderSide::Buy => (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64, + OrderSide::Sell => { + let buy_alpha_equiv: u64 = if current_price > U96F32::from_num(0u32) { + (U96F32::from_num(total_buy_net) / current_price) + .saturating_to_num::() + } else { + 0u64 + }; + (total_sell_net as u64).saturating_sub(buy_alpha_equiv) + } + } + } + fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { let result = (amount.to_u64() as u128) .saturating_mul(ppb as u128) diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 73d774c410..7e24b57147 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -80,6 +80,33 @@ pub trait OrderSwapInterface { /// Current spot price: TAO per alpha, same scale as /// `SwapHandler::current_alpha_price`. fn current_alpha_price(netuid: NetUid) -> U96F32; + + /// Transfer `amount` TAO from `from`'s free balance to `to`'s free balance. + /// + /// Used by the batch executor to collect TAO from buy-order signers into + /// the pallet intermediary account and to distribute TAO to sell-order + /// signers after internal matching. + fn transfer_tao( + from: &AccountId, + to: &AccountId, + amount: TaoBalance, + ) -> DispatchResult; + + /// Move `amount` staked alpha directly between two (coldkey, hotkey) pairs + /// on `netuid` **without going through the AMM pool**. + /// + /// This is a pure stake-accounting transfer used for internal order + /// matching in `execute_batched_orders`: it lets the pallet collect alpha + /// from sell-order signers into its intermediary account, and later + /// distribute alpha to buy-order signers, all without touching the pool. + fn transfer_staked_alpha( + from_coldkey: &AccountId, + from_hotkey: &AccountId, + to_coldkey: &AccountId, + to_hotkey: &AccountId, + netuid: NetUid, + amount: AlphaBalance, + ) -> DispatchResult; } pub trait DefaultPriceLimit From e441dc7d147cf8c087cc940d7e4ebf1cd2190805 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 10:43:15 +0100 Subject: [PATCH 04/85] start adding tests --- Cargo.lock | 2 + pallets/limit-orders/Cargo.toml | 4 + pallets/limit-orders/src/lib.rs | 18 +- pallets/limit-orders/src/tests/auxiliary.rs | 727 ++++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 281 ++++++++ pallets/limit-orders/src/tests/mod.rs | 2 + 6 files changed, 1026 insertions(+), 8 deletions(-) create mode 100644 pallets/limit-orders/src/tests/auxiliary.rs create mode 100644 pallets/limit-orders/src/tests/mock.rs create mode 100644 pallets/limit-orders/src/tests/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 88996c5897..060ab60722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9974,6 +9974,8 @@ dependencies = [ "parity-scale-codec", "scale-info", "sp-core", + "sp-io", + "sp-keyring", "sp-runtime", "substrate-fixed", "subtensor-runtime-common", diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 809659369f..8cc40bc645 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -14,6 +14,10 @@ substrate-fixed.workspace = true subtensor-runtime-common.workspace = true subtensor-swap-interface.workspace = true +[dev-dependencies] +sp-io.workspace = true +sp-keyring.workspace = true + [lints] workspace = true diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f18bf6c775..7e1af9e899 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -2,6 +2,9 @@ pub use pallet::*; +#[cfg(test)] +mod tests; + use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::traits::{IdentifyAccount, Verify}; @@ -262,7 +265,6 @@ pub mod pallet { for signed_order in orders { // Best-effort: individual order failures do not revert the batch. - // TODO: VERIFY IF PRICE IS CHECK AFTER EACH ORDER let _ = Self::try_execute_order(signed_order); } @@ -569,7 +571,7 @@ pub mod pallet { /// Validate every order against `netuid`, signature, expiry, and price. /// Valid orders are split into two BoundedVecs by side. /// Each entry is `(order_id, signer, hotkey, gross, net, fee)`. - fn validate_and_classify( + pub(crate) fn validate_and_classify( netuid: NetUid, orders: &BoundedVec, T::MaxOrdersPerBatch>, now_ms: u64, @@ -695,7 +697,7 @@ pub mod pallet { /// /// - Buy-dominant: total alpha = pool output + sell-side alpha (passed through). /// - Sell-dominant: total alpha = buy-side TAO converted at `current_price`. - fn distribute_alpha_pro_rata( + pub(crate) fn distribute_alpha_pro_rata( buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, actual_out: u128, total_buy_net: u128, @@ -748,7 +750,7 @@ pub mod pallet { /// /// Fee on TAO output: `ppb(share)` is withheld from each seller's payout and /// left in the pallet account. Returns the total sell-side fee TAO accumulated. - fn distribute_tao_pro_rata( + pub(crate) fn distribute_tao_pro_rata( sells: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, actual_out: u128, total_buy_net: u128, @@ -799,7 +801,7 @@ pub mod pallet { /// (passed in as `sell_fee_tao`). /// /// Both transfers are best-effort and do not revert the batch on failure. - fn collect_fees( + pub(crate) fn collect_fees( buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, sell_fee_tao: u64, pallet_acct: &T::AccountId, @@ -824,7 +826,7 @@ pub mod pallet { } /// Compute the net amount field for the `GroupExecutionSummary` event. - fn net_amount_for_event( + pub(crate) fn net_amount_for_event( net_side: &OrderSide, total_buy_net: u128, total_sell_net: u128, @@ -845,14 +847,14 @@ pub mod pallet { } } - fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { + pub(crate) fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { let result = (amount.to_u64() as u128) .saturating_mul(ppb as u128) .saturating_div(1_000_000_000); TaoBalance::from(result as u64) } - fn ppb_of_alpha(amount: AlphaBalance, ppb: u32) -> AlphaBalance { + pub(crate) fn ppb_of_alpha(amount: AlphaBalance, ppb: u32) -> AlphaBalance { let result = (amount.to_u64() as u128) .saturating_mul(ppb as u128) .saturating_div(1_000_000_000); diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs new file mode 100644 index 0000000000..ff27fe68b7 --- /dev/null +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -0,0 +1,727 @@ +//! Unit tests for the auxiliary helper functions in `pallet-limit-orders`. +//! +//! Extrinsics are NOT tested here. Each section focuses on one helper. + +use frame_support::{BoundedVec, traits::ConstU32}; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{AccountId32, MultiSignature}; +use substrate_fixed::types::U96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; + +use crate::{Order, OrderSide, OrderStatus, Orders, SignedOrder, pallet::ProtocolFee}; +use crate::pallet::Pallet as LimitOrders; + +use super::mock::*; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn alice() -> AccountId32 { + AccountKeyring::Alice.to_account_id() +} + +fn bob() -> AccountId32 { + AccountKeyring::Bob.to_account_id() +} + +fn charlie() -> AccountId32 { + AccountKeyring::Charlie.to_account_id() +} + +fn netuid_1() -> NetUid { + NetUid::from(1u16) +} + +/// Create a `SignedOrder` signed by the given `AccountKeyring` key. +fn make_signed_order( + keyring: AccountKeyring, + hotkey: AccountId32, + netuid: NetUid, + side: OrderSide, + amount: u64, + limit_price: u64, + expiry: u64, +) -> SignedOrder { + let signer = keyring.to_account_id(); + let order = Order { + signer, + hotkey, + netuid, + side, + amount, + limit_price, + expiry, + }; + use codec::Encode; + let msg = order.encode(); + let sig = keyring.pair().sign(&msg); + SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + } +} + +fn bounded_orders( + v: Vec>, +) -> BoundedVec, ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +// ───────────────────────────────────────────────────────────────────────────── +// ppb_of_tao / ppb_of_alpha +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn ppb_of_tao_zero_fee_returns_zero() { + new_test_ext().execute_with(|| { + // 0 ppb → no fee regardless of amount + let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1_000_000u64), 0); + assert_eq!(fee, TaoBalance::from(0u64)); + }); +} + +#[test] +fn ppb_of_tao_full_ppb_returns_amount() { + new_test_ext().execute_with(|| { + // 1_000_000_000 ppb = 100% → fee == amount + let amount = TaoBalance::from(500_000u64); + let fee = LimitOrders::::ppb_of_tao(amount, 1_000_000_000u32); + assert_eq!(fee, amount); + }); +} + +#[test] +fn ppb_of_tao_one_tenth_percent() { + new_test_ext().execute_with(|| { + // 1_000_000 ppb = 0.1% + // 1_000_000 * 1_000_000 / 1_000_000_000 = 1_000 + let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1_000_000_000u64), 1_000_000u32); + assert_eq!(fee, TaoBalance::from(1_000_000u64)); + }); +} + +#[test] +fn ppb_of_alpha_one_tenth_percent() { + new_test_ext().execute_with(|| { + let fee = + LimitOrders::::ppb_of_alpha(AlphaBalance::from(1_000_000_000u64), 1_000_000u32); + assert_eq!(fee, AlphaBalance::from(1_000_000u64)); + }); +} + +#[test] +fn ppb_of_tao_rounds_down() { + new_test_ext().execute_with(|| { + // amount=1, ppb=999_999_999 (just under 100%) → floor(0.999…) = 0 + let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1u64), 999_999_999u32); + assert_eq!(fee, TaoBalance::from(0u64)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// net_amount_for_event +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn net_amount_for_event_buy_dominant() { + new_test_ext().execute_with(|| { + // Buys = 1000 TAO net, sells TAO-equiv = 300 TAO → net 700 TAO buy-side + let price = U96F32::from_num(2u32); // 2 TAO/alpha + let net = LimitOrders::::net_amount_for_event( + &OrderSide::Buy, + 1_000u128, // total_buy_net (TAO) + 150u128, // total_sell_net (alpha) ← not used in Buy branch + 300u128, // total_sell_tao_equiv + price, + ); + assert_eq!(net, 700u64); + }); +} + +#[test] +fn net_amount_for_event_sell_dominant() { + new_test_ext().execute_with(|| { + // Sells = 500 alpha net, buys TAO = 200 TAO at price 2 → buy_alpha_equiv = 100 + // net sell = 500 - 100 = 400 alpha + let price = U96F32::from_num(2u32); // 2 TAO/alpha → 1 alpha = 2 TAO + let net = LimitOrders::::net_amount_for_event( + &OrderSide::Sell, + 200u128, // total_buy_net (TAO) + 500u128, // total_sell_net (alpha) + 400u128, // total_sell_tao_equiv (not used in Sell branch directly) + price, + ); + // buy_alpha_equiv = 200 / 2 = 100; net = 500 - 100 = 400 + assert_eq!(net, 400u64); + }); +} + +#[test] +fn net_amount_for_event_perfectly_offset() { + new_test_ext().execute_with(|| { + // Buys = 200 TAO, sells TAO-equiv = 200 → net = 0 (buy-side result = 0) + let price = U96F32::from_num(2u32); + let net = LimitOrders::::net_amount_for_event( + &OrderSide::Buy, + 200u128, + 100u128, + 200u128, + price, + ); + assert_eq!(net, 0u64); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// validate_and_classify +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn validate_and_classify_separates_buys_and_sells() { + new_test_ext().execute_with(|| { + // Current time = 1_000_000 ms; expiry = 2_000_000 ms (well in the future). + MockTime::set(1_000_000); + // Price = 1.0 TAO/alpha. + MockSwap::set_price(1.0); + + // Fee = 0 ppb for simplicity. + ProtocolFee::::put(0u32); + + let buy_order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid_1(), + OrderSide::Buy, + 1_000u64, // amount in TAO + 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) + 2_000_000u64, // expiry ms + ); + let sell_order = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid_1(), + OrderSide::Sell, + 500u64, // amount in alpha + 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) + 2_000_000u64, + ); + + let orders = bounded_orders(vec![buy_order, sell_order]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid_1(), + &orders, + 1_000_000u64, + 0u32, + U96F32::from_num(1u32), + ); + + assert_eq!(buys.len(), 1, "expected 1 valid buy"); + assert_eq!(sells.len(), 1, "expected 1 valid sell"); + + // Buy entry: gross=1000, net=1000 (0% fee), fee=0 + let (_, signer, _, gross, net, fee) = &buys[0]; + assert_eq!(signer, &alice()); + assert_eq!(*gross, 1_000u64); + assert_eq!(*net, 1_000u64); + assert_eq!(*fee, 0u64); + + // Sell entry: gross=500, net=500, fee=0 (fee deferred to distribution) + let (_, signer, _, gross, net, fee) = &sells[0]; + assert_eq!(signer, &bob()); + assert_eq!(*gross, 500u64); + assert_eq!(*net, 500u64); + assert_eq!(*fee, 0u64, "sell fee is always 0 here — applied on TAO output"); + }); +} + +#[test] +fn validate_and_classify_skips_wrong_netuid() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + ProtocolFee::::put(0u32); + + let wrong_netuid_order = make_signed_order( + AccountKeyring::Alice, + bob(), + NetUid::from(99u16), // different netuid + OrderSide::Buy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, + ); + + let orders = bounded_orders(vec![wrong_netuid_order]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid_1(), // batch is for netuid 1 + &orders, + 1_000_000u64, + 0u32, + U96F32::from_num(1u32), + ); + + assert_eq!(buys.len(), 0); + assert_eq!(sells.len(), 0); + }); +} + +#[test] +fn validate_and_classify_skips_expired_order() { + new_test_ext().execute_with(|| { + // now_ms = 2_000_001, expiry = 2_000_000 → expired + MockTime::set(2_000_001); + MockSwap::set_price(1.0); + ProtocolFee::::put(0u32); + + let expired = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid_1(), + OrderSide::Buy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, // expiry already past + ); + + let orders = bounded_orders(vec![expired]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid_1(), + &orders, + 2_000_001u64, + 0u32, + U96F32::from_num(1u32), + ); + + assert_eq!(buys.len(), 0); + assert_eq!(sells.len(), 0); + }); +} + +#[test] +fn validate_and_classify_skips_price_condition_not_met_for_buy() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price = 3.0 TAO/alpha, buyer's limit = 2.0 → price > limit → skip + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid_1(), + OrderSide::Buy, + 1_000u64, + 2u64, // limit_price = 2 TAO/alpha + 2_000_000u64, + ); + + let orders = bounded_orders(vec![order]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid_1(), + &orders, + 1_000_000u64, + 0u32, + U96F32::from_num(3u32), // current price = 3 > limit 2 → skip + ); + + assert_eq!(buys.len(), 0); + }); +} + +#[test] +fn validate_and_classify_skips_already_processed_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid_1(), + OrderSide::Buy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, + ); + + // Pre-mark as fulfilled on-chain. + use codec::Encode; + let order_id = H256(sp_core::hashing::blake2_256(&order.order.encode())); + Orders::::insert(order_id, OrderStatus::Fulfilled); + + let orders = bounded_orders(vec![order]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid_1(), + &orders, + 1_000_000u64, + 0u32, + U96F32::from_num(1u32), + ); + + assert_eq!(buys.len(), 0); + }); +} + +#[test] +fn validate_and_classify_applies_buy_fee_to_net() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // 1_000_000 ppb = 0.1% + // amount = 1_000_000_000, fee = 1_000_000, net = 999_000_000 + ProtocolFee::::put(1_000_000u32); + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid_1(), + OrderSide::Buy, + 1_000_000_000u64, + u64::MAX, // limit price: accept any price + 2_000_000u64, + ); + + let orders = bounded_orders(vec![order]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid_1(), + &orders, + 1_000_000u64, + 1_000_000u32, + U96F32::from_num(1u32), + ); + + assert_eq!(buys.len(), 1); + let (_, _, _, gross, net, fee) = &buys[0]; + assert_eq!(*gross, 1_000_000_000u64); + assert_eq!(*fee, 1_000_000u64); + assert_eq!(*net, 999_000_000u64); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// distribute_alpha_pro_rata +// ───────────────────────────────────────────────────────────────────────────── +// +// Scenario A – buy-dominant +// ────────────────────────── +// 3 buyers: Alice 300 TAO net, Bob 200 TAO net, Charlie 500 TAO net (total 1000) +// Pool returns 800 alpha; seller alpha passed-through = 200. +// Total alpha pool = 800 + 200 = 1000 alpha. +// +// Pro-rata shares (proportional to each buyer's net TAO): +// Alice: 1000 * 300 / 1000 = 300 alpha +// Bob: 1000 * 200 / 1000 = 200 alpha +// Charlie: 1000 * 500 / 1000 = 500 alpha +// +// Scenario B – sell-dominant +// ─────────────────────────── +// 2 buyers: Alice 400 TAO net, Bob 600 TAO net (total 1000) +// Price = 2.0 TAO/alpha → total alpha for buyers = 1000 / 2 = 500 alpha. +// +// Pro-rata shares: +// Alice: 500 * 400 / 1000 = 200 alpha +// Bob: 500 * 600 / 1000 = 300 alpha + +fn make_buy_entry( + order_id: H256, + signer: AccountId32, + hotkey: AccountId32, + gross: u64, + net: u64, + fee: u64, +) -> (H256, AccountId32, AccountId32, u64, u64, u64) { + (order_id, signer, hotkey, gross, net, fee) +} + +fn bounded_buy_entries( + v: Vec<(H256, AccountId32, AccountId32, u64, u64, u64)>, +) -> BoundedVec<(H256, AccountId32, AccountId32, u64, u64, u64), ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +fn bounded_sell_entries( + v: Vec<(H256, AccountId32, AccountId32, u64, u64, u64)>, +) -> BoundedVec<(H256, AccountId32, AccountId32, u64, u64, u64), ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +#[test] +fn distribute_alpha_pro_rata_buy_dominant() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + // Pool returned 800 alpha; sell-side passthrough = 200 alpha. + // Total = 1000 alpha distributed across 3 buyers (300, 200, 500 TAO net). + // Expected shares: Alice 300, Bob 200, Charlie 500. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(1), alice(), hotkey.clone(), 300, 300, 0), + make_buy_entry(H256::repeat_byte(2), bob(), hotkey.clone(), 200, 200, 0), + make_buy_entry(H256::repeat_byte(3), charlie(), hotkey.clone(), 500, 500, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); // reuse as coldkey for brevity + let pallet_hk = PalletHotkeyAccount::get(); + + LimitOrders::::distribute_alpha_pro_rata( + &entries, + 800u128, // actual_out from pool (alpha) + 1_000u128, // total_buy_net (TAO) + 200u128, // total_sell_net (alpha passthrough) + &OrderSide::Buy, + U96F32::from_num(1u32), + &pallet_acct, + &pallet_hk, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::alpha_transfers(); + // 3 transfers expected (one per buyer) + assert_eq!(transfers.len(), 3); + + // Check each recipient's amount (signer is to_coldkey). + let alice_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &alice()).unwrap().5; + let bob_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &bob()).unwrap().5; + let charlie_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &charlie()).unwrap().5; + + assert_eq!(alice_amt, 300u64, "Alice should receive 300 alpha"); + assert_eq!(bob_amt, 200u64, "Bob should receive 200 alpha"); + assert_eq!(charlie_amt, 500u64, "Charlie should receive 500 alpha"); + }); +} + +#[test] +fn distribute_alpha_pro_rata_sell_dominant() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + // Price = 2.0 TAO/alpha; buyers have 400 + 600 = 1000 TAO net. + // Total alpha = 1000 / 2 = 500. + // Expected: Alice 200 alpha, Bob 300 alpha. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(4), alice(), hotkey.clone(), 400, 400, 0), + make_buy_entry(H256::repeat_byte(5), bob(), hotkey.clone(), 600, 600, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + let pallet_hk = PalletHotkeyAccount::get(); + + LimitOrders::::distribute_alpha_pro_rata( + &entries, + 0u128, // actual_out unused in sell-dominant branch + 1_000u128, // total_buy_net (TAO) + 999u128, // total_sell_net — doesn't matter for sell-dominant logic + &OrderSide::Sell, + U96F32::from_num(2u32), // price = 2 TAO/alpha + &pallet_acct, + &pallet_hk, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::alpha_transfers(); + assert_eq!(transfers.len(), 2); + + let alice_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &alice()).unwrap().5; + let bob_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &bob()).unwrap().5; + + assert_eq!(alice_amt, 200u64, "Alice should receive 200 alpha"); + assert_eq!(bob_amt, 300u64, "Bob should receive 300 alpha"); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// distribute_tao_pro_rata +// ───────────────────────────────────────────────────────────────────────────── +// +// Scenario A – sell-dominant, fee = 0 +// ────────────────────────────────── +// 2 sellers: Alice 400 alpha, Bob 600 alpha (total 1000 alpha) +// Price = 2.0 TAO/alpha → sell_tao_equiv: Alice 800, Bob 1200, total 2000 +// Pool returned 1200 TAO; buy-side passthrough = 800 TAO. Total = 2000 TAO. +// +// Pro-rata shares (proportional to each seller's TAO-equiv): +// Alice: 2000 * 800 / 2000 = 800 TAO +// Bob: 2000 * 1200 / 2000 = 1200 TAO +// +// Scenario B – sell-dominant, fee = 1% (10_000_000 ppb) +// ───────────────────────────────────────────────────── +// Same setup. Fee on gross TAO payout: +// Alice: gross 800, fee 8 (1% of 800), net 792 TAO +// Bob: gross 1200, fee 12, net 1188 TAO +// +// Scenario C – buy-dominant +// ────────────────────────── +// 2 sellers: Alice 300 alpha, Bob 200 alpha (total 500 alpha) +// Price = 2.0 TAO/alpha → sell_tao_equiv: Alice 600, Bob 400, total 1000. +// (buy-dominant branch) total_tao = total_sell_tao_equiv = 1000. +// +// Shares: +// Alice: 1000 * 600 / 1000 = 600 TAO +// Bob: 1000 * 400 / 1000 = 400 TAO + +#[test] +fn distribute_tao_pro_rata_sell_dominant_no_fee() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + // Price = 2, total_tao = 1200 (pool) + 800 (buy passthrough) = 2000 + // Alice alpha=400 → tao_equiv=800; Bob alpha=600 → tao_equiv=1200. + // total_sell_tao_equiv = 2000. + // Shares: Alice 800, Bob 1200. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_sell_entries(vec![ + make_buy_entry(H256::repeat_byte(6), alice(), hotkey.clone(), 400, 400, 0), + make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 600, 600, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + let sell_fee = LimitOrders::::distribute_tao_pro_rata( + &entries, + 1_200u128, // actual_out (pool TAO) + 800u128, // total_buy_net (buy passthrough TAO) + 2_000u128, // total_sell_tao_equiv (Alice 800 + Bob 1200) + &OrderSide::Sell, + U96F32::from_num(2u32), + 0u32, // fee_ppb = 0 + &pallet_acct, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::tao_transfers(); + assert_eq!(transfers.len(), 2); + let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + + assert_eq!(alice_tao, 800u64, "Alice should receive 800 TAO"); + assert_eq!(bob_tao, 1_200u64, "Bob should receive 1200 TAO"); + assert_eq!(sell_fee, 0u64, "No fees at 0 ppb"); + }); +} + +#[test] +fn distribute_tao_pro_rata_sell_dominant_with_fee() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + // Same setup as above but fee = 10_000_000 ppb = 1%. + // Alice gross=800, fee=8, net=792; Bob gross=1200, fee=12, net=1188. + // Total sell fee = 20. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_sell_entries(vec![ + make_buy_entry(H256::repeat_byte(8), alice(), hotkey.clone(), 400, 400, 0), + make_buy_entry(H256::repeat_byte(9), bob(), hotkey.clone(), 600, 600, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + let sell_fee = LimitOrders::::distribute_tao_pro_rata( + &entries, + 1_200u128, + 800u128, + 2_000u128, + &OrderSide::Sell, + U96F32::from_num(2u32), + 10_000_000u32, // 1% fee + &pallet_acct, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::tao_transfers(); + assert_eq!(transfers.len(), 2); + let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + + assert_eq!(alice_tao, 792u64, "Alice net after 1% fee on 800"); + assert_eq!(bob_tao, 1_188u64, "Bob net after 1% fee on 1200"); + assert_eq!(sell_fee, 20u64, "total sell fee = 8 + 12"); + }); +} + +#[test] +fn distribute_tao_pro_rata_buy_dominant() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + // Buy-dominant: total_tao = total_sell_tao_equiv = 1000. + // Alice alpha=300 → tao_equiv=600; Bob alpha=200 → tao_equiv=400. + // Shares: Alice 600, Bob 400. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_sell_entries(vec![ + make_buy_entry(H256::repeat_byte(10), alice(), hotkey.clone(), 300, 300, 0), + make_buy_entry(H256::repeat_byte(11), bob(), hotkey.clone(), 200, 200, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + let sell_fee = LimitOrders::::distribute_tao_pro_rata( + &entries, + 0u128, // actual_out unused in Buy-dominant branch + 0u128, // total_buy_net unused in Buy-dominant branch + 1_000u128, // total_sell_tao_equiv (total_tao = this in Buy branch) + &OrderSide::Buy, + U96F32::from_num(2u32), + 0u32, + &pallet_acct, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::tao_transfers(); + assert_eq!(transfers.len(), 2); + let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + + assert_eq!(alice_tao, 600u64, "Alice should receive 600 TAO"); + assert_eq!(bob_tao, 400u64, "Bob should receive 400 TAO"); + assert_eq!(sell_fee, 0u64); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// collect_fees +// ───────────────────────────────────────────────────────────────────────────── +// +// Scenario: +// 2 buy orders with fees 50 and 150 TAO → total_buy_fee = 200 TAO. +// sell_fee_tao passed in = 80 TAO. +// Total fee = 280 TAO forwarded to FeeCollector in one transfer. + +#[test] +fn collect_fees_forwards_combined_fees_to_collector() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + + let hotkey = AccountKeyring::Dave.to_account_id(); + // Buy entries carry fee in field index 5. + let buys = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(20), alice(), hotkey.clone(), 1_000, 950, 50), + make_buy_entry(H256::repeat_byte(21), bob(), hotkey.clone(), 1_500, 1_350, 150), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + LimitOrders::::collect_fees(&buys, 80u64, &pallet_acct); + + let tao_transfers = MockSwap::tao_transfers(); + assert_eq!(tao_transfers.len(), 1, "single transfer to FeeCollector"); + let (from, to, amount) = &tao_transfers[0]; + assert_eq!(from, &pallet_acct, "fee comes from pallet account"); + assert_eq!(to, &FeeCollectorAccount::get(), "fee goes to FeeCollector"); + assert_eq!(*amount, 280u64, "total fee = 200 (buy) + 80 (sell)"); + }); +} + +#[test] +fn collect_fees_no_transfer_when_zero_fees() { + new_test_ext().execute_with(|| { + MockSwap::clear_log(); + + // No buy fees, no sell fee. + let hotkey = AccountKeyring::Dave.to_account_id(); + let buys = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(22), alice(), hotkey, 1_000, 1_000, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + LimitOrders::::collect_fees(&buys, 0u64, &pallet_acct); + + let tao_transfers = MockSwap::tao_transfers(); + assert_eq!(tao_transfers.len(), 0, "no transfer when total fee is zero"); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs new file mode 100644 index 0000000000..8691ed2c3e --- /dev/null +++ b/pallets/limit-orders/src/tests/mock.rs @@ -0,0 +1,281 @@ +//! Minimal mock runtime for `pallet-limit-orders` unit tests. +//! +//! `AccountId` is `sp_runtime::AccountId32` so that `MultiSignature` works +//! out of the box; test keys come from `sp_keyring::AccountKeyring`. + +use std::cell::RefCell; + +use frame_support::{ + PalletId, construct_runtime, derive_impl, parameter_types, + traits::{ConstU32, Everything}, +}; +use frame_system as system; +use sp_core::H256; +use sp_runtime::{ + AccountId32, BuildStorage, MultiSignature, + traits::{BlakeTwo256, IdentityLookup}, +}; +use substrate_fixed::types::U96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_swap_interface::OrderSwapInterface; + +use crate as pallet_limit_orders; + +// ── Runtime ────────────────────────────────────────────────────────────────── + +construct_runtime!( + pub enum Test { + System: system = 0, + LimitOrders: pallet_limit_orders = 1, + } +); + +pub type Block = frame_system::mocking::MockBlock; +pub type AccountId = AccountId32; + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl system::Config for Test { + type BaseCallFilter = Everything; + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type PalletInfo = PalletInfo; + type MaxConsumers = ConstU32<16>; + type Nonce = u64; + type Block = Block; +} + +// ── MockSwap ───────────────────────────────────────────────────────────────── +// +// Records every call so tests can assert that the right transfers happened. + +#[derive(Debug, Clone, PartialEq)] +pub enum SwapCall { + BuyAlpha { + coldkey: AccountId, + hotkey: AccountId, + netuid: NetUid, + tao: u64, + }, + SellAlpha { + coldkey: AccountId, + hotkey: AccountId, + netuid: NetUid, + alpha: u64, + }, + TransferTao { + from: AccountId, + to: AccountId, + amount: u64, + }, + TransferStakedAlpha { + from_coldkey: AccountId, + from_hotkey: AccountId, + to_coldkey: AccountId, + to_hotkey: AccountId, + netuid: NetUid, + amount: u64, + }, +} + +thread_local! { + /// Log of every `OrderSwapInterface` call made during a test. + pub static SWAP_LOG: RefCell> = RefCell::new(Vec::new()); + /// Fixed price returned by `current_alpha_price` (default 1.0). + pub static MOCK_PRICE: RefCell = RefCell::new(U96F32::from_num(1u32)); + /// Fixed alpha returned by `buy_alpha` (default 0 — tests override as needed). + pub static MOCK_BUY_ALPHA_RETURN: RefCell = RefCell::new(0u64); + /// Fixed TAO returned by `sell_alpha` (default 0 — tests override as needed). + pub static MOCK_SELL_TAO_RETURN: RefCell = RefCell::new(0u64); +} + +pub struct MockSwap; + +impl MockSwap { + pub fn set_price(price: f64) { + MOCK_PRICE.with(|p| *p.borrow_mut() = U96F32::from_num(price)); + } + pub fn set_buy_alpha_return(alpha: u64) { + MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow_mut() = alpha); + } + pub fn set_sell_tao_return(tao: u64) { + MOCK_SELL_TAO_RETURN.with(|v| *v.borrow_mut() = tao); + } + pub fn clear_log() { + SWAP_LOG.with(|l| l.borrow_mut().clear()); + } + pub fn log() -> Vec { + SWAP_LOG.with(|l| l.borrow().clone()) + } + pub fn tao_transfers() -> Vec<(AccountId, AccountId, u64)> { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::TransferTao { from, to, amount } = c { + Some((from, to, amount)) + } else { + None + } + }) + .collect() + } + pub fn alpha_transfers() -> Vec<(AccountId, AccountId, AccountId, AccountId, NetUid, u64)> { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::TransferStakedAlpha { + from_coldkey, + from_hotkey, + to_coldkey, + to_hotkey, + netuid, + amount, + } = c + { + Some((from_coldkey, from_hotkey, to_coldkey, to_hotkey, netuid, amount)) + } else { + None + } + }) + .collect() + } +} + +impl OrderSwapInterface for MockSwap { + fn buy_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + netuid: NetUid, + tao_amount: TaoBalance, + _limit_price: TaoBalance, + ) -> Result { + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::BuyAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + tao: tao_amount.to_u64(), + }) + }); + Ok(AlphaBalance::from( + MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow()), + )) + } + + fn sell_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + netuid: NetUid, + alpha_amount: AlphaBalance, + _limit_price: TaoBalance, + ) -> Result { + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::SellAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + alpha: alpha_amount.to_u64(), + }) + }); + Ok(TaoBalance::from( + MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()), + )) + } + + fn current_alpha_price(_netuid: NetUid) -> U96F32 { + MOCK_PRICE.with(|p| *p.borrow()) + } + + fn transfer_tao( + from: &AccountId, + to: &AccountId, + amount: TaoBalance, + ) -> frame_support::pallet_prelude::DispatchResult { + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::TransferTao { + from: from.clone(), + to: to.clone(), + amount: amount.to_u64(), + }) + }); + Ok(()) + } + + fn transfer_staked_alpha( + from_coldkey: &AccountId, + from_hotkey: &AccountId, + to_coldkey: &AccountId, + to_hotkey: &AccountId, + netuid: NetUid, + amount: AlphaBalance, + ) -> frame_support::pallet_prelude::DispatchResult { + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::TransferStakedAlpha { + from_coldkey: from_coldkey.clone(), + from_hotkey: from_hotkey.clone(), + to_coldkey: to_coldkey.clone(), + to_hotkey: to_hotkey.clone(), + netuid, + amount: amount.to_u64(), + }) + }); + Ok(()) + } +} + +// ── MockTime ───────────────────────────────────────────────────────────────── + +thread_local! { + pub static MOCK_TIME_MS: RefCell = RefCell::new(1_000_000u64); +} + +pub struct MockTime; + +impl MockTime { + pub fn set(ms: u64) { + MOCK_TIME_MS.with(|t| *t.borrow_mut() = ms); + } +} + +impl frame_support::traits::UnixTime for MockTime { + fn now() -> core::time::Duration { + let ms = MOCK_TIME_MS.with(|t| *t.borrow()); + core::time::Duration::from_millis(ms) + } +} + +// ── Pallet config ───────────────────────────────────────────────────────────── + +parameter_types! { + pub const LimitOrdersPalletId: PalletId = PalletId(*b"lmt/ordr"); + pub const FeeCollectorAccount: AccountId = AccountId::new([0xfe; 32]); + pub const PalletHotkeyAccount: AccountId = AccountId::new([0xaa; 32]); +} + +impl pallet_limit_orders::Config for Test { + type Signature = MultiSignature; + type SwapInterface = MockSwap; + type TimeProvider = MockTime; + type FeeCollector = FeeCollectorAccount; + type MaxOrdersPerBatch = ConstU32<64>; + type PalletId = LimitOrdersPalletId; + type PalletHotkey = PalletHotkeyAccount; +} + +// ── Test externalities ──────────────────────────────────────────────────────── + +pub fn new_test_ext() -> sp_io::TestExternalities { + let storage = system::GenesisConfig::::default() + .build_storage() + .unwrap(); + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| { + System::set_block_number(1); + MockSwap::clear_log(); + }); + ext +} diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs new file mode 100644 index 0000000000..4256913116 --- /dev/null +++ b/pallets/limit-orders/src/tests/mod.rs @@ -0,0 +1,2 @@ +pub mod auxiliary; +pub mod mock; From 3010345d033f3ecd92a36d772451bf9277526617 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 12:13:58 +0100 Subject: [PATCH 05/85] fmt plus tests --- pallets/limit-orders/src/lib.rs | 151 ++++--- pallets/limit-orders/src/tests/mock.rs | 74 +++- pallets/limit-orders/src/tests/mod.rs | 2 +- .../src/tests/{auxiliary.rs => tests.rs} | 417 +++++++++++++++--- pallets/subtensor/src/staking/mod.rs | 2 +- pallets/subtensor/src/staking/order_swap.rs | 12 +- primitives/swap-interface/src/lib.rs | 6 +- 7 files changed, 530 insertions(+), 134 deletions(-) rename pallets/limit-orders/src/tests/{auxiliary.rs => tests.rs} (62%) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 7e1af9e899..da31525415 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -15,15 +15,7 @@ use subtensor_swap_interface::OrderSwapInterface; // ── Data structures ────────────────────────────────────────────────────────── #[derive( - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - MaxEncodedLen, - Clone, - PartialEq, - Eq, - Debug, + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub enum OrderSide { Buy, @@ -34,15 +26,7 @@ pub enum OrderSide { /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). #[derive( - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - MaxEncodedLen, - Clone, - PartialEq, - Eq, - Debug, + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub struct Order { /// The coldkey that authorised this order (pays TAO for buys; owns the @@ -71,15 +55,7 @@ pub struct Order /// directly, which works because in Substrate sr25519/ed25519 AccountIds are /// the raw public keys. #[derive( - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - MaxEncodedLen, - Clone, - PartialEq, - Eq, - Debug, + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub struct SignedOrder< AccountId: Encode + Decode + TypeInfo + MaxEncodedLen + Clone, @@ -91,15 +67,7 @@ pub struct SignedOrder< } #[derive( - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - MaxEncodedLen, - Clone, - PartialEq, - Eq, - Debug, + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub enum OrderStatus { /// The order was successfully executed. @@ -114,9 +82,9 @@ pub enum OrderStatus { pub mod pallet { use super::*; use frame_support::{ + PalletId, pallet_prelude::*, traits::{Get, UnixTime}, - PalletId, }; use frame_system::pallet_prelude::*; use sp_core::H256; @@ -314,10 +282,7 @@ pub mod pallet { /// Cancelled, the order can never be executed. #[pallet::call_index(1)] #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] - pub fn cancel_order( - origin: OriginFor, - order: Order, - ) -> DispatchResult { + pub fn cancel_order(origin: OriginFor, order: Order) -> DispatchResult { let who = ensure_signed(origin)?; ensure!(order.signer == who, Error::::Unauthorized); @@ -371,7 +336,9 @@ pub mod pallet { current_price: U96F32, ) -> bool { let order = &signed_order.order; - signed_order.signature.verify(order.encode().as_slice(), &order.signer) + signed_order + .signature + .verify(order.encode().as_slice(), &order.signer) && Orders::::get(order_id).is_none() && now_ms <= order.expiry && match order.side { @@ -495,10 +462,11 @@ pub mod pallet { return Ok(()); } - let total_buy_net: u128 = - valid_buys.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); - let total_sell_net: u128 = - valid_sells.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); + let total_buy_net: u128 = valid_buys.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); + let total_sell_net: u128 = valid_sells + .iter() + .map(|(_, _, _, _, n, _)| *n as u128) + .sum(); let total_sell_tao_equiv: u128 = current_price .saturating_mul(U96F32::from_num(total_sell_net)) .saturating_to_num::(); @@ -507,7 +475,13 @@ pub mod pallet { let pallet_hotkey = T::PalletHotkey::get(); // Pull all input assets into the pallet intermediary before touching the pool. - Self::collect_assets(&valid_buys, &valid_sells, &pallet_acct, &pallet_hotkey, netuid)?; + Self::collect_assets( + &valid_buys, + &valid_sells, + &pallet_acct, + &pallet_hotkey, + netuid, + )?; // Execute a single pool swap for the residual (buy TAO minus sell TAO-equiv, or vice versa). let (net_side, actual_out) = Self::net_pool_swap( @@ -601,8 +575,8 @@ pub mod pallet { let (net, fee) = match order.side { // Buy: fee on TAO input — buyer contributes less TAO to the pool. OrderSide::Buy => { - let f = Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb) - .to_u64(); + let f = + Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb).to_u64(); (order.amount.saturating_sub(f), f) } // Sell: fee on TAO output — seller contributes full alpha; the fee @@ -613,14 +587,25 @@ pub mod pallet { Some(( order.side.clone(), - (order_id, order.signer.clone(), order.hotkey.clone(), order.amount, net, fee), + ( + order_id, + order.signer.clone(), + order.hotkey.clone(), + order.amount, + net, + fee, + ), )) }) .for_each(|(side, entry)| { // try_push cannot fail: both vecs share the same bound as `orders`. match side { - OrderSide::Buy => { let _ = buys.try_push(entry); } - OrderSide::Sell => { let _ = sells.try_push(entry); } + OrderSide::Buy => { + let _ = buys.try_push(entry); + } + OrderSide::Sell => { + let _ = sells.try_push(entry); + } } }); @@ -630,8 +615,14 @@ pub mod pallet { /// Pull gross TAO from each buyer and gross staked alpha from each seller /// into the pallet intermediary account, bypassing the pool. fn collect_assets( - buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, - sells: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + buys: &BoundedVec< + (H256, T::AccountId, T::AccountId, u64, u64, u64), + T::MaxOrdersPerBatch, + >, + sells: &BoundedVec< + (H256, T::AccountId, T::AccountId, u64, u64, u64), + T::MaxOrdersPerBatch, + >, pallet_acct: &T::AccountId, pallet_hotkey: &T::AccountId, netuid: NetUid, @@ -641,7 +632,12 @@ pub mod pallet { } for (_, signer, hotkey, gross, _, _) in sells.iter() { T::SwapInterface::transfer_staked_alpha( - signer, hotkey, pallet_acct, pallet_hotkey, netuid, AlphaBalance::from(*gross), + signer, + hotkey, + pallet_acct, + pallet_hotkey, + netuid, + AlphaBalance::from(*gross), )?; } Ok(()) @@ -663,8 +659,11 @@ pub mod pallet { let net_tao = (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64; let actual_alpha = if net_tao > 0 { T::SwapInterface::buy_alpha( - pallet_acct, pallet_hotkey, netuid, - TaoBalance::from(net_tao), TaoBalance::ZERO, + pallet_acct, + pallet_hotkey, + netuid, + TaoBalance::from(net_tao), + TaoBalance::ZERO, ) .unwrap_or(AlphaBalance::ZERO) .to_u64() as u128 @@ -681,8 +680,11 @@ pub mod pallet { let net_alpha = (total_sell_net.saturating_sub(total_buy_alpha_equiv)) as u64; let actual_tao = if net_alpha > 0 { T::SwapInterface::sell_alpha( - pallet_acct, pallet_hotkey, netuid, - AlphaBalance::from(net_alpha), TaoBalance::ZERO, + pallet_acct, + pallet_hotkey, + netuid, + AlphaBalance::from(net_alpha), + TaoBalance::ZERO, ) .unwrap_or(TaoBalance::ZERO) .to_u64() as u128 @@ -698,7 +700,10 @@ pub mod pallet { /// - Buy-dominant: total alpha = pool output + sell-side alpha (passed through). /// - Sell-dominant: total alpha = buy-side TAO converted at `current_price`. pub(crate) fn distribute_alpha_pro_rata( - buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + buys: &BoundedVec< + (H256, T::AccountId, T::AccountId, u64, u64, u64), + T::MaxOrdersPerBatch, + >, actual_out: u128, total_buy_net: u128, total_sell_net: u128, @@ -728,7 +733,11 @@ pub mod pallet { }; if share > 0 { T::SwapInterface::transfer_staked_alpha( - pallet_acct, pallet_hotkey, signer, hotkey, netuid, + pallet_acct, + pallet_hotkey, + signer, + hotkey, + netuid, AlphaBalance::from(share), )?; } @@ -751,7 +760,10 @@ pub mod pallet { /// Fee on TAO output: `ppb(share)` is withheld from each seller's payout and /// left in the pallet account. Returns the total sell-side fee TAO accumulated. pub(crate) fn distribute_tao_pro_rata( - sells: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + sells: &BoundedVec< + (H256, T::AccountId, T::AccountId, u64, u64, u64), + T::MaxOrdersPerBatch, + >, actual_out: u128, total_buy_net: u128, total_sell_tao_equiv: u128, @@ -802,7 +814,10 @@ pub mod pallet { /// /// Both transfers are best-effort and do not revert the batch on failure. pub(crate) fn collect_fees( - buys: &BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + buys: &BoundedVec< + (H256, T::AccountId, T::AccountId, u64, u64, u64), + T::MaxOrdersPerBatch, + >, sell_fee_tao: u64, pallet_acct: &T::AccountId, ) { @@ -812,8 +827,11 @@ pub mod pallet { let total_fee = total_buy_fee.saturating_add(sell_fee_tao); if total_fee > 0 { T::SwapInterface::transfer_tao( - pallet_acct, &fee_collector, TaoBalance::from(total_fee), - ).ok(); + pallet_acct, + &fee_collector, + TaoBalance::from(total_fee), + ) + .ok(); } // TODO: sweep rounding dust and any emissions accrued on the pallet account. @@ -837,8 +855,7 @@ pub mod pallet { OrderSide::Buy => (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64, OrderSide::Sell => { let buy_alpha_equiv: u64 = if current_price > U96F32::from_num(0u32) { - (U96F32::from_num(total_buy_net) / current_price) - .saturating_to_num::() + (U96F32::from_num(total_buy_net) / current_price).saturating_to_num::() } else { 0u64 }; diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 8691ed2c3e..3401e0c59c 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -4,6 +4,7 @@ //! out of the box; test keys come from `sp_keyring::AccountKeyring`. use std::cell::RefCell; +use std::collections::HashMap; use frame_support::{ PalletId, construct_runtime, derive_impl, parameter_types, @@ -91,6 +92,16 @@ thread_local! { pub static MOCK_BUY_ALPHA_RETURN: RefCell = RefCell::new(0u64); /// Fixed TAO returned by `sell_alpha` (default 0 — tests override as needed). pub static MOCK_SELL_TAO_RETURN: RefCell = RefCell::new(0u64); + /// In-memory staked alpha ledger: (coldkey, hotkey, netuid) → balance. + /// `transfer_staked_alpha` debits/credits this map so tests can assert + /// on residual balances after distribution. + pub static ALPHA_BALANCES: RefCell> = + RefCell::new(HashMap::new()); + /// In-memory free TAO ledger: account → balance. + /// `transfer_tao` debits/credits this map so tests can assert + /// on residual balances after distribution. + pub static TAO_BALANCES: RefCell> = + RefCell::new(HashMap::new()); } pub struct MockSwap; @@ -107,6 +118,32 @@ impl MockSwap { } pub fn clear_log() { SWAP_LOG.with(|l| l.borrow_mut().clear()); + ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); + TAO_BALANCES.with(|b| b.borrow_mut().clear()); + } + /// Seed a staked alpha balance for a (coldkey, hotkey, netuid) triple. + pub fn set_alpha_balance(coldkey: AccountId, hotkey: AccountId, netuid: NetUid, amount: u64) { + ALPHA_BALANCES.with(|b| { + b.borrow_mut().insert((coldkey, hotkey, netuid), amount); + }); + } + /// Query the current staked alpha balance for a (coldkey, hotkey, netuid) triple. + pub fn alpha_balance(coldkey: &AccountId, hotkey: &AccountId, netuid: NetUid) -> u64 { + ALPHA_BALANCES.with(|b| { + *b.borrow() + .get(&(coldkey.clone(), hotkey.clone(), netuid)) + .unwrap_or(&0) + }) + } + /// Seed a free TAO balance for an account. + pub fn set_tao_balance(account: AccountId, amount: u64) { + TAO_BALANCES.with(|b| { + b.borrow_mut().insert(account, amount); + }); + } + /// Query the current free TAO balance for an account. + pub fn tao_balance(account: &AccountId) -> u64 { + TAO_BALANCES.with(|b| *b.borrow().get(account).unwrap_or(&0)) } pub fn log() -> Vec { SWAP_LOG.with(|l| l.borrow().clone()) @@ -136,7 +173,14 @@ impl MockSwap { amount, } = c { - Some((from_coldkey, from_hotkey, to_coldkey, to_hotkey, netuid, amount)) + Some(( + from_coldkey, + from_hotkey, + to_coldkey, + to_hotkey, + netuid, + amount, + )) } else { None } @@ -181,9 +225,7 @@ impl OrderSwapInterface for MockSwap { alpha: alpha_amount.to_u64(), }) }); - Ok(TaoBalance::from( - MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()), - )) + Ok(TaoBalance::from(MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()))) } fn current_alpha_price(_netuid: NetUid) -> U96F32 { @@ -195,11 +237,19 @@ impl OrderSwapInterface for MockSwap { to: &AccountId, amount: TaoBalance, ) -> frame_support::pallet_prelude::DispatchResult { + let amt = amount.to_u64(); + TAO_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let from_bal = map.entry(from.clone()).or_insert(0); + *from_bal = from_bal.saturating_sub(amt); + let to_bal = map.entry(to.clone()).or_insert(0); + *to_bal = to_bal.saturating_add(amt); + }); SWAP_LOG.with(|l| { l.borrow_mut().push(SwapCall::TransferTao { from: from.clone(), to: to.clone(), - amount: amount.to_u64(), + amount: amt, }) }); Ok(()) @@ -213,6 +263,18 @@ impl OrderSwapInterface for MockSwap { netuid: NetUid, amount: AlphaBalance, ) -> frame_support::pallet_prelude::DispatchResult { + let amt = amount.to_u64(); + ALPHA_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let from_bal = map + .entry((from_coldkey.clone(), from_hotkey.clone(), netuid)) + .or_insert(0); + *from_bal = from_bal.saturating_sub(amt); + let to_bal = map + .entry((to_coldkey.clone(), to_hotkey.clone(), netuid)) + .or_insert(0); + *to_bal = to_bal.saturating_add(amt); + }); SWAP_LOG.with(|l| { l.borrow_mut().push(SwapCall::TransferStakedAlpha { from_coldkey: from_coldkey.clone(), @@ -220,7 +282,7 @@ impl OrderSwapInterface for MockSwap { to_coldkey: to_coldkey.clone(), to_hotkey: to_hotkey.clone(), netuid, - amount: amount.to_u64(), + amount: amt, }) }); Ok(()) diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs index 4256913116..4ffbca37a1 100644 --- a/pallets/limit-orders/src/tests/mod.rs +++ b/pallets/limit-orders/src/tests/mod.rs @@ -1,2 +1,2 @@ -pub mod auxiliary; pub mod mock; +pub mod tests; diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/tests.rs similarity index 62% rename from pallets/limit-orders/src/tests/auxiliary.rs rename to pallets/limit-orders/src/tests/tests.rs index ff27fe68b7..596b4240c1 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/tests.rs @@ -9,8 +9,8 @@ use sp_runtime::{AccountId32, MultiSignature}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; -use crate::{Order, OrderSide, OrderStatus, Orders, SignedOrder, pallet::ProtocolFee}; use crate::pallet::Pallet as LimitOrders; +use crate::{Order, OrderSide, OrderStatus, Orders, SignedOrder, pallet::ProtocolFee}; use super::mock::*; @@ -203,8 +203,8 @@ fn validate_and_classify_separates_buys_and_sells() { alice(), netuid_1(), OrderSide::Sell, - 500u64, // amount in alpha - 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) + 500u64, // amount in alpha + 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) 2_000_000u64, ); @@ -232,7 +232,10 @@ fn validate_and_classify_separates_buys_and_sells() { assert_eq!(signer, &bob()); assert_eq!(*gross, 500u64); assert_eq!(*net, 500u64); - assert_eq!(*fee, 0u64, "sell fee is always 0 here — applied on TAO output"); + assert_eq!( + *fee, 0u64, + "sell fee is always 0 here — applied on TAO output" + ); }); } @@ -373,7 +376,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { netuid_1(), OrderSide::Buy, 1_000_000_000u64, - u64::MAX, // limit price: accept any price + u64::MAX, // limit price: accept any price 2_000_000u64, ); @@ -398,11 +401,17 @@ fn validate_and_classify_applies_buy_fee_to_net() { // distribute_alpha_pro_rata // ───────────────────────────────────────────────────────────────────────────── // -// Scenario A – buy-dominant -// ────────────────────────── +// Scenario A – buy-dominant, pool rate = 1:1 +// ─────────────────────────────────────────── +// Both buyers and sellers are present, but buys exceed sells in TAO terms. +// Sellers are settled first (they receive TAO in distribute_tao_pro_rata). +// Their alpha (200 total) stays in the pallet account as passthrough for buyers. +// The residual buy TAO hits the pool and returns 800 alpha (at 1:1 rate). +// // 3 buyers: Alice 300 TAO net, Bob 200 TAO net, Charlie 500 TAO net (total 1000) -// Pool returns 800 alpha; seller alpha passed-through = 200. -// Total alpha pool = 800 + 200 = 1000 alpha. +// Sellers contributed 200 alpha (passthrough, no pool interaction). +// Net residual TAO to pool = 1000 - 200 = 800 TAO → pool returns 800 alpha (1:1). +// Total alpha available to buyers = 800 (pool) + 200 (seller passthrough) = 1000. // // Pro-rata shares (proportional to each buyer's net TAO): // Alice: 1000 * 300 / 1000 = 300 alpha @@ -411,12 +420,48 @@ fn validate_and_classify_applies_buy_fee_to_net() { // // Scenario B – sell-dominant // ─────────────────────────── +// Both buyers and sellers are present, but sells exceed buys in TAO terms. +// Buyers are settled from the sellers' alpha directly (no pool for them). +// The residual sell alpha hits the pool; sellers receive TAO in distribute_tao_pro_rata. +// // 2 buyers: Alice 400 TAO net, Bob 600 TAO net (total 1000) // Price = 2.0 TAO/alpha → total alpha for buyers = 1000 / 2 = 500 alpha. // // Pro-rata shares: // Alice: 500 * 400 / 1000 = 200 alpha // Bob: 500 * 600 / 1000 = 300 alpha +// +// Scenario C – buy-dominant, pool rate != 1:1 +// ──────────────────────────────────────────────────────── +// Same structure as Scenario A but the pool returns fewer alpha than the TAO +// sent in, simulating realistic AMM. Pro-rata is computed over +// whatever the pool actually returned — the distribution logic is rate-agnostic. +// +// 3 buyers: Alice 300 TAO net, Bob 200 TAO net, Charlie 500 TAO net (total 1000) +// Sellers contributed 200 alpha (passthrough). +// Net residual TAO to pool = 800 TAO → pool returns 750 alpha (slippage). +// Total alpha available to buyers = 750 (pool) + 200 (seller passthrough) = 950. +// +// Pro-rata shares: +// Alice: 950 * 300 / 1000 = 285 alpha +// Bob: 950 * 200 / 1000 = 190 alpha +// Charlie: 950 * 500 / 1000 = 475 alpha +// +// Scenario D – buy-dominant, indivisible remainder (dust) +// ───────────────────────────────────────────────────────── +// Integer division floors every share. The sum of floors is strictly less than +// total_alpha when total_alpha is not divisible by total_buy_net. +// The leftover alpha stays in the pallet intermediary account (never transferred). +// +// 3 buyers: Alice 1 TAO net, Bob 1 TAO net, Charlie 1 TAO net (total 3) +// Pool returns 10 alpha; no sellers → total_alpha = 10. +// +// Pro-rata shares (floor): +// Alice: floor(10 * 1 / 3) = 3 alpha +// Bob: floor(10 * 1 / 3) = 3 alpha +// Charlie: floor(10 * 1 / 3) = 3 alpha +// Total distributed: 9 alpha +// Dust remaining in pallet account: 10 - 9 = 1 alpha (never transferred) fn make_buy_entry( order_id: H256, @@ -442,9 +487,8 @@ fn bounded_sell_entries( } #[test] -fn distribute_alpha_pro_rata_buy_dominant() { +fn distribute_alpha_pro_rata_buy_dominant_scenario_a() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); // Pool returned 800 alpha; sell-side passthrough = 200 alpha. // Total = 1000 alpha distributed across 3 buyers (300, 200, 500 TAO net). // Expected shares: Alice 300, Bob 200, Charlie 500. @@ -452,7 +496,7 @@ fn distribute_alpha_pro_rata_buy_dominant() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_buy_entries(vec![ make_buy_entry(H256::repeat_byte(1), alice(), hotkey.clone(), 300, 300, 0), - make_buy_entry(H256::repeat_byte(2), bob(), hotkey.clone(), 200, 200, 0), + make_buy_entry(H256::repeat_byte(2), bob(), hotkey.clone(), 200, 200, 0), make_buy_entry(H256::repeat_byte(3), charlie(), hotkey.clone(), 500, 500, 0), ]); let pallet_acct = PalletHotkeyAccount::get(); // reuse as coldkey for brevity @@ -476,9 +520,21 @@ fn distribute_alpha_pro_rata_buy_dominant() { assert_eq!(transfers.len(), 3); // Check each recipient's amount (signer is to_coldkey). - let alice_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &alice()).unwrap().5; - let bob_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &bob()).unwrap().5; - let charlie_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &charlie()).unwrap().5; + let alice_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &alice()) + .unwrap() + .5; + let bob_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &bob()) + .unwrap() + .5; + let charlie_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &charlie()) + .unwrap() + .5; assert_eq!(alice_amt, 300u64, "Alice should receive 300 alpha"); assert_eq!(bob_amt, 200u64, "Bob should receive 200 alpha"); @@ -487,9 +543,8 @@ fn distribute_alpha_pro_rata_buy_dominant() { } #[test] -fn distribute_alpha_pro_rata_sell_dominant() { +fn distribute_alpha_pro_rata_sell_dominant_scenario_b() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); // Price = 2.0 TAO/alpha; buyers have 400 + 600 = 1000 TAO net. // Total alpha = 1000 / 2 = 500. // Expected: Alice 200 alpha, Bob 300 alpha. @@ -497,7 +552,7 @@ fn distribute_alpha_pro_rata_sell_dominant() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_buy_entries(vec![ make_buy_entry(H256::repeat_byte(4), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(5), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry(H256::repeat_byte(5), bob(), hotkey.clone(), 600, 600, 0), ]); let pallet_acct = PalletHotkeyAccount::get(); let pallet_hk = PalletHotkeyAccount::get(); @@ -518,48 +573,219 @@ fn distribute_alpha_pro_rata_sell_dominant() { let transfers = MockSwap::alpha_transfers(); assert_eq!(transfers.len(), 2); - let alice_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &alice()).unwrap().5; - let bob_amt = transfers.iter().find(|(_, _, to_ck, _, _, _)| to_ck == &bob()).unwrap().5; + let alice_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &alice()) + .unwrap() + .5; + let bob_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &bob()) + .unwrap() + .5; assert_eq!(alice_amt, 200u64, "Alice should receive 200 alpha"); assert_eq!(bob_amt, 300u64, "Bob should receive 300 alpha"); }); } +#[test] +fn distribute_alpha_pro_rata_buy_dominant_scenario_c() { + new_test_ext().execute_with(|| { + // Scenario C: same buyer setup as A but pool returns 750 alpha (slippage) + // instead of 800. Proves pro-rata is computed over actual pool output and + // is therefore rate-agnostic — the distribution logic doesn't assume 1:1. + // + // Net residual TAO to pool = 800 TAO → pool returns 750 alpha (not 800). + // Total alpha = 750 (pool) + 200 (seller passthrough) = 950. + // + // Expected shares: + // Alice: 950 * 300 / 1000 = 285 alpha + // Bob: 950 * 200 / 1000 = 190 alpha + // Charlie: 950 * 500 / 1000 = 475 alpha + + let hotkey = AccountKeyring::Dave.to_account_id(); + let entries = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(6), alice(), hotkey.clone(), 300, 300, 0), + make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 200, 200, 0), + make_buy_entry(H256::repeat_byte(8), charlie(), hotkey.clone(), 500, 500, 0), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + let pallet_hk = PalletHotkeyAccount::get(); + + LimitOrders::::distribute_alpha_pro_rata( + &entries, + 750u128, // actual_out from pool (750, not 800 — slippage) + 1_000u128, // total_buy_net (TAO) + 200u128, // total_sell_net (alpha passthrough) + &OrderSide::Buy, + U96F32::from_num(1u32), + &pallet_acct, + &pallet_hk, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::alpha_transfers(); + assert_eq!(transfers.len(), 3); + + let alice_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &alice()) + .unwrap() + .5; + let bob_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &bob()) + .unwrap() + .5; + let charlie_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &charlie()) + .unwrap() + .5; + + assert_eq!( + alice_amt, 285u64, + "Alice receives 950 * 300/1000 = 285 alpha" + ); + assert_eq!(bob_amt, 190u64, "Bob receives 950 * 200/1000 = 190 alpha"); + assert_eq!( + charlie_amt, 475u64, + "Charlie receives 950 * 500/1000 = 475 alpha" + ); + }); +} + +#[test] +fn distribute_alpha_pro_rata_dust_remains_in_pallet_scenario_d() { + new_test_ext().execute_with(|| { + // Scenario D: total_alpha = 10, three equal buyers (total_buy_net = 3). + // floor(10 * 1/3) = 3 each → 9 distributed → 1 alpha dust stays in pallet. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let pallet_acct = PalletHotkeyAccount::get(); + let pallet_hk = PalletHotkeyAccount::get(); + + // Seed the pallet account with the 10 alpha it would hold after collect_assets + // and the pool swap (actual_out=10, no sellers). + MockSwap::set_alpha_balance(pallet_acct.clone(), pallet_hk.clone(), netuid_1(), 10); + + let entries = bounded_buy_entries(vec![ + make_buy_entry(H256::repeat_byte(9), alice(), hotkey.clone(), 1, 1, 0), + make_buy_entry(H256::repeat_byte(10), bob(), hotkey.clone(), 1, 1, 0), + make_buy_entry(H256::repeat_byte(11), charlie(), hotkey.clone(), 1, 1, 0), + ]); + + LimitOrders::::distribute_alpha_pro_rata( + &entries, + 10u128, // actual_out from pool + 3u128, // total_buy_net (TAO) — not divisible into 10 evenly + 0u128, // total_sell_net — no sellers + &OrderSide::Buy, + U96F32::from_num(1u32), + &pallet_acct, + &pallet_hk, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::alpha_transfers(); + assert_eq!(transfers.len(), 3); + + let alice_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &alice()) + .unwrap() + .5; + let bob_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &bob()) + .unwrap() + .5; + let charlie_amt = transfers + .iter() + .find(|(_, _, to_ck, _, _, _)| to_ck == &charlie()) + .unwrap() + .5; + + assert_eq!(alice_amt, 3u64, "floor(10 * 1/3) = 3"); + assert_eq!(bob_amt, 3u64, "floor(10 * 1/3) = 3"); + assert_eq!(charlie_amt, 3u64, "floor(10 * 1/3) = 3"); + + // The pallet account started with 10 and sent out 9 — 1 alpha dust remains + // in the pallet account, not burnt, not distributed. + let pallet_remaining = MockSwap::alpha_balance(&pallet_acct, &pallet_hk, netuid_1()); + assert_eq!( + pallet_remaining, 1u64, + "1 alpha dust stays in pallet account, not burnt" + ); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // distribute_tao_pro_rata // ───────────────────────────────────────────────────────────────────────────── // // Scenario A – sell-dominant, fee = 0 -// ────────────────────────────────── +// ───────────────────────────────────── +// Both buyers and sellers are present, but sells exceed buys in TAO terms. +// Buyers are settled first (they receive alpha in distribute_alpha_pro_rata). +// The residual sell alpha hits the pool; pool returns TAO. +// Buy-side TAO also stays in pallet as passthrough for sellers. +// // 2 sellers: Alice 400 alpha, Bob 600 alpha (total 1000 alpha) -// Price = 2.0 TAO/alpha → sell_tao_equiv: Alice 800, Bob 1200, total 2000 -// Pool returned 1200 TAO; buy-side passthrough = 800 TAO. Total = 2000 TAO. +// Price = 2.0 TAO/alpha → sell_tao_equiv: Alice 800, Bob 1200, total 2000. +// Pool returned 1200 TAO for the residual alpha; buy passthrough = 800 TAO. +// Total TAO available to sellers = 1200 (pool) + 800 (buy passthrough) = 2000. // // Pro-rata shares (proportional to each seller's TAO-equiv): // Alice: 2000 * 800 / 2000 = 800 TAO // Bob: 2000 * 1200 / 2000 = 1200 TAO // // Scenario B – sell-dominant, fee = 1% (10_000_000 ppb) -// ───────────────────────────────────────────────────── -// Same setup. Fee on gross TAO payout: -// Alice: gross 800, fee 8 (1% of 800), net 792 TAO -// Bob: gross 1200, fee 12, net 1188 TAO +// ──────────────────────────────────────────────────────── +// Same structure as Scenario A. Fee is deducted from each seller's gross TAO +// payout; the withheld TAO stays in the pallet account for collect_fees. +// +// Alice gross=800, fee=8 (1% of 800), net=792 TAO +// Bob gross=1200, fee=12, net=1188 TAO +// Total sell fee returned: 20 TAO // // Scenario C – buy-dominant // ────────────────────────── +// Both buyers and sellers are present, but buys exceed sells in TAO terms. +// Sellers receive their alpha valued at current_price — no pool interaction +// for them. The TAO they receive comes from the buyers' collected TAO directly. +// // 2 sellers: Alice 300 alpha, Bob 200 alpha (total 500 alpha) // Price = 2.0 TAO/alpha → sell_tao_equiv: Alice 600, Bob 400, total 1000. -// (buy-dominant branch) total_tao = total_sell_tao_equiv = 1000. +// Buy-dominant branch: total_tao = total_sell_tao_equiv = 1000 TAO. // // Shares: // Alice: 1000 * 600 / 1000 = 600 TAO // Bob: 1000 * 400 / 1000 = 400 TAO +// +// Scenario D – sell-dominant, indivisible remainder (dust) +// ───────────────────────────────────────────────────────── +// Integer division floors every gross share. The leftover TAO stays in the +// pallet intermediary account (never transferred, not burnt). +// +// 3 sellers: Alice 1 alpha, Bob 1 alpha, Charlie 1 alpha (total 3 alpha) +// Price = 1.0 TAO/alpha → sell_tao_equiv = 1 each, total_sell_tao_equiv = 3. +// No buyers; actual_out from pool = 10 TAO, buy passthrough = 0. +// total_tao = 10 + 0 = 10. +// +// Pro-rata shares (floor): +// Alice: floor(10 * 1 / 3) = 3 TAO +// Bob: floor(10 * 1 / 3) = 3 TAO +// Charlie: floor(10 * 1 / 3) = 3 TAO +// Total distributed: 9 TAO +// Dust remaining in pallet account: 10 - 9 = 1 TAO (never transferred) #[test] -fn distribute_tao_pro_rata_sell_dominant_no_fee() { +fn distribute_tao_pro_rata_sell_dominant_no_fee_scenario_a() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); // Price = 2, total_tao = 1200 (pool) + 800 (buy passthrough) = 2000 // Alice alpha=400 → tao_equiv=800; Bob alpha=600 → tao_equiv=1200. // total_sell_tao_equiv = 2000. @@ -568,7 +794,7 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ make_buy_entry(H256::repeat_byte(6), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 600, 600, 0), ]); let pallet_acct = PalletHotkeyAccount::get(); @@ -587,8 +813,12 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee() { let transfers = MockSwap::tao_transfers(); assert_eq!(transfers.len(), 2); - let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; - let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + let alice_tao = transfers + .iter() + .find(|(_, to, _)| to == &alice()) + .unwrap() + .2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; assert_eq!(alice_tao, 800u64, "Alice should receive 800 TAO"); assert_eq!(bob_tao, 1_200u64, "Bob should receive 1200 TAO"); @@ -597,9 +827,8 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee() { } #[test] -fn distribute_tao_pro_rata_sell_dominant_with_fee() { +fn distribute_tao_pro_rata_sell_dominant_with_fee_scenario_b() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); // Same setup as above but fee = 10_000_000 ppb = 1%. // Alice gross=800, fee=8, net=792; Bob gross=1200, fee=12, net=1188. // Total sell fee = 20. @@ -607,7 +836,7 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ make_buy_entry(H256::repeat_byte(8), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(9), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry(H256::repeat_byte(9), bob(), hotkey.clone(), 600, 600, 0), ]); let pallet_acct = PalletHotkeyAccount::get(); @@ -626,8 +855,12 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee() { let transfers = MockSwap::tao_transfers(); assert_eq!(transfers.len(), 2); - let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; - let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + let alice_tao = transfers + .iter() + .find(|(_, to, _)| to == &alice()) + .unwrap() + .2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; assert_eq!(alice_tao, 792u64, "Alice net after 1% fee on 800"); assert_eq!(bob_tao, 1_188u64, "Bob net after 1% fee on 1200"); @@ -636,9 +869,8 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee() { } #[test] -fn distribute_tao_pro_rata_buy_dominant() { +fn distribute_tao_pro_rata_buy_dominant_scenario_c() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); // Buy-dominant: total_tao = total_sell_tao_equiv = 1000. // Alice alpha=300 → tao_equiv=600; Bob alpha=200 → tao_equiv=400. // Shares: Alice 600, Bob 400. @@ -646,7 +878,7 @@ fn distribute_tao_pro_rata_buy_dominant() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ make_buy_entry(H256::repeat_byte(10), alice(), hotkey.clone(), 300, 300, 0), - make_buy_entry(H256::repeat_byte(11), bob(), hotkey.clone(), 200, 200, 0), + make_buy_entry(H256::repeat_byte(11), bob(), hotkey.clone(), 200, 200, 0), ]); let pallet_acct = PalletHotkeyAccount::get(); @@ -665,8 +897,12 @@ fn distribute_tao_pro_rata_buy_dominant() { let transfers = MockSwap::tao_transfers(); assert_eq!(transfers.len(), 2); - let alice_tao = transfers.iter().find(|(_, to, _)| to == &alice()).unwrap().2; - let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + let alice_tao = transfers + .iter() + .find(|(_, to, _)| to == &alice()) + .unwrap() + .2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; assert_eq!(alice_tao, 600u64, "Alice should receive 600 TAO"); assert_eq!(bob_tao, 400u64, "Bob should receive 400 TAO"); @@ -674,6 +910,68 @@ fn distribute_tao_pro_rata_buy_dominant() { }); } +#[test] +fn distribute_tao_pro_rata_dust_remains_in_pallet_scenario_d() { + new_test_ext().execute_with(|| { + // Scenario D: total_tao = 10, three equal sellers (total_sell_tao_equiv = 3). + // floor(10 * 1/3) = 3 each → 9 distributed → 1 TAO dust stays in pallet. + + let hotkey = AccountKeyring::Dave.to_account_id(); + let pallet_acct = PalletHotkeyAccount::get(); + + // Seed the pallet account with the 10 TAO it would hold after collect_assets + // and the pool swap (actual_out=10, no buyers). + MockSwap::set_tao_balance(pallet_acct.clone(), 10); + + let entries = bounded_sell_entries(vec![ + make_buy_entry(H256::repeat_byte(12), alice(), hotkey.clone(), 1, 1, 0), + make_buy_entry(H256::repeat_byte(13), bob(), hotkey.clone(), 1, 1, 0), + make_buy_entry(H256::repeat_byte(14), charlie(), hotkey.clone(), 1, 1, 0), + ]); + + let sell_fee = LimitOrders::::distribute_tao_pro_rata( + &entries, + 10u128, // actual_out from pool (TAO) + 0u128, // total_buy_net — no buyers + 3u128, // total_sell_tao_equiv — not divisible into 10 evenly + &OrderSide::Sell, + U96F32::from_num(1u32), + 0u32, // fee_ppb = 0 + &pallet_acct, + netuid_1(), + ) + .unwrap(); + + let transfers = MockSwap::tao_transfers(); + assert_eq!(transfers.len(), 3); + + let alice_tao = transfers + .iter() + .find(|(_, to, _)| to == &alice()) + .unwrap() + .2; + let bob_tao = transfers.iter().find(|(_, to, _)| to == &bob()).unwrap().2; + let charlie_tao = transfers + .iter() + .find(|(_, to, _)| to == &charlie()) + .unwrap() + .2; + + assert_eq!(alice_tao, 3u64, "floor(10 * 1/3) = 3"); + assert_eq!(bob_tao, 3u64, "floor(10 * 1/3) = 3"); + assert_eq!(charlie_tao, 3u64, "floor(10 * 1/3) = 3"); + assert_eq!(sell_fee, 0u64); + + // The pallet account started with 10 TAO and sent out 9 — 1 TAO dust remains, + // not burnt, not distributed. + let pallet_remaining = MockSwap::tao_balance(&pallet_acct); + assert_eq!( + pallet_remaining, 1u64, + "1 TAO dust stays in pallet account, not burnt" + ); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // collect_fees // ───────────────────────────────────────────────────────────────────────────── @@ -686,13 +984,25 @@ fn distribute_tao_pro_rata_buy_dominant() { #[test] fn collect_fees_forwards_combined_fees_to_collector() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); - let hotkey = AccountKeyring::Dave.to_account_id(); // Buy entries carry fee in field index 5. let buys = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(20), alice(), hotkey.clone(), 1_000, 950, 50), - make_buy_entry(H256::repeat_byte(21), bob(), hotkey.clone(), 1_500, 1_350, 150), + make_buy_entry( + H256::repeat_byte(20), + alice(), + hotkey.clone(), + 1_000, + 950, + 50, + ), + make_buy_entry( + H256::repeat_byte(21), + bob(), + hotkey.clone(), + 1_500, + 1_350, + 150, + ), ]); let pallet_acct = PalletHotkeyAccount::get(); @@ -710,13 +1020,16 @@ fn collect_fees_forwards_combined_fees_to_collector() { #[test] fn collect_fees_no_transfer_when_zero_fees() { new_test_ext().execute_with(|| { - MockSwap::clear_log(); - // No buy fees, no sell fee. let hotkey = AccountKeyring::Dave.to_account_id(); - let buys = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(22), alice(), hotkey, 1_000, 1_000, 0), - ]); + let buys = bounded_buy_entries(vec![make_buy_entry( + H256::repeat_byte(22), + alice(), + hotkey, + 1_000, + 1_000, + 0, + )]); let pallet_acct = PalletHotkeyAccount::get(); LimitOrders::::collect_fees(&buys, 0u64, &pallet_acct); diff --git a/pallets/subtensor/src/staking/mod.rs b/pallets/subtensor/src/staking/mod.rs index 83edf45244..8a4585db30 100644 --- a/pallets/subtensor/src/staking/mod.rs +++ b/pallets/subtensor/src/staking/mod.rs @@ -6,8 +6,8 @@ pub mod decrease_take; pub mod helpers; pub mod increase_take; pub mod move_stake; +pub mod order_swap; pub mod recycle_alpha; pub mod remove_stake; pub mod set_children; -pub mod order_swap; pub mod stake_utils; diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 15b9c86e65..336d900df1 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -1,7 +1,7 @@ use super::*; +use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; -use substrate_fixed::types::U96F32; impl OrderSwapInterface for Pallet { fn buy_alpha( @@ -11,7 +11,15 @@ impl OrderSwapInterface for Pallet { tao_amount: TaoBalance, limit_price: TaoBalance, ) -> Result { - Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, limit_price, false, false) + Self::stake_into_subnet( + hotkey, + coldkey, + netuid, + tao_amount, + limit_price, + false, + false, + ) } fn sell_alpha( diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 7e24b57147..d8626557a0 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -86,11 +86,7 @@ pub trait OrderSwapInterface { /// Used by the batch executor to collect TAO from buy-order signers into /// the pallet intermediary account and to distribute TAO to sell-order /// signers after internal matching. - fn transfer_tao( - from: &AccountId, - to: &AccountId, - amount: TaoBalance, - ) -> DispatchResult; + fn transfer_tao(from: &AccountId, to: &AccountId, amount: TaoBalance) -> DispatchResult; /// Move `amount` staked alpha directly between two (coldkey, hotkey) pairs /// on `netuid` **without going through the AMM pool**. From c1e26a177895254548236b91fff6e944e039ce63 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 12:29:39 +0100 Subject: [PATCH 06/85] fmt function --- pallets/limit-orders/src/lib.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index da31525415..1cd18c12e7 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -726,20 +726,19 @@ pub mod pallet { }; for (order_id, signer, hotkey, _, net, _) in buys.iter() { - let share: u64 = if total_buy_net > 0 { - (total_alpha.saturating_mul(*net as u128) / total_buy_net) as u64 - } else { - 0u64 - }; - if share > 0 { - T::SwapInterface::transfer_staked_alpha( - pallet_acct, - pallet_hotkey, - signer, - hotkey, - netuid, - AlphaBalance::from(share), - )?; + if total_buy_net > 0 { + let share: u64 = + (total_alpha.saturating_mul(*net as u128) / total_buy_net) as u64; + if share > 0 { + T::SwapInterface::transfer_staked_alpha( + pallet_acct, + pallet_hotkey, + signer, + hotkey, + netuid, + AlphaBalance::from(share), + )?; + } } Orders::::insert(order_id, OrderStatus::Fulfilled); Self::deposit_event(Event::OrderExecuted { From ffb1637f6a2b6dc9aae6d536b843ed92cf6503b5 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 17:13:10 +0100 Subject: [PATCH 07/85] fixes here and there --- pallets/limit-orders/src/lib.rs | 111 ++- .../src/tests/{tests.rs => auxiliary.rs} | 0 pallets/limit-orders/src/tests/extrinsics.rs | 885 ++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 47 +- pallets/limit-orders/src/tests/mod.rs | 3 +- 5 files changed, 996 insertions(+), 50 deletions(-) rename pallets/limit-orders/src/tests/{tests.rs => auxiliary.rs} (100%) create mode 100644 pallets/limit-orders/src/tests/extrinsics.rs diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 1cd18c12e7..5afdf8543f 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -51,6 +51,12 @@ pub struct Order /// The envelope the admin submits on-chain: the order payload plus the user's /// signature over the SCALE-encoded `Order`. /// +/// TODO: evaluate cross-chain replay protection. The signature covers only the +/// SCALE-encoded `Order` with no chain-specific domain separator (genesis hash, +/// chain ID, or pallet prefix). A signed order is therefore valid on any chain +/// that shares the same runtime types (e.g. a testnet fork). Consider prepending +/// a domain tag to the signed payload or adding the genesis hash as an `Order` field. +/// /// Signature verification is performed against `order.signer` (the AccountId) /// directly, which works because in Substrate sr25519/ed25519 AccountIds are /// the raw public keys. @@ -150,6 +156,12 @@ pub mod pallet { #[pallet::storage] pub type ProtocolFee = StorageValue<_, u32, ValueQuery>; + /// The privileged account that may call `set_protocol_fee`. + /// Absent ⇒ no admin set; only root can change the fee. + /// Set by root via `set_admin`. + #[pallet::storage] + pub type Admin = StorageValue<_, T::AccountId, OptionQuery>; + /// Tracks the on-chain status of a known `OrderId`. /// Absent ⇒ never seen (still executable if valid). /// Present ⇒ Fulfilled or Cancelled (both are terminal). @@ -178,6 +190,8 @@ pub mod pallet { }, /// The protocol fee was updated. ProtocolFeeSet { fee: u32 }, + /// The admin account was updated by root. + AdminSet { new_admin: Option }, /// Summary emitted once per `execute_batched_orders` call. GroupExecutionSummary { /// The subnet all orders in this batch belong to. @@ -209,6 +223,10 @@ pub mod pallet { PriceConditionNotMet, /// Caller is not the order signer (required for cancellation). Unauthorized, + /// Caller is neither root nor the current admin. + NotAdmin, + /// The pool swap returned zero output for a non-zero input. + SwapReturnedZero, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -302,15 +320,36 @@ pub mod pallet { Ok(()) } - /// Set the protocol fee in parts-per-billion. Requires root. + /// Set the protocol fee in parts-per-billion. + /// + /// May be called by root or the current admin account. #[pallet::call_index(3)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().reads_writes(1, 1)))] pub fn set_protocol_fee(origin: OriginFor, fee: u32) -> DispatchResult { - ensure_root(origin)?; + let is_root = ensure_root(origin.clone()).is_ok(); + if !is_root { + let who = ensure_signed(origin)?; + ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + } ProtocolFee::::put(fee); Self::deposit_event(Event::ProtocolFeeSet { fee }); Ok(()) } + + /// Set or clear the admin account. Requires root. + /// + /// Pass `None` to remove the admin, leaving only root able to change fees. + #[pallet::call_index(5)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + pub fn set_admin(origin: OriginFor, new_admin: Option) -> DispatchResult { + ensure_root(origin)?; + match &new_admin { + Some(a) => Admin::::put(a), + None => Admin::::kill(), + } + Self::deposit_event(Event::AdminSet { new_admin }); + Ok(()) + } } // ── Internal helpers ────────────────────────────────────────────────────── @@ -383,51 +422,35 @@ pub mod pallet { TaoBalance::from(order.limit_price), )?; - // Route the fee TAO to the fee collector as staked alpha. + // Forward the fee TAO directly to FeeCollector. if !fee_tao.is_zero() { - T::SwapInterface::buy_alpha( + T::SwapInterface::transfer_tao( &order.signer, &T::FeeCollector::get(), - order.netuid, fee_tao, - T::SwapInterface::current_alpha_price(order.netuid) - .saturating_to_num::() - .into(), ) .ok(); } } OrderSide::Sell => { - let alpha_in = AlphaBalance::from(order.amount); - let fee_alpha = Self::ppb_of_alpha(alpha_in, fee_ppb); - let alpha_after_fee = alpha_in.saturating_sub(fee_alpha); - - T::SwapInterface::sell_alpha( + // Sell the full alpha amount; fee is taken from the TAO output. + let tao_out = T::SwapInterface::sell_alpha( &order.signer, &order.hotkey, order.netuid, - alpha_after_fee, + AlphaBalance::from(order.amount), TaoBalance::from(order.limit_price), )?; - // Sell fee alpha separately; TAO proceeds go to fee collector. - if !fee_alpha.is_zero() { - let fee_tao = T::SwapInterface::sell_alpha( + // Deduct protocol fee from TAO output and forward to FeeCollector. + let fee_tao = Self::ppb_of_tao(tao_out, fee_ppb); + if !fee_tao.is_zero() { + T::SwapInterface::transfer_tao( &order.signer, - &order.hotkey, - order.netuid, - fee_alpha, - TaoBalance::ZERO, + &T::FeeCollector::get(), + fee_tao, ) - .unwrap_or(TaoBalance::ZERO); - - if !fee_tao.is_zero() { - // The sell_alpha implementation is expected to credit TAO to - // the signer; transferring to fee collector requires a - // runtime-level BalanceOps call outside this pallet's scope. - // TODO: integrate BalanceOps to move fee TAO to FeeCollector. - let _ = fee_tao; - } + .ok(); } } } @@ -492,7 +515,7 @@ pub mod pallet { &pallet_acct, &pallet_hotkey, netuid, - ); + )?; // Give every buyer their pro-rata share of (pool alpha output + offset sell alpha). Self::distribute_alpha_pro_rata( @@ -654,23 +677,24 @@ pub mod pallet { pallet_acct: &T::AccountId, pallet_hotkey: &T::AccountId, netuid: NetUid, - ) -> (OrderSide, u128) { + ) -> Result<(OrderSide, u128), DispatchError> { if total_buy_net >= total_sell_tao_equiv { let net_tao = (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64; let actual_alpha = if net_tao > 0 { - T::SwapInterface::buy_alpha( + let out = T::SwapInterface::buy_alpha( pallet_acct, pallet_hotkey, netuid, TaoBalance::from(net_tao), TaoBalance::ZERO, - ) - .unwrap_or(AlphaBalance::ZERO) - .to_u64() as u128 + )? + .to_u64() as u128; + ensure!(out > 0, Error::::SwapReturnedZero); + out } else { 0u128 }; - (OrderSide::Buy, actual_alpha) + Ok((OrderSide::Buy, actual_alpha)) } else { let total_buy_alpha_equiv: u128 = if current_price > U96F32::from_num(0u32) { (U96F32::from_num(total_buy_net) / current_price).saturating_to_num::() @@ -679,19 +703,20 @@ pub mod pallet { }; let net_alpha = (total_sell_net.saturating_sub(total_buy_alpha_equiv)) as u64; let actual_tao = if net_alpha > 0 { - T::SwapInterface::sell_alpha( + let out = T::SwapInterface::sell_alpha( pallet_acct, pallet_hotkey, netuid, AlphaBalance::from(net_alpha), TaoBalance::ZERO, - ) - .unwrap_or(TaoBalance::ZERO) - .to_u64() as u128 + )? + .to_u64() as u128; + ensure!(out > 0, Error::::SwapReturnedZero); + out } else { 0u128 }; - (OrderSide::Sell, actual_tao) + Ok((OrderSide::Sell, actual_tao)) } } diff --git a/pallets/limit-orders/src/tests/tests.rs b/pallets/limit-orders/src/tests/auxiliary.rs similarity index 100% rename from pallets/limit-orders/src/tests/tests.rs rename to pallets/limit-orders/src/tests/auxiliary.rs diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs new file mode 100644 index 0000000000..e73888aae1 --- /dev/null +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -0,0 +1,885 @@ +//! Integration tests for `pallet-limit-orders` extrinsics. +//! +//! Tests go through the full dispatch path: origin enforcement, storage changes, +//! and event emission are all verified. SwapInterface calls are handled by +//! `MockSwap`, which records calls and maintains in-memory balance ledgers. + +use frame_support::{assert_noop, assert_ok, BoundedVec}; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{DispatchError, MultiSignature}; +use subtensor_runtime_common::NetUid; + +use crate::{ + Admin, Error, Order, OrderSide, OrderStatus, Orders, + pallet::{Event, ProtocolFee}, +}; + +type LimitOrders = crate::pallet::Pallet; + +use super::mock::*; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn alice() -> AccountId { + AccountKeyring::Alice.to_account_id() +} +fn bob() -> AccountId { + AccountKeyring::Bob.to_account_id() +} +fn charlie() -> AccountId { + AccountKeyring::Charlie.to_account_id() +} +fn dave() -> AccountId { + AccountKeyring::Dave.to_account_id() +} + +fn netuid() -> NetUid { + NetUid::from(1u16) +} + +fn make_signed_order( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: NetUid, + side: OrderSide, + amount: u64, + limit_price: u64, + expiry: u64, +) -> crate::SignedOrder { + use codec::Encode; + let signer = keyring.to_account_id(); + let order = Order { signer, hotkey, netuid, side, amount, limit_price, expiry }; + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig) } +} + +fn bounded( + v: Vec>, +) -> BoundedVec, frame_support::traits::ConstU32<64>> +{ + BoundedVec::try_from(v).unwrap() +} + +/// Check that a specific pallet event was emitted. +fn assert_event(event: Event) { + assert!( + System::events() + .iter() + .any(|r| r.event == RuntimeEvent::LimitOrders(event.clone())), + "expected event not found: {event:?}", + ); +} + +fn order_id(order: &Order) -> H256 { + use codec::Encode; + H256(sp_core::hashing::blake2_256(&order.encode())) +} + +const FAR_FUTURE: u64 = u64::MAX; + +// ───────────────────────────────────────────────────────────────────────────── +// set_admin +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn set_admin_root_can_set_admin() { + new_test_ext().execute_with(|| { + assert_ok!(LimitOrders::set_admin(RuntimeOrigin::root(), Some(alice()))); + assert_eq!(Admin::::get(), Some(alice())); + assert_event(Event::AdminSet { new_admin: Some(alice()) }); + }); +} + +#[test] +fn set_admin_root_can_clear_admin() { + new_test_ext().execute_with(|| { + Admin::::put(alice()); + assert_ok!(LimitOrders::set_admin(RuntimeOrigin::root(), None)); + assert!(Admin::::get().is_none()); + assert_event(Event::AdminSet { new_admin: None }); + }); +} + +#[test] +fn set_admin_signed_origin_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + LimitOrders::set_admin(RuntimeOrigin::signed(alice()), Some(bob())), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn set_admin_unsigned_origin_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + LimitOrders::set_admin(RuntimeOrigin::none(), Some(alice())), + DispatchError::BadOrigin + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// set_protocol_fee +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn set_protocol_fee_root_can_set() { + new_test_ext().execute_with(|| { + assert_ok!(LimitOrders::set_protocol_fee(RuntimeOrigin::root(), 1_000_000)); + assert_eq!(ProtocolFee::::get(), 1_000_000); + assert_event(Event::ProtocolFeeSet { fee: 1_000_000 }); + }); +} + +#[test] +fn set_protocol_fee_admin_can_set() { + new_test_ext().execute_with(|| { + Admin::::put(alice()); + assert_ok!(LimitOrders::set_protocol_fee(RuntimeOrigin::signed(alice()), 500_000)); + assert_eq!(ProtocolFee::::get(), 500_000); + assert_event(Event::ProtocolFeeSet { fee: 500_000 }); + }); +} + +#[test] +fn set_protocol_fee_non_admin_rejected() { + new_test_ext().execute_with(|| { + Admin::::put(alice()); + // Bob is not the admin. + assert_noop!( + LimitOrders::set_protocol_fee(RuntimeOrigin::signed(bob()), 999), + Error::::NotAdmin + ); + }); +} + +#[test] +fn set_protocol_fee_no_admin_signed_rejected() { + new_test_ext().execute_with(|| { + // No admin set at all; signed origin that is not root must be rejected. + assert_noop!( + LimitOrders::set_protocol_fee(RuntimeOrigin::signed(alice()), 999), + Error::::NotAdmin + ); + }); +} + +#[test] +fn set_protocol_fee_unsigned_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + LimitOrders::set_protocol_fee(RuntimeOrigin::none(), 1), + DispatchError::BadOrigin + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cancel_order +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn cancel_order_signer_can_cancel() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + side: OrderSide::Buy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + }; + let id = order_id(&order); + + assert_ok!(LimitOrders::cancel_order(RuntimeOrigin::signed(alice()), order)); + assert_eq!(Orders::::get(id), Some(OrderStatus::Cancelled)); + assert_event(Event::OrderCancelled { order_id: id, signer: alice() }); + }); +} + +#[test] +fn cancel_order_non_signer_rejected() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + side: OrderSide::Buy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + }; + // Bob tries to cancel Alice's order. + assert_noop!( + LimitOrders::cancel_order(RuntimeOrigin::signed(bob()), order), + Error::::Unauthorized + ); + }); +} + +#[test] +fn cancel_order_already_cancelled_rejected() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + side: OrderSide::Buy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + }; + let id = order_id(&order); + Orders::::insert(id, OrderStatus::Cancelled); + + assert_noop!( + LimitOrders::cancel_order(RuntimeOrigin::signed(alice()), order), + Error::::OrderAlreadyProcessed + ); + }); +} + +#[test] +fn cancel_order_already_fulfilled_rejected() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + side: OrderSide::Buy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + }; + let id = order_id(&order); + Orders::::insert(id, OrderStatus::Fulfilled); + + assert_noop!( + LimitOrders::cancel_order(RuntimeOrigin::signed(alice()), order), + Error::::OrderAlreadyProcessed + ); + }); +} + +#[test] +fn cancel_order_unsigned_rejected() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + side: OrderSide::Buy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + }; + assert_noop!( + LimitOrders::cancel_order(RuntimeOrigin::none(), order), + DispatchError::BadOrigin + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_orders_buy_order_fulfilled() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + // Price = 1.0 ≤ limit = 2.0 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, 2_000_000_000, FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + side: OrderSide::Buy, + }); + }); +} + +#[test] +fn execute_orders_sell_order_fulfilled() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + // Price = 2.0 ≥ limit = 1 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Sell, 500, 1, FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + side: OrderSide::Sell, + }); + }); +} + +#[test] +fn execute_orders_expired_order_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(2_000_001); // now > expiry + MockSwap::set_price(1.0); + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, 2_000_000, // expiry in the past + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + // Skipped — storage untouched. + assert!(Orders::::get(id).is_none()); + }); +} + +#[test] +fn execute_orders_price_not_met_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(5.0); // price 5.0 > limit 2 → buy condition not met + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, 2, FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + assert!(Orders::::get(id).is_none()); + }); +} + +#[test] +fn execute_orders_already_processed_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + let id = order_id(&signed.order); + Orders::::insert(id, OrderStatus::Fulfilled); + + // Should succeed (batch-level) but skip this order silently. + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + // Still Fulfilled (not changed). + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn execute_orders_mixed_batch_valid_and_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let valid = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + let expired = make_signed_order( + AccountKeyring::Bob, alice(), netuid(), + OrderSide::Buy, 500, u64::MAX, 500_000, // already expired + ); + let valid_id = order_id(&valid.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![valid, expired]), + )); + + assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn execute_orders_unsigned_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + LimitOrders::execute_orders(RuntimeOrigin::none(), bounded(vec![])), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn execute_orders_buy_with_fee_charges_fee() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + ProtocolFee::::put(10_000_000u32); // 1% + + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + MockSwap::set_tao_balance(alice(), 1_000); + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + // One buy_alpha call for the net amount (990 TAO after 1% fee). + let buys: Vec<_> = MockSwap::log().into_iter() + .filter_map(|c| if let super::mock::SwapCall::BuyAlpha { tao, .. } = c { Some(tao) } else { None }) + .collect(); + assert_eq!(buys, vec![990], "main swap must use 990 TAO after 1% fee"); + + // Fee (10 TAO) forwarded directly to FeeCollector via transfer_tao. + assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 10); + }); +} + +#[test] +fn execute_orders_sell_with_fee_charges_fee() { + new_test_ext().execute_with(|| { + // fee = 1% (10_000_000 ppb). + // Alice sells 1_000 alpha; pool returns 800 TAO. + // fee_tao = 1% of 800 = 8 TAO, forwarded to FeeCollector via transfer_tao. + // Alice keeps 792 TAO. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_sell_tao_return(800); + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + ProtocolFee::::put(10_000_000u32); // 1% + + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Sell, 1_000, 0, FAR_FUTURE, + ); + assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + + // Full 1_000 alpha sold (no alpha deducted for fee). + let sells: Vec<_> = MockSwap::log().into_iter() + .filter_map(|c| if let super::mock::SwapCall::SellAlpha { alpha, .. } = c { Some(alpha) } else { None }) + .collect(); + assert_eq!(sells, vec![1_000], "full alpha amount must be sold"); + + // FeeCollector received 8 TAO (1% of 800). + assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 8); + // Alice kept the remaining 792 TAO. + assert_eq!(MockSwap::tao_balance(&alice()), 792); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute_batched_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_unsigned_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + LimitOrders::execute_batched_orders(RuntimeOrigin::none(), netuid(), bounded(vec![])), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn execute_batched_orders_all_invalid_returns_ok() { + new_test_ext().execute_with(|| { + MockTime::set(2_000_001); // all expired + let expired = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, 1_000_000, + ); + // Returns Ok even when nothing executes. + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![expired]), + )); + // No summary event — early return when executed_count == 0. + let has_summary = System::events().iter().any(|r| { + matches!(&r.event, RuntimeEvent::LimitOrders(Event::GroupExecutionSummary { .. })) + }); + assert!(!has_summary); + }); +} + +#[test] +fn execute_batched_orders_skips_wrong_netuid() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(100); + + let wrong_net = make_signed_order( + AccountKeyring::Alice, bob(), NetUid::from(99u16), // wrong netuid + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + let id = order_id(&wrong_net.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), // batch targets netuid 1 + bounded(vec![wrong_net]), + )); + + assert!(Orders::::get(id).is_none(), "wrong-netuid order must not be fulfilled"); + }); +} + +#[test] +fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { + new_test_ext().execute_with(|| { + // Setup: + // Alice buys 600 TAO, Bob buys 400 TAO (total 1000 TAO net, fee=0). + // Pool returns 500 alpha (MOCK_BUY_ALPHA_RETURN). + // No sellers → total_alpha = 500. + // Pro-rata: Alice 500*600/1000=300, Bob 500*400/1000=200. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 600); + MockSwap::set_tao_balance(bob(), 400); + + let alice_order = make_signed_order( + AccountKeyring::Alice, dave(), netuid(), + OrderSide::Buy, 600, u64::MAX, FAR_FUTURE, + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, dave(), netuid(), + OrderSide::Buy, 400, u64::MAX, FAR_FUTURE, + ); + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order]), + )); + + // Both orders fulfilled. + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + + // Alpha distributed pro-rata. + assert_eq!(MockSwap::alpha_balance(&alice(), &dave(), netuid()), 300); + assert_eq!(MockSwap::alpha_balance(&bob(), &dave(), netuid()), 200); + + // Summary event. + assert_event(Event::GroupExecutionSummary { + netuid: netuid(), + net_side: OrderSide::Buy, + net_amount: 1_000, + actual_out: 500, + executed_count: 2, + }); + }); +} + +#[test] +fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { + new_test_ext().execute_with(|| { + // Setup: + // Alice sells 300 alpha, Bob sells 200 alpha (total 500 alpha, fee=0). + // Price = 2.0 → sell_tao_equiv: Alice 600, Bob 400, total 1000. + // Pool returns 800 TAO (MOCK_SELL_TAO_RETURN) for the net 500 alpha. + // No buyers → total_tao = 800 + 0 = 800. + // Pro-rata: Alice 800*600/1000=480, Bob 800*400/1000=320. + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + MockSwap::set_sell_tao_return(800); + MockSwap::set_alpha_balance(alice(), dave(), netuid(), 300); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); + + let alice_order = make_signed_order( + AccountKeyring::Alice, dave(), netuid(), + OrderSide::Sell, 300, 0, FAR_FUTURE, // limit=0 → accept any price + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, dave(), netuid(), + OrderSide::Sell, 200, 0, FAR_FUTURE, + ); + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order]), + )); + + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + + // TAO distributed pro-rata. + assert_eq!(MockSwap::tao_balance(&alice()), 480); + assert_eq!(MockSwap::tao_balance(&bob()), 320); + + assert_event(Event::GroupExecutionSummary { + netuid: netuid(), + net_side: OrderSide::Sell, + net_amount: 500, + actual_out: 800, + executed_count: 2, + }); + }); +} + +#[test] +fn execute_batched_orders_buy_dominant_mixed() { + new_test_ext().execute_with(|| { + // Setup (fee=0, price=2.0 TAO/alpha): + // Buyers: Alice 1000 TAO, Bob 600 TAO → total_buy_net = 1600. + // Sellers: Charlie 200 alpha → sell_tao_equiv = 400 TAO. + // Net (buy-dominant): 1600 - 400 = 1200 TAO goes to pool. + // Pool returns 300 alpha (MOCK_BUY_ALPHA_RETURN). + // total_alpha for buyers = 300 (pool) + 200 (seller passthrough) = 500. + // Pro-rata buyers (by buy_net TAO): + // Alice: 500 * 1000/1600 = 312 alpha + // Bob: 500 * 600/1600 = 187 alpha + // (dust = 1 alpha stays in pallet) + // Sellers (buy-dominant branch): total_tao = total_sell_tao_equiv = 400. + // Charlie: 400 * 400/400 = 400 TAO. + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + MockSwap::set_buy_alpha_return(300); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_tao_balance(bob(), 600); + MockSwap::set_alpha_balance(charlie(), dave(), netuid(), 200); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, dave(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, dave(), netuid(), + OrderSide::Buy, 600, u64::MAX, FAR_FUTURE, + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, dave(), netuid(), + OrderSide::Sell, 200, 0, FAR_FUTURE, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(dave()), + netuid(), + bounded(vec![alice_buy, bob_buy, charlie_sell]), + )); + + assert_eq!(MockSwap::alpha_balance(&alice(), &dave(), netuid()), 312); + assert_eq!(MockSwap::alpha_balance(&bob(), &dave(), netuid()), 187); + assert_eq!(MockSwap::tao_balance(&charlie()), 400); + + assert_event(Event::GroupExecutionSummary { + netuid: netuid(), + net_side: OrderSide::Buy, + net_amount: 1_200, + actual_out: 300, + executed_count: 3, + }); + }); +} + +#[test] +fn execute_batched_orders_sell_dominant_mixed() { + new_test_ext().execute_with(|| { + // Setup (fee=0, price=2.0 TAO/alpha): + // Buyers: Alice 200 TAO → total_buy_net = 200. + // Sellers: Bob 300 alpha, Charlie 200 alpha → total_sell_net = 500. + // sell_tao_equiv: Bob 600, Charlie 400, total 1000. + // Net (sell-dominant): buy_alpha_equiv = 200/2 = 100 alpha; + // residual sell alpha = 500 - 100 = 400 alpha → pool returns 300 TAO. + // total_tao for sellers = 300 (pool) + 200 (buy passthrough) = 500 TAO. + // Pro-rata sellers (by sell_tao_equiv): + // Bob: 500 * 600/1000 = 300 TAO + // Charlie: 500 * 400/1000 = 200 TAO + // total_alpha for buyers = buy_net / price = 200/2 = 100 alpha. + // Alice: 100 * 200/200 = 100 alpha. + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + MockSwap::set_sell_tao_return(300); + MockSwap::set_tao_balance(alice(), 200); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 300); + MockSwap::set_alpha_balance(charlie(), dave(), netuid(), 200); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, dave(), netuid(), + OrderSide::Buy, 200, u64::MAX, FAR_FUTURE, + ); + let bob_sell = make_signed_order( + AccountKeyring::Bob, dave(), netuid(), + OrderSide::Sell, 300, 0, FAR_FUTURE, + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, dave(), netuid(), + OrderSide::Sell, 200, 0, FAR_FUTURE, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(dave()), + netuid(), + bounded(vec![alice_buy, bob_sell, charlie_sell]), + )); + + assert_eq!(MockSwap::alpha_balance(&alice(), &dave(), netuid()), 100); + assert_eq!(MockSwap::tao_balance(&bob()), 300); + assert_eq!(MockSwap::tao_balance(&charlie()), 200); + + assert_event(Event::GroupExecutionSummary { + netuid: netuid(), + net_side: OrderSide::Sell, + net_amount: 400, + actual_out: 300, + executed_count: 3, + }); + }); +} + +#[test] +fn execute_batched_orders_fee_forwarded_to_collector() { + new_test_ext().execute_with(|| { + // fee = 1% (10_000_000 ppb). + // Alice buys 1000 TAO: fee = 10, net = 990. + // Pool returns 500 alpha for 990 TAO. + // collect_fees transfers 10 TAO (buy fee) to FeeCollector. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + ProtocolFee::::put(10_000_000u32); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, dave(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_buy]), + )); + + // Fee collector received the buy-side fee. + assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 10); + }); +} + +#[test] +fn execute_batched_orders_cancelled_order_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(100); + + let signed = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + let id = order_id(&signed.order); + Orders::::insert(id, OrderStatus::Cancelled); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed]), + )); + + // Still cancelled, not changed to Fulfilled. + assert_eq!(Orders::::get(id), Some(OrderStatus::Cancelled)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// net_pool_swap – SwapReturnedZero errors +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_buy_zero_alpha_returns_error() { + new_test_ext().execute_with(|| { + // buy_alpha returns 0 alpha for a non-zero TAO input → SwapReturnedZero. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(0); // pool gives back nothing + MockSwap::set_tao_balance(alice(), 1_000); + + let order = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + Error::::SwapReturnedZero + ); + }); +} + +#[test] +fn execute_batched_orders_sell_zero_tao_returns_error() { + new_test_ext().execute_with(|| { + // sell_alpha returns 0 TAO for a non-zero alpha input → SwapReturnedZero. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_sell_tao_return(0); // pool gives back nothing + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Sell, 1_000, 0, FAR_FUTURE, + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + Error::::SwapReturnedZero + ); + }); +} + +#[test] +fn execute_batched_orders_sell_alpha_respects_swap_fail() { + new_test_ext().execute_with(|| { + // sell_alpha should propagate DispatchError when MOCK_SWAP_FAIL is set. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_swap_fail(true); + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order( + AccountKeyring::Alice, bob(), netuid(), + OrderSide::Sell, 1_000, 0, FAR_FUTURE, + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("pool error") + ); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 3401e0c59c..d1cb01ed4f 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -102,6 +102,8 @@ thread_local! { /// on residual balances after distribution. pub static TAO_BALANCES: RefCell> = RefCell::new(HashMap::new()); + /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. + pub static MOCK_SWAP_FAIL: RefCell = RefCell::new(false); } pub struct MockSwap; @@ -116,6 +118,9 @@ impl MockSwap { pub fn set_sell_tao_return(tao: u64) { MOCK_SELL_TAO_RETURN.with(|v| *v.borrow_mut() = tao); } + pub fn set_swap_fail(fail: bool) { + MOCK_SWAP_FAIL.with(|v| *v.borrow_mut() = fail); + } pub fn clear_log() { SWAP_LOG.with(|l| l.borrow_mut().clear()); ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); @@ -197,17 +202,31 @@ impl OrderSwapInterface for MockSwap { tao_amount: TaoBalance, _limit_price: TaoBalance, ) -> Result { + if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other("pool error")); + } + let tao = tao_amount.to_u64(); + let alpha_out = MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow()); + // Debit TAO from coldkey, credit alpha to (coldkey, hotkey, netuid). + TAO_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let bal = map.entry(coldkey.clone()).or_insert(0); + *bal = bal.saturating_sub(tao); + }); + ALPHA_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let bal = map.entry((coldkey.clone(), hotkey.clone(), netuid)).or_insert(0); + *bal = bal.saturating_add(alpha_out); + }); SWAP_LOG.with(|l| { l.borrow_mut().push(SwapCall::BuyAlpha { coldkey: coldkey.clone(), hotkey: hotkey.clone(), netuid, - tao: tao_amount.to_u64(), + tao, }) }); - Ok(AlphaBalance::from( - MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow()), - )) + Ok(AlphaBalance::from(alpha_out)) } fn sell_alpha( @@ -217,15 +236,31 @@ impl OrderSwapInterface for MockSwap { alpha_amount: AlphaBalance, _limit_price: TaoBalance, ) -> Result { + if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other("pool error")); + } + let alpha = alpha_amount.to_u64(); + let tao_out = MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()); + // Debit alpha from (coldkey, hotkey, netuid), credit TAO to coldkey. + ALPHA_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let bal = map.entry((coldkey.clone(), hotkey.clone(), netuid)).or_insert(0); + *bal = bal.saturating_sub(alpha); + }); + TAO_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + let bal = map.entry(coldkey.clone()).or_insert(0); + *bal = bal.saturating_add(tao_out); + }); SWAP_LOG.with(|l| { l.borrow_mut().push(SwapCall::SellAlpha { coldkey: coldkey.clone(), hotkey: hotkey.clone(), netuid, - alpha: alpha_amount.to_u64(), + alpha, }) }); - Ok(TaoBalance::from(MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()))) + Ok(TaoBalance::from(tao_out)) } fn current_alpha_price(_netuid: NetUid) -> U96F32 { diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs index 4ffbca37a1..1a32805c45 100644 --- a/pallets/limit-orders/src/tests/mod.rs +++ b/pallets/limit-orders/src/tests/mod.rs @@ -1,2 +1,3 @@ +pub mod extrinsics; pub mod mock; -pub mod tests; +pub mod auxiliary; From bf262b40f6fb29bf27e7cb29c1dfc140ebe50a10 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 24 Mar 2026 17:43:25 +0100 Subject: [PATCH 08/85] readme, refactors and security --- pallets/limit-orders/README.md | 231 +++++++++++ pallets/limit-orders/src/lib.rs | 155 ++++---- pallets/limit-orders/src/tests/auxiliary.rs | 172 +++------ pallets/limit-orders/src/tests/extrinsics.rs | 381 +++++++++++++------ pallets/limit-orders/src/tests/mock.rs | 78 +++- pallets/limit-orders/src/tests/mod.rs | 2 +- 6 files changed, 700 insertions(+), 319 deletions(-) create mode 100644 pallets/limit-orders/README.md diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md new file mode 100644 index 0000000000..99205fbcb2 --- /dev/null +++ b/pallets/limit-orders/README.md @@ -0,0 +1,231 @@ +# pallet-limit-orders + +A FRAME pallet for off-chain signed limit orders on Bittensor subnets. + +Users sign orders off-chain and submit them to a relayer. The relayer batches +orders targeting the same subnet and submits them via `execute_batched_orders`, +which nets the buy and sell sides, executes a single AMM pool swap for the +residual, and distributes outputs pro-rata to all participants. This minimises +price impact compared to executing each order independently against the pool. + +MEV protection is available for free: any caller can wrap `execute_orders` or +`execute_batched_orders` inside `pallet_shield::submit_encrypted` to hide the +batch contents from the mempool until the block is proposed. + +--- + +## Order lifecycle + +``` +User signs Order off-chain + │ + ▼ +Relayer submits via execute_orders (one-by-one) + or execute_batched_orders (aggregated) + │ + ├─ Invalid / expired / price-not-met → OrderSkipped (no state change) + │ + └─ Valid → executed → OrderExecuted + │ + └─ order_id written to Orders storage + (prevents replay) + +User can cancel at any time via cancel_order + └─ order_id written to Orders as Cancelled +``` + +--- + +## Data structures + +### `Order` + +The payload that a user signs off-chain. Never stored in full on-chain — only +its `blake2_256` hash (`OrderId`) is persisted. + +| Field | Type | Description | +|---------------|-------------|-------------| +| `signer` | `AccountId` | Coldkey that authorises the order. For buys: pays TAO. For sells: owns the staked alpha. | +| `hotkey` | `AccountId` | Hotkey to stake to (buy) or unstake from (sell). | +| `netuid` | `NetUid` | Target subnet. | +| `side` | `OrderSide` | `Buy` or `Sell`. | +| `amount` | `u64` | Input amount in raw units. TAO for buys; alpha for sells. | +| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Buy: maximum acceptable price. Sell: minimum acceptable price. | +| `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | + +### `SignedOrder` + +Envelope submitted by the relayer: the `Order` payload plus the user's +sr25519/ed25519 signature over its SCALE encoding. Signature verification +uses `order.signer` as the expected public key. + +### `OrderStatus` + +Terminal state of a processed order, stored under its `OrderId`. + +| Variant | Meaning | +|-------------|---------| +| `Fulfilled` | Order was successfully executed. | +| `Cancelled` | User registered a cancellation intent before execution. | + +--- + +## Storage + +### `ProtocolFee: StorageValue` + +Protocol fee in parts-per-billion (PPB). + +- `0` = no fee. +- `1_000_000` = 0.1%. +- `1_000_000_000` = 100%. + +For buy orders the fee is deducted from the TAO input before swapping. For sell +orders the fee is deducted from the TAO output after swapping. Both flows result +in the fee being collected in TAO and forwarded to `FeeCollector`. + +Default: `0`. + +### `Orders: StorageMap` + +Maps an `OrderId` (blake2_256 of the SCALE-encoded `Order`) to its terminal +`OrderStatus`. Absence means the order has never been seen and is still +executable (provided it is valid). Presence means it is permanently closed — +neither `Fulfilled` nor `Cancelled` orders can be re-executed. + +--- + +## Config + +| Item | Type | Description | +|-----------------------|---------------------------------------------------|-------------| +| `Signature` | `Verify + ...` | Signature type for off-chain order authorisation. Set to `sp_runtime::MultiSignature` in the subtensor runtime. | +| `SwapInterface` | `OrderSwapInterface` | Full swap + balance execution interface. Implemented by `pallet_subtensor::Pallet`. Provides `buy_alpha`, `sell_alpha`, `transfer_tao`, `transfer_staked_alpha`, and `current_alpha_price`. | +| `TimeProvider` | `UnixTime` | Current wall-clock time for expiry checks. | +| `FeeCollector` | `Get` (constant) | Account that receives all accumulated protocol fees in TAO. | +| `MaxOrdersPerBatch` | `Get` (constant) | Maximum number of orders accepted in a single `execute_orders` or `execute_batched_orders` call. Should equal `floor(max_block_weight / per_order_weight)`. | +| `PalletId` | `Get` (constant) | Used to derive the pallet intermediary account (`PalletId::into_account_truncating`). This account temporarily holds pooled TAO and staked alpha during `execute_batched_orders`. | +| `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated, intentionally unregistered hotkey (not a validator neuron). | + +--- + +## Extrinsics + +### `execute_orders(orders)` — call index 0 + +**Origin:** any signed account (typically a relayer). + +Executes a list of signed limit orders one by one, each interacting with the +AMM pool independently. Orders that fail validation or whose price condition is +not met are silently skipped — a single bad order does not revert the batch. + +**Fee handling:** protocol fee is deducted from each order's input before the +pool swap. + +**When to use:** suitable for small batches or when orders target different +subnets. Use `execute_batched_orders` for same-subnet batches to reduce price +impact. + +--- + +### `execute_batched_orders(netuid, orders)` — call index 4 + +**Origin:** any signed account (typically a relayer). + +Aggregates all valid orders targeting `netuid` into a single net pool +interaction: + +1. **Validate & classify** — orders with wrong netuid, invalid signature, + already-processed id, past expiry, or price condition not met emit + `OrderSkipped` and are dropped. The rest are split into `buys` and `sells`. + +2. **Collect assets** — gross TAO is pulled from each buyer's free balance into + the pallet intermediary account. Gross alpha stake is moved from each seller's + `(coldkey, hotkey)` position to the pallet intermediary's `(pallet_account, + pallet_hotkey)` position. + +3. **Net pool swap** — buy TAO and sell alpha are converted to a common TAO + basis at the current spot price and offset against each other. Only the + residual amount touches the pool in a single swap: + - Buy-dominant: residual TAO is sent to the pool; pool returns alpha. + - Sell-dominant: residual alpha is sent to the pool; pool returns TAO. + - Perfectly offset: no pool interaction. + +4. **Distribute alpha pro-rata** — every buyer receives their share of the total + available alpha (pool output + seller passthrough alpha). Share is + proportional to each buyer's net TAO contribution. Integer division floors + each share; any remainder stays in the pallet intermediary account as dust. + +5. **Distribute TAO pro-rata** — every seller receives their share of the total + available TAO (pool output + buyer passthrough TAO), minus the protocol fee. + Share is proportional to each seller's alpha valued at the current spot price. + Integer division floors each share; any remainder stays in the pallet + intermediary account as dust. + +6. **Collect fees** — total buy-side fees (withheld from TAO input) plus total + sell-side fees (withheld from TAO output) are forwarded in a single transfer + to `FeeCollector`. + +7. **Emit `GroupExecutionSummary`.** + +> **Note:** rounding dust (alpha and TAO) accumulates in the pallet intermediary +> account between batches. If an emission epoch fires while dust is present, the +> pallet earns emissions it never distributes. See the TODO in `collect_fees`. + +--- + +### `cancel_order(order)` — call index 1 + +**Origin:** the order's `signer` (coldkey). + +Registers a cancellation intent by writing the `OrderId` into `Orders` as +`Cancelled`. Once cancelled an order can never be executed. The full `Order` +payload is required so the pallet can derive the `OrderId`. + +--- + +### `set_protocol_fee(fee)` — call index 3 + +**Origin:** root. + +Sets `ProtocolFee` to `fee` (PPB). Emits `ProtocolFeeSet`. + +--- + +## Events + +| Event | Fields | Emitted when | +|-------|--------|--------------| +| `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). | +| `OrderSkipped` | `order_id` | An order was dropped during batch validation (bad signature, expired, wrong netuid, already processed, or price condition not met). | +| `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | +| `ProtocolFeeSet` | `fee` | Root updated the protocol fee. | +| `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | + +--- + +## Errors + +| Error | Cause | +|-------|-------| +| `InvalidSignature` | Signature does not match the order payload and signer. Also used as a catch-all for failed validation in `execute_orders`. | +| `OrderAlreadyProcessed` | The `OrderId` is already present in `Orders` (either `Fulfilled` or `Cancelled`). | +| `OrderExpired` | `now > order.expiry`. | +| `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. | +| `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. | + +--- + +## Fee model + +All fees are collected in TAO regardless of order side. + +| Order side | Fee deducted from | Timing | +|------------|-------------------|--------| +| Buy | TAO input | Before pool swap (`validate_and_classify`) | +| Sell | TAO output | After pool swap (`distribute_tao_pro_rata`) | + +Fee formula: `fee = floor(amount × fee_ppb / 1_000_000_000)`. + +Accumulated fees are forwarded to `FeeCollector` at the end of each batch +execution in a single transfer. diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 5afdf8543f..c9d54df520 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -7,6 +7,7 @@ mod tests; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; +use sp_core::H256; use sp_runtime::traits::{IdentifyAccount, Verify}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; @@ -82,6 +83,21 @@ pub enum OrderStatus { Cancelled, } +/// Classified, fee-adjusted entry produced by `validate_and_classify`. +/// Used in every in-memory batch pipeline step; never stored on-chain. +#[derive(Debug)] +pub(crate) struct OrderEntry { + pub(crate) order_id: H256, + pub(crate) signer: AccountId, + pub(crate) hotkey: AccountId, + /// Gross input amount (before fee). + pub(crate) gross: u64, + /// Net input amount (after fee). + pub(crate) net: u64, + /// Fee amount (TAO for buys; 0 for sells – applied on TAO output). + pub(crate) fee: u64, +} + // ── Pallet ─────────────────────────────────────────────────────────────────── #[frame_support::pallet] @@ -93,7 +109,6 @@ pub mod pallet { traits::{Get, UnixTime}, }; use frame_system::pallet_prelude::*; - use sp_core::H256; use sp_runtime::traits::AccountIdConversion; #[pallet::pallet] @@ -329,7 +344,10 @@ pub mod pallet { let is_root = ensure_root(origin.clone()).is_ok(); if !is_root { let who = ensure_signed(origin)?; - ensure!(Admin::::get().as_ref() == Some(&who), Error::::NotAdmin); + ensure!( + Admin::::get().as_ref() == Some(&who), + Error::::NotAdmin + ); } ProtocolFee::::put(fee); Self::deposit_event(Event::ProtocolFeeSet { fee }); @@ -485,14 +503,9 @@ pub mod pallet { return Ok(()); } - let total_buy_net: u128 = valid_buys.iter().map(|(_, _, _, _, n, _)| *n as u128).sum(); - let total_sell_net: u128 = valid_sells - .iter() - .map(|(_, _, _, _, n, _)| *n as u128) - .sum(); - let total_sell_tao_equiv: u128 = current_price - .saturating_mul(U96F32::from_num(total_sell_net)) - .saturating_to_num::(); + let total_buy_net: u128 = valid_buys.iter().map(|e| e.net as u128).sum(); + let total_sell_net: u128 = valid_sells.iter().map(|e| e.net as u128).sum(); + let total_sell_tao_equiv: u128 = Self::alpha_to_tao(total_sell_net, current_price); let pallet_acct = Self::pallet_account(); let pallet_hotkey = T::PalletHotkey::get(); @@ -575,8 +588,8 @@ pub mod pallet { fee_ppb: u32, current_price: U96F32, ) -> ( - BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, - BoundedVec<(H256, T::AccountId, T::AccountId, u64, u64, u64), T::MaxOrdersPerBatch>, + BoundedVec, T::MaxOrdersPerBatch>, + BoundedVec, T::MaxOrdersPerBatch>, ) { let mut buys = BoundedVec::new(); let mut sells = BoundedVec::new(); @@ -610,14 +623,14 @@ pub mod pallet { Some(( order.side.clone(), - ( + OrderEntry { order_id, - order.signer.clone(), - order.hotkey.clone(), - order.amount, + signer: order.signer.clone(), + hotkey: order.hotkey.clone(), + gross: order.amount, net, fee, - ), + }, )) }) .for_each(|(side, entry)| { @@ -638,29 +651,23 @@ pub mod pallet { /// Pull gross TAO from each buyer and gross staked alpha from each seller /// into the pallet intermediary account, bypassing the pool. fn collect_assets( - buys: &BoundedVec< - (H256, T::AccountId, T::AccountId, u64, u64, u64), - T::MaxOrdersPerBatch, - >, - sells: &BoundedVec< - (H256, T::AccountId, T::AccountId, u64, u64, u64), - T::MaxOrdersPerBatch, - >, + buys: &BoundedVec, T::MaxOrdersPerBatch>, + sells: &BoundedVec, T::MaxOrdersPerBatch>, pallet_acct: &T::AccountId, pallet_hotkey: &T::AccountId, netuid: NetUid, ) -> DispatchResult { - for (_, signer, _, gross, _, _) in buys.iter() { - T::SwapInterface::transfer_tao(signer, pallet_acct, TaoBalance::from(*gross))?; + for e in buys.iter() { + T::SwapInterface::transfer_tao(&e.signer, pallet_acct, TaoBalance::from(e.gross))?; } - for (_, signer, hotkey, gross, _, _) in sells.iter() { + for e in sells.iter() { T::SwapInterface::transfer_staked_alpha( - signer, - hotkey, + &e.signer, + &e.hotkey, pallet_acct, pallet_hotkey, netuid, - AlphaBalance::from(*gross), + AlphaBalance::from(e.gross), )?; } Ok(()) @@ -696,11 +703,7 @@ pub mod pallet { }; Ok((OrderSide::Buy, actual_alpha)) } else { - let total_buy_alpha_equiv: u128 = if current_price > U96F32::from_num(0u32) { - (U96F32::from_num(total_buy_net) / current_price).saturating_to_num::() - } else { - 0u128 - }; + let total_buy_alpha_equiv = Self::tao_to_alpha(total_buy_net, current_price); let net_alpha = (total_sell_net.saturating_sub(total_buy_alpha_equiv)) as u64; let actual_tao = if net_alpha > 0 { let out = T::SwapInterface::sell_alpha( @@ -725,10 +728,7 @@ pub mod pallet { /// - Buy-dominant: total alpha = pool output + sell-side alpha (passed through). /// - Sell-dominant: total alpha = buy-side TAO converted at `current_price`. pub(crate) fn distribute_alpha_pro_rata( - buys: &BoundedVec< - (H256, T::AccountId, T::AccountId, u64, u64, u64), - T::MaxOrdersPerBatch, - >, + buys: &BoundedVec, T::MaxOrdersPerBatch>, actual_out: u128, total_buy_net: u128, total_sell_net: u128, @@ -740,35 +740,28 @@ pub mod pallet { ) -> DispatchResult { let total_alpha: u128 = match net_side { OrderSide::Buy => actual_out.saturating_add(total_sell_net), - OrderSide::Sell => { - if current_price > U96F32::from_num(0u32) { - (U96F32::from_num(total_buy_net) / current_price) - .saturating_to_num::() - } else { - 0u128 - } - } + OrderSide::Sell => Self::tao_to_alpha(total_buy_net, current_price), }; - for (order_id, signer, hotkey, _, net, _) in buys.iter() { + for e in buys.iter() { if total_buy_net > 0 { let share: u64 = - (total_alpha.saturating_mul(*net as u128) / total_buy_net) as u64; + (total_alpha.saturating_mul(e.net as u128) / total_buy_net) as u64; if share > 0 { T::SwapInterface::transfer_staked_alpha( pallet_acct, pallet_hotkey, - signer, - hotkey, + &e.signer, + &e.hotkey, netuid, AlphaBalance::from(share), )?; } } - Orders::::insert(order_id, OrderStatus::Fulfilled); + Orders::::insert(e.order_id, OrderStatus::Fulfilled); Self::deposit_event(Event::OrderExecuted { - order_id: *order_id, - signer: signer.clone(), + order_id: e.order_id, + signer: e.signer.clone(), netuid, side: OrderSide::Buy, }); @@ -784,10 +777,7 @@ pub mod pallet { /// Fee on TAO output: `ppb(share)` is withheld from each seller's payout and /// left in the pallet account. Returns the total sell-side fee TAO accumulated. pub(crate) fn distribute_tao_pro_rata( - sells: &BoundedVec< - (H256, T::AccountId, T::AccountId, u64, u64, u64), - T::MaxOrdersPerBatch, - >, + sells: &BoundedVec, T::MaxOrdersPerBatch>, actual_out: u128, total_buy_net: u128, total_sell_tao_equiv: u128, @@ -804,10 +794,8 @@ pub mod pallet { let mut total_sell_fee_tao: u64 = 0; - for (order_id, signer, _, _, net, _) in sells.iter() { - let sell_tao_equiv: u128 = current_price - .saturating_mul(U96F32::from_num(*net)) - .saturating_to_num::(); + for e in sells.iter() { + 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.saturating_mul(sell_tao_equiv) / total_sell_tao_equiv) as u64 } else { @@ -817,11 +805,15 @@ pub mod pallet { let net_share = gross_share.saturating_sub(fee); total_sell_fee_tao = total_sell_fee_tao.saturating_add(fee); - T::SwapInterface::transfer_tao(pallet_acct, signer, TaoBalance::from(net_share))?; - Orders::::insert(order_id, OrderStatus::Fulfilled); + T::SwapInterface::transfer_tao( + pallet_acct, + &e.signer, + TaoBalance::from(net_share), + )?; + Orders::::insert(e.order_id, OrderStatus::Fulfilled); Self::deposit_event(Event::OrderExecuted { - order_id: *order_id, - signer: signer.clone(), + order_id: e.order_id, + signer: e.signer.clone(), netuid, side: OrderSide::Sell, }); @@ -838,16 +830,13 @@ pub mod pallet { /// /// Both transfers are best-effort and do not revert the batch on failure. pub(crate) fn collect_fees( - buys: &BoundedVec< - (H256, T::AccountId, T::AccountId, u64, u64, u64), - T::MaxOrdersPerBatch, - >, + buys: &BoundedVec, T::MaxOrdersPerBatch>, sell_fee_tao: u64, pallet_acct: &T::AccountId, ) { let fee_collector = T::FeeCollector::get(); - let total_buy_fee: u64 = buys.iter().map(|(_, _, _, _, _, f)| *f).sum(); + let total_buy_fee: u64 = buys.iter().map(|e| e.fee).sum(); let total_fee = total_buy_fee.saturating_add(sell_fee_tao); if total_fee > 0 { T::SwapInterface::transfer_tao( @@ -878,16 +867,28 @@ pub mod pallet { match net_side { OrderSide::Buy => (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64, OrderSide::Sell => { - let buy_alpha_equiv: u64 = if current_price > U96F32::from_num(0u32) { - (U96F32::from_num(total_buy_net) / current_price).saturating_to_num::() - } else { - 0u64 - }; + let buy_alpha_equiv = Self::tao_to_alpha(total_buy_net, current_price) as u64; (total_sell_net as u64).saturating_sub(buy_alpha_equiv) } } } + /// Convert a TAO amount to alpha at `price` (TAO/alpha). + /// Returns 0 when `price` is zero. + fn tao_to_alpha(tao: u128, price: U96F32) -> u128 { + if price == U96F32::from_num(0u32) { + return 0u128; + } + (U96F32::from_num(tao) / price).saturating_to_num::() + } + + /// Convert an alpha amount to TAO at `price` (TAO/alpha). + fn alpha_to_tao(alpha: u128, price: U96F32) -> u128 { + price + .saturating_mul(U96F32::from_num(alpha)) + .saturating_to_num::() + } + pub(crate) fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { let result = (amount.to_u64() as u128) .saturating_mul(ppb as u128) diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 596b4240c1..89460a5a1c 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -3,72 +3,16 @@ //! Extrinsics are NOT tested here. Each section focuses on one helper. use frame_support::{BoundedVec, traits::ConstU32}; -use sp_core::{H256, Pair}; +use sp_core::H256; use sp_keyring::Sr25519Keyring as AccountKeyring; -use sp_runtime::{AccountId32, MultiSignature}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use crate::pallet::Pallet as LimitOrders; -use crate::{Order, OrderSide, OrderStatus, Orders, SignedOrder, pallet::ProtocolFee}; +use crate::{OrderEntry, OrderSide, OrderStatus, Orders, pallet::ProtocolFee}; use super::mock::*; -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn alice() -> AccountId32 { - AccountKeyring::Alice.to_account_id() -} - -fn bob() -> AccountId32 { - AccountKeyring::Bob.to_account_id() -} - -fn charlie() -> AccountId32 { - AccountKeyring::Charlie.to_account_id() -} - -fn netuid_1() -> NetUid { - NetUid::from(1u16) -} - -/// Create a `SignedOrder` signed by the given `AccountKeyring` key. -fn make_signed_order( - keyring: AccountKeyring, - hotkey: AccountId32, - netuid: NetUid, - side: OrderSide, - amount: u64, - limit_price: u64, - expiry: u64, -) -> SignedOrder { - let signer = keyring.to_account_id(); - let order = Order { - signer, - hotkey, - netuid, - side, - amount, - limit_price, - expiry, - }; - use codec::Encode; - let msg = order.encode(); - let sig = keyring.pair().sign(&msg); - SignedOrder { - order, - signature: MultiSignature::Sr25519(sig), - } -} - -fn bounded_orders( - v: Vec>, -) -> BoundedVec, ConstU32<64>> { - BoundedVec::try_from(v).unwrap() -} - // ───────────────────────────────────────────────────────────────────────────── // ppb_of_tao / ppb_of_alpha // ───────────────────────────────────────────────────────────────────────────── @@ -192,7 +136,7 @@ fn validate_and_classify_separates_buys_and_sells() { let buy_order = make_signed_order( AccountKeyring::Alice, bob(), - netuid_1(), + netuid(), OrderSide::Buy, 1_000u64, // amount in TAO 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) @@ -201,16 +145,16 @@ fn validate_and_classify_separates_buys_and_sells() { let sell_order = make_signed_order( AccountKeyring::Bob, alice(), - netuid_1(), + netuid(), OrderSide::Sell, 500u64, // amount in alpha 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) 2_000_000u64, ); - let orders = bounded_orders(vec![buy_order, sell_order]); + let orders = bounded(vec![buy_order, sell_order]); let (buys, sells) = LimitOrders::::validate_and_classify( - netuid_1(), + netuid(), &orders, 1_000_000u64, 0u32, @@ -221,19 +165,19 @@ fn validate_and_classify_separates_buys_and_sells() { assert_eq!(sells.len(), 1, "expected 1 valid sell"); // Buy entry: gross=1000, net=1000 (0% fee), fee=0 - let (_, signer, _, gross, net, fee) = &buys[0]; - assert_eq!(signer, &alice()); - assert_eq!(*gross, 1_000u64); - assert_eq!(*net, 1_000u64); - assert_eq!(*fee, 0u64); + let buy = &buys[0]; + assert_eq!(buy.signer, alice()); + assert_eq!(buy.gross, 1_000u64); + assert_eq!(buy.net, 1_000u64); + assert_eq!(buy.fee, 0u64); // Sell entry: gross=500, net=500, fee=0 (fee deferred to distribution) - let (_, signer, _, gross, net, fee) = &sells[0]; - assert_eq!(signer, &bob()); - assert_eq!(*gross, 500u64); - assert_eq!(*net, 500u64); + let sell = &sells[0]; + assert_eq!(sell.signer, bob()); + assert_eq!(sell.gross, 500u64); + assert_eq!(sell.net, 500u64); assert_eq!( - *fee, 0u64, + sell.fee, 0u64, "sell fee is always 0 here — applied on TAO output" ); }); @@ -256,9 +200,9 @@ fn validate_and_classify_skips_wrong_netuid() { 2_000_000u64, ); - let orders = bounded_orders(vec![wrong_netuid_order]); + let orders = bounded(vec![wrong_netuid_order]); let (buys, sells) = LimitOrders::::validate_and_classify( - netuid_1(), // batch is for netuid 1 + netuid(), // batch is for netuid 1 &orders, 1_000_000u64, 0u32, @@ -281,16 +225,16 @@ fn validate_and_classify_skips_expired_order() { let expired = make_signed_order( AccountKeyring::Alice, bob(), - netuid_1(), + netuid(), OrderSide::Buy, 1_000u64, 2_000_000u64, 2_000_000u64, // expiry already past ); - let orders = bounded_orders(vec![expired]); + let orders = bounded(vec![expired]); let (buys, sells) = LimitOrders::::validate_and_classify( - netuid_1(), + netuid(), &orders, 2_000_001u64, 0u32, @@ -310,16 +254,16 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { let order = make_signed_order( AccountKeyring::Alice, bob(), - netuid_1(), + netuid(), OrderSide::Buy, 1_000u64, 2u64, // limit_price = 2 TAO/alpha 2_000_000u64, ); - let orders = bounded_orders(vec![order]); + let orders = bounded(vec![order]); let (buys, _) = LimitOrders::::validate_and_classify( - netuid_1(), + netuid(), &orders, 1_000_000u64, 0u32, @@ -337,7 +281,7 @@ fn validate_and_classify_skips_already_processed_order() { let order = make_signed_order( AccountKeyring::Alice, bob(), - netuid_1(), + netuid(), OrderSide::Buy, 1_000u64, 2_000_000u64, @@ -345,13 +289,12 @@ fn validate_and_classify_skips_already_processed_order() { ); // Pre-mark as fulfilled on-chain. - use codec::Encode; - let order_id = H256(sp_core::hashing::blake2_256(&order.order.encode())); - Orders::::insert(order_id, OrderStatus::Fulfilled); + let oid = LimitOrders::::derive_order_id(&order.order); + Orders::::insert(oid, OrderStatus::Fulfilled); - let orders = bounded_orders(vec![order]); + let orders = bounded(vec![order]); let (buys, _) = LimitOrders::::validate_and_classify( - netuid_1(), + netuid(), &orders, 1_000_000u64, 0u32, @@ -373,16 +316,16 @@ fn validate_and_classify_applies_buy_fee_to_net() { let order = make_signed_order( AccountKeyring::Alice, bob(), - netuid_1(), + netuid(), OrderSide::Buy, 1_000_000_000u64, u64::MAX, // limit price: accept any price 2_000_000u64, ); - let orders = bounded_orders(vec![order]); + let orders = bounded(vec![order]); let (buys, _) = LimitOrders::::validate_and_classify( - netuid_1(), + netuid(), &orders, 1_000_000u64, 1_000_000u32, @@ -390,10 +333,10 @@ fn validate_and_classify_applies_buy_fee_to_net() { ); assert_eq!(buys.len(), 1); - let (_, _, _, gross, net, fee) = &buys[0]; - assert_eq!(*gross, 1_000_000_000u64); - assert_eq!(*fee, 1_000_000u64); - assert_eq!(*net, 999_000_000u64); + let entry = &buys[0]; + assert_eq!(entry.gross, 1_000_000_000u64); + assert_eq!(entry.fee, 1_000_000u64); + assert_eq!(entry.net, 999_000_000u64); }); } @@ -465,24 +408,31 @@ fn validate_and_classify_applies_buy_fee_to_net() { fn make_buy_entry( order_id: H256, - signer: AccountId32, - hotkey: AccountId32, + signer: AccountId, + hotkey: AccountId, gross: u64, net: u64, fee: u64, -) -> (H256, AccountId32, AccountId32, u64, u64, u64) { - (order_id, signer, hotkey, gross, net, fee) +) -> OrderEntry { + OrderEntry { + order_id, + signer, + hotkey, + gross, + net, + fee, + } } fn bounded_buy_entries( - v: Vec<(H256, AccountId32, AccountId32, u64, u64, u64)>, -) -> BoundedVec<(H256, AccountId32, AccountId32, u64, u64, u64), ConstU32<64>> { + v: Vec>, +) -> BoundedVec, ConstU32<64>> { BoundedVec::try_from(v).unwrap() } fn bounded_sell_entries( - v: Vec<(H256, AccountId32, AccountId32, u64, u64, u64)>, -) -> BoundedVec<(H256, AccountId32, AccountId32, u64, u64, u64), ConstU32<64>> { + v: Vec>, +) -> BoundedVec, ConstU32<64>> { BoundedVec::try_from(v).unwrap() } @@ -511,7 +461,7 @@ fn distribute_alpha_pro_rata_buy_dominant_scenario_a() { U96F32::from_num(1u32), &pallet_acct, &pallet_hk, - netuid_1(), + netuid(), ) .unwrap(); @@ -566,7 +516,7 @@ fn distribute_alpha_pro_rata_sell_dominant_scenario_b() { U96F32::from_num(2u32), // price = 2 TAO/alpha &pallet_acct, &pallet_hk, - netuid_1(), + netuid(), ) .unwrap(); @@ -622,7 +572,7 @@ fn distribute_alpha_pro_rata_buy_dominant_scenario_c() { U96F32::from_num(1u32), &pallet_acct, &pallet_hk, - netuid_1(), + netuid(), ) .unwrap(); @@ -669,7 +619,7 @@ fn distribute_alpha_pro_rata_dust_remains_in_pallet_scenario_d() { // Seed the pallet account with the 10 alpha it would hold after collect_assets // and the pool swap (actual_out=10, no sellers). - MockSwap::set_alpha_balance(pallet_acct.clone(), pallet_hk.clone(), netuid_1(), 10); + MockSwap::set_alpha_balance(pallet_acct.clone(), pallet_hk.clone(), netuid(), 10); let entries = bounded_buy_entries(vec![ make_buy_entry(H256::repeat_byte(9), alice(), hotkey.clone(), 1, 1, 0), @@ -686,7 +636,7 @@ fn distribute_alpha_pro_rata_dust_remains_in_pallet_scenario_d() { U96F32::from_num(1u32), &pallet_acct, &pallet_hk, - netuid_1(), + netuid(), ) .unwrap(); @@ -715,7 +665,7 @@ fn distribute_alpha_pro_rata_dust_remains_in_pallet_scenario_d() { // The pallet account started with 10 and sent out 9 — 1 alpha dust remains // in the pallet account, not burnt, not distributed. - let pallet_remaining = MockSwap::alpha_balance(&pallet_acct, &pallet_hk, netuid_1()); + let pallet_remaining = MockSwap::alpha_balance(&pallet_acct, &pallet_hk, netuid()); assert_eq!( pallet_remaining, 1u64, "1 alpha dust stays in pallet account, not burnt" @@ -807,7 +757,7 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee_scenario_a() { U96F32::from_num(2u32), 0u32, // fee_ppb = 0 &pallet_acct, - netuid_1(), + netuid(), ) .unwrap(); @@ -849,7 +799,7 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee_scenario_b() { U96F32::from_num(2u32), 10_000_000u32, // 1% fee &pallet_acct, - netuid_1(), + netuid(), ) .unwrap(); @@ -891,7 +841,7 @@ fn distribute_tao_pro_rata_buy_dominant_scenario_c() { U96F32::from_num(2u32), 0u32, &pallet_acct, - netuid_1(), + netuid(), ) .unwrap(); @@ -938,7 +888,7 @@ fn distribute_tao_pro_rata_dust_remains_in_pallet_scenario_d() { U96F32::from_num(1u32), 0u32, // fee_ppb = 0 &pallet_acct, - netuid_1(), + netuid(), ) .unwrap(); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index e73888aae1..ae4080b7e9 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -4,10 +4,9 @@ //! and event emission are all verified. SwapInterface calls are handled by //! `MockSwap`, which records calls and maintains in-memory balance ledgers. -use frame_support::{assert_noop, assert_ok, BoundedVec}; -use sp_core::{H256, Pair}; +use frame_support::{assert_noop, assert_ok}; use sp_keyring::Sr25519Keyring as AccountKeyring; -use sp_runtime::{DispatchError, MultiSignature}; +use sp_runtime::DispatchError; use subtensor_runtime_common::NetUid; use crate::{ @@ -19,50 +18,6 @@ type LimitOrders = crate::pallet::Pallet; use super::mock::*; -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn alice() -> AccountId { - AccountKeyring::Alice.to_account_id() -} -fn bob() -> AccountId { - AccountKeyring::Bob.to_account_id() -} -fn charlie() -> AccountId { - AccountKeyring::Charlie.to_account_id() -} -fn dave() -> AccountId { - AccountKeyring::Dave.to_account_id() -} - -fn netuid() -> NetUid { - NetUid::from(1u16) -} - -fn make_signed_order( - keyring: AccountKeyring, - hotkey: AccountId, - netuid: NetUid, - side: OrderSide, - amount: u64, - limit_price: u64, - expiry: u64, -) -> crate::SignedOrder { - use codec::Encode; - let signer = keyring.to_account_id(); - let order = Order { signer, hotkey, netuid, side, amount, limit_price, expiry }; - let sig = keyring.pair().sign(&order.encode()); - crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig) } -} - -fn bounded( - v: Vec>, -) -> BoundedVec, frame_support::traits::ConstU32<64>> -{ - BoundedVec::try_from(v).unwrap() -} - /// Check that a specific pallet event was emitted. fn assert_event(event: Event) { assert!( @@ -73,13 +28,6 @@ fn assert_event(event: Event) { ); } -fn order_id(order: &Order) -> H256 { - use codec::Encode; - H256(sp_core::hashing::blake2_256(&order.encode())) -} - -const FAR_FUTURE: u64 = u64::MAX; - // ───────────────────────────────────────────────────────────────────────────── // set_admin // ───────────────────────────────────────────────────────────────────────────── @@ -89,7 +37,9 @@ fn set_admin_root_can_set_admin() { new_test_ext().execute_with(|| { assert_ok!(LimitOrders::set_admin(RuntimeOrigin::root(), Some(alice()))); assert_eq!(Admin::::get(), Some(alice())); - assert_event(Event::AdminSet { new_admin: Some(alice()) }); + assert_event(Event::AdminSet { + new_admin: Some(alice()), + }); }); } @@ -130,7 +80,10 @@ fn set_admin_unsigned_origin_rejected() { #[test] fn set_protocol_fee_root_can_set() { new_test_ext().execute_with(|| { - assert_ok!(LimitOrders::set_protocol_fee(RuntimeOrigin::root(), 1_000_000)); + assert_ok!(LimitOrders::set_protocol_fee( + RuntimeOrigin::root(), + 1_000_000 + )); assert_eq!(ProtocolFee::::get(), 1_000_000); assert_event(Event::ProtocolFeeSet { fee: 1_000_000 }); }); @@ -140,7 +93,10 @@ fn set_protocol_fee_root_can_set() { fn set_protocol_fee_admin_can_set() { new_test_ext().execute_with(|| { Admin::::put(alice()); - assert_ok!(LimitOrders::set_protocol_fee(RuntimeOrigin::signed(alice()), 500_000)); + assert_ok!(LimitOrders::set_protocol_fee( + RuntimeOrigin::signed(alice()), + 500_000 + )); assert_eq!(ProtocolFee::::get(), 500_000); assert_event(Event::ProtocolFeeSet { fee: 500_000 }); }); @@ -197,9 +153,15 @@ fn cancel_order_signer_can_cancel() { }; let id = order_id(&order); - assert_ok!(LimitOrders::cancel_order(RuntimeOrigin::signed(alice()), order)); + assert_ok!(LimitOrders::cancel_order( + RuntimeOrigin::signed(alice()), + order + )); assert_eq!(Orders::::get(id), Some(OrderStatus::Cancelled)); - assert_event(Event::OrderCancelled { order_id: id, signer: alice() }); + assert_event(Event::OrderCancelled { + order_id: id, + signer: alice(), + }); }); } @@ -297,12 +259,20 @@ fn execute_orders_buy_order_fulfilled() { MockSwap::set_price(1.0); // Price = 1.0 ≤ limit = 2.0 → condition met. let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, 2_000_000_000, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + 2_000_000_000, + FAR_FUTURE, ); let id = order_id(&signed.order); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); assert_event(Event::OrderExecuted { @@ -321,12 +291,20 @@ fn execute_orders_sell_order_fulfilled() { MockSwap::set_price(2.0); // Price = 2.0 ≥ limit = 1 → condition met. let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, 500, 1, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Sell, + 500, + 1, + FAR_FUTURE, ); let id = order_id(&signed.order); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); assert_event(Event::OrderExecuted { @@ -344,12 +322,20 @@ fn execute_orders_expired_order_skipped() { MockTime::set(2_000_001); // now > expiry MockSwap::set_price(1.0); let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, 2_000_000, // expiry in the past + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + 2_000_000, // expiry in the past ); let id = order_id(&signed.order); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); // Skipped — storage untouched. assert!(Orders::::get(id).is_none()); @@ -362,12 +348,20 @@ fn execute_orders_price_not_met_skipped() { MockTime::set(1_000_000); MockSwap::set_price(5.0); // price 5.0 > limit 2 → buy condition not met let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, 2, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + 2, + FAR_FUTURE, ); let id = order_id(&signed.order); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); assert!(Orders::::get(id).is_none()); }); @@ -379,14 +373,22 @@ fn execute_orders_already_processed_skipped() { MockTime::set(1_000_000); MockSwap::set_price(1.0); let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Fulfilled); // Should succeed (batch-level) but skip this order silently. - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); // Still Fulfilled (not changed). assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); }); @@ -399,12 +401,22 @@ fn execute_orders_mixed_batch_valid_and_skipped() { MockSwap::set_price(1.0); let valid = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); let expired = make_signed_order( - AccountKeyring::Bob, alice(), netuid(), - OrderSide::Buy, 500, u64::MAX, 500_000, // already expired + AccountKeyring::Bob, + alice(), + netuid(), + OrderSide::Buy, + 500, + u64::MAX, + 500_000, // already expired ); let valid_id = order_id(&valid.order); @@ -435,15 +447,30 @@ fn execute_orders_buy_with_fee_charges_fee() { ProtocolFee::::put(10_000_000u32); // 1% let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); MockSwap::set_tao_balance(alice(), 1_000); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); // One buy_alpha call for the net amount (990 TAO after 1% fee). - let buys: Vec<_> = MockSwap::log().into_iter() - .filter_map(|c| if let super::mock::SwapCall::BuyAlpha { tao, .. } = c { Some(tao) } else { None }) + let buys: Vec<_> = MockSwap::log() + .into_iter() + .filter_map(|c| { + if let super::mock::SwapCall::BuyAlpha { tao, .. } = c { + Some(tao) + } else { + None + } + }) .collect(); assert_eq!(buys, vec![990], "main swap must use 990 TAO after 1% fee"); @@ -466,14 +493,29 @@ fn execute_orders_sell_with_fee_charges_fee() { ProtocolFee::::put(10_000_000u32); // 1% let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, 1_000, 0, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Sell, + 1_000, + 0, + FAR_FUTURE, ); - assert_ok!(LimitOrders::execute_orders(RuntimeOrigin::signed(charlie()), bounded(vec![signed]))); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); // Full 1_000 alpha sold (no alpha deducted for fee). - let sells: Vec<_> = MockSwap::log().into_iter() - .filter_map(|c| if let super::mock::SwapCall::SellAlpha { alpha, .. } = c { Some(alpha) } else { None }) + let sells: Vec<_> = MockSwap::log() + .into_iter() + .filter_map(|c| { + if let super::mock::SwapCall::SellAlpha { alpha, .. } = c { + Some(alpha) + } else { + None + } + }) .collect(); assert_eq!(sells, vec![1_000], "full alpha amount must be sold"); @@ -503,8 +545,13 @@ fn execute_batched_orders_all_invalid_returns_ok() { new_test_ext().execute_with(|| { MockTime::set(2_000_001); // all expired let expired = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, 1_000_000, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + 1_000_000, ); // Returns Ok even when nothing executes. assert_ok!(LimitOrders::execute_batched_orders( @@ -514,7 +561,10 @@ fn execute_batched_orders_all_invalid_returns_ok() { )); // No summary event — early return when executed_count == 0. let has_summary = System::events().iter().any(|r| { - matches!(&r.event, RuntimeEvent::LimitOrders(Event::GroupExecutionSummary { .. })) + matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::GroupExecutionSummary { .. }) + ) }); assert!(!has_summary); }); @@ -528,8 +578,13 @@ fn execute_batched_orders_skips_wrong_netuid() { MockSwap::set_buy_alpha_return(100); let wrong_net = make_signed_order( - AccountKeyring::Alice, bob(), NetUid::from(99u16), // wrong netuid - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + NetUid::from(99u16), // wrong netuid + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); let id = order_id(&wrong_net.order); @@ -539,7 +594,10 @@ fn execute_batched_orders_skips_wrong_netuid() { bounded(vec![wrong_net]), )); - assert!(Orders::::get(id).is_none(), "wrong-netuid order must not be fulfilled"); + assert!( + Orders::::get(id).is_none(), + "wrong-netuid order must not be fulfilled" + ); }); } @@ -558,12 +616,22 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { MockSwap::set_tao_balance(bob(), 400); let alice_order = make_signed_order( - AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, 600, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Buy, + 600, + u64::MAX, + FAR_FUTURE, ); let bob_order = make_signed_order( - AccountKeyring::Bob, dave(), netuid(), - OrderSide::Buy, 400, u64::MAX, FAR_FUTURE, + AccountKeyring::Bob, + dave(), + netuid(), + OrderSide::Buy, + 400, + u64::MAX, + FAR_FUTURE, ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -609,12 +677,22 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); let alice_order = make_signed_order( - AccountKeyring::Alice, dave(), netuid(), - OrderSide::Sell, 300, 0, FAR_FUTURE, // limit=0 → accept any price + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Sell, + 300, + 0, + FAR_FUTURE, // limit=0 → accept any price ); let bob_order = make_signed_order( - AccountKeyring::Bob, dave(), netuid(), - OrderSide::Sell, 200, 0, FAR_FUTURE, + AccountKeyring::Bob, + dave(), + netuid(), + OrderSide::Sell, + 200, + 0, + FAR_FUTURE, ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -665,16 +743,31 @@ fn execute_batched_orders_buy_dominant_mixed() { MockSwap::set_alpha_balance(charlie(), dave(), netuid(), 200); let alice_buy = make_signed_order( - AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); let bob_buy = make_signed_order( - AccountKeyring::Bob, dave(), netuid(), - OrderSide::Buy, 600, u64::MAX, FAR_FUTURE, + AccountKeyring::Bob, + dave(), + netuid(), + OrderSide::Buy, + 600, + u64::MAX, + FAR_FUTURE, ); let charlie_sell = make_signed_order( - AccountKeyring::Charlie, dave(), netuid(), - OrderSide::Sell, 200, 0, FAR_FUTURE, + AccountKeyring::Charlie, + dave(), + netuid(), + OrderSide::Sell, + 200, + 0, + FAR_FUTURE, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -720,16 +813,31 @@ fn execute_batched_orders_sell_dominant_mixed() { MockSwap::set_alpha_balance(charlie(), dave(), netuid(), 200); let alice_buy = make_signed_order( - AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, 200, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Buy, + 200, + u64::MAX, + FAR_FUTURE, ); let bob_sell = make_signed_order( - AccountKeyring::Bob, dave(), netuid(), - OrderSide::Sell, 300, 0, FAR_FUTURE, + AccountKeyring::Bob, + dave(), + netuid(), + OrderSide::Sell, + 300, + 0, + FAR_FUTURE, ); let charlie_sell = make_signed_order( - AccountKeyring::Charlie, dave(), netuid(), - OrderSide::Sell, 200, 0, FAR_FUTURE, + AccountKeyring::Charlie, + dave(), + netuid(), + OrderSide::Sell, + 200, + 0, + FAR_FUTURE, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -765,8 +873,13 @@ fn execute_batched_orders_fee_forwarded_to_collector() { ProtocolFee::::put(10_000_000u32); let alice_buy = make_signed_order( - AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -788,8 +901,13 @@ fn execute_batched_orders_cancelled_order_skipped() { MockSwap::set_buy_alpha_return(100); let signed = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Cancelled); @@ -819,8 +937,13 @@ fn execute_batched_orders_buy_zero_alpha_returns_error() { MockSwap::set_tao_balance(alice(), 1_000); let order = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, 1_000, u64::MAX, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, ); assert_noop!( @@ -844,8 +967,13 @@ fn execute_batched_orders_sell_zero_tao_returns_error() { MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); let order = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, 1_000, 0, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Sell, + 1_000, + 0, + FAR_FUTURE, ); assert_noop!( @@ -869,8 +997,13 @@ fn execute_batched_orders_sell_alpha_respects_swap_fail() { MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); let order = make_signed_order( - AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, 1_000, 0, FAR_FUTURE, + AccountKeyring::Alice, + bob(), + netuid(), + OrderSide::Sell, + 1_000, + 0, + FAR_FUTURE, ); assert_noop!( diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index d1cb01ed4f..997e7e98e4 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -6,12 +6,14 @@ use std::cell::RefCell; use std::collections::HashMap; +use codec::Encode; use frame_support::{ - PalletId, construct_runtime, derive_impl, parameter_types, + BoundedVec, PalletId, construct_runtime, derive_impl, parameter_types, traits::{ConstU32, Everything}, }; use frame_system as system; -use sp_core::H256; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{ AccountId32, BuildStorage, MultiSignature, traits::{BlakeTwo256, IdentityLookup}, @@ -203,7 +205,9 @@ impl OrderSwapInterface for MockSwap { _limit_price: TaoBalance, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { - return Err(frame_support::pallet_prelude::DispatchError::Other("pool error")); + return Err(frame_support::pallet_prelude::DispatchError::Other( + "pool error", + )); } let tao = tao_amount.to_u64(); let alpha_out = MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow()); @@ -215,7 +219,9 @@ impl OrderSwapInterface for MockSwap { }); ALPHA_BALANCES.with(|b| { let mut map = b.borrow_mut(); - let bal = map.entry((coldkey.clone(), hotkey.clone(), netuid)).or_insert(0); + let bal = map + .entry((coldkey.clone(), hotkey.clone(), netuid)) + .or_insert(0); *bal = bal.saturating_add(alpha_out); }); SWAP_LOG.with(|l| { @@ -237,14 +243,18 @@ impl OrderSwapInterface for MockSwap { _limit_price: TaoBalance, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { - return Err(frame_support::pallet_prelude::DispatchError::Other("pool error")); + return Err(frame_support::pallet_prelude::DispatchError::Other( + "pool error", + )); } let alpha = alpha_amount.to_u64(); let tao_out = MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()); // Debit alpha from (coldkey, hotkey, netuid), credit TAO to coldkey. ALPHA_BALANCES.with(|b| { let mut map = b.borrow_mut(); - let bal = map.entry((coldkey.clone(), hotkey.clone(), netuid)).or_insert(0); + let bal = map + .entry((coldkey.clone(), hotkey.clone(), netuid)) + .or_insert(0); *bal = bal.saturating_sub(alpha); }); TAO_BALANCES.with(|b| { @@ -363,6 +373,62 @@ impl pallet_limit_orders::Config for Test { type PalletHotkey = PalletHotkeyAccount; } +// ── Shared test helpers ─────────────────────────────────────────────────────── + +pub fn alice() -> AccountId { + AccountKeyring::Alice.to_account_id() +} +pub fn bob() -> AccountId { + AccountKeyring::Bob.to_account_id() +} +pub fn charlie() -> AccountId { + AccountKeyring::Charlie.to_account_id() +} +pub fn dave() -> AccountId { + AccountKeyring::Dave.to_account_id() +} +pub fn netuid() -> NetUid { + NetUid::from(1u16) +} + +pub const FAR_FUTURE: u64 = u64::MAX; + +pub fn make_signed_order( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: NetUid, + side: crate::OrderSide, + amount: u64, + limit_price: u64, + expiry: u64, +) -> crate::SignedOrder { + let signer = keyring.to_account_id(); + let order = crate::Order { + signer, + hotkey, + netuid, + side, + amount, + limit_price, + expiry, + }; + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + } +} + +pub fn bounded( + v: Vec>, +) -> BoundedVec, ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +pub fn order_id(order: &crate::Order) -> H256 { + crate::pallet::Pallet::::derive_order_id(order) +} + // ── Test externalities ──────────────────────────────────────────────────────── pub fn new_test_ext() -> sp_io::TestExternalities { diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs index 1a32805c45..9cc3736c43 100644 --- a/pallets/limit-orders/src/tests/mod.rs +++ b/pallets/limit-orders/src/tests/mod.rs @@ -1,3 +1,3 @@ +pub mod auxiliary; pub mod extrinsics; pub mod mock; -pub mod auxiliary; From 3f853a9c116270d2198f2d624da468fcf70a9ec2 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 25 Mar 2026 10:09:04 +0100 Subject: [PATCH 09/85] add an additional test checking we get fees from both sides --- pallets/limit-orders/src/tests/extrinsics.rs | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index ae4080b7e9..fad4e6c985 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -923,6 +923,59 @@ fn execute_batched_orders_cancelled_order_skipped() { }); } +#[test] +fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { + new_test_ext().execute_with(|| { + // fee = 1% (10_000_000 ppb), price = 1.0 TAO/alpha. + // + // Alice buys 1_000 TAO → buy fee = 10 TAO, net = 990 TAO. + // Bob sells 1_000 alpha → sell_tao_equiv = 1_000 TAO. + // + // sell-dominant: residual = 1_000 - 990 = 10 alpha sent to pool. + // Pool returns 9 TAO (mocked) for that residual. + // total_tao for sellers = 9 (pool) + 990 (buy passthrough) = 999. + // Bob gross_share = 999 * 1_000/1_000 = 999. + // Sell fee = 1% of 999 = 9 TAO; Bob nets 990 TAO. + // FeeCollector total = buy_fee(10) + sell_fee(9) = 19 TAO. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_sell_tao_return(9); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 1_000); + ProtocolFee::::put(10_000_000u32); // 1% + + let alice_buy = make_signed_order( + AccountKeyring::Alice, + dave(), + netuid(), + OrderSide::Buy, + 1_000, + u64::MAX, + FAR_FUTURE, + ); + let bob_sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderSide::Sell, + 1_000, + 0, + FAR_FUTURE, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_buy, bob_sell]), + )); + + // Both sides charged: FeeCollector gets buy fee (10) + sell fee (9) = 19. + assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 19); + // Bob receives 990 TAO after sell-side fee. + assert_eq!(MockSwap::tao_balance(&bob()), 990); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // net_pool_swap – SwapReturnedZero errors // ───────────────────────────────────────────────────────────────────────────── From 14077626029ab5ec8ecc74958b6f23428af1fde1 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 30 Mar 2026 10:17:01 +0200 Subject: [PATCH 10/85] Add all order types --- pallets/limit-orders/README.md | 28 ++- pallets/limit-orders/src/lib.rs | 202 +++++++++++-------- pallets/limit-orders/src/tests/auxiliary.rs | 17 +- pallets/limit-orders/src/tests/extrinsics.rs | 194 ++++++++++++++---- pallets/limit-orders/src/tests/mock.rs | 2 +- pallets/subtensor/src/staking/order_swap.rs | 57 ++++++ 6 files changed, 362 insertions(+), 138 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 99205fbcb2..c56a7a7bd4 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -86,7 +86,15 @@ in the fee being collected in TAO and forwarded to `FeeCollector`. Default: `0`. -### `Orders: StorageMap` +### `Admin: StorageValue>` + +The privileged account that may call `set_protocol_fee` alongside root. +`None` means no admin is set; only root can change the fee. +Set by root via `set_admin`. + +Default: absent (`None`). + +### `OrderStatus: StorageMap` Maps an `OrderId` (blake2_256 of the SCALE-encoded `Order`) to its terminal `OrderStatus`. Absence means the order has never been seen and is still @@ -105,7 +113,7 @@ neither `Fulfilled` nor `Cancelled` orders can be re-executed. | `FeeCollector` | `Get` (constant) | Account that receives all accumulated protocol fees in TAO. | | `MaxOrdersPerBatch` | `Get` (constant) | Maximum number of orders accepted in a single `execute_orders` or `execute_batched_orders` call. Should equal `floor(max_block_weight / per_order_weight)`. | | `PalletId` | `Get` (constant) | Used to derive the pallet intermediary account (`PalletId::into_account_truncating`). This account temporarily holds pooled TAO and staked alpha during `execute_batched_orders`. | -| `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated, intentionally unregistered hotkey (not a validator neuron). | +| `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated hotkey registered on every subnet the pallet may operate on. Operators should register it as a non-validator neuron. | --- @@ -186,12 +194,21 @@ payload is required so the pallet can derive the `OrderId`. ### `set_protocol_fee(fee)` — call index 3 -**Origin:** root. +**Origin:** root or the current admin account (see `set_admin`). Sets `ProtocolFee` to `fee` (PPB). Emits `ProtocolFeeSet`. --- +### `set_admin(new_admin)` — call index 5 + +**Origin:** root. + +Sets or clears the privileged admin account stored in `Admin`. Pass `None` to +remove the admin, leaving only root able to change the fee. Emits `AdminSet`. + +--- + ## Events | Event | Fields | Emitted when | @@ -199,7 +216,8 @@ Sets `ProtocolFee` to `fee` (PPB). Emits `ProtocolFeeSet`. | `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). | | `OrderSkipped` | `order_id` | An order was dropped during batch validation (bad signature, expired, wrong netuid, already processed, or price condition not met). | | `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | -| `ProtocolFeeSet` | `fee` | Root updated the protocol fee. | +| `ProtocolFeeSet` | `fee` | Root or admin updated the protocol fee. | +| `AdminSet` | `new_admin` | Root updated the admin account (`None` means admin was removed). | | `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | --- @@ -213,6 +231,8 @@ Sets `ProtocolFee` to `fee` (PPB). Emits `ProtocolFeeSet`. | `OrderExpired` | `now > order.expiry`. | | `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. | | `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. | +| `NotAdmin` | Caller of `set_protocol_fee` is neither root nor the current admin. | +| `SwapReturnedZero` | The pool swap returned zero output for a non-zero residual input. | --- diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index c9d54df520..2602022176 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -15,6 +15,8 @@ use subtensor_swap_interface::OrderSwapInterface; // ── Data structures ────────────────────────────────────────────────────────── +/// Internal direction of a net pool trade. Used only for `GroupExecutionSummary` +/// and pool-swap bookkeeping; not part of the public order payload. #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -23,6 +25,32 @@ pub enum OrderSide { Sell, } +/// The user-facing order type. Each variant encodes both the execution action +/// (buy alpha / sell alpha) and the price-trigger direction. +/// +/// | Variant | Action | Triggers when | +/// |--------------|--------|---------------------| +/// | `BuyLimit` | Buy | price ≤ limit_price | +/// | `BuyStop` | Buy | price ≥ limit_price | +/// | `TakeProfit` | Sell | price ≥ limit_price | +/// | `StopLoss` | Sell | price ≤ limit_price | +#[derive( + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, +)] +pub enum OrderType { + BuyLimit, + BuyStop, + TakeProfit, + StopLoss, +} + +impl OrderType { + /// `true` if this order results in buying alpha (staking into subnet). + pub fn is_buy(&self) -> bool { + matches!(self, OrderType::BuyLimit | OrderType::BuyStop) + } +} + /// The canonical order payload that users sign off-chain. /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). @@ -37,8 +65,8 @@ pub struct Order pub hotkey: AccountId, /// Target subnet. pub netuid: NetUid, - /// Buy or Sell. - pub side: OrderSide, + /// Order type (BuyLimit, BuyStop, TakeProfit, or StopLoss). + pub side: OrderType, /// Input amount: TAO (raw) for Buy, alpha (raw) for Sell. pub amount: u64, /// Price threshold in TAO/alpha (raw units, same scale as @@ -90,6 +118,7 @@ pub(crate) struct OrderEntry { pub(crate) order_id: H256, pub(crate) signer: AccountId, pub(crate) hotkey: AccountId, + pub(crate) side: OrderType, /// Gross input amount (before fee). pub(crate) gross: u64, /// Net input amount (after fee). @@ -193,7 +222,11 @@ pub mod pallet { order_id: H256, signer: T::AccountId, netuid: NetUid, - side: OrderSide, + side: OrderType, + /// Input amount: TAO (raw) for Buy orders, alpha (raw) for Sell orders. + amount_in: u64, + /// Output amount: alpha (raw) received for Buy orders, TAO (raw) received for Sell orders (after fee). + amount_out: u64, }, /// An order was skipped during batch execution (invalid signature, /// expired, already processed, wrong netuid, or price not met). @@ -399,12 +432,12 @@ pub mod pallet { && Orders::::get(order_id).is_none() && now_ms <= order.expiry && match order.side { - OrderSide::Buy => { - current_price <= U96F32::saturating_from_num(order.limit_price) - } - OrderSide::Sell => { + OrderType::TakeProfit | OrderType::BuyStop => { current_price >= U96F32::saturating_from_num(order.limit_price) } + OrderType::StopLoss | OrderType::BuyLimit => { + current_price <= U96F32::saturating_from_num(order.limit_price) + } } } @@ -425,53 +458,44 @@ pub mod pallet { // 5. Execute the swap, taking protocol fee from the input. let fee_ppb = ProtocolFee::::get(); - match order.side { - OrderSide::Buy => { - let tao_in = TaoBalance::from(order.amount); - // Deduct protocol fee from TAO input before swapping. - let fee_tao = Self::ppb_of_tao(tao_in, fee_ppb); - let tao_after_fee = tao_in.saturating_sub(fee_tao); - - T::SwapInterface::buy_alpha( - &order.signer, - &order.hotkey, - order.netuid, - tao_after_fee, - TaoBalance::from(order.limit_price), - )?; + let (amount_in, amount_out) = if order.side.is_buy() { + let tao_in = TaoBalance::from(order.amount); + // Deduct protocol fee from TAO input before swapping. + let fee_tao = Self::ppb_of_tao(tao_in, fee_ppb); + let tao_after_fee = tao_in.saturating_sub(fee_tao); + + let alpha_out = T::SwapInterface::buy_alpha( + &order.signer, + &order.hotkey, + order.netuid, + tao_after_fee, + TaoBalance::from(order.limit_price), + )?; - // Forward the fee TAO directly to FeeCollector. - if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao( - &order.signer, - &T::FeeCollector::get(), - fee_tao, - ) + // Forward the fee TAO directly to FeeCollector. + if !fee_tao.is_zero() { + T::SwapInterface::transfer_tao(&order.signer, &T::FeeCollector::get(), fee_tao) .ok(); - } } - OrderSide::Sell => { - // Sell the full alpha amount; fee is taken from the TAO output. - let tao_out = T::SwapInterface::sell_alpha( - &order.signer, - &order.hotkey, - order.netuid, - AlphaBalance::from(order.amount), - TaoBalance::from(order.limit_price), - )?; + (order.amount, alpha_out.to_u64()) + } else { + // Sell the full alpha amount; fee is taken from the TAO output. + let tao_out = T::SwapInterface::sell_alpha( + &order.signer, + &order.hotkey, + order.netuid, + AlphaBalance::from(order.amount), + TaoBalance::from(order.limit_price), + )?; - // Deduct protocol fee from TAO output and forward to FeeCollector. - let fee_tao = Self::ppb_of_tao(tao_out, fee_ppb); - if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao( - &order.signer, - &T::FeeCollector::get(), - fee_tao, - ) + // Deduct protocol fee from TAO output and forward to FeeCollector. + let fee_tao = Self::ppb_of_tao(tao_out, fee_ppb); + if !fee_tao.is_zero() { + T::SwapInterface::transfer_tao(&order.signer, &T::FeeCollector::get(), fee_tao) .ok(); - } } - } + (order.amount, tao_out.saturating_sub(fee_tao).to_u64()) + }; // 6. Mark as fulfilled and emit event. Orders::::insert(order_id, OrderStatus::Fulfilled); @@ -480,6 +504,8 @@ pub mod pallet { signer: order.signer.clone(), netuid: order.netuid, side: order.side.clone(), + amount_in, + amount_out, }); Ok(()) @@ -608,40 +634,33 @@ pub mod pallet { return None; } - let (net, fee) = match order.side { + let (net, fee) = if order.side.is_buy() { // Buy: fee on TAO input — buyer contributes less TAO to the pool. - OrderSide::Buy => { - let f = - Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb).to_u64(); - (order.amount.saturating_sub(f), f) - } + let f = Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb).to_u64(); + (order.amount.saturating_sub(f), f) + } else { // Sell: fee on TAO output — seller contributes full alpha; the fee // is deducted from their TAO payout in `distribute_tao_pro_rata`. // No alpha is withheld here, so fee is recorded as 0 in the entry. - OrderSide::Sell => (order.amount, 0u64), + (order.amount, 0u64) }; - Some(( - order.side.clone(), - OrderEntry { - order_id, - signer: order.signer.clone(), - hotkey: order.hotkey.clone(), - gross: order.amount, - net, - fee, - }, - )) + Some(OrderEntry { + order_id, + signer: order.signer.clone(), + hotkey: order.hotkey.clone(), + side: order.side.clone(), + gross: order.amount, + net, + fee, + }) }) - .for_each(|(side, entry)| { + .for_each(|entry| { // try_push cannot fail: both vecs share the same bound as `orders`. - match side { - OrderSide::Buy => { - let _ = buys.try_push(entry); - } - OrderSide::Sell => { - let _ = sells.try_push(entry); - } + if entry.side.is_buy() { + let _ = buys.try_push(entry); + } else { + let _ = sells.try_push(entry); } }); @@ -744,26 +763,29 @@ pub mod pallet { }; for e in buys.iter() { - if total_buy_net > 0 { - let share: u64 = - (total_alpha.saturating_mul(e.net as u128) / total_buy_net) as u64; - if share > 0 { - T::SwapInterface::transfer_staked_alpha( - pallet_acct, - pallet_hotkey, - &e.signer, - &e.hotkey, - netuid, - AlphaBalance::from(share), - )?; - } + let share: u64 = if total_buy_net > 0 { + (total_alpha.saturating_mul(e.net as u128) / total_buy_net) as u64 + } else { + 0 + }; + if share > 0 { + T::SwapInterface::transfer_staked_alpha( + pallet_acct, + pallet_hotkey, + &e.signer, + &e.hotkey, + netuid, + AlphaBalance::from(share), + )?; } Orders::::insert(e.order_id, OrderStatus::Fulfilled); Self::deposit_event(Event::OrderExecuted { order_id: e.order_id, signer: e.signer.clone(), netuid, - side: OrderSide::Buy, + side: e.side.clone(), + amount_in: e.gross, + amount_out: share, }); } Ok(()) @@ -815,7 +837,9 @@ pub mod pallet { order_id: e.order_id, signer: e.signer.clone(), netuid, - side: OrderSide::Sell, + side: e.side.clone(), + amount_in: e.gross, + amount_out: net_share, }); } Ok(total_sell_fee_tao) diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 89460a5a1c..0e85b15b84 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -9,7 +9,7 @@ use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use crate::pallet::Pallet as LimitOrders; -use crate::{OrderEntry, OrderSide, OrderStatus, Orders, pallet::ProtocolFee}; +use crate::{OrderEntry, OrderSide, OrderStatus, OrderType, Orders, pallet::ProtocolFee}; use super::mock::*; @@ -137,7 +137,7 @@ fn validate_and_classify_separates_buys_and_sells() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000u64, // amount in TAO 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) 2_000_000u64, // expiry ms @@ -146,7 +146,7 @@ fn validate_and_classify_separates_buys_and_sells() { AccountKeyring::Bob, alice(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 500u64, // amount in alpha 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) 2_000_000u64, @@ -194,7 +194,7 @@ fn validate_and_classify_skips_wrong_netuid() { AccountKeyring::Alice, bob(), NetUid::from(99u16), // different netuid - OrderSide::Buy, + OrderType::BuyLimit, 1_000u64, 2_000_000u64, 2_000_000u64, @@ -226,7 +226,7 @@ fn validate_and_classify_skips_expired_order() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000u64, 2_000_000u64, 2_000_000u64, // expiry already past @@ -255,7 +255,7 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000u64, 2u64, // limit_price = 2 TAO/alpha 2_000_000u64, @@ -282,7 +282,7 @@ fn validate_and_classify_skips_already_processed_order() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000u64, 2_000_000u64, 2_000_000u64, @@ -317,7 +317,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000_000_000u64, u64::MAX, // limit price: accept any price 2_000_000u64, @@ -418,6 +418,7 @@ fn make_buy_entry( order_id, signer, hotkey, + side: OrderType::BuyLimit, gross, net, fee, diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index fad4e6c985..1382e96f91 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -10,7 +10,7 @@ use sp_runtime::DispatchError; use subtensor_runtime_common::NetUid; use crate::{ - Admin, Error, Order, OrderSide, OrderStatus, Orders, + Admin, Error, Order, OrderSide, OrderStatus, OrderType, Orders, pallet::{Event, ProtocolFee}, }; @@ -146,7 +146,7 @@ fn cancel_order_signer_can_cancel() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -172,7 +172,7 @@ fn cancel_order_non_signer_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -192,7 +192,7 @@ fn cancel_order_already_cancelled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -214,7 +214,7 @@ fn cancel_order_already_fulfilled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -236,7 +236,7 @@ fn cancel_order_unsigned_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -262,7 +262,7 @@ fn execute_orders_buy_order_fulfilled() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, 2_000_000_000, FAR_FUTURE, @@ -279,7 +279,9 @@ fn execute_orders_buy_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderSide::Buy, + side: OrderType::BuyLimit, + amount_in: 1_000, + amount_out: 0, }); }); } @@ -294,7 +296,7 @@ fn execute_orders_sell_order_fulfilled() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 500, 1, FAR_FUTURE, @@ -311,11 +313,131 @@ fn execute_orders_sell_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderSide::Sell, + side: OrderType::TakeProfit, + amount_in: 500, + amount_out: 0, }); }); } +#[test] +fn execute_orders_buy_stop_order_fulfilled() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(3.0); + // Price = 3.0 ≥ limit = 2.0 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::BuyStop, + 1_000, + 2, // raw limit_price = 2 TAO/alpha + FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + side: OrderType::BuyStop, + amount_in: 1_000, + amount_out: 0, + }); + }); +} + +#[test] +fn execute_orders_buy_stop_price_not_met_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); // price 1.0 < limit 2.0 → buy stop condition not met + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::BuyStop, + 1_000, + 2, // raw limit_price = 2 TAO/alpha + FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert!(Orders::::get(id).is_none()); + }); +} + +#[test] +fn execute_orders_stop_loss_order_fulfilled() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(0.5); + // Price = 0.5 ≤ limit = 1.0 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::StopLoss, + 500, + 1, // raw limit_price = 1 TAO/alpha + FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + side: OrderType::StopLoss, + amount_in: 500, + amount_out: 0, + }); + }); +} + +#[test] +fn execute_orders_stop_loss_price_not_met_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(2.0); // price 2.0 > limit 1.0 → stop loss condition not met + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::StopLoss, + 500, + 1, // raw limit_price = 1 TAO/alpha + FAR_FUTURE, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert!(Orders::::get(id).is_none()); + }); +} + #[test] fn execute_orders_expired_order_skipped() { new_test_ext().execute_with(|| { @@ -325,7 +447,7 @@ fn execute_orders_expired_order_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, 2_000_000, // expiry in the past @@ -351,7 +473,7 @@ fn execute_orders_price_not_met_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, 2, FAR_FUTURE, @@ -376,7 +498,7 @@ fn execute_orders_already_processed_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -404,7 +526,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -413,7 +535,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { AccountKeyring::Bob, alice(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 500, u64::MAX, 500_000, // already expired @@ -450,7 +572,7 @@ fn execute_orders_buy_with_fee_charges_fee() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -496,7 +618,7 @@ fn execute_orders_sell_with_fee_charges_fee() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 1_000, 0, FAR_FUTURE, @@ -548,7 +670,7 @@ fn execute_batched_orders_all_invalid_returns_ok() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, 1_000_000, @@ -581,7 +703,7 @@ fn execute_batched_orders_skips_wrong_netuid() { AccountKeyring::Alice, bob(), NetUid::from(99u16), // wrong netuid - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -619,7 +741,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 600, u64::MAX, FAR_FUTURE, @@ -628,7 +750,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { AccountKeyring::Bob, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 400, u64::MAX, FAR_FUTURE, @@ -680,7 +802,7 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 300, 0, FAR_FUTURE, // limit=0 → accept any price @@ -689,7 +811,7 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { AccountKeyring::Bob, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 200, 0, FAR_FUTURE, @@ -746,7 +868,7 @@ fn execute_batched_orders_buy_dominant_mixed() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -755,7 +877,7 @@ fn execute_batched_orders_buy_dominant_mixed() { AccountKeyring::Bob, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 600, u64::MAX, FAR_FUTURE, @@ -764,7 +886,7 @@ fn execute_batched_orders_buy_dominant_mixed() { AccountKeyring::Charlie, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 200, 0, FAR_FUTURE, @@ -816,7 +938,7 @@ fn execute_batched_orders_sell_dominant_mixed() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 200, u64::MAX, FAR_FUTURE, @@ -825,7 +947,7 @@ fn execute_batched_orders_sell_dominant_mixed() { AccountKeyring::Bob, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 300, 0, FAR_FUTURE, @@ -834,7 +956,7 @@ fn execute_batched_orders_sell_dominant_mixed() { AccountKeyring::Charlie, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 200, 0, FAR_FUTURE, @@ -876,7 +998,7 @@ fn execute_batched_orders_fee_forwarded_to_collector() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -904,7 +1026,7 @@ fn execute_batched_orders_cancelled_order_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -948,7 +1070,7 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { AccountKeyring::Alice, dave(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -957,7 +1079,7 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { AccountKeyring::Bob, dave(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 1_000, 0, FAR_FUTURE, @@ -993,7 +1115,7 @@ fn execute_batched_orders_buy_zero_alpha_returns_error() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Buy, + OrderType::BuyLimit, 1_000, u64::MAX, FAR_FUTURE, @@ -1023,7 +1145,7 @@ fn execute_batched_orders_sell_zero_tao_returns_error() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 1_000, 0, FAR_FUTURE, @@ -1053,7 +1175,7 @@ fn execute_batched_orders_sell_alpha_respects_swap_fail() { AccountKeyring::Alice, bob(), netuid(), - OrderSide::Sell, + OrderType::TakeProfit, 1_000, 0, FAR_FUTURE, diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 997e7e98e4..aad16bcaec 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -397,7 +397,7 @@ pub fn make_signed_order( keyring: AccountKeyring, hotkey: AccountId, netuid: NetUid, - side: crate::OrderSide, + side: crate::OrderType, amount: u64, limit_price: u64, expiry: u64, diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 336d900df1..8e53e3fe25 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -1,4 +1,6 @@ use super::*; +use frame_support::traits::fungible::Mutate; +use frame_support::traits::tokens::Preservation; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; @@ -35,4 +37,59 @@ impl OrderSwapInterface for Pallet { fn current_alpha_price(netuid: NetUid) -> U96F32 { T::SwapInterface::current_alpha_price(netuid) } + + fn transfer_tao(from: &T::AccountId, to: &T::AccountId, amount: TaoBalance) -> DispatchResult { + ::Currency::transfer(from, to, amount, Preservation::Expendable)?; + Ok(()) + } + + fn transfer_staked_alpha( + from_coldkey: &T::AccountId, + from_hotkey: &T::AccountId, + to_coldkey: &T::AccountId, + to_hotkey: &T::AccountId, + netuid: NetUid, + amount: AlphaBalance, + intermediate_account: Option, + ) -> DispatchResult { + // Why not `transfer_stake_within_subnet`? + // + // 1. Silent no-op on insufficient balance — `decrease_stake_for_hotkey_and_coldkey_on_subnet` + // returns `()` without error when the coldkey has less stake than requested. Without the + // explicit `ensure!` below, the decrease would silently fail while the increase still + // runs, creating alpha out of thin air on the destination. + // + // 2. `AmountTooLow` minimum-stake check — `transfer_stake_within_subnet` rejects transfers + // whose TAO equivalent is below `DefaultMinStake`. Small pro-rata shares distributed to + // buyers in `distribute_alpha_pro_rata` are legitimate but can fall below that threshold, + // which would abort the entire batch. + // + // 3. Rate-limit (`StakingOperationRateLimitExceeded`) — `validate_stake_transition` (called + // via `do_transfer_stake`) checks `StakingOperationRateLimiter` on the origin account. + // The pallet intermediary account would be rate-limited after the first transfer per block. + // + // `LastColdkeyHotkeyStakeBlock` is updated for the destination after the transfer, + // consistent with `transfer_stake_within_subnet`. It is a write-only observability item + // (never read on-chain) but keeping it up-to-date is cheap and keeps off-chain indexers + // accurate. + + let available = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(from_hotkey, from_coldkey, netuid); + ensure!(available >= amount, Error::::NotEnoughStakeToWithdraw); + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet( + from_hotkey, + from_coldkey, + netuid, + amount, + ); + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + to_hotkey, to_coldkey, netuid, amount, + ); + LastColdkeyHotkeyStakeBlock::::insert( + to_coldkey, + to_hotkey, + Self::get_current_block_as_u64(), + ); + Ok(()) + } } From 1c2f173c91ad8710d87148fdc4809c2baaeddcd6 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 30 Mar 2026 10:22:32 +0200 Subject: [PATCH 11/85] rename order.side to order_type --- pallets/limit-orders/README.md | 30 +++++++++++++------- pallets/limit-orders/src/lib.rs | 18 ++++++------ pallets/limit-orders/src/tests/extrinsics.rs | 18 ++++++------ pallets/limit-orders/src/tests/mock.rs | 4 +-- 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index c56a7a7bd4..39ae34fddb 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -45,14 +45,23 @@ its `blake2_256` hash (`OrderId`) is persisted. | Field | Type | Description | |---------------|-------------|-------------| -| `signer` | `AccountId` | Coldkey that authorises the order. For buys: pays TAO. For sells: owns the staked alpha. | -| `hotkey` | `AccountId` | Hotkey to stake to (buy) or unstake from (sell). | +| `signer` | `AccountId` | Coldkey that authorises the order. For buy types: pays TAO. For sell types: owns the staked alpha. | +| `hotkey` | `AccountId` | Hotkey to stake to (buy types) or unstake from (sell types). | | `netuid` | `NetUid` | Target subnet. | -| `side` | `OrderSide` | `Buy` or `Sell`. | -| `amount` | `u64` | Input amount in raw units. TAO for buys; alpha for sells. | -| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Buy: maximum acceptable price. Sell: minimum acceptable price. | +| `order_type` | `OrderType` | One of `BuyLimit`, `BuyStop`, `TakeProfit`, or `StopLoss` (see table below). | +| `amount` | `u64` | Input amount in raw units. TAO for buy types; alpha for sell types. | +| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Trigger direction depends on `OrderType` (see table below). | | `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | +### `OrderType` + +| Variant | Action | Triggers when | Use case | +|--------------|---------------|-------------------------|----------| +| `BuyLimit` | Buy alpha | price ≤ `limit_price` | Enter a position at or below a target price. | +| `BuyStop` | Buy alpha | price ≥ `limit_price` | Enter a position once price breaks above a level (momentum / breakout). | +| `TakeProfit` | Sell alpha | price ≥ `limit_price` | Exit a position once price rises to a profit target. | +| `StopLoss` | Sell alpha | price ≤ `limit_price` | Exit a position to limit downside if price falls to a floor. | + ### `SignedOrder` Envelope submitted by the relayer: the `Order` payload plus the user's @@ -145,7 +154,8 @@ interaction: 1. **Validate & classify** — orders with wrong netuid, invalid signature, already-processed id, past expiry, or price condition not met emit - `OrderSkipped` and are dropped. The rest are split into `buys` and `sells`. + `OrderSkipped` and are dropped. The rest are split into buy-side + (`BuyLimit`, `BuyStop`) and sell-side (`TakeProfit`, `StopLoss`) groups. 2. **Collect assets** — gross TAO is pulled from each buyer's free balance into the pallet intermediary account. Gross alpha stake is moved from each seller's @@ -240,10 +250,10 @@ remove the admin, leaving only root able to change the fee. Emits `AdminSet`. All fees are collected in TAO regardless of order side. -| Order side | Fee deducted from | Timing | -|------------|-------------------|--------| -| Buy | TAO input | Before pool swap (`validate_and_classify`) | -| Sell | TAO output | After pool swap (`distribute_tao_pro_rata`) | +| Order type | Fee deducted from | Timing | +|-------------------------|-------------------|--------| +| `BuyLimit`, `BuyStop` | TAO input | Before pool swap (`validate_and_classify`) | +| `TakeProfit`, `StopLoss`| TAO output | After pool swap (`distribute_tao_pro_rata`) | Fee formula: `fee = floor(amount × fee_ppb / 1_000_000_000)`. diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 2602022176..14894d04fb 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -66,7 +66,7 @@ pub struct Order /// Target subnet. pub netuid: NetUid, /// Order type (BuyLimit, BuyStop, TakeProfit, or StopLoss). - pub side: OrderType, + pub order_type: OrderType, /// Input amount: TAO (raw) for Buy, alpha (raw) for Sell. pub amount: u64, /// Price threshold in TAO/alpha (raw units, same scale as @@ -222,7 +222,7 @@ pub mod pallet { order_id: H256, signer: T::AccountId, netuid: NetUid, - side: OrderType, + order_type: OrderType, /// Input amount: TAO (raw) for Buy orders, alpha (raw) for Sell orders. amount_in: u64, /// Output amount: alpha (raw) received for Buy orders, TAO (raw) received for Sell orders (after fee). @@ -431,7 +431,7 @@ pub mod pallet { .verify(order.encode().as_slice(), &order.signer) && Orders::::get(order_id).is_none() && now_ms <= order.expiry - && match order.side { + && match order.order_type { OrderType::TakeProfit | OrderType::BuyStop => { current_price >= U96F32::saturating_from_num(order.limit_price) } @@ -458,7 +458,7 @@ pub mod pallet { // 5. Execute the swap, taking protocol fee from the input. let fee_ppb = ProtocolFee::::get(); - let (amount_in, amount_out) = if order.side.is_buy() { + let (amount_in, amount_out) = if order.order_type.is_buy() { let tao_in = TaoBalance::from(order.amount); // Deduct protocol fee from TAO input before swapping. let fee_tao = Self::ppb_of_tao(tao_in, fee_ppb); @@ -503,7 +503,7 @@ pub mod pallet { order_id, signer: order.signer.clone(), netuid: order.netuid, - side: order.side.clone(), + order_type: order.order_type.clone(), amount_in, amount_out, }); @@ -634,7 +634,7 @@ pub mod pallet { return None; } - let (net, fee) = if order.side.is_buy() { + let (net, fee) = if order.order_type.is_buy() { // Buy: fee on TAO input — buyer contributes less TAO to the pool. let f = Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb).to_u64(); (order.amount.saturating_sub(f), f) @@ -649,7 +649,7 @@ pub mod pallet { order_id, signer: order.signer.clone(), hotkey: order.hotkey.clone(), - side: order.side.clone(), + side: order.order_type.clone(), gross: order.amount, net, fee, @@ -783,7 +783,7 @@ pub mod pallet { order_id: e.order_id, signer: e.signer.clone(), netuid, - side: e.side.clone(), + order_type: e.side.clone(), amount_in: e.gross, amount_out: share, }); @@ -837,7 +837,7 @@ pub mod pallet { order_id: e.order_id, signer: e.signer.clone(), netuid, - side: e.side.clone(), + order_type: e.side.clone(), amount_in: e.gross, amount_out: net_share, }); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 1382e96f91..724cfc8175 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -146,7 +146,7 @@ fn cancel_order_signer_can_cancel() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -172,7 +172,7 @@ fn cancel_order_non_signer_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -192,7 +192,7 @@ fn cancel_order_already_cancelled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -214,7 +214,7 @@ fn cancel_order_already_fulfilled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -236,7 +236,7 @@ fn cancel_order_unsigned_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -279,7 +279,7 @@ fn execute_orders_buy_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderType::BuyLimit, + order_type: OrderType::BuyLimit, amount_in: 1_000, amount_out: 0, }); @@ -313,7 +313,7 @@ fn execute_orders_sell_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderType::TakeProfit, + order_type: OrderType::TakeProfit, amount_in: 500, amount_out: 0, }); @@ -347,7 +347,7 @@ fn execute_orders_buy_stop_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderType::BuyStop, + order_type: OrderType::BuyStop, amount_in: 1_000, amount_out: 0, }); @@ -406,7 +406,7 @@ fn execute_orders_stop_loss_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - side: OrderType::StopLoss, + order_type: OrderType::StopLoss, amount_in: 500, amount_out: 0, }); diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index aad16bcaec..8ded8105c5 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -397,7 +397,7 @@ pub fn make_signed_order( keyring: AccountKeyring, hotkey: AccountId, netuid: NetUid, - side: crate::OrderType, + order_type: crate::OrderType, amount: u64, limit_price: u64, expiry: u64, @@ -407,7 +407,7 @@ pub fn make_signed_order( signer, hotkey, netuid, - side, + order_type, amount, limit_price, expiry, From bf2917084bf56a4c7e89fa647a3e234cde630849 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 30 Mar 2026 13:07:50 +0200 Subject: [PATCH 12/85] remove order-limits --- pallets/limit-orders/README.md | 9 +- pallets/limit-orders/src/lib.rs | 14 ++- pallets/limit-orders/src/tests/auxiliary.rs | 14 +-- pallets/limit-orders/src/tests/extrinsics.rs | 107 +++++-------------- 4 files changed, 41 insertions(+), 103 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 39ae34fddb..c921227d75 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -48,7 +48,7 @@ its `blake2_256` hash (`OrderId`) is persisted. | `signer` | `AccountId` | Coldkey that authorises the order. For buy types: pays TAO. For sell types: owns the staked alpha. | | `hotkey` | `AccountId` | Hotkey to stake to (buy types) or unstake from (sell types). | | `netuid` | `NetUid` | Target subnet. | -| `order_type` | `OrderType` | One of `BuyLimit`, `BuyStop`, `TakeProfit`, or `StopLoss` (see table below). | +| `order_type` | `OrderType` | One of `LimitBuy`, `TakeProfit`, or `StopLoss` (see table below). | | `amount` | `u64` | Input amount in raw units. TAO for buy types; alpha for sell types. | | `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Trigger direction depends on `OrderType` (see table below). | | `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | @@ -57,8 +57,7 @@ its `blake2_256` hash (`OrderId`) is persisted. | Variant | Action | Triggers when | Use case | |--------------|---------------|-------------------------|----------| -| `BuyLimit` | Buy alpha | price ≤ `limit_price` | Enter a position at or below a target price. | -| `BuyStop` | Buy alpha | price ≥ `limit_price` | Enter a position once price breaks above a level (momentum / breakout). | +| `LimitBuy` | Buy alpha | price ≤ `limit_price` | Enter a position at or below a target price. | | `TakeProfit` | Sell alpha | price ≥ `limit_price` | Exit a position once price rises to a profit target. | | `StopLoss` | Sell alpha | price ≤ `limit_price` | Exit a position to limit downside if price falls to a floor. | @@ -155,7 +154,7 @@ interaction: 1. **Validate & classify** — orders with wrong netuid, invalid signature, already-processed id, past expiry, or price condition not met emit `OrderSkipped` and are dropped. The rest are split into buy-side - (`BuyLimit`, `BuyStop`) and sell-side (`TakeProfit`, `StopLoss`) groups. + (`LimitBuy`) and sell-side (`TakeProfit`, `StopLoss`) groups. 2. **Collect assets** — gross TAO is pulled from each buyer's free balance into the pallet intermediary account. Gross alpha stake is moved from each seller's @@ -252,7 +251,7 @@ All fees are collected in TAO regardless of order side. | Order type | Fee deducted from | Timing | |-------------------------|-------------------|--------| -| `BuyLimit`, `BuyStop` | TAO input | Before pool swap (`validate_and_classify`) | +| `LimitBuy` | TAO input | Before pool swap (`validate_and_classify`) | | `TakeProfit`, `StopLoss`| TAO output | After pool swap (`distribute_tao_pro_rata`) | Fee formula: `fee = floor(amount × fee_ppb / 1_000_000_000)`. diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 14894d04fb..8f8f28efdc 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -30,16 +30,14 @@ pub enum OrderSide { /// /// | Variant | Action | Triggers when | /// |--------------|--------|---------------------| -/// | `BuyLimit` | Buy | price ≤ limit_price | -/// | `BuyStop` | Buy | price ≥ limit_price | +/// | `LimitBuy` | Buy | price ≤ limit_price | /// | `TakeProfit` | Sell | price ≥ limit_price | /// | `StopLoss` | Sell | price ≤ limit_price | #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub enum OrderType { - BuyLimit, - BuyStop, + LimitBuy, TakeProfit, StopLoss, } @@ -47,7 +45,7 @@ pub enum OrderType { impl OrderType { /// `true` if this order results in buying alpha (staking into subnet). pub fn is_buy(&self) -> bool { - matches!(self, OrderType::BuyLimit | OrderType::BuyStop) + matches!(self, OrderType::LimitBuy) } } @@ -65,7 +63,7 @@ pub struct Order pub hotkey: AccountId, /// Target subnet. pub netuid: NetUid, - /// Order type (BuyLimit, BuyStop, TakeProfit, or StopLoss). + /// Order type (LimitBuy, TakeProfit, or StopLoss). pub order_type: OrderType, /// Input amount: TAO (raw) for Buy, alpha (raw) for Sell. pub amount: u64, @@ -432,10 +430,10 @@ pub mod pallet { && Orders::::get(order_id).is_none() && now_ms <= order.expiry && match order.order_type { - OrderType::TakeProfit | OrderType::BuyStop => { + OrderType::TakeProfit => { current_price >= U96F32::saturating_from_num(order.limit_price) } - OrderType::StopLoss | OrderType::BuyLimit => { + OrderType::StopLoss | OrderType::LimitBuy => { current_price <= U96F32::saturating_from_num(order.limit_price) } } diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 0e85b15b84..cda650174f 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -137,7 +137,7 @@ fn validate_and_classify_separates_buys_and_sells() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000u64, // amount in TAO 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) 2_000_000u64, // expiry ms @@ -194,7 +194,7 @@ fn validate_and_classify_skips_wrong_netuid() { AccountKeyring::Alice, bob(), NetUid::from(99u16), // different netuid - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000u64, 2_000_000u64, 2_000_000u64, @@ -226,7 +226,7 @@ fn validate_and_classify_skips_expired_order() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000u64, 2_000_000u64, 2_000_000u64, // expiry already past @@ -255,7 +255,7 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000u64, 2u64, // limit_price = 2 TAO/alpha 2_000_000u64, @@ -282,7 +282,7 @@ fn validate_and_classify_skips_already_processed_order() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000u64, 2_000_000u64, 2_000_000u64, @@ -317,7 +317,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000_000_000u64, u64::MAX, // limit price: accept any price 2_000_000u64, @@ -418,7 +418,7 @@ fn make_buy_entry( order_id, signer, hotkey, - side: OrderType::BuyLimit, + side: OrderType::LimitBuy, gross, net, fee, diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 724cfc8175..6e324f3b94 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -146,7 +146,7 @@ fn cancel_order_signer_can_cancel() { signer: alice(), hotkey: bob(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -172,7 +172,7 @@ fn cancel_order_non_signer_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -192,7 +192,7 @@ fn cancel_order_already_cancelled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -214,7 +214,7 @@ fn cancel_order_already_fulfilled_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -236,7 +236,7 @@ fn cancel_order_unsigned_rejected() { signer: alice(), hotkey: bob(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, @@ -262,7 +262,7 @@ fn execute_orders_buy_order_fulfilled() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, 2_000_000_000, FAR_FUTURE, @@ -279,7 +279,7 @@ fn execute_orders_buy_order_fulfilled() { order_id: id, signer: alice(), netuid: netuid(), - order_type: OrderType::BuyLimit, + order_type: OrderType::LimitBuy, amount_in: 1_000, amount_out: 0, }); @@ -320,65 +320,6 @@ fn execute_orders_sell_order_fulfilled() { }); } -#[test] -fn execute_orders_buy_stop_order_fulfilled() { - new_test_ext().execute_with(|| { - MockTime::set(1_000_000); - MockSwap::set_price(3.0); - // Price = 3.0 ≥ limit = 2.0 → condition met. - let signed = make_signed_order( - AccountKeyring::Alice, - bob(), - netuid(), - OrderType::BuyStop, - 1_000, - 2, // raw limit_price = 2 TAO/alpha - FAR_FUTURE, - ); - let id = order_id(&signed.order); - - assert_ok!(LimitOrders::execute_orders( - RuntimeOrigin::signed(charlie()), - bounded(vec![signed]) - )); - - assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); - assert_event(Event::OrderExecuted { - order_id: id, - signer: alice(), - netuid: netuid(), - order_type: OrderType::BuyStop, - amount_in: 1_000, - amount_out: 0, - }); - }); -} - -#[test] -fn execute_orders_buy_stop_price_not_met_skipped() { - new_test_ext().execute_with(|| { - MockTime::set(1_000_000); - MockSwap::set_price(1.0); // price 1.0 < limit 2.0 → buy stop condition not met - let signed = make_signed_order( - AccountKeyring::Alice, - bob(), - netuid(), - OrderType::BuyStop, - 1_000, - 2, // raw limit_price = 2 TAO/alpha - FAR_FUTURE, - ); - let id = order_id(&signed.order); - - assert_ok!(LimitOrders::execute_orders( - RuntimeOrigin::signed(charlie()), - bounded(vec![signed]) - )); - - assert!(Orders::::get(id).is_none()); - }); -} - #[test] fn execute_orders_stop_loss_order_fulfilled() { new_test_ext().execute_with(|| { @@ -447,7 +388,7 @@ fn execute_orders_expired_order_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, 2_000_000, // expiry in the past @@ -473,7 +414,7 @@ fn execute_orders_price_not_met_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, 2, FAR_FUTURE, @@ -498,7 +439,7 @@ fn execute_orders_already_processed_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -526,7 +467,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -535,7 +476,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { AccountKeyring::Bob, alice(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 500, u64::MAX, 500_000, // already expired @@ -572,7 +513,7 @@ fn execute_orders_buy_with_fee_charges_fee() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -670,7 +611,7 @@ fn execute_batched_orders_all_invalid_returns_ok() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, 1_000_000, @@ -703,7 +644,7 @@ fn execute_batched_orders_skips_wrong_netuid() { AccountKeyring::Alice, bob(), NetUid::from(99u16), // wrong netuid - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -741,7 +682,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { AccountKeyring::Alice, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 600, u64::MAX, FAR_FUTURE, @@ -750,7 +691,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { AccountKeyring::Bob, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 400, u64::MAX, FAR_FUTURE, @@ -868,7 +809,7 @@ fn execute_batched_orders_buy_dominant_mixed() { AccountKeyring::Alice, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -877,7 +818,7 @@ fn execute_batched_orders_buy_dominant_mixed() { AccountKeyring::Bob, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 600, u64::MAX, FAR_FUTURE, @@ -938,7 +879,7 @@ fn execute_batched_orders_sell_dominant_mixed() { AccountKeyring::Alice, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 200, u64::MAX, FAR_FUTURE, @@ -998,7 +939,7 @@ fn execute_batched_orders_fee_forwarded_to_collector() { AccountKeyring::Alice, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -1026,7 +967,7 @@ fn execute_batched_orders_cancelled_order_skipped() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -1070,7 +1011,7 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { AccountKeyring::Alice, dave(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, @@ -1115,7 +1056,7 @@ fn execute_batched_orders_buy_zero_alpha_returns_error() { AccountKeyring::Alice, bob(), netuid(), - OrderType::BuyLimit, + OrderType::LimitBuy, 1_000, u64::MAX, FAR_FUTURE, From 88bfa69f564171b86da209cd6c51da423c2fee14 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 30 Mar 2026 13:10:34 +0200 Subject: [PATCH 13/85] order swap remove --- pallets/subtensor/src/staking/order_swap.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 8e53e3fe25..ef7c582ad2 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -50,7 +50,6 @@ impl OrderSwapInterface for Pallet { to_hotkey: &T::AccountId, netuid: NetUid, amount: AlphaBalance, - intermediate_account: Option, ) -> DispatchResult { // Why not `transfer_stake_within_subnet`? // From 7c228a7d5c445f79215630bfc002a62fad9c9266 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 30 Mar 2026 13:20:43 +0200 Subject: [PATCH 14/85] remove signature --- pallets/limit-orders/src/lib.rs | 53 +++++++++----------------- pallets/limit-orders/src/tests/mock.rs | 7 ++-- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 8f8f28efdc..8d116f0cc6 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -8,7 +8,7 @@ mod tests; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_core::H256; -use sp_runtime::traits::{IdentifyAccount, Verify}; +use sp_runtime::{AccountId32, MultiSignature, traits::Verify}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; @@ -85,18 +85,15 @@ pub struct Order /// a domain tag to the signed payload or adding the genesis hash as an `Order` field. /// /// Signature verification is performed against `order.signer` (the AccountId) -/// directly, which works because in Substrate sr25519/ed25519 AccountIds are -/// the raw public keys. +/// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants +/// of `MultiSignature` are rejected at validation time. #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] -pub struct SignedOrder< - AccountId: Encode + Decode + TypeInfo + MaxEncodedLen + Clone, - Signature: Encode + Decode + TypeInfo + MaxEncodedLen + Clone, -> { +pub struct SignedOrder { pub order: Order, - /// Signature over `SCALE_ENCODE(order)`. - pub signature: Signature, + /// Sr25519 signature over `SCALE_ENCODE(order)`. + pub signature: MultiSignature, } #[derive( @@ -142,24 +139,7 @@ pub mod pallet { pub struct Pallet(_); #[pallet::config] - pub trait Config: frame_system::Config { - /// Signature type used to verify off-chain order authorisations. - /// - /// The `Verify::verify` method is called with the order's `signer` - /// (`T::AccountId`) as the expected signer, which works for - /// sr25519/ed25519 where AccountId == public key. - /// - /// For the subtensor runtime, set this to `sp_runtime::MultiSignature`. - type Signature: Verify> - + Encode - + Decode - + DecodeWithMemTracking - + TypeInfo - + MaxEncodedLen - + Clone - + PartialEq - + core::fmt::Debug; - + pub trait Config: frame_system::Config { /// Full swap + balance execution interface (see [`OrderSwapInterface`]). type SwapInterface: OrderSwapInterface; @@ -291,7 +271,7 @@ pub mod pallet { ))] pub fn execute_orders( origin: OriginFor, - orders: BoundedVec, T::MaxOrdersPerBatch>, + orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { ensure_signed(origin)?; @@ -332,7 +312,7 @@ pub mod pallet { pub fn execute_batched_orders( origin: OriginFor, netuid: NetUid, - orders: BoundedVec, T::MaxOrdersPerBatch>, + orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { ensure_signed(origin)?; @@ -418,15 +398,16 @@ pub mod pallet { /// valid signature, not yet processed, not expired, and price condition met. /// Netuid is intentionally not checked here; callers handle that separately. fn is_order_valid( - signed_order: &SignedOrder, + signed_order: &SignedOrder, order_id: H256, now_ms: u64, current_price: U96F32, ) -> bool { let order = &signed_order.order; - signed_order - .signature - .verify(order.encode().as_slice(), &order.signer) + matches!(signed_order.signature, MultiSignature::Sr25519(_)) + && signed_order + .signature + .verify(order.encode().as_slice(), &order.signer) && Orders::::get(order_id).is_none() && now_ms <= order.expiry && match order.order_type { @@ -442,7 +423,7 @@ pub mod pallet { /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. fn try_execute_order( - signed_order: SignedOrder, + signed_order: SignedOrder, ) -> DispatchResult { let order = &signed_order.order; let order_id = Self::derive_order_id(order); @@ -512,7 +493,7 @@ pub mod pallet { /// Thin orchestrator for `execute_batched_orders`. fn do_execute_batched_orders( netuid: NetUid, - orders: BoundedVec, T::MaxOrdersPerBatch>, + orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { let now_ms = T::TimeProvider::now().as_millis() as u64; let fee_ppb = ProtocolFee::::get(); @@ -607,7 +588,7 @@ pub mod pallet { /// Each entry is `(order_id, signer, hotkey, gross, net, fee)`. pub(crate) fn validate_and_classify( netuid: NetUid, - orders: &BoundedVec, T::MaxOrdersPerBatch>, + orders: &BoundedVec, T::MaxOrdersPerBatch>, now_ms: u64, fee_ppb: u32, current_price: U96F32, diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 8ded8105c5..af0be157bf 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -364,7 +364,6 @@ parameter_types! { } impl pallet_limit_orders::Config for Test { - type Signature = MultiSignature; type SwapInterface = MockSwap; type TimeProvider = MockTime; type FeeCollector = FeeCollectorAccount; @@ -401,7 +400,7 @@ pub fn make_signed_order( amount: u64, limit_price: u64, expiry: u64, -) -> crate::SignedOrder { +) -> crate::SignedOrder { let signer = keyring.to_account_id(); let order = crate::Order { signer, @@ -420,8 +419,8 @@ pub fn make_signed_order( } pub fn bounded( - v: Vec>, -) -> BoundedVec, ConstU32<64>> { + v: Vec>, +) -> BoundedVec, ConstU32<64>> { BoundedVec::try_from(v).unwrap() } From e7d3584845f6dde8162b7a94628d68499b20f4fd Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 11:34:37 +0200 Subject: [PATCH 15/85] fee shoudl be part of the order, as well as fee account --- pallets/limit-orders/Cargo.toml | 2 + pallets/limit-orders/README.md | 102 ++---- pallets/limit-orders/src/lib.rs | 205 +++++------ pallets/limit-orders/src/tests/auxiliary.rs | 344 +++++++++++++------ pallets/limit-orders/src/tests/extrinsics.rs | 328 +++++++++++------- pallets/limit-orders/src/tests/mock.rs | 18 +- 6 files changed, 559 insertions(+), 440 deletions(-) diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 8cc40bc645..0e2fd5a715 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -10,6 +10,7 @@ frame-system.workspace = true scale-info.workspace = true sp-core.workspace = true sp-runtime.workspace = true +sp-std.workspace = true substrate-fixed.workspace = true subtensor-runtime-common.workspace = true subtensor-swap-interface.workspace = true @@ -30,6 +31,7 @@ std = [ "scale-info/std", "sp-core/std", "sp-runtime/std", + "sp-std/std", "substrate-fixed/std", "subtensor-runtime-common/std", "subtensor-swap-interface/std", diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index c921227d75..22d71cffaf 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -43,15 +43,17 @@ User can cancel at any time via cancel_order The payload that a user signs off-chain. Never stored in full on-chain — only its `blake2_256` hash (`OrderId`) is persisted. -| Field | Type | Description | -|---------------|-------------|-------------| -| `signer` | `AccountId` | Coldkey that authorises the order. For buy types: pays TAO. For sell types: owns the staked alpha. | -| `hotkey` | `AccountId` | Hotkey to stake to (buy types) or unstake from (sell types). | -| `netuid` | `NetUid` | Target subnet. | -| `order_type` | `OrderType` | One of `LimitBuy`, `TakeProfit`, or `StopLoss` (see table below). | -| `amount` | `u64` | Input amount in raw units. TAO for buy types; alpha for sell types. | -| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Trigger direction depends on `OrderType` (see table below). | -| `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | +| Field | Type | Description | +|-----------------|-------------|-------------| +| `signer` | `AccountId` | Coldkey that authorises the order. For buy types: pays TAO. For sell types: owns the staked alpha. | +| `hotkey` | `AccountId` | Hotkey to stake to (buy types) or unstake from (sell types). | +| `netuid` | `NetUid` | Target subnet. | +| `order_type` | `OrderType` | One of `LimitBuy`, `TakeProfit`, or `StopLoss` (see table below). | +| `amount` | `u64` | Input amount in raw units. TAO for buy types; alpha for sell types. | +| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Trigger direction depends on `OrderType` (see table below). | +| `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | +| `fee_rate` | `Perbill` | Per-order fee as a fraction of the input amount. `Perbill::zero()` = no fee. | +| `fee_recipient` | `AccountId` | Account that receives the fee collected for this order. | ### `OrderType` @@ -80,29 +82,7 @@ Terminal state of a processed order, stored under its `OrderId`. ## Storage -### `ProtocolFee: StorageValue` - -Protocol fee in parts-per-billion (PPB). - -- `0` = no fee. -- `1_000_000` = 0.1%. -- `1_000_000_000` = 100%. - -For buy orders the fee is deducted from the TAO input before swapping. For sell -orders the fee is deducted from the TAO output after swapping. Both flows result -in the fee being collected in TAO and forwarded to `FeeCollector`. - -Default: `0`. - -### `Admin: StorageValue>` - -The privileged account that may call `set_protocol_fee` alongside root. -`None` means no admin is set; only root can change the fee. -Set by root via `set_admin`. - -Default: absent (`None`). - -### `OrderStatus: StorageMap` +### `Orders: StorageMap` Maps an `OrderId` (blake2_256 of the SCALE-encoded `Order`) to its terminal `OrderStatus`. Absence means the order has never been seen and is still @@ -118,7 +98,6 @@ neither `Fulfilled` nor `Cancelled` orders can be re-executed. | `Signature` | `Verify + ...` | Signature type for off-chain order authorisation. Set to `sp_runtime::MultiSignature` in the subtensor runtime. | | `SwapInterface` | `OrderSwapInterface` | Full swap + balance execution interface. Implemented by `pallet_subtensor::Pallet`. Provides `buy_alpha`, `sell_alpha`, `transfer_tao`, `transfer_staked_alpha`, and `current_alpha_price`. | | `TimeProvider` | `UnixTime` | Current wall-clock time for expiry checks. | -| `FeeCollector` | `Get` (constant) | Account that receives all accumulated protocol fees in TAO. | | `MaxOrdersPerBatch` | `Get` (constant) | Maximum number of orders accepted in a single `execute_orders` or `execute_batched_orders` call. Should equal `floor(max_block_weight / per_order_weight)`. | | `PalletId` | `Get` (constant) | Used to derive the pallet intermediary account (`PalletId::into_account_truncating`). This account temporarily holds pooled TAO and staked alpha during `execute_batched_orders`. | | `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated hotkey registered on every subnet the pallet may operate on. Operators should register it as a non-validator neuron. | @@ -135,8 +114,8 @@ Executes a list of signed limit orders one by one, each interacting with the AMM pool independently. Orders that fail validation or whose price condition is not met are silently skipped — a single bad order does not revert the batch. -**Fee handling:** protocol fee is deducted from each order's input before the -pool swap. +**Fee handling:** each order's `fee_rate` is deducted from the input amount and +forwarded to that order's `fee_recipient` after execution. **When to use:** suitable for small batches or when orders target different subnets. Use `execute_batched_orders` for same-subnet batches to reduce price @@ -154,7 +133,8 @@ interaction: 1. **Validate & classify** — orders with wrong netuid, invalid signature, already-processed id, past expiry, or price condition not met emit `OrderSkipped` and are dropped. The rest are split into buy-side - (`LimitBuy`) and sell-side (`TakeProfit`, `StopLoss`) groups. + (`LimitBuy`) and sell-side (`TakeProfit`, `StopLoss`) groups. For buy + orders the net TAO (after fee) is pre-computed here. 2. **Collect assets** — gross TAO is pulled from each buyer's free balance into the pallet intermediary account. Gross alpha stake is moved from each seller's @@ -174,20 +154,20 @@ interaction: each share; any remainder stays in the pallet intermediary account as dust. 5. **Distribute TAO pro-rata** — every seller receives their share of the total - available TAO (pool output + buyer passthrough TAO), minus the protocol fee. - Share is proportional to each seller's alpha valued at the current spot price. - Integer division floors each share; any remainder stays in the pallet + available TAO (pool output + buyer passthrough TAO), minus their order's + fee. Share is proportional to each seller's alpha valued at the current spot + price. Integer division floors each share; any remainder stays in the pallet intermediary account as dust. -6. **Collect fees** — total buy-side fees (withheld from TAO input) plus total - sell-side fees (withheld from TAO output) are forwarded in a single transfer - to `FeeCollector`. +6. **Collect fees** — buy-side fees (withheld from each order's TAO input) and + sell-side fees (withheld from each order's TAO output) are accumulated per + unique `fee_recipient` and forwarded in a single transfer per recipient. 7. **Emit `GroupExecutionSummary`.** > **Note:** rounding dust (alpha and TAO) accumulates in the pallet intermediary > account between batches. If an emission epoch fires while dust is present, the -> pallet earns emissions it never distributes. See the TODO in `collect_fees`. +> pallet earns emissions it never distributes. --- @@ -201,23 +181,6 @@ payload is required so the pallet can derive the `OrderId`. --- -### `set_protocol_fee(fee)` — call index 3 - -**Origin:** root or the current admin account (see `set_admin`). - -Sets `ProtocolFee` to `fee` (PPB). Emits `ProtocolFeeSet`. - ---- - -### `set_admin(new_admin)` — call index 5 - -**Origin:** root. - -Sets or clears the privileged admin account stored in `Admin`. Pass `None` to -remove the admin, leaving only root able to change the fee. Emits `AdminSet`. - ---- - ## Events | Event | Fields | Emitted when | @@ -225,8 +188,6 @@ remove the admin, leaving only root able to change the fee. Emits `AdminSet`. | `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). | | `OrderSkipped` | `order_id` | An order was dropped during batch validation (bad signature, expired, wrong netuid, already processed, or price condition not met). | | `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | -| `ProtocolFeeSet` | `fee` | Root or admin updated the protocol fee. | -| `AdminSet` | `new_admin` | Root updated the admin account (`None` means admin was removed). | | `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | --- @@ -240,21 +201,26 @@ remove the admin, leaving only root able to change the fee. Emits `AdminSet`. | `OrderExpired` | `now > order.expiry`. | | `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. | | `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. | -| `NotAdmin` | Caller of `set_protocol_fee` is neither root nor the current admin. | | `SwapReturnedZero` | The pool swap returned zero output for a non-zero residual input. | --- ## Fee model +Fees are specified per-order via `fee_rate: Perbill` and `fee_recipient: +AccountId` fields on the `Order` struct. There is no global protocol fee or +admin key. + All fees are collected in TAO regardless of order side. | Order type | Fee deducted from | Timing | |-------------------------|-------------------|--------| -| `LimitBuy` | TAO input | Before pool swap (`validate_and_classify`) | -| `TakeProfit`, `StopLoss`| TAO output | After pool swap (`distribute_tao_pro_rata`) | +| `LimitBuy` | TAO input | Pre-computed in `validate_and_classify`, before pool swap. | +| `TakeProfit`, `StopLoss`| TAO output | Deducted in `distribute_tao_pro_rata`, after pool swap. | -Fee formula: `fee = floor(amount × fee_ppb / 1_000_000_000)`. +Fee formula: `fee = fee_rate * amount` (using `Perbill` multiplication, which +upcasts to u128 internally to avoid overflow). -Accumulated fees are forwarded to `FeeCollector` at the end of each batch -execution in a single transfer. +At the end of each batch, fees are accumulated per unique `fee_recipient` and +forwarded in a single transfer per recipient. If multiple orders share the same +`fee_recipient`, they result in exactly one transfer rather than one per order. diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 8d116f0cc6..5841a3fe31 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -8,7 +8,7 @@ mod tests; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_core::H256; -use sp_runtime::{AccountId32, MultiSignature, traits::Verify}; +use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::Verify}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; @@ -73,6 +73,10 @@ pub struct Order pub limit_price: u64, /// Unix timestamp in milliseconds after which this order must not be executed. pub expiry: u64, + /// Fee rate applied to this order's TAO amount (input for buys, output for sells). + pub fee_rate: Perbill, + /// Account that receives the fee collected from this order. + pub fee_recipient: AccountId, } /// The envelope the admin submits on-chain: the order payload plus the user's @@ -117,9 +121,12 @@ pub(crate) struct OrderEntry { /// Gross input amount (before fee). pub(crate) gross: u64, /// Net input amount (after fee). + /// For buys: `gross - fee_rate * gross`. For sells: equals `gross` (fee on TAO output). pub(crate) net: u64, - /// Fee amount (TAO for buys; 0 for sells – applied on TAO output). - pub(crate) fee: u64, + /// Per-order fee rate. + pub(crate) fee_rate: Perbill, + /// Per-order fee recipient. + pub(crate) fee_recipient: AccountId, } // ── Pallet ─────────────────────────────────────────────────────────────────── @@ -134,6 +141,7 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use sp_runtime::traits::AccountIdConversion; + use sp_std::vec::Vec; #[pallet::pallet] pub struct Pallet(_); @@ -146,10 +154,6 @@ pub mod pallet { /// Time provider for expiry checks. type TimeProvider: UnixTime; - /// Account that collects protocol fees. - #[pallet::constant] - type FeeCollector: Get; - /// Maximum number of orders in a single `execute_orders` call. /// Should equal `floor(max_block_weight / per_order_weight)`. #[pallet::constant] @@ -174,16 +178,6 @@ pub mod pallet { // ── Storage ─────────────────────────────────────────────────────────────── - /// Protocol fee in parts-per-billion (PPB). e.g. 1_000_000 PPB = 0.1%. - #[pallet::storage] - pub type ProtocolFee = StorageValue<_, u32, ValueQuery>; - - /// The privileged account that may call `set_protocol_fee`. - /// Absent ⇒ no admin set; only root can change the fee. - /// Set by root via `set_admin`. - #[pallet::storage] - pub type Admin = StorageValue<_, T::AccountId, OptionQuery>; - /// Tracks the on-chain status of a known `OrderId`. /// Absent ⇒ never seen (still executable if valid). /// Present ⇒ Fulfilled or Cancelled (both are terminal). @@ -214,10 +208,6 @@ pub mod pallet { order_id: H256, signer: T::AccountId, }, - /// The protocol fee was updated. - ProtocolFeeSet { fee: u32 }, - /// The admin account was updated by root. - AdminSet { new_admin: Option }, /// Summary emitted once per `execute_batched_orders` call. GroupExecutionSummary { /// The subnet all orders in this batch belong to. @@ -249,8 +239,6 @@ pub mod pallet { PriceConditionNotMet, /// Caller is not the order signer (required for cancellation). Unauthorized, - /// Caller is neither root nor the current admin. - NotAdmin, /// The pool swap returned zero output for a non-zero input. SwapReturnedZero, } @@ -345,40 +333,6 @@ pub mod pallet { Ok(()) } - - /// Set the protocol fee in parts-per-billion. - /// - /// May be called by root or the current admin account. - #[pallet::call_index(3)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().reads_writes(1, 1)))] - pub fn set_protocol_fee(origin: OriginFor, fee: u32) -> DispatchResult { - let is_root = ensure_root(origin.clone()).is_ok(); - if !is_root { - let who = ensure_signed(origin)?; - ensure!( - Admin::::get().as_ref() == Some(&who), - Error::::NotAdmin - ); - } - ProtocolFee::::put(fee); - Self::deposit_event(Event::ProtocolFeeSet { fee }); - Ok(()) - } - - /// Set or clear the admin account. Requires root. - /// - /// Pass `None` to remove the admin, leaving only root able to change fees. - #[pallet::call_index(5)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] - pub fn set_admin(origin: OriginFor, new_admin: Option) -> DispatchResult { - ensure_root(origin)?; - match &new_admin { - Some(a) => Admin::::put(a), - None => Admin::::kill(), - } - Self::deposit_event(Event::AdminSet { new_admin }); - Ok(()) - } } // ── Internal helpers ────────────────────────────────────────────────────── @@ -404,7 +358,8 @@ pub mod pallet { current_price: U96F32, ) -> bool { let order = &signed_order.order; - matches!(signed_order.signature, MultiSignature::Sr25519(_)) + T::SwapInterface::is_subtoken_enabled(order.netuid) + && matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order .signature .verify(order.encode().as_slice(), &order.signer) @@ -422,9 +377,7 @@ pub mod pallet { /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. - fn try_execute_order( - signed_order: SignedOrder, - ) -> DispatchResult { + fn try_execute_order(signed_order: SignedOrder) -> DispatchResult { let order = &signed_order.order; let order_id = Self::derive_order_id(order); let now_ms = T::TimeProvider::now().as_millis() as u64; @@ -435,12 +388,11 @@ pub mod pallet { Error::::InvalidSignature ); - // 5. Execute the swap, taking protocol fee from the input. - let fee_ppb = ProtocolFee::::get(); + // 5. Execute the swap, taking the order's fee from the input (buys) or output (sells). let (amount_in, amount_out) = if order.order_type.is_buy() { let tao_in = TaoBalance::from(order.amount); - // Deduct protocol fee from TAO input before swapping. - let fee_tao = Self::ppb_of_tao(tao_in, fee_ppb); + // Deduct fee from TAO input before swapping. + let fee_tao = TaoBalance::from(order.fee_rate * tao_in.to_u64()); let tao_after_fee = tao_in.saturating_sub(fee_tao); let alpha_out = T::SwapInterface::buy_alpha( @@ -451,9 +403,9 @@ pub mod pallet { TaoBalance::from(order.limit_price), )?; - // Forward the fee TAO directly to FeeCollector. + // Forward the fee TAO to the order's fee recipient. if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao(&order.signer, &T::FeeCollector::get(), fee_tao) + T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) .ok(); } (order.amount, alpha_out.to_u64()) @@ -467,10 +419,10 @@ pub mod pallet { TaoBalance::from(order.limit_price), )?; - // Deduct protocol fee from TAO output and forward to FeeCollector. - let fee_tao = Self::ppb_of_tao(tao_out, fee_ppb); + // Deduct fee from TAO output and forward to the order's fee recipient. + let fee_tao = TaoBalance::from(order.fee_rate * tao_out.to_u64()); if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao(&order.signer, &T::FeeCollector::get(), fee_tao) + T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) .ok(); } (order.amount, tao_out.saturating_sub(fee_tao).to_u64()) @@ -496,12 +448,11 @@ pub mod pallet { orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { let now_ms = T::TimeProvider::now().as_millis() as u64; - let fee_ppb = ProtocolFee::::get(); let current_price = T::SwapInterface::current_alpha_price(netuid); // Filter invalid/expired/price-missed orders; classify the rest into buys and sells. let (valid_buys, valid_sells) = - Self::validate_and_classify(netuid, &orders, now_ms, fee_ppb, current_price); + Self::validate_and_classify(netuid, &orders, now_ms, current_price); let executed_count = (valid_buys.len() + valid_sells.len()) as u32; if executed_count == 0 { @@ -549,21 +500,20 @@ pub mod pallet { )?; // Give every seller their pro-rata share of (pool TAO output + offset buy TAO), - // deducting the fee from each payout; returns the total sell-side fee in TAO. - let sell_fee_tao = Self::distribute_tao_pro_rata( + // deducting per-order fees from each payout; returns accumulated sell fees by recipient. + let sell_fees = Self::distribute_tao_pro_rata( &valid_sells, actual_out, total_buy_net, total_sell_tao_equiv, &net_side, current_price, - fee_ppb, &pallet_acct, netuid, )?; - // Forward all accumulated TAO fees (buy input fees + sell output fees) to FeeCollector. - Self::collect_fees(&valid_buys, sell_fee_tao, &pallet_acct); + // Merge buy and sell fees by recipient and transfer once per unique recipient. + Self::collect_fees(&valid_buys, sell_fees, &pallet_acct); let net_amount = Self::net_amount_for_event( &net_side, @@ -590,7 +540,6 @@ pub mod pallet { netuid: NetUid, orders: &BoundedVec, T::MaxOrdersPerBatch>, now_ms: u64, - fee_ppb: u32, current_price: U96F32, ) -> ( BoundedVec, T::MaxOrdersPerBatch>, @@ -613,15 +562,13 @@ pub mod pallet { return None; } - let (net, fee) = if order.order_type.is_buy() { - // Buy: fee on TAO input — buyer contributes less TAO to the pool. - let f = Self::ppb_of_tao(TaoBalance::from(order.amount), fee_ppb).to_u64(); - (order.amount.saturating_sub(f), f) + let net = if order.order_type.is_buy() { + // Buy: fee on TAO input — net is the amount that reaches the pool. + order.amount.saturating_sub(order.fee_rate * order.amount) } else { - // Sell: fee on TAO output — seller contributes full alpha; the fee - // is deducted from their TAO payout in `distribute_tao_pro_rata`. - // No alpha is withheld here, so fee is recorded as 0 in the entry. - (order.amount, 0u64) + // Sell: fee on TAO output — full alpha enters the pool; the fee is + // deducted from the TAO payout later in `distribute_tao_pro_rata`. + order.amount }; Some(OrderEntry { @@ -631,7 +578,8 @@ pub mod pallet { side: order.order_type.clone(), gross: order.amount, net, - fee, + fee_rate: order.fee_rate, + fee_recipient: order.fee_recipient.clone(), }) }) .for_each(|entry| { @@ -691,7 +639,7 @@ pub mod pallet { pallet_hotkey, netuid, TaoBalance::from(net_tao), - TaoBalance::ZERO, + TaoBalance::from(u64::MAX), // no price ceiling for net pool swap )? .to_u64() as u128; ensure!(out > 0, Error::::SwapReturnedZero); @@ -784,16 +732,16 @@ pub mod pallet { total_sell_tao_equiv: u128, net_side: &OrderSide, current_price: U96F32, - fee_ppb: u32, pallet_acct: &T::AccountId, netuid: NetUid, - ) -> Result { + ) -> Result, DispatchError> { let total_tao: u128 = match net_side { OrderSide::Sell => actual_out.saturating_add(total_buy_net), OrderSide::Buy => total_sell_tao_equiv, }; - let mut total_sell_fee_tao: u64 = 0; + // Accumulate sell-side fees by recipient (one entry per unique recipient). + let mut sell_fees: Vec<(T::AccountId, u64)> = Vec::new(); for e in sells.iter() { let sell_tao_equiv = Self::alpha_to_tao(e.net as u128, current_price); @@ -802,9 +750,16 @@ pub mod pallet { } else { 0u64 }; - let fee = Self::ppb_of_tao(TaoBalance::from(gross_share), fee_ppb).to_u64(); + let fee = e.fee_rate * gross_share; let net_share = gross_share.saturating_sub(fee); - total_sell_fee_tao = total_sell_fee_tao.saturating_add(fee); + + if fee > 0 { + if let Some(entry) = sell_fees.iter_mut().find(|(r, _)| r == &e.fee_recipient) { + entry.1 = entry.1.saturating_add(fee); + } else { + sell_fees.push((e.fee_recipient.clone(), fee)); + } + } T::SwapInterface::transfer_tao( pallet_acct, @@ -821,33 +776,45 @@ pub mod pallet { amount_out: net_share, }); } - Ok(total_sell_fee_tao) + Ok(sell_fees) } - /// Route accumulated protocol fees to `FeeCollector`. - /// - /// Both buy and sell fees are always in TAO by this point: - /// - Buy fees: withheld from TAO input in `validate_and_classify`. - /// - Sell fees: withheld from TAO output in `distribute_tao_pro_rata` - /// (passed in as `sell_fee_tao`). + /// Forward accumulated fees to their respective recipients. /// - /// Both transfers are best-effort and do not revert the batch on failure. + /// Merges buy-side fees (withheld from TAO input) and sell-side fees + /// (withheld from TAO output, passed in as `sell_fees`) by recipient, + /// then performs one TAO transfer per unique `fee_recipient`. + /// All transfers are best-effort and do not revert the batch on failure. pub(crate) fn collect_fees( buys: &BoundedVec, T::MaxOrdersPerBatch>, - sell_fee_tao: u64, + sell_fees: Vec<(T::AccountId, u64)>, pallet_acct: &T::AccountId, ) { - let fee_collector = T::FeeCollector::get(); + // Start with sell fees; fold in buy fees. + // Buy fee was already computed in `validate_and_classify` as `gross - net`, + // so we recover it here without recomputing. + let mut fees: Vec<(T::AccountId, u64)> = sell_fees; + for e in buys.iter() { + let fee = e.gross.saturating_sub(e.net); + if fee > 0 { + if let Some(entry) = fees.iter_mut().find(|(r, _)| r == &e.fee_recipient) { + entry.1 = entry.1.saturating_add(fee); + } else { + fees.push((e.fee_recipient.clone(), fee)); + } + } + } - let total_buy_fee: u64 = buys.iter().map(|e| e.fee).sum(); - let total_fee = total_buy_fee.saturating_add(sell_fee_tao); - if total_fee > 0 { - T::SwapInterface::transfer_tao( - pallet_acct, - &fee_collector, - TaoBalance::from(total_fee), - ) - .ok(); + // One transfer per unique fee recipient. + for (recipient, amount) in fees { + if amount > 0 { + T::SwapInterface::transfer_tao( + pallet_acct, + &recipient, + TaoBalance::from(amount), + ) + .ok(); + } } // TODO: sweep rounding dust and any emissions accrued on the pallet account. @@ -891,19 +858,5 @@ pub mod pallet { .saturating_mul(U96F32::from_num(alpha)) .saturating_to_num::() } - - pub(crate) fn ppb_of_tao(amount: TaoBalance, ppb: u32) -> TaoBalance { - let result = (amount.to_u64() as u128) - .saturating_mul(ppb as u128) - .saturating_div(1_000_000_000); - TaoBalance::from(result as u64) - } - - pub(crate) fn ppb_of_alpha(amount: AlphaBalance, ppb: u32) -> AlphaBalance { - let result = (amount.to_u64() as u128) - .saturating_mul(ppb as u128) - .saturating_div(1_000_000_000); - AlphaBalance::from(result as u64) - } } } diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index cda650174f..9202c2c9a6 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -8,62 +8,13 @@ use sp_keyring::Sr25519Keyring as AccountKeyring; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use sp_runtime::Perbill; + use crate::pallet::Pallet as LimitOrders; -use crate::{OrderEntry, OrderSide, OrderStatus, OrderType, Orders, pallet::ProtocolFee}; +use crate::{OrderEntry, OrderSide, OrderStatus, OrderType, Orders}; use super::mock::*; -// ───────────────────────────────────────────────────────────────────────────── -// ppb_of_tao / ppb_of_alpha -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn ppb_of_tao_zero_fee_returns_zero() { - new_test_ext().execute_with(|| { - // 0 ppb → no fee regardless of amount - let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1_000_000u64), 0); - assert_eq!(fee, TaoBalance::from(0u64)); - }); -} - -#[test] -fn ppb_of_tao_full_ppb_returns_amount() { - new_test_ext().execute_with(|| { - // 1_000_000_000 ppb = 100% → fee == amount - let amount = TaoBalance::from(500_000u64); - let fee = LimitOrders::::ppb_of_tao(amount, 1_000_000_000u32); - assert_eq!(fee, amount); - }); -} - -#[test] -fn ppb_of_tao_one_tenth_percent() { - new_test_ext().execute_with(|| { - // 1_000_000 ppb = 0.1% - // 1_000_000 * 1_000_000 / 1_000_000_000 = 1_000 - let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1_000_000_000u64), 1_000_000u32); - assert_eq!(fee, TaoBalance::from(1_000_000u64)); - }); -} - -#[test] -fn ppb_of_alpha_one_tenth_percent() { - new_test_ext().execute_with(|| { - let fee = - LimitOrders::::ppb_of_alpha(AlphaBalance::from(1_000_000_000u64), 1_000_000u32); - assert_eq!(fee, AlphaBalance::from(1_000_000u64)); - }); -} - -#[test] -fn ppb_of_tao_rounds_down() { - new_test_ext().execute_with(|| { - // amount=1, ppb=999_999_999 (just under 100%) → floor(0.999…) = 0 - let fee = LimitOrders::::ppb_of_tao(TaoBalance::from(1u64), 999_999_999u32); - assert_eq!(fee, TaoBalance::from(0u64)); - }); -} - // ───────────────────────────────────────────────────────────────────────────── // net_amount_for_event // ───────────────────────────────────────────────────────────────────────────── @@ -130,9 +81,6 @@ fn validate_and_classify_separates_buys_and_sells() { // Price = 1.0 TAO/alpha. MockSwap::set_price(1.0); - // Fee = 0 ppb for simplicity. - ProtocolFee::::put(0u32); - let buy_order = make_signed_order( AccountKeyring::Alice, bob(), @@ -141,6 +89,8 @@ fn validate_and_classify_separates_buys_and_sells() { 1_000u64, // amount in TAO 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) 2_000_000u64, // expiry ms + Perbill::zero(), + fee_recipient(), ); let sell_order = make_signed_order( AccountKeyring::Bob, @@ -150,6 +100,8 @@ fn validate_and_classify_separates_buys_and_sells() { 500u64, // amount in alpha 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) 2_000_000u64, + Perbill::zero(), + fee_recipient(), ); let orders = bounded(vec![buy_order, sell_order]); @@ -157,29 +109,24 @@ fn validate_and_classify_separates_buys_and_sells() { netuid(), &orders, 1_000_000u64, - 0u32, U96F32::from_num(1u32), ); assert_eq!(buys.len(), 1, "expected 1 valid buy"); assert_eq!(sells.len(), 1, "expected 1 valid sell"); - // Buy entry: gross=1000, net=1000 (0% fee), fee=0 + // Buy entry: gross=1000, net=1000 (0% fee_rate) let buy = &buys[0]; assert_eq!(buy.signer, alice()); assert_eq!(buy.gross, 1_000u64); assert_eq!(buy.net, 1_000u64); - assert_eq!(buy.fee, 0u64); + assert_eq!(buy.fee_rate, Perbill::zero()); - // Sell entry: gross=500, net=500, fee=0 (fee deferred to distribution) + // Sell entry: gross=500, net=500 (fee applied on TAO output, not alpha input) let sell = &sells[0]; assert_eq!(sell.signer, bob()); assert_eq!(sell.gross, 500u64); assert_eq!(sell.net, 500u64); - assert_eq!( - sell.fee, 0u64, - "sell fee is always 0 here — applied on TAO output" - ); }); } @@ -188,7 +135,6 @@ fn validate_and_classify_skips_wrong_netuid() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(1.0); - ProtocolFee::::put(0u32); let wrong_netuid_order = make_signed_order( AccountKeyring::Alice, @@ -198,6 +144,8 @@ fn validate_and_classify_skips_wrong_netuid() { 1_000u64, 2_000_000u64, 2_000_000u64, + Perbill::zero(), + fee_recipient(), ); let orders = bounded(vec![wrong_netuid_order]); @@ -205,7 +153,6 @@ fn validate_and_classify_skips_wrong_netuid() { netuid(), // batch is for netuid 1 &orders, 1_000_000u64, - 0u32, U96F32::from_num(1u32), ); @@ -220,7 +167,6 @@ fn validate_and_classify_skips_expired_order() { // now_ms = 2_000_001, expiry = 2_000_000 → expired MockTime::set(2_000_001); MockSwap::set_price(1.0); - ProtocolFee::::put(0u32); let expired = make_signed_order( AccountKeyring::Alice, @@ -230,6 +176,8 @@ fn validate_and_classify_skips_expired_order() { 1_000u64, 2_000_000u64, 2_000_000u64, // expiry already past + Perbill::zero(), + fee_recipient(), ); let orders = bounded(vec![expired]); @@ -237,7 +185,6 @@ fn validate_and_classify_skips_expired_order() { netuid(), &orders, 2_000_001u64, - 0u32, U96F32::from_num(1u32), ); @@ -259,6 +206,8 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { 1_000u64, 2u64, // limit_price = 2 TAO/alpha 2_000_000u64, + Perbill::zero(), + fee_recipient(), ); let orders = bounded(vec![order]); @@ -266,7 +215,6 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { netuid(), &orders, 1_000_000u64, - 0u32, U96F32::from_num(3u32), // current price = 3 > limit 2 → skip ); @@ -286,6 +234,8 @@ fn validate_and_classify_skips_already_processed_order() { 1_000u64, 2_000_000u64, 2_000_000u64, + Perbill::zero(), + fee_recipient(), ); // Pre-mark as fulfilled on-chain. @@ -297,7 +247,6 @@ fn validate_and_classify_skips_already_processed_order() { netuid(), &orders, 1_000_000u64, - 0u32, U96F32::from_num(1u32), ); @@ -311,7 +260,6 @@ fn validate_and_classify_applies_buy_fee_to_net() { MockTime::set(1_000_000); // 1_000_000 ppb = 0.1% // amount = 1_000_000_000, fee = 1_000_000, net = 999_000_000 - ProtocolFee::::put(1_000_000u32); let order = make_signed_order( AccountKeyring::Alice, @@ -321,6 +269,8 @@ fn validate_and_classify_applies_buy_fee_to_net() { 1_000_000_000u64, u64::MAX, // limit price: accept any price 2_000_000u64, + Perbill::from_parts(1_000_000), // 0.1% fee + fee_recipient(), ); let orders = bounded(vec![order]); @@ -328,14 +278,13 @@ fn validate_and_classify_applies_buy_fee_to_net() { netuid(), &orders, 1_000_000u64, - 1_000_000u32, U96F32::from_num(1u32), ); assert_eq!(buys.len(), 1); let entry = &buys[0]; assert_eq!(entry.gross, 1_000_000_000u64); - assert_eq!(entry.fee, 1_000_000u64); + assert_eq!(entry.fee_rate, Perbill::from_parts(1_000_000)); assert_eq!(entry.net, 999_000_000u64); }); } @@ -412,7 +361,8 @@ fn make_buy_entry( hotkey: AccountId, gross: u64, net: u64, - fee: u64, + fee_rate: Perbill, + fee_recipient: AccountId, ) -> OrderEntry { OrderEntry { order_id, @@ -421,7 +371,8 @@ fn make_buy_entry( side: OrderType::LimitBuy, gross, net, - fee, + fee_rate, + fee_recipient, } } @@ -446,9 +397,33 @@ fn distribute_alpha_pro_rata_buy_dominant_scenario_a() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(1), alice(), hotkey.clone(), 300, 300, 0), - make_buy_entry(H256::repeat_byte(2), bob(), hotkey.clone(), 200, 200, 0), - make_buy_entry(H256::repeat_byte(3), charlie(), hotkey.clone(), 500, 500, 0), + make_buy_entry( + H256::repeat_byte(1), + alice(), + hotkey.clone(), + 300, + 300, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(2), + bob(), + hotkey.clone(), + 200, + 200, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(3), + charlie(), + hotkey.clone(), + 500, + 500, + Perbill::zero(), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); // reuse as coldkey for brevity let pallet_hk = PalletHotkeyAccount::get(); @@ -502,8 +477,24 @@ fn distribute_alpha_pro_rata_sell_dominant_scenario_b() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(4), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(5), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry( + H256::repeat_byte(4), + alice(), + hotkey.clone(), + 400, + 400, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(5), + bob(), + hotkey.clone(), + 600, + 600, + Perbill::zero(), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); let pallet_hk = PalletHotkeyAccount::get(); @@ -557,9 +548,33 @@ fn distribute_alpha_pro_rata_buy_dominant_scenario_c() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(6), alice(), hotkey.clone(), 300, 300, 0), - make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 200, 200, 0), - make_buy_entry(H256::repeat_byte(8), charlie(), hotkey.clone(), 500, 500, 0), + make_buy_entry( + H256::repeat_byte(6), + alice(), + hotkey.clone(), + 300, + 300, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(7), + bob(), + hotkey.clone(), + 200, + 200, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(8), + charlie(), + hotkey.clone(), + 500, + 500, + Perbill::zero(), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); let pallet_hk = PalletHotkeyAccount::get(); @@ -623,9 +638,33 @@ fn distribute_alpha_pro_rata_dust_remains_in_pallet_scenario_d() { MockSwap::set_alpha_balance(pallet_acct.clone(), pallet_hk.clone(), netuid(), 10); let entries = bounded_buy_entries(vec![ - make_buy_entry(H256::repeat_byte(9), alice(), hotkey.clone(), 1, 1, 0), - make_buy_entry(H256::repeat_byte(10), bob(), hotkey.clone(), 1, 1, 0), - make_buy_entry(H256::repeat_byte(11), charlie(), hotkey.clone(), 1, 1, 0), + make_buy_entry( + H256::repeat_byte(9), + alice(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(10), + bob(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(11), + charlie(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), ]); LimitOrders::::distribute_alpha_pro_rata( @@ -744,19 +783,34 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee_scenario_a() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ - make_buy_entry(H256::repeat_byte(6), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(7), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry( + H256::repeat_byte(6), + alice(), + hotkey.clone(), + 400, + 400, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(7), + bob(), + hotkey.clone(), + 600, + 600, + Perbill::zero(), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); - let sell_fee = LimitOrders::::distribute_tao_pro_rata( + let sell_fees = LimitOrders::::distribute_tao_pro_rata( &entries, 1_200u128, // actual_out (pool TAO) 800u128, // total_buy_net (buy passthrough TAO) 2_000u128, // total_sell_tao_equiv (Alice 800 + Bob 1200) &OrderSide::Sell, U96F32::from_num(2u32), - 0u32, // fee_ppb = 0 &pallet_acct, netuid(), ) @@ -773,7 +827,11 @@ fn distribute_tao_pro_rata_sell_dominant_no_fee_scenario_a() { assert_eq!(alice_tao, 800u64, "Alice should receive 800 TAO"); assert_eq!(bob_tao, 1_200u64, "Bob should receive 1200 TAO"); - assert_eq!(sell_fee, 0u64, "No fees at 0 ppb"); + assert_eq!( + sell_fees, + vec![] as Vec<(AccountId, u64)>, + "No fees at 0 ppb" + ); }); } @@ -786,19 +844,34 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee_scenario_b() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ - make_buy_entry(H256::repeat_byte(8), alice(), hotkey.clone(), 400, 400, 0), - make_buy_entry(H256::repeat_byte(9), bob(), hotkey.clone(), 600, 600, 0), + make_buy_entry( + H256::repeat_byte(8), + alice(), + hotkey.clone(), + 400, + 400, + Perbill::from_parts(10_000_000), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(9), + bob(), + hotkey.clone(), + 600, + 600, + Perbill::from_parts(10_000_000), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); - let sell_fee = LimitOrders::::distribute_tao_pro_rata( + let sell_fees = LimitOrders::::distribute_tao_pro_rata( &entries, 1_200u128, 800u128, 2_000u128, &OrderSide::Sell, U96F32::from_num(2u32), - 10_000_000u32, // 1% fee &pallet_acct, netuid(), ) @@ -815,7 +888,11 @@ fn distribute_tao_pro_rata_sell_dominant_with_fee_scenario_b() { assert_eq!(alice_tao, 792u64, "Alice net after 1% fee on 800"); assert_eq!(bob_tao, 1_188u64, "Bob net after 1% fee on 1200"); - assert_eq!(sell_fee, 20u64, "total sell fee = 8 + 12"); + assert_eq!( + sell_fees, + vec![(fee_recipient(), 20u64)], + "total sell fee = 8 + 12" + ); }); } @@ -828,19 +905,34 @@ fn distribute_tao_pro_rata_buy_dominant_scenario_c() { let hotkey = AccountKeyring::Dave.to_account_id(); let entries = bounded_sell_entries(vec![ - make_buy_entry(H256::repeat_byte(10), alice(), hotkey.clone(), 300, 300, 0), - make_buy_entry(H256::repeat_byte(11), bob(), hotkey.clone(), 200, 200, 0), + make_buy_entry( + H256::repeat_byte(10), + alice(), + hotkey.clone(), + 300, + 300, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(11), + bob(), + hotkey.clone(), + 200, + 200, + Perbill::zero(), + fee_recipient(), + ), ]); let pallet_acct = PalletHotkeyAccount::get(); - let sell_fee = LimitOrders::::distribute_tao_pro_rata( + let sell_fees = LimitOrders::::distribute_tao_pro_rata( &entries, 0u128, // actual_out unused in Buy-dominant branch 0u128, // total_buy_net unused in Buy-dominant branch 1_000u128, // total_sell_tao_equiv (total_tao = this in Buy branch) &OrderSide::Buy, U96F32::from_num(2u32), - 0u32, &pallet_acct, netuid(), ) @@ -857,7 +949,7 @@ fn distribute_tao_pro_rata_buy_dominant_scenario_c() { assert_eq!(alice_tao, 600u64, "Alice should receive 600 TAO"); assert_eq!(bob_tao, 400u64, "Bob should receive 400 TAO"); - assert_eq!(sell_fee, 0u64); + assert_eq!(sell_fees, vec![] as Vec<(AccountId, u64)>); }); } @@ -875,19 +967,42 @@ fn distribute_tao_pro_rata_dust_remains_in_pallet_scenario_d() { MockSwap::set_tao_balance(pallet_acct.clone(), 10); let entries = bounded_sell_entries(vec![ - make_buy_entry(H256::repeat_byte(12), alice(), hotkey.clone(), 1, 1, 0), - make_buy_entry(H256::repeat_byte(13), bob(), hotkey.clone(), 1, 1, 0), - make_buy_entry(H256::repeat_byte(14), charlie(), hotkey.clone(), 1, 1, 0), + make_buy_entry( + H256::repeat_byte(12), + alice(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(13), + bob(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(14), + charlie(), + hotkey.clone(), + 1, + 1, + Perbill::zero(), + fee_recipient(), + ), ]); - let sell_fee = LimitOrders::::distribute_tao_pro_rata( + let sell_fees = LimitOrders::::distribute_tao_pro_rata( &entries, 10u128, // actual_out from pool (TAO) 0u128, // total_buy_net — no buyers 3u128, // total_sell_tao_equiv — not divisible into 10 evenly &OrderSide::Sell, U96F32::from_num(1u32), - 0u32, // fee_ppb = 0 &pallet_acct, netuid(), ) @@ -911,7 +1026,7 @@ fn distribute_tao_pro_rata_dust_remains_in_pallet_scenario_d() { assert_eq!(alice_tao, 3u64, "floor(10 * 1/3) = 3"); assert_eq!(bob_tao, 3u64, "floor(10 * 1/3) = 3"); assert_eq!(charlie_tao, 3u64, "floor(10 * 1/3) = 3"); - assert_eq!(sell_fee, 0u64); + assert_eq!(sell_fees, vec![] as Vec<(AccountId, u64)>); // The pallet account started with 10 TAO and sent out 9 — 1 TAO dust remains, // not burnt, not distributed. @@ -944,7 +1059,8 @@ fn collect_fees_forwards_combined_fees_to_collector() { hotkey.clone(), 1_000, 950, - 50, + Perbill::from_parts(50_000_000), // 5% of 1000 = 50 + fee_recipient(), ), make_buy_entry( H256::repeat_byte(21), @@ -952,18 +1068,19 @@ fn collect_fees_forwards_combined_fees_to_collector() { hotkey.clone(), 1_500, 1_350, - 150, + Perbill::from_parts(100_000_000), // 10% of 1500 = 150 + fee_recipient(), ), ]); let pallet_acct = PalletHotkeyAccount::get(); - LimitOrders::::collect_fees(&buys, 80u64, &pallet_acct); + LimitOrders::::collect_fees(&buys, vec![(fee_recipient(), 80u64)], &pallet_acct); let tao_transfers = MockSwap::tao_transfers(); - assert_eq!(tao_transfers.len(), 1, "single transfer to FeeCollector"); + assert_eq!(tao_transfers.len(), 1, "single transfer to fee_recipient"); let (from, to, amount) = &tao_transfers[0]; assert_eq!(from, &pallet_acct, "fee comes from pallet account"); - assert_eq!(to, &FeeCollectorAccount::get(), "fee goes to FeeCollector"); + assert_eq!(to, &fee_recipient(), "fee goes to fee_recipient"); assert_eq!(*amount, 280u64, "total fee = 200 (buy) + 80 (sell)"); }); } @@ -979,11 +1096,12 @@ fn collect_fees_no_transfer_when_zero_fees() { hotkey, 1_000, 1_000, - 0, + Perbill::zero(), + fee_recipient(), )]); let pallet_acct = PalletHotkeyAccount::get(); - LimitOrders::::collect_fees(&buys, 0u64, &pallet_acct); + LimitOrders::::collect_fees(&buys, vec![], &pallet_acct); let tao_transfers = MockSwap::tao_transfers(); assert_eq!(tao_transfers.len(), 0, "no transfer when total fee is zero"); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 6e324f3b94..71358ececa 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -6,13 +6,10 @@ use frame_support::{assert_noop, assert_ok}; use sp_keyring::Sr25519Keyring as AccountKeyring; -use sp_runtime::DispatchError; +use sp_runtime::{DispatchError, Perbill}; use subtensor_runtime_common::NetUid; -use crate::{ - Admin, Error, Order, OrderSide, OrderStatus, OrderType, Orders, - pallet::{Event, ProtocolFee}, -}; +use crate::{Error, Order, OrderSide, OrderStatus, OrderType, Orders, pallet::Event}; type LimitOrders = crate::pallet::Pallet; @@ -28,113 +25,6 @@ fn assert_event(event: Event) { ); } -// ───────────────────────────────────────────────────────────────────────────── -// set_admin -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn set_admin_root_can_set_admin() { - new_test_ext().execute_with(|| { - assert_ok!(LimitOrders::set_admin(RuntimeOrigin::root(), Some(alice()))); - assert_eq!(Admin::::get(), Some(alice())); - assert_event(Event::AdminSet { - new_admin: Some(alice()), - }); - }); -} - -#[test] -fn set_admin_root_can_clear_admin() { - new_test_ext().execute_with(|| { - Admin::::put(alice()); - assert_ok!(LimitOrders::set_admin(RuntimeOrigin::root(), None)); - assert!(Admin::::get().is_none()); - assert_event(Event::AdminSet { new_admin: None }); - }); -} - -#[test] -fn set_admin_signed_origin_rejected() { - new_test_ext().execute_with(|| { - assert_noop!( - LimitOrders::set_admin(RuntimeOrigin::signed(alice()), Some(bob())), - DispatchError::BadOrigin - ); - }); -} - -#[test] -fn set_admin_unsigned_origin_rejected() { - new_test_ext().execute_with(|| { - assert_noop!( - LimitOrders::set_admin(RuntimeOrigin::none(), Some(alice())), - DispatchError::BadOrigin - ); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// set_protocol_fee -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn set_protocol_fee_root_can_set() { - new_test_ext().execute_with(|| { - assert_ok!(LimitOrders::set_protocol_fee( - RuntimeOrigin::root(), - 1_000_000 - )); - assert_eq!(ProtocolFee::::get(), 1_000_000); - assert_event(Event::ProtocolFeeSet { fee: 1_000_000 }); - }); -} - -#[test] -fn set_protocol_fee_admin_can_set() { - new_test_ext().execute_with(|| { - Admin::::put(alice()); - assert_ok!(LimitOrders::set_protocol_fee( - RuntimeOrigin::signed(alice()), - 500_000 - )); - assert_eq!(ProtocolFee::::get(), 500_000); - assert_event(Event::ProtocolFeeSet { fee: 500_000 }); - }); -} - -#[test] -fn set_protocol_fee_non_admin_rejected() { - new_test_ext().execute_with(|| { - Admin::::put(alice()); - // Bob is not the admin. - assert_noop!( - LimitOrders::set_protocol_fee(RuntimeOrigin::signed(bob()), 999), - Error::::NotAdmin - ); - }); -} - -#[test] -fn set_protocol_fee_no_admin_signed_rejected() { - new_test_ext().execute_with(|| { - // No admin set at all; signed origin that is not root must be rejected. - assert_noop!( - LimitOrders::set_protocol_fee(RuntimeOrigin::signed(alice()), 999), - Error::::NotAdmin - ); - }); -} - -#[test] -fn set_protocol_fee_unsigned_rejected() { - new_test_ext().execute_with(|| { - assert_noop!( - LimitOrders::set_protocol_fee(RuntimeOrigin::none(), 1), - DispatchError::BadOrigin - ); - }); -} - // ───────────────────────────────────────────────────────────────────────────── // cancel_order // ───────────────────────────────────────────────────────────────────────────── @@ -150,6 +40,8 @@ fn cancel_order_signer_can_cancel() { amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), }; let id = order_id(&order); @@ -176,6 +68,8 @@ fn cancel_order_non_signer_rejected() { amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), }; // Bob tries to cancel Alice's order. assert_noop!( @@ -196,6 +90,8 @@ fn cancel_order_already_cancelled_rejected() { amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), }; let id = order_id(&order); Orders::::insert(id, OrderStatus::Cancelled); @@ -218,6 +114,8 @@ fn cancel_order_already_fulfilled_rejected() { amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), }; let id = order_id(&order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -240,6 +138,8 @@ fn cancel_order_unsigned_rejected() { amount: 1_000, limit_price: u64::MAX, expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), }; assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::none(), order), @@ -266,6 +166,8 @@ fn execute_orders_buy_order_fulfilled() { 1_000, 2_000_000_000, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -300,6 +202,8 @@ fn execute_orders_sell_order_fulfilled() { 500, 1, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -334,6 +238,8 @@ fn execute_orders_stop_loss_order_fulfilled() { 500, 1, // raw limit_price = 1 TAO/alpha FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -367,6 +273,8 @@ fn execute_orders_stop_loss_price_not_met_skipped() { 500, 1, // raw limit_price = 1 TAO/alpha FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -392,6 +300,8 @@ fn execute_orders_expired_order_skipped() { 1_000, u64::MAX, 2_000_000, // expiry in the past + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -418,6 +328,8 @@ fn execute_orders_price_not_met_skipped() { 1_000, 2, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); @@ -443,6 +355,8 @@ fn execute_orders_already_processed_skipped() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -471,6 +385,8 @@ fn execute_orders_mixed_batch_valid_and_skipped() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let expired = make_signed_order( AccountKeyring::Bob, @@ -480,6 +396,8 @@ fn execute_orders_mixed_batch_valid_and_skipped() { 500, u64::MAX, 500_000, // already expired + Perbill::zero(), + fee_recipient(), ); let valid_id = order_id(&valid.order); @@ -507,8 +425,8 @@ fn execute_orders_buy_with_fee_charges_fee() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(1.0); - ProtocolFee::::put(10_000_000u32); // 1% + // fee_rate = 1% (10_000_000 parts-per-billion), recipient = fee_recipient(). let signed = make_signed_order( AccountKeyring::Alice, bob(), @@ -517,6 +435,8 @@ fn execute_orders_buy_with_fee_charges_fee() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), ); MockSwap::set_tao_balance(alice(), 1_000); assert_ok!(LimitOrders::execute_orders( @@ -537,8 +457,8 @@ fn execute_orders_buy_with_fee_charges_fee() { .collect(); assert_eq!(buys, vec![990], "main swap must use 990 TAO after 1% fee"); - // Fee (10 TAO) forwarded directly to FeeCollector via transfer_tao. - assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 10); + // Fee (10 TAO) forwarded directly to fee_recipient via transfer_tao. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 10); }); } @@ -547,13 +467,12 @@ fn execute_orders_sell_with_fee_charges_fee() { new_test_ext().execute_with(|| { // fee = 1% (10_000_000 ppb). // Alice sells 1_000 alpha; pool returns 800 TAO. - // fee_tao = 1% of 800 = 8 TAO, forwarded to FeeCollector via transfer_tao. + // fee_tao = 1% of 800 = 8 TAO, forwarded to fee_recipient via transfer_tao. // Alice keeps 792 TAO. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_sell_tao_return(800); MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); - ProtocolFee::::put(10_000_000u32); // 1% let signed = make_signed_order( AccountKeyring::Alice, @@ -563,6 +482,8 @@ fn execute_orders_sell_with_fee_charges_fee() { 1_000, 0, FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), ); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie()), @@ -582,8 +503,8 @@ fn execute_orders_sell_with_fee_charges_fee() { .collect(); assert_eq!(sells, vec![1_000], "full alpha amount must be sold"); - // FeeCollector received 8 TAO (1% of 800). - assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 8); + // fee_recipient received 8 TAO (1% of 800). + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 8); // Alice kept the remaining 792 TAO. assert_eq!(MockSwap::tao_balance(&alice()), 792); }); @@ -615,6 +536,8 @@ fn execute_batched_orders_all_invalid_returns_ok() { 1_000, u64::MAX, 1_000_000, + Perbill::zero(), + fee_recipient(), ); // Returns Ok even when nothing executes. assert_ok!(LimitOrders::execute_batched_orders( @@ -648,6 +571,8 @@ fn execute_batched_orders_skips_wrong_netuid() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&wrong_net.order); @@ -686,6 +611,8 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { 600, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let bob_order = make_signed_order( AccountKeyring::Bob, @@ -695,6 +622,8 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { 400, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -747,6 +676,8 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { 300, 0, FAR_FUTURE, // limit=0 → accept any price + Perbill::zero(), + fee_recipient(), ); let bob_order = make_signed_order( AccountKeyring::Bob, @@ -756,6 +687,8 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { 200, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -813,6 +746,8 @@ fn execute_batched_orders_buy_dominant_mixed() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let bob_buy = make_signed_order( AccountKeyring::Bob, @@ -822,6 +757,8 @@ fn execute_batched_orders_buy_dominant_mixed() { 600, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let charlie_sell = make_signed_order( AccountKeyring::Charlie, @@ -831,6 +768,8 @@ fn execute_batched_orders_buy_dominant_mixed() { 200, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); assert_ok!(LimitOrders::execute_batched_orders( @@ -883,6 +822,8 @@ fn execute_batched_orders_sell_dominant_mixed() { 200, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let bob_sell = make_signed_order( AccountKeyring::Bob, @@ -892,6 +833,8 @@ fn execute_batched_orders_sell_dominant_mixed() { 300, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let charlie_sell = make_signed_order( AccountKeyring::Charlie, @@ -901,6 +844,8 @@ fn execute_batched_orders_sell_dominant_mixed() { 200, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); assert_ok!(LimitOrders::execute_batched_orders( @@ -929,11 +874,10 @@ fn execute_batched_orders_fee_forwarded_to_collector() { // fee = 1% (10_000_000 ppb). // Alice buys 1000 TAO: fee = 10, net = 990. // Pool returns 500 alpha for 990 TAO. - // collect_fees transfers 10 TAO (buy fee) to FeeCollector. + // collect_fees transfers 10 TAO (buy fee) to fee_recipient. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_buy_alpha_return(500); - ProtocolFee::::put(10_000_000u32); let alice_buy = make_signed_order( AccountKeyring::Alice, @@ -943,6 +887,8 @@ fn execute_batched_orders_fee_forwarded_to_collector() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), ); assert_ok!(LimitOrders::execute_batched_orders( @@ -951,8 +897,8 @@ fn execute_batched_orders_fee_forwarded_to_collector() { bounded(vec![alice_buy]), )); - // Fee collector received the buy-side fee. - assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 10); + // Fee recipient received the buy-side fee. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 10); }); } @@ -971,6 +917,8 @@ fn execute_batched_orders_cancelled_order_skipped() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Cancelled); @@ -998,14 +946,13 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { // Pool returns 9 TAO (mocked) for that residual. // total_tao for sellers = 9 (pool) + 990 (buy passthrough) = 999. // Bob gross_share = 999 * 1_000/1_000 = 999. - // Sell fee = 1% of 999 = 9 TAO; Bob nets 990 TAO. - // FeeCollector total = buy_fee(10) + sell_fee(9) = 19 TAO. + // Sell fee = 1% of 999 = 9.99 → rounds to 10 TAO; Bob nets 989 TAO. + // fee_recipient total = buy_fee(10) + sell_fee(10) = 20 TAO. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_sell_tao_return(9); MockSwap::set_tao_balance(alice(), 1_000); MockSwap::set_alpha_balance(bob(), dave(), netuid(), 1_000); - ProtocolFee::::put(10_000_000u32); // 1% let alice_buy = make_signed_order( AccountKeyring::Alice, @@ -1015,6 +962,8 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), ); let bob_sell = make_signed_order( AccountKeyring::Bob, @@ -1024,6 +973,8 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { 1_000, 0, FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1032,10 +983,10 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { bounded(vec![alice_buy, bob_sell]), )); - // Both sides charged: FeeCollector gets buy fee (10) + sell fee (9) = 19. - assert_eq!(MockSwap::tao_balance(&FeeCollectorAccount::get()), 19); - // Bob receives 990 TAO after sell-side fee. - assert_eq!(MockSwap::tao_balance(&bob()), 990); + // Both sides charged: fee_recipient gets buy fee (10) + sell fee (10) = 20. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 20); + // Bob receives 989 TAO after sell-side fee. + assert_eq!(MockSwap::tao_balance(&bob()), 989); }); } @@ -1060,6 +1011,8 @@ fn execute_batched_orders_buy_zero_alpha_returns_error() { 1_000, u64::MAX, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); assert_noop!( @@ -1090,6 +1043,8 @@ fn execute_batched_orders_sell_zero_tao_returns_error() { 1_000, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); assert_noop!( @@ -1120,6 +1075,8 @@ fn execute_batched_orders_sell_alpha_respects_swap_fail() { 1_000, 0, FAR_FUTURE, + Perbill::zero(), + fee_recipient(), ); assert_noop!( @@ -1132,3 +1089,114 @@ fn execute_batched_orders_sell_alpha_respects_swap_fail() { ); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// fee routing – multiple recipients +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_fees_routed_to_different_recipients() { + new_test_ext().execute_with(|| { + // Alice and Bob both buy; Alice's fee goes to charlie(), Bob's to dave(). + // fee = 1% for both orders. + // Alice buys 1_000 TAO: fee = 10 → charlie(). + // Bob buys 1_000 TAO: fee = 10 → dave(). + // Pool returns 900 alpha total for 1_980 TAO net. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(900); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_tao_balance(bob(), 1_000); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + charlie(), + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + dave(), + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_buy, bob_buy]), + )); + + // Each recipient gets exactly their order's fee. + assert_eq!( + MockSwap::tao_balance(&charlie()), + 10, + "charlie gets Alice's fee" + ); + assert_eq!(MockSwap::tao_balance(&dave()), 10, "dave gets Bob's fee"); + }); +} + +#[test] +fn execute_batched_orders_fees_batched_for_shared_recipient() { + new_test_ext().execute_with(|| { + // Both Alice and Bob's fees go to the same recipient (charlie()). + // Expect a single combined transfer of 20 TAO to charlie(). + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(900); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_tao_balance(bob(), 1_000); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + charlie(), + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + charlie(), + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_buy, bob_buy]), + )); + + // One combined transfer: charlie() receives 10 + 10 = 20 TAO. + let fee_transfers: Vec<_> = MockSwap::tao_transfers() + .into_iter() + .filter(|(_, to, _)| to == &charlie()) + .collect(); + assert_eq!( + fee_transfers.len(), + 1, + "single transfer to shared recipient" + ); + assert_eq!(fee_transfers[0].2, 20, "combined fee = 20 TAO"); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index af0be157bf..dfc3ce5c25 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -15,7 +15,7 @@ use frame_system as system; use sp_core::{H256, Pair}; use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{ - AccountId32, BuildStorage, MultiSignature, + AccountId32, BuildStorage, MultiSignature, Perbill, traits::{BlakeTwo256, IdentityLookup}, }; use substrate_fixed::types::U96F32; @@ -277,6 +277,10 @@ impl OrderSwapInterface for MockSwap { MOCK_PRICE.with(|p| *p.borrow()) } + fn is_subtoken_enabled(_netuid: NetUid) -> bool { + true + } + fn transfer_tao( from: &AccountId, to: &AccountId, @@ -359,14 +363,18 @@ impl frame_support::traits::UnixTime for MockTime { parameter_types! { pub const LimitOrdersPalletId: PalletId = PalletId(*b"lmt/ordr"); - pub const FeeCollectorAccount: AccountId = AccountId::new([0xfe; 32]); pub const PalletHotkeyAccount: AccountId = AccountId::new([0xaa; 32]); } +/// A fixed account used in tests as the fee recipient when a concrete +/// recipient is needed but the test isn't specifically about fees. +pub fn fee_recipient() -> AccountId { + AccountId::new([0xfe; 32]) +} + impl pallet_limit_orders::Config for Test { type SwapInterface = MockSwap; type TimeProvider = MockTime; - type FeeCollector = FeeCollectorAccount; type MaxOrdersPerBatch = ConstU32<64>; type PalletId = LimitOrdersPalletId; type PalletHotkey = PalletHotkeyAccount; @@ -400,6 +408,8 @@ pub fn make_signed_order( amount: u64, limit_price: u64, expiry: u64, + fee_rate: sp_runtime::Perbill, + fee_recipient: AccountId, ) -> crate::SignedOrder { let signer = keyring.to_account_id(); let order = crate::Order { @@ -410,6 +420,8 @@ pub fn make_signed_order( amount, limit_price, expiry, + fee_rate, + fee_recipient, }; let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { From 1594eeca8e004a3e4aff4bf3ab09c24a2399fca1 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 11:40:57 +0200 Subject: [PATCH 16/85] add tests regarding fee --- pallets/limit-orders/src/tests/extrinsics.rs | 112 +++++++++++++++++++ pallets/subtensor/src/staking/order_swap.rs | 18 ++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 71358ececa..81b28b0be8 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1200,3 +1200,115 @@ fn execute_batched_orders_fees_batched_for_shared_recipient() { assert_eq!(fee_transfers[0].2, 20, "combined fee = 20 TAO"); }); } + +/// 4 orders split across 2 fee recipients. +/// +/// Orders: +/// Alice LimitBuy 1_000 TAO fee_recipient = ferdie (buy-fee collector) +/// Bob LimitBuy 1_000 TAO fee_recipient = ferdie (buy-fee collector) +/// Charlie TakeProfit 1_000 α fee_recipient = fee_recipient() (sell-fee collector) +/// Eve TakeProfit 1_000 α fee_recipient = fee_recipient() (sell-fee collector) +/// +/// Neither ferdie nor fee_recipient() are order signers, so every TAO transfer +/// to those accounts is exclusively a fee transfer — making the single-transfer +/// assertion unambiguous. +/// +/// At price 1.0 (1 TAO = 1 α), fee = 1%: +/// net buy TAO = (1_000 - 10) + (1_000 - 10) = 1_980 +/// sell α equiv = 2_000 TAO → sell-dominant, residual = 20 α → pool +/// pool returns 18 TAO for residual +/// total TAO for sellers = 18 + 1_980 = 1_998 +/// each seller gross_share = 1_998 * 1_000 / 2_000 = 999 +/// sell fee = 1% * 999 = 10 TAO each +/// +/// Expected: +/// ferdie receives 10 (Alice) + 10 (Bob) = 20 TAO (1 transfer) +/// fee_recipient() receives 10 (Charlie) + 10 (Eve) = 20 TAO (1 transfer) +#[test] +fn execute_batched_orders_four_orders_two_fee_recipients() { + new_test_ext().execute_with(|| { + let ferdie = AccountKeyring::Ferdie.to_account_id(); + let eve = AccountKeyring::Eve.to_account_id(); + + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_sell_tao_return(18); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_tao_balance(bob(), 1_000); + MockSwap::set_alpha_balance(charlie(), dave(), netuid(), 1_000); + MockSwap::set_alpha_balance(eve.clone(), dave(), netuid(), 1_000); + + let alice_buy = make_signed_order( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + ferdie.clone(), + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + ferdie.clone(), + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, + dave(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), + ); + let eve_sell = make_signed_order( + AccountKeyring::Eve, + dave(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(alice()), + netuid(), + bounded(vec![alice_buy, bob_buy, charlie_sell, eve_sell]), + )); + + // ferdie collects Alice's and Bob's buy fees: 10 + 10 = 20 TAO in one transfer. + let ferdie_transfers: Vec<_> = MockSwap::tao_transfers() + .into_iter() + .filter(|(_, to, _)| to == &ferdie) + .collect(); + assert_eq!(ferdie_transfers.len(), 1, "single transfer to ferdie"); + assert_eq!( + ferdie_transfers[0].2, 20, + "ferdie receives 20 TAO in buy fees" + ); + + // fee_recipient() collects Charlie's and Eve's sell fees: 10 + 10 = 20 TAO in one transfer. + let fp_transfers: Vec<_> = MockSwap::tao_transfers() + .into_iter() + .filter(|(_, to, _)| to == &fee_recipient()) + .collect(); + assert_eq!(fp_transfers.len(), 1, "single transfer to fee_recipient"); + assert_eq!( + fp_transfers[0].2, 20, + "fee_recipient receives 20 TAO in sell fees" + ); + }); +} diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index ef7c582ad2..de2ed3f12b 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -13,11 +13,15 @@ impl OrderSwapInterface for Pallet { tao_amount: TaoBalance, limit_price: TaoBalance, ) -> Result { + // Debit TAO from the buyer before the pool swap so the pallet's + // intermediary account (and individual buyers in execute_orders) cannot + // stake more TAO than they actually hold. + let actual_tao = Self::remove_balance_from_coldkey_account(coldkey, tao_amount)?; Self::stake_into_subnet( hotkey, coldkey, netuid, - tao_amount, + actual_tao, limit_price, false, false, @@ -31,13 +35,23 @@ impl OrderSwapInterface for Pallet { alpha_amount: AlphaBalance, limit_price: TaoBalance, ) -> Result { - Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, limit_price, false) + let tao_out = + Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, limit_price, false)?; + // Credit TAO proceeds to the seller so the pallet's intermediary account + // (and individual sellers in execute_orders) have real balance to + // distribute or forward to the fee collector. + Self::add_balance_to_coldkey_account(coldkey, tao_out); + Ok(tao_out) } fn current_alpha_price(netuid: NetUid) -> U96F32 { T::SwapInterface::current_alpha_price(netuid) } + fn is_subtoken_enabled(netuid: NetUid) -> bool { + Self::is_subtoken_enabled(netuid) + } + fn transfer_tao(from: &T::AccountId, to: &T::AccountId, amount: TaoBalance) -> DispatchResult { ::Currency::transfer(from, to, amount, Preservation::Expendable)?; Ok(()) From 97eac2dcc181bdcf0e355a80037aa724390d68dc Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 15:03:32 +0200 Subject: [PATCH 17/85] apply limits, including rates, min stake, etc --- pallets/limit-orders/src/lib.rs | 11 +++- pallets/limit-orders/src/tests/extrinsics.rs | 69 ++++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 33 ++++++++-- primitives/swap-interface/src/lib.rs | 36 ++++++++++ 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 5841a3fe31..062287fc5a 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -358,8 +358,7 @@ pub mod pallet { current_price: U96F32, ) -> bool { let order = &signed_order.order; - T::SwapInterface::is_subtoken_enabled(order.netuid) - && matches!(signed_order.signature, MultiSignature::Sr25519(_)) + matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order .signature .verify(order.encode().as_slice(), &order.signer) @@ -401,6 +400,7 @@ pub mod pallet { order.netuid, tao_after_fee, TaoBalance::from(order.limit_price), + true, )?; // Forward the fee TAO to the order's fee recipient. @@ -417,6 +417,7 @@ pub mod pallet { order.netuid, AlphaBalance::from(order.amount), TaoBalance::from(order.limit_price), + true, )?; // Deduct fee from TAO output and forward to the order's fee recipient. @@ -614,6 +615,8 @@ pub mod pallet { pallet_hotkey, netuid, AlphaBalance::from(e.gross), + true, // validate_sender: check user's rate limit, subnet, min stake + false, // set_receiver_limit: do not rate-limit the pallet intermediary )?; } Ok(()) @@ -640,6 +643,7 @@ pub mod pallet { netuid, TaoBalance::from(net_tao), TaoBalance::from(u64::MAX), // no price ceiling for net pool swap + false, )? .to_u64() as u128; ensure!(out > 0, Error::::SwapReturnedZero); @@ -658,6 +662,7 @@ pub mod pallet { netuid, AlphaBalance::from(net_alpha), TaoBalance::ZERO, + false, )? .to_u64() as u128; ensure!(out > 0, Error::::SwapReturnedZero); @@ -703,6 +708,8 @@ pub mod pallet { &e.hotkey, netuid, AlphaBalance::from(share), + false, // validate_sender: skip — pallet intermediary needs no validation + true, // set_receiver_limit: rate-limit the buyer after they receive stake )?; } Orders::::insert(e.order_id, OrderStatus::Fulfilled); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 81b28b0be8..0432bbfc33 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1312,3 +1312,72 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { ); }); } + +/// A mixed batch (buy + sell) must not rate-limit the pallet intermediary +/// account during asset collection, which would otherwise block the +/// subsequent alpha distribution to buyers. +/// +/// Regression test: previously `transfer_staked_alpha` with a single +/// `apply_limits: true` flag set the rate-limit on `to_coldkey` (pallet) +/// during collection, then the distribution step checked `from_coldkey` +/// (pallet) and failed with `StakingOperationRateLimitExceeded`. +#[test] +fn execute_batched_orders_mixed_batch_does_not_rate_limit_pallet_intermediary() { + new_test_ext().execute_with(|| { + // Alice buys 1_000 TAO; Bob sells 500 alpha. + // Buy-dominant: residual 500 TAO goes to pool, pool returns 400 alpha. + // Total alpha = 400 (pool) + 500 (Bob passthrough) = 900 → all to Alice. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(400); + MockSwap::set_tao_balance(alice(), 1_000); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 500); + + let buy = make_signed_order( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 500, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + // Must succeed: collecting Bob's alpha must not rate-limit the pallet + // intermediary, so distributing alpha to Alice is not blocked. + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![buy, sell]), + )); + + // Alice received staked alpha. + assert!( + MockSwap::alpha_balance(&alice(), &dave(), netuid()) > 0, + "alice should hold staked alpha after the buy" + ); + // Alice is rate-limited after receiving stake (set_receiver_limit=true). + assert!( + MockSwap::is_rate_limited(&dave(), &alice(), netuid()), + "alice should be rate-limited after receiving stake" + ); + // Bob's hotkey on the pallet side is NOT rate-limited (set_receiver_limit=false on collect). + assert!( + !MockSwap::is_rate_limited(&dave(), &bob(), netuid()), + "bob's rate-limit should not be set by the collection step" + ); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index dfc3ce5c25..406f48024b 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -106,6 +106,10 @@ thread_local! { RefCell::new(HashMap::new()); /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. pub static MOCK_SWAP_FAIL: RefCell = RefCell::new(false); + /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. + /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. + pub static RATE_LIMITS: RefCell> = + RefCell::new(std::collections::HashSet::new()); } pub struct MockSwap; @@ -127,6 +131,10 @@ impl MockSwap { SWAP_LOG.with(|l| l.borrow_mut().clear()); ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); TAO_BALANCES.with(|b| b.borrow_mut().clear()); + RATE_LIMITS.with(|r| r.borrow_mut().clear()); + } + pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { + RATE_LIMITS.with(|r| r.borrow().contains(&(hotkey.clone(), coldkey.clone(), netuid))) } /// Seed a staked alpha balance for a (coldkey, hotkey, netuid) triple. pub fn set_alpha_balance(coldkey: AccountId, hotkey: AccountId, netuid: NetUid, amount: u64) { @@ -203,6 +211,7 @@ impl OrderSwapInterface for MockSwap { netuid: NetUid, tao_amount: TaoBalance, _limit_price: TaoBalance, + _apply_limits: bool, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { return Err(frame_support::pallet_prelude::DispatchError::Other( @@ -241,6 +250,7 @@ impl OrderSwapInterface for MockSwap { netuid: NetUid, alpha_amount: AlphaBalance, _limit_price: TaoBalance, + _apply_limits: bool, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { return Err(frame_support::pallet_prelude::DispatchError::Other( @@ -277,10 +287,6 @@ impl OrderSwapInterface for MockSwap { MOCK_PRICE.with(|p| *p.borrow()) } - fn is_subtoken_enabled(_netuid: NetUid) -> bool { - true - } - fn transfer_tao( from: &AccountId, to: &AccountId, @@ -311,7 +317,20 @@ impl OrderSwapInterface for MockSwap { to_hotkey: &AccountId, netuid: NetUid, amount: AlphaBalance, + validate_sender: bool, + set_receiver_limit: bool, ) -> frame_support::pallet_prelude::DispatchResult { + if validate_sender { + let rate_limited = RATE_LIMITS.with(|r| { + r.borrow() + .contains(&(from_hotkey.clone(), from_coldkey.clone(), netuid)) + }); + if rate_limited { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "StakingOperationRateLimitExceeded", + )); + } + } let amt = amount.to_u64(); ALPHA_BALANCES.with(|b| { let mut map = b.borrow_mut(); @@ -324,6 +343,12 @@ impl OrderSwapInterface for MockSwap { .or_insert(0); *to_bal = to_bal.saturating_add(amt); }); + if set_receiver_limit { + RATE_LIMITS.with(|r| { + r.borrow_mut() + .insert((to_hotkey.clone(), to_coldkey.clone(), netuid)); + }); + } SWAP_LOG.with(|l| { l.borrow_mut().push(SwapCall::TransferStakedAlpha { from_coldkey: from_coldkey.clone(), diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index d8626557a0..94e37cad5a 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -59,22 +59,36 @@ pub trait SwapHandler { pub trait OrderSwapInterface { /// Buy alpha with TAO: debit `tao_amount` from `coldkey`'s free balance, /// credit resulting alpha as stake at `hotkey` on `netuid`. + /// + /// When `apply_limits` is `true` the implementation enforces subnet + /// existence, hotkey registration, minimum stake amount, sufficient + /// coldkey balance, and sets the staking rate-limit flag for `(hotkey, + /// coldkey, netuid)` after a successful stake. Pass `false` for internal + /// pallet-intermediary swaps that must bypass these user-facing guards. fn buy_alpha( coldkey: &AccountId, hotkey: &AccountId, netuid: NetUid, tao_amount: TaoBalance, limit_price: TaoBalance, + apply_limits: bool, ) -> Result; /// Sell alpha for TAO: remove `alpha_amount` from `coldkey`'s stake at /// `hotkey` on `netuid`, credit resulting TAO to `coldkey`'s free balance. + /// + /// When `apply_limits` is `true` the implementation enforces subnet + /// existence, hotkey registration, minimum stake amount, sufficient alpha + /// balance, and checks that the staking rate-limit flag is not set for + /// `(hotkey, coldkey, netuid)` (i.e. the account did not stake this + /// block). Pass `false` for internal pallet-intermediary swaps. fn sell_alpha( coldkey: &AccountId, hotkey: &AccountId, netuid: NetUid, alpha_amount: AlphaBalance, limit_price: TaoBalance, + apply_limits: bool, ) -> Result; /// Current spot price: TAO per alpha, same scale as @@ -95,6 +109,25 @@ pub trait OrderSwapInterface { /// matching in `execute_batched_orders`: it lets the pallet collect alpha /// from sell-order signers into its intermediary account, and later /// distribute alpha to buy-order signers, all without touching the pool. + /// + /// When `validate_sender` is `true`, the sender side is validated before + /// the transfer: subnet existence, subtoken enabled, minimum stake amount, + /// and the staking rate-limit flag for `(from_hotkey, from_coldkey, + /// netuid)` is checked — the transfer is rejected if `from_coldkey` + /// already staked this block. + /// + /// When `set_receiver_limit` is `true`, the staking rate-limit flag for + /// `(to_hotkey, to_coldkey, netuid)` is set after the transfer, marking + /// that `to_coldkey` has received stake this block. + /// + /// The two flags are intentionally separate so that each call site can + /// opt into only the half it needs: + /// - Collecting alpha from users into the pallet intermediary: + /// `validate_sender: true, set_receiver_limit: false` — validates the + /// user but does not rate-limit the intermediary account. + /// - Distributing alpha from the pallet intermediary to buyers: + /// `validate_sender: false, set_receiver_limit: true` — skips checking + /// the intermediary (which would fail) and rate-limits the buyer. fn transfer_staked_alpha( from_coldkey: &AccountId, from_hotkey: &AccountId, @@ -102,7 +135,10 @@ pub trait OrderSwapInterface { to_hotkey: &AccountId, netuid: NetUid, amount: AlphaBalance, + validate_sender: bool, + set_receiver_limit: bool, ) -> DispatchResult; + } pub trait DefaultPriceLimit From 0a6adbd07fb1ef216b20cbb0031cc681b8c6d148 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 15:07:35 +0200 Subject: [PATCH 18/85] keep adding more validations --- pallets/limit-orders/src/lib.rs | 49 ++++++------ pallets/subtensor/src/staking/order_swap.rs | 84 ++++++++++++++------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 062287fc5a..dc13fa5502 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -348,30 +348,37 @@ pub mod pallet { T::PalletId::get().into_account_truncating() } - /// Returns `true` if `signed_order` passes all execution preconditions: - /// valid signature, not yet processed, not expired, and price condition met. + /// Validates all execution preconditions for a signed order. /// Netuid is intentionally not checked here; callers handle that separately. fn is_order_valid( signed_order: &SignedOrder, order_id: H256, now_ms: u64, current_price: U96F32, - ) -> bool { + ) -> Result<(), Error> { let order = &signed_order.order; - matches!(signed_order.signature, MultiSignature::Sr25519(_)) - && signed_order - .signature - .verify(order.encode().as_slice(), &order.signer) - && Orders::::get(order_id).is_none() - && now_ms <= order.expiry - && match order.order_type { - OrderType::TakeProfit => { - current_price >= U96F32::saturating_from_num(order.limit_price) - } - OrderType::StopLoss | OrderType::LimitBuy => { - current_price <= U96F32::saturating_from_num(order.limit_price) - } - } + ensure!( + matches!(signed_order.signature, MultiSignature::Sr25519(_)) + && signed_order + .signature + .verify(order.encode().as_slice(), &order.signer), + Error::::InvalidSignature + ); + ensure!( + Orders::::get(order_id).is_none(), + Error::::OrderAlreadyProcessed + ); + ensure!(now_ms <= order.expiry, Error::::OrderExpired); + ensure!( + match order.order_type { + OrderType::TakeProfit => + current_price >= U96F32::saturating_from_num(order.limit_price), + OrderType::StopLoss | OrderType::LimitBuy => + current_price <= U96F32::saturating_from_num(order.limit_price), + }, + Error::::PriceConditionNotMet + ); + Ok(()) } /// Attempt to execute one signed order. Returns an error on any @@ -382,10 +389,7 @@ pub mod pallet { let now_ms = T::TimeProvider::now().as_millis() as u64; let current_price = T::SwapInterface::current_alpha_price(order.netuid); - ensure!( - Self::is_order_valid(&signed_order, order_id, now_ms, current_price), - Error::::InvalidSignature - ); + Self::is_order_valid(&signed_order, order_id, now_ms, current_price)?; // 5. Execute the swap, taking the order's fee from the input (buys) or output (sells). let (amount_in, amount_out) = if order.order_type.is_buy() { @@ -556,7 +560,8 @@ pub mod pallet { let order_id = Self::derive_order_id(order); let valid = order.netuid == netuid - && Self::is_order_valid(signed_order, order_id, now_ms, current_price); + && Self::is_order_valid(signed_order, order_id, now_ms, current_price) + .is_ok(); if !valid { Self::deposit_event(Event::OrderSkipped { order_id }); diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index de2ed3f12b..77ed056ae4 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -12,12 +12,29 @@ impl OrderSwapInterface for Pallet { netuid: NetUid, tao_amount: TaoBalance, limit_price: TaoBalance, + apply_limits: bool, ) -> Result { + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + Self::ensure_subtoken_enabled(netuid)?; + if apply_limits { + ensure!( + Self::hotkey_account_exists(hotkey), + Error::::HotKeyAccountNotExists + ); + ensure!( + tao_amount >= DefaultMinStake::::get(), + Error::::AmountTooLow + ); + ensure!( + Self::can_remove_balance_from_coldkey_account(coldkey, tao_amount), + Error::::NotEnoughBalanceToStake + ); + } // Debit TAO from the buyer before the pool swap so the pallet's // intermediary account (and individual buyers in execute_orders) cannot // stake more TAO than they actually hold. let actual_tao = Self::remove_balance_from_coldkey_account(coldkey, tao_amount)?; - Self::stake_into_subnet( + let alpha_out = Self::stake_into_subnet( hotkey, coldkey, netuid, @@ -25,7 +42,11 @@ impl OrderSwapInterface for Pallet { limit_price, false, false, - ) + )?; + if apply_limits { + Self::set_stake_operation_limit(hotkey, coldkey, netuid); + } + Ok(alpha_out) } fn sell_alpha( @@ -34,7 +55,24 @@ impl OrderSwapInterface for Pallet { netuid: NetUid, alpha_amount: AlphaBalance, limit_price: TaoBalance, + apply_limits: bool, ) -> Result { + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + Self::ensure_subtoken_enabled(netuid)?; + if apply_limits { + ensure!(!alpha_amount.is_zero(), Error::::AmountTooLow); + let tao_equiv = T::SwapInterface::current_alpha_price(netuid) + .saturating_mul(U96F32::saturating_from_num(alpha_amount.to_u64())) + .saturating_to_num::(); + ensure!( + TaoBalance::from(tao_equiv) >= DefaultMinStake::::get(), + Error::::AmountTooLow + ); + let available = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); + ensure!(available >= alpha_amount, Error::::NotEnoughStakeToWithdraw); + Self::ensure_stake_operation_limit_not_exceeded(hotkey, coldkey, netuid)?; + } let tao_out = Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, limit_price, false)?; // Credit TAO proceeds to the seller so the pallet's intermediary account @@ -48,10 +86,6 @@ impl OrderSwapInterface for Pallet { T::SwapInterface::current_alpha_price(netuid) } - fn is_subtoken_enabled(netuid: NetUid) -> bool { - Self::is_subtoken_enabled(netuid) - } - fn transfer_tao(from: &T::AccountId, to: &T::AccountId, amount: TaoBalance) -> DispatchResult { ::Currency::transfer(from, to, amount, Preservation::Expendable)?; Ok(()) @@ -64,27 +98,22 @@ impl OrderSwapInterface for Pallet { to_hotkey: &T::AccountId, netuid: NetUid, amount: AlphaBalance, + validate_sender: bool, + set_receiver_limit: bool, ) -> DispatchResult { - // Why not `transfer_stake_within_subnet`? - // - // 1. Silent no-op on insufficient balance — `decrease_stake_for_hotkey_and_coldkey_on_subnet` - // returns `()` without error when the coldkey has less stake than requested. Without the - // explicit `ensure!` below, the decrease would silently fail while the increase still - // runs, creating alpha out of thin air on the destination. - // - // 2. `AmountTooLow` minimum-stake check — `transfer_stake_within_subnet` rejects transfers - // whose TAO equivalent is below `DefaultMinStake`. Small pro-rata shares distributed to - // buyers in `distribute_alpha_pro_rata` are legitimate but can fall below that threshold, - // which would abort the entire batch. - // - // 3. Rate-limit (`StakingOperationRateLimitExceeded`) — `validate_stake_transition` (called - // via `do_transfer_stake`) checks `StakingOperationRateLimiter` on the origin account. - // The pallet intermediary account would be rate-limited after the first transfer per block. - // - // `LastColdkeyHotkeyStakeBlock` is updated for the destination after the transfer, - // consistent with `transfer_stake_within_subnet`. It is a write-only observability item - // (never read on-chain) but keeping it up-to-date is cheap and keeps off-chain indexers - // accurate. + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + Self::ensure_subtoken_enabled(netuid)?; + if validate_sender { + ensure!(!amount.is_zero(), Error::::AmountTooLow); + let tao_equiv = T::SwapInterface::current_alpha_price(netuid) + .saturating_mul(U96F32::saturating_from_num(amount.to_u64())) + .saturating_to_num::(); + ensure!( + TaoBalance::from(tao_equiv) >= DefaultMinStake::::get(), + Error::::AmountTooLow + ); + Self::ensure_stake_operation_limit_not_exceeded(from_hotkey, from_coldkey, netuid)?; + } let available = Self::get_stake_for_hotkey_and_coldkey_on_subnet(from_hotkey, from_coldkey, netuid); @@ -103,6 +132,9 @@ impl OrderSwapInterface for Pallet { to_hotkey, Self::get_current_block_as_u64(), ); + if set_receiver_limit { + Self::set_stake_operation_limit(to_hotkey, to_coldkey, netuid); + } Ok(()) } } From 738e7bdc13188fe622c316ff7df798f9bb843d8c Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 15:18:05 +0200 Subject: [PATCH 19/85] check for order validity --- pallets/limit-orders/src/lib.rs | 4 +- pallets/limit-orders/src/tests/auxiliary.rs | 152 +++++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index dc13fa5502..ec519b4050 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -350,12 +350,12 @@ pub mod pallet { /// Validates all execution preconditions for a signed order. /// Netuid is intentionally not checked here; callers handle that separately. - fn is_order_valid( + pub(crate) fn is_order_valid( signed_order: &SignedOrder, order_id: H256, now_ms: u64, current_price: U96F32, - ) -> Result<(), Error> { + ) -> DispatchResult { let order = &signed_order.order; ensure!( matches!(signed_order.signature, MultiSignature::Sr25519(_)) diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 9202c2c9a6..2a2afea9e9 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -2,7 +2,7 @@ //! //! Extrinsics are NOT tested here. Each section focuses on one helper. -use frame_support::{BoundedVec, traits::ConstU32}; +use frame_support::{assert_noop, assert_ok, BoundedVec, traits::ConstU32}; use sp_core::H256; use sp_keyring::Sr25519Keyring as AccountKeyring; use substrate_fixed::types::U96F32; @@ -1107,3 +1107,153 @@ fn collect_fees_no_transfer_when_zero_fees() { assert_eq!(tao_transfers.len(), 0, "no transfer when total fee is zero"); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// is_order_valid +// ───────────────────────────────────────────────────────────────────────────── + +use codec::Encode; +use sp_core::Pair; +use sp_runtime::{MultiSignature, traits::Verify}; +use subtensor_swap_interface::OrderSwapInterface; +use crate::Error; + +fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { + let keyring = AccountKeyring::Alice; + let order = crate::Order { + signer: keyring.to_account_id(), + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + }; + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + }; + (signed, id) +} + +#[test] +fn is_order_valid_returns_ok_for_well_formed_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let (signed, id) = make_valid_signed_order(); + let price = MockSwap::current_alpha_price(netuid()); + assert_ok!(LimitOrders::::is_order_valid(&signed, id, 1_000_000, price)); + }); +} + +#[test] +fn is_order_valid_invalid_signature_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let (mut signed, id) = make_valid_signed_order(); + // Replace with a signature from a different key. + let wrong_sig = AccountKeyring::Bob.pair().sign(&signed.order.encode()); + signed.signature = MultiSignature::Sr25519(wrong_sig); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn is_order_valid_non_sr25519_signature_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let (mut signed, id) = make_valid_signed_order(); + let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_sig = ed_pair.sign(&signed.order.encode()); + signed.signature = MultiSignature::Ed25519(ed_sig); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn is_order_valid_already_processed_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let (signed, id) = make_valid_signed_order(); + Orders::::insert(id, crate::OrderStatus::Fulfilled); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + Error::::OrderAlreadyProcessed + ); + }); +} + +#[test] +fn is_order_valid_expired_order_returns_error() { + new_test_ext().execute_with(|| { + MockSwap::set_price(1.0); + let (signed, id) = make_valid_signed_order(); + // now_ms (2_000_001) > expiry (u64::MAX is fine, so use a low expiry order). + // Re-build a signed order with a past expiry. + let keyring = AccountKeyring::Alice; + let order = crate::Order { + expiry: 500_000, + ..signed.order.clone() + }; + let id2 = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed2 = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed2, id2, 1_000_000, price), + Error::::OrderExpired + ); + }); +} + +#[test] +fn is_order_valid_price_condition_not_met_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price 5.0 > limit_price 2 → LimitBuy condition (price ≤ limit) not met. + MockSwap::set_price(5.0); + let keyring = AccountKeyring::Alice; + let order = crate::Order { + signer: keyring.to_account_id(), + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: 2, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + }; + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + Error::::PriceConditionNotMet + ); + }); +} From c7243cd8610bf4b09e5e43dc6b0063b19787b1fd Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 16:48:24 +0200 Subject: [PATCH 20/85] first integration tests running --- Cargo.lock | 3 + pallets/limit-orders/src/tests/auxiliary.rs | 8 +- pallets/limit-orders/src/tests/mock.rs | 5 +- pallets/subtensor/src/staking/order_swap.rs | 5 +- primitives/swap-interface/src/lib.rs | 1 - runtime/Cargo.toml | 5 + runtime/src/lib.rs | 23 + runtime/tests/limit_orders.rs | 451 ++++++++++++++++++++ 8 files changed, 495 insertions(+), 6 deletions(-) create mode 100644 runtime/tests/limit_orders.rs diff --git a/Cargo.lock b/Cargo.lock index 060ab60722..420f5dc9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8414,6 +8414,7 @@ dependencies = [ "pallet-grandpa", "pallet-hotfix-sufficients", "pallet-insecure-randomness-collective-flip", + "pallet-limit-orders", "pallet-multisig", "pallet-nomination-pools", "pallet-nomination-pools-runtime-api", @@ -8457,6 +8458,7 @@ dependencies = [ "sp-genesis-builder", "sp-inherents", "sp-io", + "sp-keyring", "sp-npos-elections", "sp-offchain", "sp-runtime", @@ -9977,6 +9979,7 @@ dependencies = [ "sp-io", "sp-keyring", "sp-runtime", + "sp-std", "substrate-fixed", "subtensor-runtime-common", "subtensor-swap-interface", diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 2a2afea9e9..d6d271cdaa 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -2,7 +2,7 @@ //! //! Extrinsics are NOT tested here. Each section focuses on one helper. -use frame_support::{assert_noop, assert_ok, BoundedVec, traits::ConstU32}; +use frame_support::{BoundedVec, assert_noop, assert_ok, traits::ConstU32}; use sp_core::H256; use sp_keyring::Sr25519Keyring as AccountKeyring; use substrate_fixed::types::U96F32; @@ -1112,11 +1112,11 @@ fn collect_fees_no_transfer_when_zero_fees() { // is_order_valid // ───────────────────────────────────────────────────────────────────────────── +use crate::Error; use codec::Encode; use sp_core::Pair; use sp_runtime::{MultiSignature, traits::Verify}; use subtensor_swap_interface::OrderSwapInterface; -use crate::Error; fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { let keyring = AccountKeyring::Alice; @@ -1147,7 +1147,9 @@ fn is_order_valid_returns_ok_for_well_formed_order() { MockSwap::set_price(1.0); let (signed, id) = make_valid_signed_order(); let price = MockSwap::current_alpha_price(netuid()); - assert_ok!(LimitOrders::::is_order_valid(&signed, id, 1_000_000, price)); + assert_ok!(LimitOrders::::is_order_valid( + &signed, id, 1_000_000, price + )); }); } diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 406f48024b..3ab1395eae 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -134,7 +134,10 @@ impl MockSwap { RATE_LIMITS.with(|r| r.borrow_mut().clear()); } pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { - RATE_LIMITS.with(|r| r.borrow().contains(&(hotkey.clone(), coldkey.clone(), netuid))) + RATE_LIMITS.with(|r| { + r.borrow() + .contains(&(hotkey.clone(), coldkey.clone(), netuid)) + }) } /// Seed a staked alpha balance for a (coldkey, hotkey, netuid) triple. pub fn set_alpha_balance(coldkey: AccountId, hotkey: AccountId, netuid: NetUid, amount: u64) { diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 77ed056ae4..268044ba3a 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -70,7 +70,10 @@ impl OrderSwapInterface for Pallet { ); let available = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); - ensure!(available >= alpha_amount, Error::::NotEnoughStakeToWithdraw); + ensure!( + available >= alpha_amount, + Error::::NotEnoughStakeToWithdraw + ); Self::ensure_stake_operation_limit_not_exceeded(hotkey, coldkey, netuid)?; } let tao_out = diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 94e37cad5a..969d835110 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -138,7 +138,6 @@ pub trait OrderSwapInterface { validate_sender: bool, set_receiver_limit: bool, ) -> DispatchResult; - } pub trait DefaultPriceLimit diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 2d7b2250d6..3c1dbf5c86 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -150,6 +150,9 @@ ark-serialize = { workspace = true, features = ["derive"] } # Crowdloan pallet-crowdloan.workspace = true +# Limit Orders +pallet-limit-orders.workspace = true + # Mev Shield pallet-shield.workspace = true stp-shield.workspace = true @@ -160,6 +163,7 @@ ethereum.workspace = true frame-metadata.workspace = true sp-io.workspace = true sp-tracing.workspace = true +sp-keyring.workspace = true precompile-utils = { workspace = true, features = ["testing"] } [build-dependencies] @@ -225,6 +229,7 @@ std = [ "sp-genesis-builder/std", "subtensor-precompiles/std", "subtensor-runtime-common/std", + "pallet-limit-orders/std", "pallet-crowdloan/std", "pallet-babe/std", "pallet-session/std", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5fd4e5b401..37e2ea6049 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -57,6 +57,7 @@ use sp_core::{ }; use sp_runtime::Cow; use sp_runtime::generic::Era; +use sp_runtime::traits::AccountIdConversion; use sp_runtime::{ AccountId32, ApplyExtrinsicResult, ConsensusEngineId, Percent, generic, impl_opaque_keys, traits::{ @@ -1535,6 +1536,27 @@ impl pallet_crowdloan::Config for Runtime { type MaxContributors = MaxContributors; } +// Limit Orders +parameter_types! { + pub const LimitOrdersPalletId: PalletId = PalletId(*b"bt/limit"); + pub const LimitOrdersMaxOrdersPerBatch: u32 = 100; +} + +pub struct LimitOrdersPalletHotkey; +impl Get for LimitOrdersPalletHotkey { + fn get() -> AccountId { + PalletId(*b"bt/lmhky").into_account_truncating() + } +} + +impl pallet_limit_orders::Config for Runtime { + type SwapInterface = SubtensorModule; + type TimeProvider = Timestamp; + type MaxOrdersPerBatch = LimitOrdersMaxOrdersPerBatch; + type PalletId = LimitOrdersPalletId; + type PalletHotkey = LimitOrdersPalletHotkey; +} + fn contracts_schedule() -> pallet_contracts::Schedule { pallet_contracts::Schedule { limits: pallet_contracts::Limits { @@ -1656,6 +1678,7 @@ construct_runtime!( Swap: pallet_subtensor_swap = 28, Contracts: pallet_contracts = 29, MevShield: pallet_shield = 30, + LimitOrders: pallet_limit_orders = 31, } ); diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs new file mode 100644 index 0000000000..e3ca9a508e --- /dev/null +++ b/runtime/tests/limit_orders.rs @@ -0,0 +1,451 @@ +#![allow(clippy::unwrap_used)] + +use codec::Encode; +use frame_support::{BoundedVec, assert_ok}; +use node_subtensor_runtime::{ + BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, + System, pallet_subtensor, +}; +use pallet_limit_orders::{Order, OrderStatus, OrderType, Orders, SignedOrder}; +use sp_core::{Get, H256, Pair}; +use sp_keyring::Sr25519Keyring; +use sp_runtime::{MultiSignature, Perbill}; +use subtensor_runtime_common::{AccountId, AlphaBalance, NetUid, TaoBalance, Token}; + +fn new_test_ext() -> sp_io::TestExternalities { + sp_tracing::try_init_simple(); + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() + .build_storage() + .unwrap() + .into(); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +/// Initialise a subnet so that limit-order execution has a pool to interact with. +/// +/// We use the stable mechanism (mechanism_id = 0, the default), which swaps at a +/// fixed 1 TAO : 1 alpha rate without requiring pre-seeded AMM liquidity. +fn setup_subnet(netuid: NetUid) { + SubtensorModule::init_new_network(netuid, 0); + pallet_subtensor::SubtokenEnabled::::insert(netuid, true); +} + +fn min_default_stake() -> TaoBalance { + pallet_subtensor::DefaultMinStake::::get() +} +fn order_id(order: &Order) -> H256 { + H256(sp_io::hashing::blake2_256(&order.encode())) +} + +fn make_signed_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, +) -> SignedOrder { + let order = Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + }; + let sig = keyring.pair().sign(&order.encode()); + SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// Signing and cancelling an order writes the order id to storage as Cancelled +/// and emits OrderCancelled. No subnet or balance setup required. +#[test] +fn cancel_order_works() { + new_test_ext().execute_with(|| { + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); + + let order = Order { + signer: alice_id.clone(), + hotkey: bob_id, + netuid: NetUid::from(1u16), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient, + }; + let id = order_id(&order); + + assert_ok!(LimitOrders::cancel_order( + RuntimeOrigin::signed(alice_id), + order, + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Cancelled)); + }); +} + +/// An order signed with an Ed25519 key is rejected at validation time even +/// though the signature itself is cryptographically valid. The order must not +/// appear in the Orders storage map after the batch runs. +#[test] +fn execute_orders_ed25519_signature_rejected() { + new_test_ext().execute_with(|| { + let alice_id = Sr25519Keyring::Alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); + + let order = Order { + signer: alice_id.clone(), + hotkey: bob_id, + netuid: NetUid::from(1u16), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient, + }; + let id = order_id(&order); + + // Sign with ed25519 — valid signature, wrong scheme. + let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_sig = ed_pair.sign(&order.encode()); + let signed = SignedOrder { + order, + signature: MultiSignature::Ed25519(ed_sig), + }; + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(alice_id), + orders, + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// A LimitBuy order whose price condition is satisfied executes against the pool, +/// marks the order as Fulfilled, and credits staked alpha to the buyer. +#[test] +fn limit_buy_order_executes_and_stakes_alpha() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Fund Alice so buy_alpha can debit her balance. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Create the hot-key association. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // limit_price = u64::MAX → current_price (1.0) ≤ MAX → condition always met. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), // default min stake units of TAO to spend + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // Alice must now have staked alpha delegated through Bob on this subnet. + let staked = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert!( + staked > AlphaBalance::ZERO, + "alice should hold staked alpha after a LimitBuy order executes" + ); + }); +} + +/// A TakeProfit order whose price condition is satisfied executes against the pool, +/// marks the order as Fulfilled, and burns the seller's staked alpha position. +#[test] +fn take_profit_order_executes_and_unstakes_alpha() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Create the hot-key association. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Seed Alice with staked alpha through Bob so she has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 0 → current_price (1.0) ≥ 0 → condition always met. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), // sell min default alpha units + 0, // price floor — always satisfied + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // Alice's staked alpha must have decreased after the sell executes. + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert!( + remaining < initial_alpha.into(), + "alice's staked alpha should decrease after a TakeProfit order executes" + ); + }); +} + +// ── Batched execution ───────────────────────────────────────────────────────── + +/// Buy side (5 000 TAO) exceeds sell side (2 000 alpha ≈ 2 000 TAO at 1:1). +/// +/// Residual 3 000 TAO goes to the pool; buyers receive pool alpha + seller passthrough +/// alpha. Sellers receive the passthrough TAO that corresponds to their alpha. +/// +/// With the stable mechanism (1 TAO = 1 alpha): +/// • Alice (buyer 5 000 TAO) → 5 000 alpha staked to Dave +/// • Bob (seller 2 000 α) → 2 000 free TAO +#[test] +fn batched_buy_dominant_executes_correctly() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + setup_subnet(netuid); + + // Alice has free TAO to spend on a buy order. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Seed Bob with staked alph so he has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + + // Bob has staked alpha (through Dave) to sell. + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + // Create the hot-key association. Alice-> Charlie, Bob -> Dave + SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); + SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().to_u64() * 2u64, + u64::MAX, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), + 0, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + // Alice spent TAO and must hold the resulting staked alpha. + let alice_alpha = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &charlie_id, + &alice_id, + netuid, + ); + assert!( + alice_alpha > AlphaBalance::ZERO, + "alice should hold staked alpha after buy-dominant batch" + ); + + // Bob sold alpha and must hold the resulting free TAO. + let bob_tao = SubtensorModule::get_coldkey_balance(&bob_id); + assert!( + bob_tao > TaoBalance::ZERO, + "bob should hold free TAO after buy-dominant batch" + ); + }); +} + +/// Sell side (5 000 alpha ≈ 5 000 TAO at 1:1) exceeds buy side (2 000 TAO). +/// +/// Residual 3 000 alpha goes to the pool; sellers receive pool TAO + buyer +/// passthrough TAO. Buyers receive the passthrough alpha corresponding to their TAO. +/// +/// With the stable mechanism (1 TAO = 1 alpha): +/// • Alice (buyer 2 000 TAO) → 2 000 alpha staked to Dave +/// • Bob (seller 5 000 α) → 5 000 free TAO +#[test] +fn batched_sell_dominant_executes_correctly() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + setup_subnet(netuid); + + // Create the hot-key association. Alice-> Charlie, Bob -> Dave + SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); + SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + + // Alice has free TAO to spend on a buy order. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Seed Bob with staked alph so he has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().to_u64() * 2u64, + 0, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + // Alice spent TAO and must hold the resulting staked alpha. + let alice_alpha = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &charlie_id, + &alice_id, + netuid, + ); + assert!( + alice_alpha > AlphaBalance::ZERO, + "alice should hold staked alpha after sell-dominant batch" + ); + + // Bob sold alpha and must hold the resulting free TAO. + let bob_tao = SubtensorModule::get_coldkey_balance(&bob_id); + assert!( + bob_tao > TaoBalance::ZERO, + "bob should hold free TAO after sell-dominant batch" + ); + }); +} From 2dd7c1685a830b1b2e4e036da950f2cd04ed2339 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 31 Mar 2026 18:15:44 +0200 Subject: [PATCH 21/85] apply filters --- pallets/subtensor/src/staking/order_swap.rs | 19 ++- runtime/tests/limit_orders.rs | 146 +++++++++++++++++++- 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 268044ba3a..151d4f6ca8 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -18,7 +18,7 @@ impl OrderSwapInterface for Pallet { Self::ensure_subtoken_enabled(netuid)?; if apply_limits { ensure!( - Self::hotkey_account_exists(hotkey), + Self::coldkey_owns_hotkey(coldkey, hotkey), Error::::HotKeyAccountNotExists ); ensure!( @@ -60,6 +60,11 @@ impl OrderSwapInterface for Pallet { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; if apply_limits { + ensure!( + Self::coldkey_owns_hotkey(coldkey, hotkey), + Error::::HotKeyAccountNotExists + ); + ensure!(!alpha_amount.is_zero(), Error::::AmountTooLow); let tao_equiv = T::SwapInterface::current_alpha_price(netuid) .saturating_mul(U96F32::saturating_from_num(alpha_amount.to_u64())) @@ -102,11 +107,15 @@ impl OrderSwapInterface for Pallet { netuid: NetUid, amount: AlphaBalance, validate_sender: bool, - set_receiver_limit: bool, + validate_receiver: bool, ) -> DispatchResult { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; if validate_sender { + ensure!( + Self::coldkey_owns_hotkey(from_coldkey, from_hotkey), + Error::::HotKeyAccountNotExists + ); ensure!(!amount.is_zero(), Error::::AmountTooLow); let tao_equiv = T::SwapInterface::current_alpha_price(netuid) .saturating_mul(U96F32::saturating_from_num(amount.to_u64())) @@ -135,7 +144,11 @@ impl OrderSwapInterface for Pallet { to_hotkey, Self::get_current_block_as_u64(), ); - if set_receiver_limit { + if validate_receiver { + ensure!( + Self::coldkey_owns_hotkey(to_coldkey, to_hotkey), + Error::::HotKeyAccountNotExists + ); Self::set_stake_operation_limit(to_hotkey, to_coldkey, netuid); } Ok(()) diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index e3ca9a508e..a314f73eaf 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] use codec::Encode; -use frame_support::{BoundedVec, assert_ok}; +use frame_support::{BoundedVec, assert_noop, assert_ok}; use node_subtensor_runtime::{ BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, System, pallet_subtensor, @@ -358,14 +358,14 @@ fn batched_buy_dominant_executes_correctly() { }); } -/// Sell side (5 000 alpha ≈ 5 000 TAO at 1:1) exceeds buy side (2 000 TAO). +/// Sell side (min_default_stake()*2 alpha ≈ min_default_stake()*2 TAO at 1:1) exceeds buy side (min_default_stake() TAO). /// -/// Residual 3 000 alpha goes to the pool; sellers receive pool TAO + buyer +/// Residual min_default_stake() alpha goes to the pool; sellers receive pool TAO + buyer /// passthrough TAO. Buyers receive the passthrough alpha corresponding to their TAO. /// /// With the stable mechanism (1 TAO = 1 alpha): -/// • Alice (buyer 2 000 TAO) → 2 000 alpha staked to Dave -/// • Bob (seller 5 000 α) → 5 000 free TAO +/// • Alice (buyer min_default_stake() TAO) → alpha staked to Dave +/// • Bob (seller min_default_stake()*2 α) → min_default_stake()*2 free TAO #[test] fn batched_sell_dominant_executes_correctly() { new_test_ext().execute_with(|| { @@ -449,3 +449,139 @@ fn batched_sell_dominant_executes_correctly() { ); }); } + +#[test] +fn batched_fails_if_executing_below_minimum_on_sell() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + setup_subnet(netuid); + + // Create the hot-key association. Alice-> Charlie, Bob -> Dave + SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); + SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + + // Alice has free TAO to spend on a buy order. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Seed Bob with staked alph so he has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + 1u64, + 0, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + ), + pallet_subtensor::Error::::AmountTooLow + ); + }); +} + +#[test] +fn batched_fails_if_executing_without_hot_key_association() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + setup_subnet(netuid); + + // Create the hot-key association. Alice is not associating to charlie + + // Alice has free TAO to spend on a buy order. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Seed Bob with staked alph so he has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().to_u64() * 2u64, + 0, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + ), + pallet_subtensor::Error::::HotKeyAccountNotExists + ); + }); +} From 375f6d887dbc36aef548b7660ae5cc4934ce0916 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 10:02:02 +0200 Subject: [PATCH 22/85] change errors and add new tests --- pallets/limit-orders/src/lib.rs | 108 +++++++++------- runtime/tests/limit_orders.rs | 219 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 48 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index ec519b4050..452dfd920a 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -241,6 +241,10 @@ pub mod pallet { Unauthorized, /// The pool swap returned zero output for a non-zero input. SwapReturnedZero, + /// Root netuid (0) is not allowed for limit orders. + RootNetUidNotAllowed, + /// An order in the batch targets a different netuid than the batch netuid parameter. + OrderNetUidMismatch, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -349,7 +353,9 @@ pub mod pallet { } /// Validates all execution preconditions for a signed order. - /// Netuid is intentionally not checked here; callers handle that separately. + /// Checks that the order's netuid is not root (0), that the signature is valid, + /// the order has not been processed, is not expired, and the price condition is met. + /// The batch netuid match (order.netuid == batch netuid) is checked separately by callers. pub(crate) fn is_order_valid( signed_order: &SignedOrder, order_id: H256, @@ -357,6 +363,10 @@ pub mod pallet { current_price: U96F32, ) -> DispatchResult { let order = &signed_order.order; + ensure!( + !order.netuid.is_root(), + Error::::RootNetUidNotAllowed + ); ensure!( matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order @@ -452,12 +462,14 @@ pub mod pallet { netuid: NetUid, orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { + ensure!(!netuid.is_root(), Error::::RootNetUidNotAllowed); + let now_ms = T::TimeProvider::now().as_millis() as u64; let current_price = T::SwapInterface::current_alpha_price(netuid); - // Filter invalid/expired/price-missed orders; classify the rest into buys and sells. + // Validate all orders; any invalid order causes the entire batch to fail. let (valid_buys, valid_sells) = - Self::validate_and_classify(netuid, &orders, now_ms, current_price); + Self::validate_and_classify(netuid, &orders, now_ms, current_price)?; let executed_count = (valid_buys.len() + valid_sells.len()) as u32; if executed_count == 0 { @@ -541,63 +553,63 @@ pub mod pallet { /// Validate every order against `netuid`, signature, expiry, and price. /// Valid orders are split into two BoundedVecs by side. /// Each entry is `(order_id, signer, hotkey, gross, net, fee)`. + /// + /// Returns an error immediately if any order fails validation (wrong netuid, + /// invalid signature, expired, already processed, or price condition not met). pub(crate) fn validate_and_classify( netuid: NetUid, orders: &BoundedVec, T::MaxOrdersPerBatch>, now_ms: u64, current_price: U96F32, - ) -> ( - BoundedVec, T::MaxOrdersPerBatch>, - BoundedVec, T::MaxOrdersPerBatch>, - ) { + ) -> Result< + ( + BoundedVec, T::MaxOrdersPerBatch>, + BoundedVec, T::MaxOrdersPerBatch>, + ), + DispatchError, + > { let mut buys = BoundedVec::new(); let mut sells = BoundedVec::new(); - orders - .iter() - .filter_map(|signed_order| { - let order = &signed_order.order; - let order_id = Self::derive_order_id(order); + for signed_order in orders.iter() { + let order = &signed_order.order; + let order_id = Self::derive_order_id(order); - let valid = order.netuid == netuid - && Self::is_order_valid(signed_order, order_id, now_ms, current_price) - .is_ok(); + // Hard-fail if the order targets a different subnet than the batch netuid. + ensure!(order.netuid == netuid, Error::::OrderNetUidMismatch); - if !valid { - Self::deposit_event(Event::OrderSkipped { order_id }); - return None; - } + // Hard-fail on any per-order validation error (signature, expiry, price, root). + Self::is_order_valid(signed_order, order_id, now_ms, current_price)?; - let net = if order.order_type.is_buy() { - // Buy: fee on TAO input — net is the amount that reaches the pool. - order.amount.saturating_sub(order.fee_rate * order.amount) - } else { - // Sell: fee on TAO output — full alpha enters the pool; the fee is - // deducted from the TAO payout later in `distribute_tao_pro_rata`. - order.amount - }; - - Some(OrderEntry { - order_id, - signer: order.signer.clone(), - hotkey: order.hotkey.clone(), - side: order.order_type.clone(), - gross: order.amount, - net, - fee_rate: order.fee_rate, - fee_recipient: order.fee_recipient.clone(), - }) - }) - .for_each(|entry| { - // try_push cannot fail: both vecs share the same bound as `orders`. - if entry.side.is_buy() { - let _ = buys.try_push(entry); - } else { - let _ = sells.try_push(entry); - } - }); + let net = if order.order_type.is_buy() { + // Buy: fee on TAO input — net is the amount that reaches the pool. + order.amount.saturating_sub(order.fee_rate * order.amount) + } else { + // Sell: fee on TAO output — full alpha enters the pool; the fee is + // deducted from the TAO payout later in `distribute_tao_pro_rata`. + order.amount + }; + + let entry = OrderEntry { + order_id, + signer: order.signer.clone(), + hotkey: order.hotkey.clone(), + side: order.order_type.clone(), + gross: order.amount, + net, + fee_rate: order.fee_rate, + fee_recipient: order.fee_recipient.clone(), + }; + + // try_push cannot fail: both vecs share the same bound as `orders`. + if entry.side.is_buy() { + let _ = buys.try_push(entry); + } else { + let _ = sells.try_push(entry); + } + } - (buys, sells) + Ok((buys, sells)) } /// Pull gross TAO from each buyer and gross staked alpha from each seller diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index a314f73eaf..06dd242176 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -585,3 +585,222 @@ fn batched_fails_if_executing_without_hot_key_association() { ); }); } + +/// `execute_batched_orders` fails when the target subnet does not exist. +/// The subnet is never initialised (no `setup_subnet`), so `buy_alpha` +/// returns `SubnetNotExists` during the pool-swap step. +#[test] +fn batched_fails_for_nonexistent_subnet() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(2u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + // Fund Alice so that `transfer_tao` succeeds; the subnet check happens + // later inside `buy_alpha`. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + let buy = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + ), + pallet_subtensor::Error::::SubnetNotExists + ); + }); +} + +/// `execute_batched_orders` fails when the subnet exists but its subtoken is +/// not enabled. The order passes validation (price condition is met) and the +/// TAO transfer succeeds, but `buy_alpha` then returns `SubtokenDisabled`. +#[test] +fn batched_fails_if_subtoken_not_enabled() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + // Initialise the network but deliberately skip setting SubtokenEnabled. + SubtensorModule::init_new_network(netuid, 0); + + // Fund Alice so that the TAO transfer in `collect_assets` succeeds. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + let buy = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + ), + pallet_subtensor::Error::::SubtokenDisabled + ); + }); +} + +/// An order whose `expiry` is in the past causes `execute_batched_orders` to +/// fail with `OrderExpired`. +#[test] +fn batched_fails_for_expired_order() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Advance the runtime timestamp so that `now_ms` exceeds the order's expiry. + // `pallet_timestamp::Now` stores milliseconds; set it to 100_000 ms. + pallet_timestamp::Now::::put(100_000u64); + + // Build an order that expired at 50_000 ms — already in the past. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + 50_000, // expiry in ms — before current timestamp of 100_000 + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + ), + pallet_limit_orders::Error::::OrderExpired + ); + }); +} + +/// An order whose price condition is not met causes `execute_batched_orders` to +/// fail with `PriceConditionNotMet`. A `LimitBuy` with `limit_price = 0` +/// requires `current_price <= 0`; since the stable mechanism prices alpha at +/// 1.0 TAO the condition is never met. +#[test] +fn batched_fails_if_price_condition_not_met() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // limit_price = 0 requires current_price <= 0, but current_price ~= 1.0 → fails. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + 0, // price ceiling of 0 — never satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + ), + pallet_limit_orders::Error::::PriceConditionNotMet + ); + }); +} + +/// `execute_batched_orders` fails immediately with `RootNetUidNotAllowed` when +/// called with `netuid = 0` (the root network). +#[test] +fn batched_fails_for_root_netuid() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(0u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + // Fund Alice so the call gets past any balance checks before hitting the root guard. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + let buy = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy].try_into().unwrap(); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + ), + pallet_limit_orders::Error::::RootNetUidNotAllowed + ); + }); +} From 59136d69538b6ce04f2048b437a2f09a96eb5f5d Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 10:14:25 +0200 Subject: [PATCH 23/85] fix pallet-iner tests --- pallets/limit-orders/src/tests/auxiliary.rs | 52 ++++++++++------- pallets/limit-orders/src/tests/extrinsics.rs | 61 ++++++++++---------- 2 files changed, 61 insertions(+), 52 deletions(-) diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index d6d271cdaa..4dad54da23 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -110,7 +110,7 @@ fn validate_and_classify_separates_buys_and_sells() { &orders, 1_000_000u64, U96F32::from_num(1u32), - ); + ).expect("validate_and_classify should succeed"); assert_eq!(buys.len(), 1, "expected 1 valid buy"); assert_eq!(sells.len(), 1, "expected 1 valid sell"); @@ -131,8 +131,9 @@ fn validate_and_classify_separates_buys_and_sells() { } #[test] -fn validate_and_classify_skips_wrong_netuid() { +fn validate_and_classify_fails_for_wrong_netuid() { new_test_ext().execute_with(|| { + // An order whose netuid does not match the batch netuid must cause a hard failure. MockTime::set(1_000_000); MockSwap::set_price(1.0); @@ -149,22 +150,24 @@ fn validate_and_classify_skips_wrong_netuid() { ); let orders = bounded(vec![wrong_netuid_order]); - let (buys, sells) = LimitOrders::::validate_and_classify( + let result = LimitOrders::::validate_and_classify( netuid(), // batch is for netuid 1 &orders, 1_000_000u64, U96F32::from_num(1u32), ); - assert_eq!(buys.len(), 0); - assert_eq!(sells.len(), 0); + assert!( + matches!(result, Err(e) if e == crate::Error::::OrderNetUidMismatch.into()), + "expected OrderNetUidMismatch error" + ); }); } #[test] -fn validate_and_classify_skips_expired_order() { +fn validate_and_classify_fails_for_expired_order() { new_test_ext().execute_with(|| { - // now_ms = 2_000_001, expiry = 2_000_000 → expired + // now_ms = 2_000_001, expiry = 2_000_000 → expired → hard failure. MockTime::set(2_000_001); MockSwap::set_price(1.0); @@ -181,23 +184,25 @@ fn validate_and_classify_skips_expired_order() { ); let orders = bounded(vec![expired]); - let (buys, sells) = LimitOrders::::validate_and_classify( + let result = LimitOrders::::validate_and_classify( netuid(), &orders, 2_000_001u64, U96F32::from_num(1u32), ); - assert_eq!(buys.len(), 0); - assert_eq!(sells.len(), 0); + assert!( + matches!(result, Err(e) if e == crate::Error::::OrderExpired.into()), + "expected OrderExpired error" + ); }); } #[test] -fn validate_and_classify_skips_price_condition_not_met_for_buy() { +fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { new_test_ext().execute_with(|| { + // Price = 3.0 TAO/alpha, buyer's limit = 2.0 → price > limit → hard failure. MockTime::set(1_000_000); - // Price = 3.0 TAO/alpha, buyer's limit = 2.0 → price > limit → skip let order = make_signed_order( AccountKeyring::Alice, bob(), @@ -211,20 +216,24 @@ fn validate_and_classify_skips_price_condition_not_met_for_buy() { ); let orders = bounded(vec![order]); - let (buys, _) = LimitOrders::::validate_and_classify( + let result = LimitOrders::::validate_and_classify( netuid(), &orders, 1_000_000u64, - U96F32::from_num(3u32), // current price = 3 > limit 2 → skip + U96F32::from_num(3u32), // current price = 3 > limit 2 → fails ); - assert_eq!(buys.len(), 0); + assert!( + matches!(result, Err(e) if e == crate::Error::::PriceConditionNotMet.into()), + "expected PriceConditionNotMet error" + ); }); } #[test] -fn validate_and_classify_skips_already_processed_order() { +fn validate_and_classify_fails_for_already_processed_order() { new_test_ext().execute_with(|| { + // An order already marked Fulfilled must cause a hard failure. MockTime::set(1_000_000); let order = make_signed_order( AccountKeyring::Alice, @@ -243,14 +252,17 @@ fn validate_and_classify_skips_already_processed_order() { Orders::::insert(oid, OrderStatus::Fulfilled); let orders = bounded(vec![order]); - let (buys, _) = LimitOrders::::validate_and_classify( + let result = LimitOrders::::validate_and_classify( netuid(), &orders, 1_000_000u64, U96F32::from_num(1u32), ); - assert_eq!(buys.len(), 0); + assert!( + matches!(result, Err(e) if e == crate::Error::::OrderAlreadyProcessed.into()), + "expected OrderAlreadyProcessed error" + ); }); } @@ -279,7 +291,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { &orders, 1_000_000u64, U96F32::from_num(1u32), - ); + ).expect("validate_and_classify should succeed"); assert_eq!(buys.len(), 1); let entry = &buys[0]; @@ -1206,7 +1218,7 @@ fn is_order_valid_already_processed_returns_error() { fn is_order_valid_expired_order_returns_error() { new_test_ext().execute_with(|| { MockSwap::set_price(1.0); - let (signed, id) = make_valid_signed_order(); + let (signed, _id) = make_valid_signed_order(); // now_ms (2_000_001) > expiry (u64::MAX is fine, so use a low expiry order). // Re-build a signed order with a past expiry. let keyring = AccountKeyring::Alice; diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 0432bbfc33..b642528102 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -525,8 +525,9 @@ fn execute_batched_orders_unsigned_rejected() { } #[test] -fn execute_batched_orders_all_invalid_returns_ok() { +fn execute_batched_orders_all_invalid_fails() { new_test_ext().execute_with(|| { + // An expired order causes the whole batch to fail. MockTime::set(2_000_001); // all expired let expired = make_signed_order( AccountKeyring::Alice, @@ -539,26 +540,21 @@ fn execute_batched_orders_all_invalid_returns_ok() { Perbill::zero(), fee_recipient(), ); - // Returns Ok even when nothing executes. - assert_ok!(LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie()), - netuid(), - bounded(vec![expired]), - )); - // No summary event — early return when executed_count == 0. - let has_summary = System::events().iter().any(|r| { - matches!( - &r.event, - RuntimeEvent::LimitOrders(Event::GroupExecutionSummary { .. }) - ) - }); - assert!(!has_summary); + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![expired]), + ), + Error::::OrderExpired + ); }); } #[test] -fn execute_batched_orders_skips_wrong_netuid() { +fn execute_batched_orders_fails_for_wrong_netuid() { new_test_ext().execute_with(|| { + // An order whose netuid does not match the batch netuid must cause the batch to fail. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_buy_alpha_return(100); @@ -574,17 +570,14 @@ fn execute_batched_orders_skips_wrong_netuid() { Perbill::zero(), fee_recipient(), ); - let id = order_id(&wrong_net.order); - - assert_ok!(LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie()), - netuid(), // batch targets netuid 1 - bounded(vec![wrong_net]), - )); - assert!( - Orders::::get(id).is_none(), - "wrong-netuid order must not be fulfilled" + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), // batch targets netuid 1 + bounded(vec![wrong_net]), + ), + Error::::OrderNetUidMismatch ); }); } @@ -903,8 +896,9 @@ fn execute_batched_orders_fee_forwarded_to_collector() { } #[test] -fn execute_batched_orders_cancelled_order_skipped() { +fn execute_batched_orders_fails_for_cancelled_order() { new_test_ext().execute_with(|| { + // A cancelled order is already processed; including it in the batch must cause a hard failure. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_buy_alpha_return(100); @@ -923,11 +917,14 @@ fn execute_batched_orders_cancelled_order_skipped() { let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Cancelled); - assert_ok!(LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie()), - netuid(), - bounded(vec![signed]), - )); + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed]), + ), + Error::::OrderAlreadyProcessed + ); // Still cancelled, not changed to Fulfilled. assert_eq!(Orders::::get(id), Some(OrderStatus::Cancelled)); From 2e70f39a1f594d8fcc0baf7d3b86a316cf8518e2 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 10:40:56 +0200 Subject: [PATCH 24/85] use assert_noop in pallet tests --- pallets/limit-orders/src/lib.rs | 2 +- pallets/limit-orders/src/tests/auxiliary.rs | 72 +++++++++------------ 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 452dfd920a..9e1d67b515 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -112,7 +112,7 @@ pub enum OrderStatus { /// Classified, fee-adjusted entry produced by `validate_and_classify`. /// Used in every in-memory batch pipeline step; never stored on-chain. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub(crate) struct OrderEntry { pub(crate) order_id: H256, pub(crate) signer: AccountId, diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 4dad54da23..450165aeb8 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -150,16 +150,14 @@ fn validate_and_classify_fails_for_wrong_netuid() { ); let orders = bounded(vec![wrong_netuid_order]); - let result = LimitOrders::::validate_and_classify( - netuid(), // batch is for netuid 1 - &orders, - 1_000_000u64, - U96F32::from_num(1u32), - ); - - assert!( - matches!(result, Err(e) if e == crate::Error::::OrderNetUidMismatch.into()), - "expected OrderNetUidMismatch error" + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), // batch is for netuid 1 + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderNetUidMismatch ); }); } @@ -184,16 +182,14 @@ fn validate_and_classify_fails_for_expired_order() { ); let orders = bounded(vec![expired]); - let result = LimitOrders::::validate_and_classify( - netuid(), - &orders, - 2_000_001u64, - U96F32::from_num(1u32), - ); - - assert!( - matches!(result, Err(e) if e == crate::Error::::OrderExpired.into()), - "expected OrderExpired error" + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 2_000_001u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderExpired ); }); } @@ -216,16 +212,14 @@ fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { ); let orders = bounded(vec![order]); - let result = LimitOrders::::validate_and_classify( - netuid(), - &orders, - 1_000_000u64, - U96F32::from_num(3u32), // current price = 3 > limit 2 → fails - ); - - assert!( - matches!(result, Err(e) if e == crate::Error::::PriceConditionNotMet.into()), - "expected PriceConditionNotMet error" + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(3u32), // current price = 3 > limit 2 → fails + ), + crate::Error::::PriceConditionNotMet ); }); } @@ -252,16 +246,14 @@ fn validate_and_classify_fails_for_already_processed_order() { Orders::::insert(oid, OrderStatus::Fulfilled); let orders = bounded(vec![order]); - let result = LimitOrders::::validate_and_classify( - netuid(), - &orders, - 1_000_000u64, - U96F32::from_num(1u32), - ); - - assert!( - matches!(result, Err(e) if e == crate::Error::::OrderAlreadyProcessed.into()), - "expected OrderAlreadyProcessed error" + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderAlreadyProcessed ); }); } From 5bb9945eba184eadcfa028a52aec6dc9b976402f Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 11:02:21 +0200 Subject: [PATCH 25/85] add more tests --- pallets/limit-orders/src/tests/extrinsics.rs | 117 ++++++++ runtime/tests/limit_orders.rs | 264 +++++++++++++++++++ 2 files changed, 381 insertions(+) diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index b642528102..8ff794c9e8 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -510,6 +510,123 @@ fn execute_orders_sell_with_fee_charges_fee() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// execute_orders — silent-skip behaviour +// ───────────────────────────────────────────────────────────────────────────── + +mod execute_orders_skip_invalid { + use super::*; + + /// A single expired order is silently skipped: the call returns `Ok` and + /// nothing is written to the `Orders` storage map. + #[test] + fn execute_orders_skips_expired_order() { + new_test_ext().execute_with(|| { + MockTime::set(2_000_001); // now > expiry + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + 2_000_000, // expiry in the past + Perbill::zero(), + fee_recipient(), + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Skipped — storage untouched. + assert!(Orders::::get(id).is_none()); + }); + } + + /// A LimitBuy with `limit_price = 0` (price ceiling below current price) + /// is silently skipped: the call returns `Ok` and nothing is written to + /// the `Orders` storage map. + #[test] + fn execute_orders_skips_price_condition_not_met() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(5.0); // price 5.0 > limit 0 → buy condition not met + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 0, // price ceiling of 0 — never satisfied at price 5.0 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Skipped — storage untouched. + assert!(Orders::::get(id).is_none()); + }); + } + + /// A batch containing one valid order and one expired order: the call + /// returns `Ok`, the valid order is stored as `Fulfilled`, and the expired + /// order is NOT written to storage. + #[test] + fn execute_orders_valid_and_invalid_mixed() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let valid = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let expired = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid(), + OrderType::LimitBuy, + 500, + u64::MAX, + 500_000, // already expired + Perbill::zero(), + fee_recipient(), + ); + let valid_id = order_id(&valid.order); + let expired_id = order_id(&expired.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![valid, expired]), + )); + + // Valid order executed successfully. + assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + // Expired order silently skipped — not written to storage. + assert!(Orders::::get(expired_id).is_none()); + }); + } +} + // ───────────────────────────────────────────────────────────────────────────── // execute_batched_orders // ───────────────────────────────────────────────────────────────────────────── diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 06dd242176..d2760fef7f 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -804,3 +804,267 @@ fn batched_fails_for_root_netuid() { ); }); } + +// ── execute_orders — silent-skip behaviour ──────────────────────────────────── + +/// `execute_orders` silently skips an expired order: the call returns `Ok` +/// and the order is NOT written to the `Orders` storage map. +#[test] +fn execute_orders_skips_expired_order() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Advance the runtime timestamp so that `now_ms` exceeds the order's expiry. + pallet_timestamp::Now::::put(100_000u64); + + // Build an order that expired at 50_000 ms — already in the past. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + 50_000, // expiry in ms — before current timestamp of 100_000 + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + // The call must succeed even though the order is expired. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Expired order silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// `execute_orders` processes a mixed batch: the valid order executes and is +/// stored as `Fulfilled`; the expired order is silently skipped and is NOT +/// written to storage. The call always returns `Ok`. +#[test] +fn execute_orders_valid_and_invalid_mixed() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Fund Alice so that her LimitBuy order can execute. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Create the hotkey association for Alice so buy_alpha succeeds. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Timestamp at 100_000 ms — Bob's order (expiry 50_000) will be expired. + pallet_timestamp::Now::::put(100_000u64); + + // Valid order: LimitBuy with price ceiling always satisfied and no expiry. + let valid = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + // Invalid order: already expired. + let expired = make_signed_order( + bob, + alice_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, + 50_000, // expiry in ms — before current timestamp of 100_000 + Perbill::zero(), + charlie_id.clone(), + ); + let valid_id = order_id(&valid.order); + let expired_id = order_id(&expired.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![valid, expired].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Valid order executed — stored as Fulfilled. + assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + // Expired order silently skipped — not written to storage. + assert!(Orders::::get(expired_id).is_none()); + }); +} + +/// `execute_orders` silently skips an order whose signer has no hotkey +/// association: the call returns `Ok` and the order is NOT written to the +/// `Orders` storage map. +#[test] +fn execute_orders_skips_order_with_unassociated_hotkey() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Fund Alice so that any balance check is not the reason for skipping. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Deliberately do NOT call create_account_if_non_existent — Alice has no + // hotkey association, so the order should be silently skipped. + + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + // The call must succeed even though the hotkey association is missing. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// `execute_orders` silently skips an order whose amount is below the minimum +/// stake threshold: the call returns `Ok` and the order is NOT written to the +/// `Orders` storage map. +#[test] +fn execute_orders_skips_order_below_minimum_stake() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Fund Alice so that any balance check is not the reason for skipping. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Create the hotkey association so that is not the reason for skipping. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // amount = 1 is well below min_default_stake(), triggering AmountTooLow. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + 1u64, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + // The call must succeed even though the amount is below the minimum. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// `execute_orders` silently skips an order targeting a subnet that does not +/// exist: the call returns `Ok` and the order is NOT written to the `Orders` +/// storage map. +#[test] +fn execute_orders_skips_order_for_nonexistent_subnet() { + new_test_ext().execute_with(|| { + // netuid 2 is not initialised — no setup_subnet call. + let netuid = NetUid::from(2u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + // Fund Alice so that any balance check is not the reason for skipping. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Create the hotkey association so that is not the reason for skipping. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + // The call must succeed even though the subnet does not exist. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} From f3375edfe528e65a3c686a26d2504e1e8fe19e5a Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 11:04:24 +0200 Subject: [PATCH 26/85] readme change --- pallets/limit-orders/README.md | 40 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 22d71cffaf..24de94106e 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -20,15 +20,17 @@ batch contents from the mempool until the block is proposed. User signs Order off-chain │ ▼ -Relayer submits via execute_orders (one-by-one) - or execute_batched_orders (aggregated) - │ - ├─ Invalid / expired / price-not-met → OrderSkipped (no state change) - │ - └─ Valid → executed → OrderExecuted - │ - └─ order_id written to Orders storage - (prevents replay) +Relayer submits via execute_orders Relayer submits via execute_batched_orders + (one-by-one, best-effort) (aggregated, atomic) + │ │ + ├─ Invalid / expired / ├─ Any order invalid / expired / + │ price-not-met → │ price-not-met / root netuid → + │ silently skipped (no state change) │ entire batch fails (DispatchError) + │ │ + └─ Valid → executed └─ All orders valid → net pool swap + │ → distribute pro-rata + └─ order_id written to Orders as Fulfilled + (prevents replay) User can cancel at any time via cancel_order └─ order_id written to Orders as Cancelled @@ -130,11 +132,13 @@ impact. Aggregates all valid orders targeting `netuid` into a single net pool interaction: -1. **Validate & classify** — orders with wrong netuid, invalid signature, - already-processed id, past expiry, or price condition not met emit - `OrderSkipped` and are dropped. The rest are split into buy-side - (`LimitBuy`) and sell-side (`TakeProfit`, `StopLoss`) groups. For buy - orders the net TAO (after fee) is pre-computed here. +1. **Validate & classify** — if any order has the wrong netuid, an invalid + signature, an already-processed id, a past expiry, a price condition not met, + or targets the root netuid (0), the **entire call fails** with the + corresponding error. All orders must be valid for execution to proceed. Valid + orders are split into buy-side (`LimitBuy`) and sell-side (`TakeProfit`, + `StopLoss`) groups. For buy orders the net TAO (after fee) is pre-computed + here. 2. **Collect assets** — gross TAO is pulled from each buyer's free balance into the pallet intermediary account. Gross alpha stake is moved from each seller's @@ -186,7 +190,7 @@ payload is required so the pallet can derive the `OrderId`. | Event | Fields | Emitted when | |-------|--------|--------------| | `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). | -| `OrderSkipped` | `order_id` | An order was dropped during batch validation (bad signature, expired, wrong netuid, already processed, or price condition not met). | +| `OrderSkipped` | `order_id` | An order was skipped by `execute_orders` (bad signature, expired, wrong netuid, already processed, price condition not met, or root netuid). Not emitted by `execute_batched_orders` — invalid orders there cause the whole call to fail. | | `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | | `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | @@ -198,8 +202,10 @@ payload is required so the pallet can derive the `OrderId`. |-------|-------| | `InvalidSignature` | Signature does not match the order payload and signer. Also used as a catch-all for failed validation in `execute_orders`. | | `OrderAlreadyProcessed` | The `OrderId` is already present in `Orders` (either `Fulfilled` or `Cancelled`). | -| `OrderExpired` | `now > order.expiry`. | -| `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. | +| `OrderExpired` | `now > order.expiry`. Only returned as a hard error by `execute_batched_orders`; silently skipped in `execute_orders`. | +| `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. Only returned as a hard error by `execute_batched_orders`; silently skipped in `execute_orders`. | +| `OrderNetUidMismatch` | An order inside a `execute_batched_orders` call targets a different netuid than the batch parameter. | +| `RootNetUidNotAllowed` | The order or batch targets netuid 0 (root). Root uses a fixed 1:1 stable mechanism with no AMM — limit orders are not meaningful there. | | `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. | | `SwapReturnedZero` | The pool swap returned zero output for a non-zero residual input. | From 95397db3cfdb6c853b78fd733ac0079ab82b3e07 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 13:07:03 +0200 Subject: [PATCH 27/85] better doc --- pallets/subtensor/src/staking/order_swap.rs | 10 +++++----- primitives/swap-interface/src/lib.rs | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 151d4f6ca8..06f422abbb 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -12,11 +12,11 @@ impl OrderSwapInterface for Pallet { netuid: NetUid, tao_amount: TaoBalance, limit_price: TaoBalance, - apply_limits: bool, + validate: bool, ) -> Result { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; - if apply_limits { + if validate { ensure!( Self::coldkey_owns_hotkey(coldkey, hotkey), Error::::HotKeyAccountNotExists @@ -43,7 +43,7 @@ impl OrderSwapInterface for Pallet { false, false, )?; - if apply_limits { + if validate { Self::set_stake_operation_limit(hotkey, coldkey, netuid); } Ok(alpha_out) @@ -55,11 +55,11 @@ impl OrderSwapInterface for Pallet { netuid: NetUid, alpha_amount: AlphaBalance, limit_price: TaoBalance, - apply_limits: bool, + validate: bool, ) -> Result { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; - if apply_limits { + if validate { ensure!( Self::coldkey_owns_hotkey(coldkey, hotkey), Error::::HotKeyAccountNotExists diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 969d835110..40b40a39e9 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -60,7 +60,7 @@ pub trait OrderSwapInterface { /// Buy alpha with TAO: debit `tao_amount` from `coldkey`'s free balance, /// credit resulting alpha as stake at `hotkey` on `netuid`. /// - /// When `apply_limits` is `true` the implementation enforces subnet + /// When `validate` is `true` the implementation enforces subnet /// existence, hotkey registration, minimum stake amount, sufficient /// coldkey balance, and sets the staking rate-limit flag for `(hotkey, /// coldkey, netuid)` after a successful stake. Pass `false` for internal @@ -71,13 +71,13 @@ pub trait OrderSwapInterface { netuid: NetUid, tao_amount: TaoBalance, limit_price: TaoBalance, - apply_limits: bool, + validate: bool, ) -> Result; /// Sell alpha for TAO: remove `alpha_amount` from `coldkey`'s stake at /// `hotkey` on `netuid`, credit resulting TAO to `coldkey`'s free balance. /// - /// When `apply_limits` is `true` the implementation enforces subnet + /// When `validate` is `true` the implementation enforces subnet /// existence, hotkey registration, minimum stake amount, sufficient alpha /// balance, and checks that the staking rate-limit flag is not set for /// `(hotkey, coldkey, netuid)` (i.e. the account did not stake this @@ -88,7 +88,7 @@ pub trait OrderSwapInterface { netuid: NetUid, alpha_amount: AlphaBalance, limit_price: TaoBalance, - apply_limits: bool, + validate: bool, ) -> Result; /// Current spot price: TAO per alpha, same scale as @@ -116,17 +116,17 @@ pub trait OrderSwapInterface { /// netuid)` is checked — the transfer is rejected if `from_coldkey` /// already staked this block. /// - /// When `set_receiver_limit` is `true`, the staking rate-limit flag for + /// When `validate_receiver` is `true`, the staking rate-limit flag for /// `(to_hotkey, to_coldkey, netuid)` is set after the transfer, marking /// that `to_coldkey` has received stake this block. /// /// The two flags are intentionally separate so that each call site can /// opt into only the half it needs: /// - Collecting alpha from users into the pallet intermediary: - /// `validate_sender: true, set_receiver_limit: false` — validates the + /// `validate_sender: true, validate_receiver: false` — validates the /// user but does not rate-limit the intermediary account. /// - Distributing alpha from the pallet intermediary to buyers: - /// `validate_sender: false, set_receiver_limit: true` — skips checking + /// `validate_sender: false, validate_receiver: true` — skips checking /// the intermediary (which would fail) and rate-limits the buyer. fn transfer_staked_alpha( from_coldkey: &AccountId, @@ -136,7 +136,7 @@ pub trait OrderSwapInterface { netuid: NetUid, amount: AlphaBalance, validate_sender: bool, - set_receiver_limit: bool, + validate_receiver: bool, ) -> DispatchResult; } From ee1520ccac065fdd83afbdd7f79f4b7b0a9f8354 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 14:50:59 +0200 Subject: [PATCH 28/85] be more accurate with numbers plus stoploss test --- runtime/tests/limit_orders.rs | 132 +++++++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 16 deletions(-) diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index d2760fef7f..67bd40ed50 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -193,11 +193,14 @@ fn limit_buy_order_executes_and_stakes_alpha() { assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); // Alice must now have staked alpha delegated through Bob on this subnet. + // AMM pool output has slight slippage even with the stable mechanism; check within 1%. let staked = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + let expected_alpha = min_default_stake().to_u64(); assert!( - staked > AlphaBalance::ZERO, - "alice should hold staked alpha after a LimitBuy order executes" + staked >= AlphaBalance::from(expected_alpha * 99 / 100) + && staked <= AlphaBalance::from(expected_alpha), + "alice should hold approximately min_default_stake alpha after a LimitBuy order executes (got {staked:?})" ); }); } @@ -252,12 +255,90 @@ fn take_profit_order_executes_and_unstakes_alpha() { // Order must be marked as executed. assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); - // Alice's staked alpha must have decreased after the sell executes. + // Alice's staked alpha must have decreased by exactly min_default_stake after the sell. + // Stable mechanism 1:1, zero fee: initial_alpha = min_default_stake * 10, + // sold min_default_stake alpha, so remaining = min_default_stake * 9. let remaining = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, + AlphaBalance::from(min_default_stake().to_u64() * 9u64), + "alice's staked alpha should be min_default_stake*9 after a TakeProfit order executes" + ); + }); +} + +/// A StopLoss order whose price condition is satisfied (price ≤ limit_price) executes +/// against the pool, marks the order as Fulfilled, decreases the seller's staked alpha, +/// and credits free TAO to the seller. +#[test] +fn stop_loss_order_executes_and_unstakes_alpha() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Create the hot-key association. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Seed Alice with staked alpha through Bob so she has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 1 → current_price (1.0) ≤ 1.0 → StopLoss condition always met. + // Using 1 (not u64::MAX) because limit_price also acts as the minimum TAO output + // in sell_alpha — u64::MAX would make the swap always fail. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + min_default_stake().into(), // sell min_default_stake alpha units + 1, // price floor — current price 1.0 ≤ 1.0, always met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // Alice's staked alpha must have decreased by exactly min_default_stake. + // Stable mechanism 1:1, zero fee: initial_alpha = min_default_stake * 10, + // sold min_default_stake alpha, so remaining = min_default_stake * 9. + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, + AlphaBalance::from(min_default_stake().to_u64() * 9u64), + "alice's staked alpha should be min_default_stake*9 after a StopLoss order executes" + ); + + // Alice must have received TAO from the sale. Pool output has slight slippage; check within 1%. + let alice_tao = SubtensorModule::get_coldkey_balance(&alice_id); + let expected_tao = min_default_stake().to_u64(); assert!( - remaining < initial_alpha.into(), - "alice's staked alpha should decrease after a TakeProfit order executes" + alice_tao >= TaoBalance::from(expected_tao * 99 / 100) + && alice_tao <= TaoBalance::from(expected_tao), + "alice should receive approximately min_default_stake TAO after a StopLoss order executes (got {alice_tao:?})" ); }); } @@ -339,21 +420,32 @@ fn batched_buy_dominant_executes_correctly() { )); // Alice spent TAO and must hold the resulting staked alpha. + // Buy-dominant: Alice buys min_default_stake*2 TAO, Bob sells min_default_stake alpha. + // total_sell_tao_equiv = min_default_stake (at 1:1). residual_buy = min_default_stake. + // pool returns min_default_stake alpha; plus Bob's passthrough = min_default_stake. + // Alice receives Bob's passthrough alpha + pool alpha for the residual TAO. + // Pool output has slight slippage; check within 1% of expected min_default_stake*2. let alice_alpha = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &charlie_id, &alice_id, netuid, ); + let expected_alice_alpha = min_default_stake().to_u64() * 2u64; assert!( - alice_alpha > AlphaBalance::ZERO, - "alice should hold staked alpha after buy-dominant batch" + alice_alpha >= AlphaBalance::from(expected_alice_alpha * 99 / 100) + && alice_alpha <= AlphaBalance::from(expected_alice_alpha), + "alice should hold approximately min_default_stake*2 alpha after buy-dominant batch (got {alice_alpha:?})" ); // Bob sold alpha and must hold the resulting free TAO. + // In buy-dominant, total_tao = total_sell_tao_equiv = min_default_stake. + // Bob's gross_share = (min_default_stake * min_default_stake) / min_default_stake + // = min_default_stake (exact). Zero fee => net_share = min_default_stake. let bob_tao = SubtensorModule::get_coldkey_balance(&bob_id); - assert!( - bob_tao > TaoBalance::ZERO, - "bob should hold free TAO after buy-dominant batch" + assert_eq!( + bob_tao, + TaoBalance::from(min_default_stake().to_u64()), + "bob should hold exactly min_default_stake TAO after buy-dominant batch" ); }); } @@ -431,21 +523,29 @@ fn batched_sell_dominant_executes_correctly() { )); // Alice spent TAO and must hold the resulting staked alpha. + // Sell-dominant: Alice buys min_default_stake TAO, Bob sells min_default_stake*2 alpha. + // total_buy_alpha_equiv = tao_to_alpha(min_default_stake, 1.0) = min_default_stake (exact). + // Alice's pro-rata share = (min_default_stake * min_default_stake) / min_default_stake + // = min_default_stake (exact, no floor rounding). let alice_alpha = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &charlie_id, &alice_id, netuid, ); - assert!( - alice_alpha > AlphaBalance::ZERO, - "alice should hold staked alpha after sell-dominant batch" + assert_eq!( + alice_alpha, + AlphaBalance::from(min_default_stake().to_u64()), + "alice should hold exactly min_default_stake alpha after sell-dominant batch" ); - // Bob sold alpha and must hold the resulting free TAO. + // Bob receives Alice's passthrough TAO + pool TAO for the residual alpha. + // Pool output has slight slippage; check within 1% of expected min_default_stake*2. let bob_tao = SubtensorModule::get_coldkey_balance(&bob_id); + let expected_bob_tao = min_default_stake().to_u64() * 2u64; assert!( - bob_tao > TaoBalance::ZERO, - "bob should hold free TAO after sell-dominant batch" + bob_tao >= TaoBalance::from(expected_bob_tao * 99 / 100) + && bob_tao <= TaoBalance::from(expected_bob_tao), + "bob should hold approximately min_default_stake*2 TAO after sell-dominant batch (got {bob_tao:?})" ); }); } From fcedbc06e24412a45b01b30684311ae7d28137fd Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 15:06:20 +0200 Subject: [PATCH 29/85] fee related tests --- runtime/tests/limit_orders.rs | 338 ++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 67bd40ed50..75ba680a12 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -1168,3 +1168,341 @@ fn execute_orders_skips_order_for_nonexistent_subnet() { assert!(Orders::::get(id).is_none()); }); } + +// ── Fee-correctness tests ───────────────────────────────────────────────────── + +/// `execute_orders` (non-batched) correctly forwards the buy-order fee to the +/// designated fee recipient and charges Alice exactly `amount` TAO in total. +/// +/// Fee mechanics for a non-batched LimitBuy: +/// fee_tao = fee_rate * tao_in (computed from input BEFORE swap, exact integer arithmetic) +/// tao_after_fee = tao_in - fee_tao (goes to the pool) +/// fee transferred directly from signer to fee_recipient via transfer_tao +/// +/// We use amount = min_default_stake() * 2 so that tao_after_fee = 90% * 2 * min_default_stake() +/// = 1.8 * min_default_stake() > min_default_stake(), satisfying the minimum-stake validation +/// inside buy_alpha. With fee_rate = 10%: +/// fee_tao = 10% * (min_default_stake() * 2) = min_default_stake() / 5 (exact integer result) +/// Alice pays min_default_stake()*2 total and has min_default_stake()*8 remaining. +/// Charlie (fee recipient) receives exactly fee_tao. +#[test] +fn execute_orders_fee_forwarded_to_recipient() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Fund Alice with 10× min_default_stake so she can cover the order amount and a margin. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Create the hotkey association Alice → Bob. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Charlie starts with zero balance — verify before submitting. + assert_eq!( + SubtensorModule::get_coldkey_balance(&charlie_id), + TaoBalance::from(0u64), + "charlie should start with zero balance" + ); + + // Use 2× min_default_stake so tao_after_fee (90%) stays above the minimum-stake threshold. + let order_amount = min_default_stake().to_u64() * 2u64; + + // limit_price = u64::MAX → condition always met; fee_recipient = Charlie. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + order_amount, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::from_percent(10), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id.clone()), + orders, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // Buy fee is computed from input: fee = 10% * order_amount. Exact integer arithmetic. + let expected_fee = Perbill::from_percent(10) * order_amount; + assert_eq!( + SubtensorModule::get_coldkey_balance(&charlie_id), + TaoBalance::from(expected_fee), + "charlie (fee recipient) should receive exactly the buy fee" + ); + + // Alice spent exactly order_amount TAO (fee is deducted from the order amount, + // not charged on top), so she has min_default_stake()*10 - order_amount remaining. + assert_eq!( + SubtensorModule::get_coldkey_balance(&alice_id), + min_default_stake() * 8u64.into(), + "alice should have min_default_stake()*8 TAO remaining after the order" + ); + + // Alice must have received staked alpha through Bob. The pool received + // tao_after_fee = order_amount - fee; check within 1% of that expected alpha. + let tao_after_fee = order_amount - expected_fee; + let staked = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert!( + staked >= AlphaBalance::from(tao_after_fee * 99 / 100) + && staked <= AlphaBalance::from(tao_after_fee), + "alice should hold approximately tao_after_fee alpha after the LimitBuy with fee (got {staked:?})" + ); + }); +} + +/// `execute_batched_orders` correctly forwards fees to a shared fee recipient (Eve) +/// when both a buy and a sell order designate the same recipient. +/// +/// Fee mechanics for batched orders: +/// Buy: fee = gross - net = fee_rate * gross (withheld from pool input, transferred from pallet). +/// Sell: fee = fee_rate * gross_share (withheld from TAO pool output, inherits slippage). +/// +/// The buy fee is exact; the sell fee is approximate (pool slippage). +#[test] +fn batched_fee_forwarded_to_recipient() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + let eve_id = Sr25519Keyring::Eve.to_account_id(); + + setup_subnet(netuid); + + // Alice (buyer) funded with free TAO. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Bob (seller) seeded with staked alpha through Dave. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + // Create hotkey associations: Alice → Charlie, Bob → Dave. + SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); + SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + + // Eve (shared fee recipient) starts with zero balance. + assert_eq!( + SubtensorModule::get_coldkey_balance(&eve_id), + TaoBalance::from(0u64), + "eve should start with zero balance" + ); + + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::from_percent(10), + eve_id.clone(), // fee goes to Eve + ); + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), + 0, // price floor — always satisfied + u64::MAX, // no expiry + Perbill::from_percent(10), + eve_id.clone(), // fee goes to Eve + ); + let buy_id = order_id(&buy.order); + let sell_id = order_id(&sell.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + // Both orders must be fulfilled. + assert_eq!(Orders::::get(buy_id), Some(OrderStatus::Fulfilled)); + assert_eq!( + Orders::::get(sell_id), + Some(OrderStatus::Fulfilled) + ); + + // Buy fee is exact: fee = 10% * min_default_stake(). + let buy_fee = Perbill::from_percent(10) * min_default_stake().to_u64(); + + // Sell fee is approximate (pool slippage). Lower bound: 10% of 99% of amount. + let sell_fee_lower_bound = + Perbill::from_percent(10) * (min_default_stake().to_u64() * 99 / 100); + + // Eve must have received at least buy_fee + sell_fee_lower_bound, + // and at most buy_fee + 10% * amount (upper bound on sell fee with no slippage). + let sell_fee_upper_bound = Perbill::from_percent(10) * min_default_stake().to_u64(); + let eve_balance = SubtensorModule::get_coldkey_balance(&eve_id); + assert!( + eve_balance >= TaoBalance::from(buy_fee + sell_fee_lower_bound) + && eve_balance <= TaoBalance::from(buy_fee + sell_fee_upper_bound), + "eve should receive combined buy+sell fee within tolerance (got {eve_balance:?})" + ); + }); +} + +/// `execute_batched_orders` routes fees to the correct recipient when two orders +/// in the same batch designate different fee recipients (Charlie for the buy, +/// Dave for the sell). +/// +/// Verifies that: +/// - Charlie receives exactly the buy fee (no pool slippage on input). +/// - Dave receives approximately the sell fee (within 1%, due to pool slippage). +/// - Neither recipient received both fees. +#[test] +fn batched_multiple_fee_recipients_each_receive_correct_amount() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob = Sr25519Keyring::Bob; + let bob_id = bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + setup_subnet(netuid); + + // Alice (buyer) funded with free TAO. + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + min_default_stake() * 10u64.into(), + ); + + // Bob (seller) seeded with staked alpha through Dave. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + + // Create hotkey associations: Alice → Charlie, Bob → Dave. + SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); + SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + + // Charlie and Dave start with zero free balance (they are hotkeys; no initial funding). + assert_eq!( + SubtensorModule::get_coldkey_balance(&charlie_id), + TaoBalance::from(0u64), + "charlie should start with zero balance" + ); + assert_eq!( + SubtensorModule::get_coldkey_balance(&dave_id), + TaoBalance::from(0u64), + "dave should start with zero balance" + ); + + // Alice: LimitBuy, fee goes to Charlie. + let buy = make_signed_order( + alice, + charlie_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + Perbill::from_percent(10), + charlie_id.clone(), // buy fee to Charlie + ); + // Bob: TakeProfit, fee goes to Dave. + let sell = make_signed_order( + bob, + dave_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), + 0, // price floor — always satisfied + u64::MAX, // no expiry + Perbill::from_percent(10), + dave_id.clone(), // sell fee to Dave + ); + let buy_id = order_id(&buy.order); + let sell_id = order_id(&sell.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![buy, sell].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + // Both orders must be fulfilled. + assert_eq!(Orders::::get(buy_id), Some(OrderStatus::Fulfilled)); + assert_eq!( + Orders::::get(sell_id), + Some(OrderStatus::Fulfilled) + ); + + // Charlie receives exactly the buy fee: 10% * min_default_stake(). + let expected_buy_fee = Perbill::from_percent(10) * min_default_stake().to_u64(); + assert_eq!( + SubtensorModule::get_coldkey_balance(&charlie_id), + TaoBalance::from(expected_buy_fee), + "charlie (buy fee recipient) should receive exactly the buy fee" + ); + + // Dave receives approximately the sell fee (pool slippage ≤ 1%). + // Expected sell fee ≈ 10% of min_default_stake (the seller's gross TAO share). + let expected_sell_fee = Perbill::from_percent(10) * min_default_stake().to_u64(); + let sell_fee_lower_bound = + Perbill::from_percent(10) * (min_default_stake().to_u64() * 99 / 100); + let dave_balance = SubtensorModule::get_coldkey_balance(&dave_id); + assert!( + dave_balance >= TaoBalance::from(sell_fee_lower_bound) + && dave_balance <= TaoBalance::from(expected_sell_fee), + "dave (sell fee recipient) should receive approximately the sell fee within 1% (got {dave_balance:?})" + ); + + // Verify fees are separate: neither recipient received both fees. + // Charlie's balance is exactly buy_fee (not buy_fee + sell_fee). + let charlie_balance = SubtensorModule::get_coldkey_balance(&charlie_id); + assert!( + charlie_balance <= TaoBalance::from(expected_buy_fee), + "charlie should not have received the sell fee (got {charlie_balance:?})" + ); + // Dave's balance is ≤ sell_fee (not sell_fee + buy_fee). + assert!( + dave_balance <= TaoBalance::from(expected_sell_fee), + "dave should not have received the buy fee (got {dave_balance:?})" + ); + }); +} From a567b6a7d8cbbb5b63e87cb778790dbd8f3f584c Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 15:57:52 +0200 Subject: [PATCH 30/85] kill switch & fmt --- pallets/limit-orders/src/lib.rs | 47 +++++++++++++++++--- pallets/limit-orders/src/tests/auxiliary.rs | 6 ++- pallets/limit-orders/src/tests/extrinsics.rs | 44 ++++++++++++++++++ runtime/tests/limit_orders.rs | 35 ++++----------- 4 files changed, 97 insertions(+), 35 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 9e1d67b515..2ba7c75bc1 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -8,7 +8,10 @@ mod tests; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_core::H256; -use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::Verify}; +use sp_runtime::{ + AccountId32, MultiSignature, Perbill, + traits::{ConstBool, Verify}, +}; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; @@ -184,6 +187,10 @@ pub mod pallet { #[pallet::storage] pub type Orders = StorageMap<_, Blake2_128Concat, H256, OrderStatus, OptionQuery>; + /// Switch to enable/disable the pallet. true by default + #[pallet::storage] + pub type LimitOrdersEnabled = StorageValue<_, bool, ValueQuery, ConstBool>; + // ── Events ──────────────────────────────────────────────────────────────── #[pallet::event] @@ -223,6 +230,8 @@ pub mod pallet { /// Number of orders that were successfully executed. executed_count: u32, }, + /// Root has either enabled(true) or disabled(false) the pallet + LimitOrdersPalletStatusChanged { enabled: bool }, } // ── Errors ──────────────────────────────────────────────────────────────── @@ -245,6 +254,8 @@ pub mod pallet { RootNetUidNotAllowed, /// An order in the batch targets a different netuid than the batch netuid parameter. OrderNetUidMismatch, + /// Limit orders are disabled + LimitOrdersDisabled, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -266,6 +277,10 @@ pub mod pallet { orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { ensure_signed(origin)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); for signed_order in orders { // Best-effort: individual order failures do not revert the batch. @@ -297,7 +312,7 @@ pub mod pallet { /// /// All orders in the batch must target `netuid`. Orders for a different /// subnet are skipped. - #[pallet::call_index(4)] + #[pallet::call_index(1)] #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add( T::DbWeight::get().reads_writes(3, 2).saturating_mul(orders.len() as u64) ))] @@ -307,6 +322,10 @@ pub mod pallet { orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { ensure_signed(origin)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); Self::do_execute_batched_orders(netuid, orders) } @@ -316,7 +335,7 @@ pub mod pallet { /// Must be called by the order's signer. The full `Order` payload is /// provided so the pallet can derive the `OrderId`. Once marked /// Cancelled, the order can never be executed. - #[pallet::call_index(1)] + #[pallet::call_index(2)] #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] pub fn cancel_order(origin: OriginFor, order: Order) -> DispatchResult { let who = ensure_signed(origin)?; @@ -337,6 +356,23 @@ pub mod pallet { Ok(()) } + + /// Set a status for the limit orders pallet + /// + /// Must be called by root + /// It allows disabling or enabling the pallet + /// true means enabling, false means disabling + #[pallet::call_index(3)] + #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + pub fn set_pallet_status(origin: OriginFor, enabled: bool) -> DispatchResult { + ensure_root(origin)?; + + LimitOrdersEnabled::::set(enabled); + + Self::deposit_event(Event::LimitOrdersPalletStatusChanged { enabled }); + + Ok(()) + } } // ── Internal helpers ────────────────────────────────────────────────────── @@ -363,10 +399,7 @@ pub mod pallet { current_price: U96F32, ) -> DispatchResult { let order = &signed_order.order; - ensure!( - !order.netuid.is_root(), - Error::::RootNetUidNotAllowed - ); + ensure!(!order.netuid.is_root(), Error::::RootNetUidNotAllowed); ensure!( matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 450165aeb8..e500abd0a5 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -110,7 +110,8 @@ fn validate_and_classify_separates_buys_and_sells() { &orders, 1_000_000u64, U96F32::from_num(1u32), - ).expect("validate_and_classify should succeed"); + ) + .expect("validate_and_classify should succeed"); assert_eq!(buys.len(), 1, "expected 1 valid buy"); assert_eq!(sells.len(), 1, "expected 1 valid sell"); @@ -283,7 +284,8 @@ fn validate_and_classify_applies_buy_fee_to_net() { &orders, 1_000_000u64, U96F32::from_num(1u32), - ).expect("validate_and_classify should succeed"); + ) + .expect("validate_and_classify should succeed"); assert_eq!(buys.len(), 1); let entry = &buys[0]; diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 8ff794c9e8..bb38d8b218 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1495,3 +1495,47 @@ fn execute_batched_orders_mixed_batch_does_not_rate_limit_pallet_intermediary() ); }); } + +/// Root changes the pallet status, extrinsics are filtered +#[test] +fn root_disables_and_extrinsics_are_filtered() { + new_test_ext().execute_with(|| { + // Disable the pallet + assert_ok!(LimitOrders::set_pallet_status(RuntimeOrigin::root(), false)); + + let sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 500, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + // Must succeed: collecting Bob's alpha must not rate-limit the pallet + // intermediary, so distributing alpha to Alice is not blocked. + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![sell]) + ), + Error::::LimitOrdersDisabled + ); + }); +} + +/// Non-root origin cannot disable the pallet +#[test] +fn non_root_cannot_disable_the_pallet() { + new_test_ext().execute_with(|| { + // Try disabling the pallet with charlie + assert_noop!( + LimitOrders::set_pallet_status(RuntimeOrigin::signed(charlie()), false), + DispatchError::BadOrigin + ); + }); +} diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 75ba680a12..2d65583369 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -721,11 +721,7 @@ fn batched_fails_for_nonexistent_subnet() { vec![buy].try_into().unwrap(); assert_noop!( - LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie_id), - netuid, - orders, - ), + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), pallet_subtensor::Error::::SubnetNotExists ); }); @@ -768,11 +764,7 @@ fn batched_fails_if_subtoken_not_enabled() { vec![buy].try_into().unwrap(); assert_noop!( - LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie_id), - netuid, - orders, - ), + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), pallet_subtensor::Error::::SubtokenDisabled ); }); @@ -811,11 +803,7 @@ fn batched_fails_for_expired_order() { vec![signed].try_into().unwrap(); assert_noop!( - LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie_id), - netuid, - orders, - ), + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), pallet_limit_orders::Error::::OrderExpired ); }); @@ -852,11 +840,7 @@ fn batched_fails_if_price_condition_not_met() { vec![signed].try_into().unwrap(); assert_noop!( - LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie_id), - netuid, - orders, - ), + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), pallet_limit_orders::Error::::PriceConditionNotMet ); }); @@ -895,11 +879,7 @@ fn batched_fails_for_root_netuid() { vec![buy].try_into().unwrap(); assert_noop!( - LimitOrders::execute_batched_orders( - RuntimeOrigin::signed(charlie_id), - netuid, - orders, - ), + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), pallet_limit_orders::Error::::RootNetUidNotAllowed ); }); @@ -1013,7 +993,10 @@ fn execute_orders_valid_and_invalid_mixed() { )); // Valid order executed — stored as Fulfilled. - assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + assert_eq!( + Orders::::get(valid_id), + Some(OrderStatus::Fulfilled) + ); // Expired order silently skipped — not written to storage. assert!(Orders::::get(expired_id).is_none()); }); From 358a52028582f287b95c99cd2b66b14c523afeb9 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 1 Apr 2026 17:38:16 +0200 Subject: [PATCH 31/85] first 2 benchmarks added --- Cargo.lock | 1 + pallets/limit-orders/Cargo.toml | 13 ++- pallets/limit-orders/src/benchmarking.rs | 93 +++++++++++++++++++++ pallets/limit-orders/src/lib.rs | 2 + pallets/limit-orders/src/tests/auxiliary.rs | 4 +- pallets/limit-orders/src/tests/mock.rs | 2 +- 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 pallets/limit-orders/src/benchmarking.rs diff --git a/Cargo.lock b/Cargo.lock index 420f5dc9a5..ae0bfc75fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9971,6 +9971,7 @@ dependencies = [ name = "pallet-limit-orders" version = "0.1.0" dependencies = [ + "frame-benchmarking", "frame-support", "frame-system", "parity-scale-codec", diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 0e2fd5a715..302ab1fac6 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true [dependencies] codec = { workspace = true, features = ["derive"] } +frame-benchmarking = { workspace = true, optional = true } +sp-keyring = { workspace = true, optional = true } frame-support.workspace = true frame-system.workspace = true scale-info.workspace = true @@ -17,7 +19,6 @@ subtensor-swap-interface.workspace = true [dev-dependencies] sp-io.workspace = true -sp-keyring.workspace = true [lints] workspace = true @@ -30,9 +31,19 @@ std = [ "frame-system/std", "scale-info/std", "sp-core/std", + "sp-keyring/std", "sp-runtime/std", "sp-std/std", "substrate-fixed/std", "subtensor-runtime-common/std", "subtensor-swap-interface/std", ] + +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "sp-keyring", + "subtensor-runtime-common/runtime-benchmarks" +] \ No newline at end of file diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs new file mode 100644 index 0000000000..b30cd98db9 --- /dev/null +++ b/pallets/limit-orders/src/benchmarking.rs @@ -0,0 +1,93 @@ +//! Benchmarks for Limit Orders Pallet +#![cfg(feature = "runtime-benchmarks")] +#![allow( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + clippy::unwrap_used +)] +use crate::{NetUid, OrderType, Orders}; +use frame_benchmarking::v2::*; +use frame_system::RawOrigin; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{MultiSignature, Perbill}; +extern crate alloc; +use crate::{Call, Config, Pallet}; +use codec::Encode; + +pub fn make_signed_order( + keyring: AccountKeyring, + hotkey: T::AccountId, + netuid: NetUid, + order_type: crate::OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: sp_runtime::Perbill, + fee_recipient: T::AccountId, +) -> crate::SignedOrder { + let signer = keyring.to_account_id(); + let order = crate::Order { + signer, + hotkey: hotkey.into(), + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient: fee_recipient.into(), + }; + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + } +} + +pub fn order_id(order: &crate::Order) -> H256 { + crate::pallet::Pallet::::derive_order_id(order) +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn cancel_order() { + let signed = make_signed_order::( + AccountKeyring::Alice, + AccountKeyring::Alice.to_account_id().into(), + NetUid::from(1u16), + OrderType::LimitBuy, + 1_000, + 2_000_000_000, + 1_000_000_000, + Perbill::zero(), + AccountKeyring::Alice.to_account_id().into(), + ); + + #[extrinsic_call] + _( + RawOrigin::Signed(AccountKeyring::Alice.to_account_id()), + signed.order.clone(), + ); + let id = order_id::(&signed.order); + + assert_eq!(Orders::::get(id), Some(crate::OrderStatus::Cancelled)); + } + + #[benchmark] + fn set_pallet_status() { + #[extrinsic_call] + _(RawOrigin::Root, false); + + assert_eq!(crate::LimitOrdersEnabled::::get(), false); + } + + impl_benchmark_test_suite!( + Pallet, + crate::tests::mock::new_test_ext(), + crate::tests::mock::Test + ); +} diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 2ba7c75bc1..d73a6cf0c5 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -2,6 +2,8 @@ pub use pallet::*; +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; #[cfg(test)] mod tests; diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index e500abd0a5..abecea347c 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -6,7 +6,7 @@ use frame_support::{BoundedVec, assert_noop, assert_ok, traits::ConstU32}; use sp_core::H256; use sp_keyring::Sr25519Keyring as AccountKeyring; use substrate_fixed::types::U96F32; -use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use subtensor_runtime_common::NetUid; use sp_runtime::Perbill; @@ -1121,7 +1121,7 @@ fn collect_fees_no_transfer_when_zero_fees() { use crate::Error; use codec::Encode; use sp_core::Pair; -use sp_runtime::{MultiSignature, traits::Verify}; +use sp_runtime::MultiSignature; use subtensor_swap_interface::OrderSwapInterface; fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 3ab1395eae..1a5e727b2a 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -15,7 +15,7 @@ use frame_system as system; use sp_core::{H256, Pair}; use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{ - AccountId32, BuildStorage, MultiSignature, Perbill, + AccountId32, BuildStorage, MultiSignature, traits::{BlakeTwo256, IdentityLookup}, }; use substrate_fixed::types::U96F32; From 8d56e16c20639379eda91a7931fb7e3dcf4f5023 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 6 Apr 2026 09:29:53 +0200 Subject: [PATCH 32/85] first placeholder for benches of order exec --- pallets/limit-orders/Cargo.toml | 3 +- pallets/limit-orders/src/benchmarking.rs | 99 +++++++++++++++++++++++- pallets/limit-orders/src/tests/mock.rs | 11 +++ primitives/swap-interface/Cargo.toml | 1 + primitives/swap-interface/src/lib.rs | 9 +++ 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 302ab1fac6..2503f29003 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -45,5 +45,6 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "sp-keyring", - "subtensor-runtime-common/runtime-benchmarks" + "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] \ No newline at end of file diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index b30cd98db9..96c2664ee5 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -10,7 +10,7 @@ use frame_benchmarking::v2::*; use frame_system::RawOrigin; use sp_core::{H256, Pair}; use sp_keyring::Sr25519Keyring as AccountKeyring; -use sp_runtime::{MultiSignature, Perbill}; +use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::AccountIdConversion}; extern crate alloc; use crate::{Call, Config, Pallet}; use codec::Encode; @@ -52,6 +52,8 @@ pub fn order_id(order: &crate::Order) -> H256 { #[benchmarks] mod benchmarks { use super::*; + use frame_support::traits::Get; + use subtensor_swap_interface::OrderSwapInterface; #[benchmark] fn cancel_order() { @@ -85,6 +87,101 @@ mod benchmarks { assert_eq!(crate::LimitOrdersEnabled::::get(), false); } + /// Worst case: `n` orders each with a distinct signer (coldkey/hotkey) and a + /// distinct fee recipient, maximising per-order storage reads and fee transfers. + #[benchmark] + fn execute_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { + let netuid = NetUid::from(1u16); + let mut orders = alloc::vec::Vec::new(); + + for i in 0..n { + // Derive a unique sr25519 keypair for each order so every order + // hits a different storage slot (different signer balance reads). + let pair = + sp_core::sr25519::Pair::from_string(&alloc::format!("//Signer{}", i), None) + .unwrap(); + let account: T::AccountId = AccountId32::from(pair.public()).into(); + let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); + + // Allow the swap implementation to fund/register this account. + T::SwapInterface::set_up_acc_for_benchmark(&account, &account); + + let order = crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000_000_000u64, + limit_price: u64::MAX, // always satisfied for a buy + expiry: u64::MAX, + fee_rate: Perbill::from_percent(1), + fee_recipient, + }; + let sig = pair.sign(&order.encode()); + orders.push(crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + }); + } + + let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = + frame_support::BoundedVec::try_from(orders).unwrap(); + let caller: T::AccountId = frame_benchmarking::account("caller", 0, 0); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), bounded_orders); + } + + /// Worst case: `n` buy orders each with a distinct signer and fee recipient, + /// maximising asset-collection reads, pro-rata distribution writes, and the + /// number of unique fee-transfer recipients in `collect_fees`. + #[benchmark] + fn execute_batched_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { + let netuid = NetUid::from(1u16); + + // Set up the pallet intermediary so the net pool swap and alpha + // distribution transfers succeed. + let pallet_acct: T::AccountId = T::PalletId::get().into_account_truncating(); + let pallet_hotkey: T::AccountId = T::PalletHotkey::get(); + T::SwapInterface::set_up_acc_for_benchmark(&pallet_hotkey, &pallet_acct); + + let mut orders = alloc::vec::Vec::new(); + + for i in 0..n { + let pair = + sp_core::sr25519::Pair::from_string(&alloc::format!("//Signer{}", i), None) + .unwrap(); + let account: T::AccountId = AccountId32::from(pair.public()).into(); + let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); + + T::SwapInterface::set_up_acc_for_benchmark(&account, &account); + + let order = crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000_000_000u64, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::from_percent(1), + fee_recipient, + }; + let sig = pair.sign(&order.encode()); + orders.push(crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + }); + } + + let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = + frame_support::BoundedVec::try_from(orders).unwrap(); + let caller: T::AccountId = frame_benchmarking::account("caller", 0, 0); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), netuid, bounded_orders); + } + impl_benchmark_test_suite!( Pallet, crate::tests::mock::new_test_ext(), diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 1a5e727b2a..a7e6ab6e35 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -313,6 +313,17 @@ impl OrderSwapInterface for MockSwap { Ok(()) } + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(hotkey: &AccountId, coldkey: &AccountId) { + // Provide non-zero swap returns so batched-order benchmarks don't hit + // `SwapReturnedZero`. Also seed TAO and alpha balances so transfers + // succeed in the mock ledgers. + MockSwap::set_buy_alpha_return(1_000_000); + MockSwap::set_sell_tao_return(1_000_000); + MockSwap::set_tao_balance(coldkey.clone(), u64::MAX / 2); + MockSwap::set_alpha_balance(coldkey.clone(), hotkey.clone(), NetUid::from(1u16), u64::MAX / 2); + } + fn transfer_staked_alpha( from_coldkey: &AccountId, from_hotkey: &AccountId, diff --git a/primitives/swap-interface/Cargo.toml b/primitives/swap-interface/Cargo.toml index e4392c6d67..06623a310b 100644 --- a/primitives/swap-interface/Cargo.toml +++ b/primitives/swap-interface/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [features] default = ["std"] +runtime-benchmarks = [] std = [ "codec/std", "frame-support/std", diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 40b40a39e9..64c3b2a949 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -138,6 +138,15 @@ pub trait OrderSwapInterface { validate_sender: bool, validate_receiver: bool, ) -> DispatchResult; + + /// Set up accounts for benchmark execution. + /// + /// Called once per order before the benchmarked extrinsic runs. Implementations + /// should fund `coldkey` with sufficient TAO (and alpha for sell orders) and + /// register `hotkey` on the relevant subnet so that swap operations succeed. + /// The default is a no-op; override in runtime implementations. + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(_hotkey: &AccountId, _coldkey: &AccountId) {} } pub trait DefaultPriceLimit From ed5e6982d6b85511b04c8c65ddcf8759011b7d7d Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 6 Apr 2026 10:04:18 +0200 Subject: [PATCH 33/85] refactor benches and things running --- Cargo.lock | 1 + pallets/limit-orders/Cargo.toml | 15 +- pallets/limit-orders/src/benchmarking.rs | 116 +++++----- pallets/limit-orders/src/tests/mock.rs | 8 + pallets/limit-orders/src/weights.rs | 237 ++++++++++++++++++++ pallets/subtensor/Cargo.toml | 1 + pallets/subtensor/src/staking/order_swap.rs | 17 ++ primitives/swap-interface/src/lib.rs | 9 + runtime/Cargo.toml | 2 + runtime/src/lib.rs | 1 + 10 files changed, 337 insertions(+), 70 deletions(-) create mode 100644 pallets/limit-orders/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index ae0bfc75fa..0f90555b8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9979,6 +9979,7 @@ dependencies = [ "sp-core", "sp-io", "sp-keyring", + "sp-keystore", "sp-runtime", "sp-std", "substrate-fixed", diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 2503f29003..3c9c99a5a0 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] codec = { workspace = true, features = ["derive"] } frame-benchmarking = { workspace = true, optional = true } +sp-io = { workspace = true, optional = true } sp-keyring = { workspace = true, optional = true } frame-support.workspace = true frame-system.workspace = true @@ -19,6 +20,8 @@ subtensor-swap-interface.workspace = true [dev-dependencies] sp-io.workspace = true +sp-keyring.workspace = true +sp-keystore.workspace = true [lints] workspace = true @@ -31,7 +34,7 @@ std = [ "frame-system/std", "scale-info/std", "sp-core/std", - "sp-keyring/std", + "sp-io?/std", "sp-runtime/std", "sp-std/std", "substrate-fixed/std", @@ -40,11 +43,11 @@ std = [ ] runtime-benchmarks = [ - "frame-benchmarking/runtime-benchmarks", - "frame-support/runtime-benchmarks", - "frame-system/runtime-benchmarks", - "sp-runtime/runtime-benchmarks", - "sp-keyring", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "sp-io", "subtensor-runtime-common/runtime-benchmarks", "subtensor-swap-interface/runtime-benchmarks", ] \ No newline at end of file diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 96c2664ee5..8452435a39 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -8,43 +8,42 @@ use crate::{NetUid, OrderType, Orders}; use frame_benchmarking::v2::*; use frame_system::RawOrigin; -use sp_core::{H256, Pair}; -use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_core::H256; use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::AccountIdConversion}; extern crate alloc; use crate::{Call, Config, Pallet}; use codec::Encode; -pub fn make_signed_order( - keyring: AccountKeyring, - hotkey: T::AccountId, - netuid: NetUid, - order_type: crate::OrderType, - amount: u64, - limit_price: u64, - expiry: u64, - fee_rate: sp_runtime::Perbill, - fee_recipient: T::AccountId, +/// Sign an order using the runtime keystore (no `full_crypto` required). +/// +/// The key identified by `public` must already be registered in the keystore +/// (e.g. via `sp_io::crypto::sr25519_generate`) before calling this. +fn sign_order( + public: sp_core::sr25519::Public, + order: &crate::Order, ) -> crate::SignedOrder { - let signer = keyring.to_account_id(); - let order = crate::Order { - signer, - hotkey: hotkey.into(), - netuid, - order_type, - amount, - limit_price, - expiry, - fee_rate, - fee_recipient: fee_recipient.into(), - }; - let sig = keyring.pair().sign(&order.encode()); + let sig = sp_io::crypto::sr25519_sign( + sp_core::crypto::key_types::ACCOUNT, + &public, + &order.encode(), + ) + .unwrap(); crate::SignedOrder { - order, + order: order.clone(), signature: MultiSignature::Sr25519(sig), } } +/// Generate a deterministic sr25519 key for benchmark index `i` and return its +/// public key. The key is inserted into the runtime keystore so it can sign. +fn benchmark_key(i: u32) -> (sp_core::sr25519::Public, AccountId32) { + let seed = alloc::format!("//BenchSigner{}", i).into_bytes(); + let public = + sp_io::crypto::sr25519_generate(sp_core::crypto::key_types::ACCOUNT, Some(seed)); + let account = AccountId32::from(public); + (public, account) +} + pub fn order_id(order: &crate::Order) -> H256 { crate::pallet::Pallet::::derive_order_id(order) } @@ -57,25 +56,26 @@ mod benchmarks { #[benchmark] fn cancel_order() { - let signed = make_signed_order::( - AccountKeyring::Alice, - AccountKeyring::Alice.to_account_id().into(), - NetUid::from(1u16), - OrderType::LimitBuy, - 1_000, - 2_000_000_000, - 1_000_000_000, - Perbill::zero(), - AccountKeyring::Alice.to_account_id().into(), - ); + let (public, account_id) = benchmark_key(0); + let account: T::AccountId = account_id.into(); + + let order = crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid: NetUid::from(1u16), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: 2_000_000_000, + expiry: 1_000_000_000, + fee_rate: Perbill::zero(), + fee_recipient: account.clone(), + }; + let signed = sign_order::(public, &order); #[extrinsic_call] - _( - RawOrigin::Signed(AccountKeyring::Alice.to_account_id()), - signed.order.clone(), - ); - let id = order_id::(&signed.order); + _(RawOrigin::Signed(account.clone()), signed.order.clone()); + let id = order_id::(&signed.order); assert_eq!(Orders::::get(id), Some(crate::OrderStatus::Cancelled)); } @@ -92,18 +92,15 @@ mod benchmarks { #[benchmark] fn execute_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { let netuid = NetUid::from(1u16); + T::SwapInterface::set_up_netuid_for_benchmark(netuid); + let mut orders = alloc::vec::Vec::new(); for i in 0..n { - // Derive a unique sr25519 keypair for each order so every order - // hits a different storage slot (different signer balance reads). - let pair = - sp_core::sr25519::Pair::from_string(&alloc::format!("//Signer{}", i), None) - .unwrap(); - let account: T::AccountId = AccountId32::from(pair.public()).into(); + let (public, account_id) = benchmark_key(i); + let account: T::AccountId = account_id.into(); let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); - // Allow the swap implementation to fund/register this account. T::SwapInterface::set_up_acc_for_benchmark(&account, &account); let order = crate::Order { @@ -112,16 +109,12 @@ mod benchmarks { netuid, order_type: OrderType::LimitBuy, amount: 1_000_000_000u64, - limit_price: u64::MAX, // always satisfied for a buy + limit_price: u64::MAX, expiry: u64::MAX, fee_rate: Perbill::from_percent(1), fee_recipient, }; - let sig = pair.sign(&order.encode()); - orders.push(crate::SignedOrder { - order, - signature: MultiSignature::Sr25519(sig), - }); + orders.push(sign_order::(public, &order)); } let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = @@ -138,6 +131,7 @@ mod benchmarks { #[benchmark] fn execute_batched_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { let netuid = NetUid::from(1u16); + T::SwapInterface::set_up_netuid_for_benchmark(netuid); // Set up the pallet intermediary so the net pool swap and alpha // distribution transfers succeed. @@ -148,10 +142,8 @@ mod benchmarks { let mut orders = alloc::vec::Vec::new(); for i in 0..n { - let pair = - sp_core::sr25519::Pair::from_string(&alloc::format!("//Signer{}", i), None) - .unwrap(); - let account: T::AccountId = AccountId32::from(pair.public()).into(); + let (public, account_id) = benchmark_key(i); + let account: T::AccountId = account_id.into(); let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); T::SwapInterface::set_up_acc_for_benchmark(&account, &account); @@ -167,11 +159,7 @@ mod benchmarks { fee_rate: Perbill::from_percent(1), fee_recipient, }; - let sig = pair.sign(&order.encode()); - orders.push(crate::SignedOrder { - order, - signature: MultiSignature::Sr25519(sig), - }); + orders.push(sign_order::(public, &order)); } let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index a7e6ab6e35..b332a312bf 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -313,6 +313,11 @@ impl OrderSwapInterface for MockSwap { Ok(()) } + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(_netuid: NetUid) { + // Mock price is already set; no subnet state to initialise. + } + #[cfg(feature = "runtime-benchmarks")] fn set_up_acc_for_benchmark(hotkey: &AccountId, coldkey: &AccountId) { // Provide non-zero swap returns so batched-order benchmarks don't hit @@ -486,6 +491,9 @@ pub fn new_test_ext() -> sp_io::TestExternalities { .build_storage() .unwrap(); let mut ext = sp_io::TestExternalities::new(storage); + // Register a keystore so `sp_io::crypto` functions work in benchmark tests. + let keystore = sp_keystore::testing::MemoryKeystore::new(); + ext.register_extension(sp_keystore::KeystoreExt::new(keystore)); ext.execute_with(|| { System::set_block_number(1); MockSwap::clear_log(); diff --git a/pallets/limit-orders/src/weights.rs b/pallets/limit-orders/src/weights.rs new file mode 100644 index 0000000000..e7fa54a543 --- /dev/null +++ b/pallets/limit-orders/src/weights.rs @@ -0,0 +1,237 @@ + +//! Autogenerated weights for `pallet_limit_orders` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-04-06, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `girazoki-XPS-15-9530`, CPU: `13th Gen Intel(R) Core(TM) i9-13900H` +//! WASM-EXECUTION: `Compiled`, CHAIN: `Some("local")`, DB CACHE: 1024 + +// Executed Command: +// ./target/release/node-subtensor +// benchmark +// pallet +// --pallet +// pallet_limit_orders +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --chain +// local +// --output +// pallets/limit-orders/src/weights.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::{traits::Get, weights::Weight}; +use core::marker::PhantomData; + +/// Weight functions for `pallet_limit_orders`. +pub struct WeightInfo(PhantomData); +impl pallet_limit_orders::WeightInfo for WeightInfo { + /// Storage: `LimitOrders::Orders` (r:1 w:1) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + fn cancel_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `42` + // Estimated: `3514` + // Minimum execution time: 12_568_000 picoseconds. + Weight::from_parts(13_219_000, 0) + .saturating_add(Weight::from_parts(0, 3514)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:0 w:1) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + fn set_pallet_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 5_899_000 picoseconds. + Weight::from_parts(6_212_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:1 w:0) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapV3Initialized` (r:1 w:1) + /// Proof: `Swap::SwapV3Initialized` (`max_values`: None, `max_size`: Some(11), added: 2486, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTaoProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaInProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetAlphaInProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `LimitOrders::Orders` (r:100 w:100) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubtokenEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::SubtokenEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Owner` (r:100 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:200 w:200) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Swap::Positions` (r:1 w:1) + /// Proof: `Swap::Positions` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + /// Storage: `Swap::Ticks` (r:2 w:2) + /// Proof: `Swap::Ticks` (`max_values`: None, `max_size`: Some(78), added: 2553, mode: `MaxEncodedLen`) + /// Storage: `Swap::TickIndexBitmapWords` (r:5 w:5) + /// Proof: `Swap::TickIndexBitmapWords` (`max_values`: None, `max_size`: Some(47), added: 2522, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalTao` (r:1 w:0) + /// Proof: `Swap::FeeGlobalTao` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalAlpha` (r:1 w:0) + /// Proof: `Swap::FeeGlobalAlpha` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentLiquidity` (r:1 w:1) + /// Proof: `Swap::CurrentLiquidity` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::LastPositionId` (r:1 w:1) + /// Proof: `Swap::LastPositionId` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeRate` (r:1 w:0) + /// Proof: `Swap::FeeRate` (`max_values`: None, `max_size`: Some(12), added: 2487, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaOut` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaOut` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetVolume` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetVolume` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:100 w:100) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyShares` (r:100 w:0) + /// Proof: `SubtensorModule::TotalHotkeyShares` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeySharesV2` (r:100 w:100) + /// Proof: `SubtensorModule::TotalHotkeySharesV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingHotkeys` (r:100 w:0) + /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Alpha` (r:100 w:0) + /// Proof: `SubtensorModule::Alpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AlphaV2` (r:100 w:100) + /// Proof: `SubtensorModule::AlphaV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoFlow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTaoFlow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingOperationRateLimiter` (r:0 w:100) + /// Proof: `SubtensorModule::StakingOperationRateLimiter` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (r:0 w:100) + /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::AlphaSqrtPrice` (r:0 w:1) + /// Proof: `Swap::AlphaSqrtPrice` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentTick` (r:0 w:1) + /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 100]`. + fn execute_orders(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1428 + n * (285 ±0)` + // Estimated: `13600 + n * (5158 ±0)` + // Minimum execution time: 425_473_000 picoseconds. + Weight::from_parts(278_641_419, 0) + .saturating_add(Weight::from_parts(0, 13600)) + // Standard Error: 327_930 + .saturating_add(Weight::from_parts(241_272_484, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(28)) + .saturating_add(T::DbWeight::get().reads((10_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(20)) + .saturating_add(T::DbWeight::get().writes((8_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5158).saturating_mul(n.into())) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:1 w:0) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapV3Initialized` (r:1 w:1) + /// Proof: `Swap::SwapV3Initialized` (`max_values`: None, `max_size`: Some(11), added: 2486, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTaoProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaInProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetAlphaInProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `LimitOrders::Orders` (r:100 w:100) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:201 w:201) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubtokenEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::SubtokenEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::Positions` (r:1 w:1) + /// Proof: `Swap::Positions` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + /// Storage: `Swap::Ticks` (r:2 w:2) + /// Proof: `Swap::Ticks` (`max_values`: None, `max_size`: Some(78), added: 2553, mode: `MaxEncodedLen`) + /// Storage: `Swap::TickIndexBitmapWords` (r:5 w:5) + /// Proof: `Swap::TickIndexBitmapWords` (`max_values`: None, `max_size`: Some(47), added: 2522, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalTao` (r:1 w:0) + /// Proof: `Swap::FeeGlobalTao` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalAlpha` (r:1 w:0) + /// Proof: `Swap::FeeGlobalAlpha` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentLiquidity` (r:1 w:1) + /// Proof: `Swap::CurrentLiquidity` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::LastPositionId` (r:1 w:1) + /// Proof: `Swap::LastPositionId` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeRate` (r:1 w:0) + /// Proof: `Swap::FeeRate` (`max_values`: None, `max_size`: Some(12), added: 2487, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaOut` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaOut` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetVolume` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetVolume` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:101 w:101) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyShares` (r:101 w:0) + /// Proof: `SubtensorModule::TotalHotkeyShares` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeySharesV2` (r:101 w:101) + /// Proof: `SubtensorModule::TotalHotkeySharesV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingHotkeys` (r:101 w:0) + /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Alpha` (r:101 w:0) + /// Proof: `SubtensorModule::Alpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AlphaV2` (r:101 w:101) + /// Proof: `SubtensorModule::AlphaV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoFlow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTaoFlow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Owner` (r:100 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingOperationRateLimiter` (r:0 w:100) + /// Proof: `SubtensorModule::StakingOperationRateLimiter` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (r:0 w:101) + /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::AlphaSqrtPrice` (r:0 w:1) + /// Proof: `Swap::AlphaSqrtPrice` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentTick` (r:0 w:1) + /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 100]`. + fn execute_batched_orders(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1622 + n * (285 ±0)` + // Estimated: `13600 + n * (5158 ±0)` + // Minimum execution time: 581_441_000 picoseconds. + Weight::from_parts(542_245_728, 0) + .saturating_add(Weight::from_parts(0, 13600)) + // Standard Error: 146_067 + .saturating_add(Weight::from_parts(228_266_487, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(35)) + .saturating_add(T::DbWeight::get().reads((10_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(25)) + .saturating_add(T::DbWeight::get().writes((8_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5158).saturating_mul(n.into())) + } +} diff --git a/pallets/subtensor/Cargo.toml b/pallets/subtensor/Cargo.toml index 01407e9020..0f3b22a007 100644 --- a/pallets/subtensor/Cargo.toml +++ b/pallets/subtensor/Cargo.toml @@ -158,6 +158,7 @@ runtime-benchmarks = [ "pallet-subtensor-utility/runtime-benchmarks", "pallet-shield/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] pow-faucet = [] fast-runtime = ["subtensor-runtime-common/fast-runtime"] diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 06f422abbb..6da4a51dac 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -153,4 +153,21 @@ impl OrderSwapInterface for Pallet { } Ok(()) } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(netuid: NetUid) { + if !Self::if_subnet_exist(netuid) { + Self::init_new_network(netuid, 100); + } + SubtokenEnabled::::insert(netuid, true); + // Seed pool reserves so the AMM price is well-defined and swaps return non-zero. + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(hotkey: &T::AccountId, coldkey: &T::AccountId) { + Self::create_account_if_non_existent(coldkey, hotkey); + Self::add_balance_to_coldkey_account(coldkey, TaoBalance::from(1_000_000_000_000_u64)); + } } diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 64c3b2a949..8f3a502ce4 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -139,6 +139,15 @@ pub trait OrderSwapInterface { validate_receiver: bool, ) -> DispatchResult; + /// Set up a subnet for benchmark execution. + /// + /// Called once per benchmark before any orders are built. Implementations + /// should initialise the subnet (registers it, enables the subtoken, seeds + /// pool reserves) so that price queries and swaps succeed. + /// The default is a no-op; override in runtime implementations. + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(_netuid: NetUid) {} + /// Set up accounts for benchmark execution. /// /// Called once per order before the benchmarked extrinsic runs. Implementations diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 3c1dbf5c86..dad5c56377 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -332,6 +332,8 @@ runtime-benchmarks = [ "pallet-shield/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "pallet-limit-orders/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] try-runtime = [ "frame-try-runtime/try-runtime", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 37e2ea6049..0fe7a47573 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1763,6 +1763,7 @@ mod benches { [pallet_subtensor_swap, Swap] [pallet_shield, MevShield] [pallet_subtensor_proxy, Proxy] + [pallet_limit_orders, LimitOrders] ); } From 8b58a4c170bf1a1f643900f78724b19b0e2107a4 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 6 Apr 2026 17:57:33 +0200 Subject: [PATCH 34/85] first ts-tests working --- .../dev/subtensor/limit-orders/helpers.ts | 158 ++++++++++++++++++ .../test-execute-orders-take-profit.ts | 108 ++++++++++++ .../limit-orders/test-pallet-status.ts | 118 +++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/helpers.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts diff --git a/ts-tests/suites/dev/subtensor/limit-orders/helpers.ts b/ts-tests/suites/dev/subtensor/limit-orders/helpers.ts new file mode 100644 index 0000000000..1de8a601c8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/helpers.ts @@ -0,0 +1,158 @@ +/** + * Polkadot.js (ApiPromise) compatible helpers for limit-orders dev tests. + * The utils/ directory uses PAPI TypedApi which is incompatible with the + * moonwall `dev` foundation that exposes context.polkadotJs(). + */ +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { SignedOrder } from "utils"; + +export async function devForceSetBalance( + polkadotJs: ApiPromise, + context: any, + address: string, + amount: bigint +): Promise { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.balances.forceSetBalance(address, amount)) + .signAsync(context.keyring.alice), + ]); +} + +export async function devAddStake( + polkadotJs: ApiPromise, + context: any, + coldkey: KeyringPair, + hotkey: string, + netuid: number, + amount: bigint +): Promise { + await context.createBlock([ + await polkadotJs.tx.subtensorModule + .addStake(hotkey, netuid, amount) + .signAsync(coldkey), + ]); +} + +export async function devAssociateHotKey( + polkadotJs: ApiPromise, + context: any, + coldkey: KeyringPair, + hotkey: string, +): Promise { + await context.createBlock([ + await polkadotJs.tx.subtensorModule + .tryAssociateHotkey(hotkey) + .signAsync(coldkey), + ]); +} + +export async function devGetAlphaStake( + polkadotJs: ApiPromise, + hotkey: string, + coldkey: string, + netuid: number +): Promise { + const value = (await polkadotJs.query.subtensorModule.alphaV2( + hotkey, + coldkey, + netuid + )); + + const mantissa = value.mantissa; + const exponent = value.exponent; + + let result: bigint; + + if (exponent >= 0n) { + result = BigInt(mantissa) * BigInt(10) ** BigInt(exponent); + } else { + result = BigInt(mantissa) / BigInt(10) ** BigInt(-exponent); + } + + return result; +} + +export async function devGetBalance( + polkadotJs: ApiPromise, + address: string +): Promise { + const account = (await polkadotJs.query.system.account(address)) as any; + return account.data.free.toBigInt(); +} + +export async function devSudoSetLockReductionInterval( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + interval: number): Promise { + await context.createBlock([ + await polkadotJs.tx.adminUtils + .sudoSetLockReductionInterval(interval) + .signAsync(alice), + ]); +} + +export async function devRegisterSubnet( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + hotkey: KeyringPair +): Promise { + await context.createBlock([ + await polkadotJs.tx.subtensorModule + .registerNetwork(hotkey.address) + .signAsync(alice), + ]); + const events = (await polkadotJs.query.system.events()) as any; + const netuid = (events as any[]) + .filter((e: any) => e.event.method === "NetworkAdded")[0] + .event.data[0].toNumber(); + return netuid; +} + +export async function devEnableSubtoken( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + netuid: number +): Promise { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.adminUtils.sudoSetSubtokenEnabled(netuid, true)) + .signAsync(alice), + ]); +} + +export async function devSeedPool( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + netuid: number, + taoReserve: bigint, + alphaIn: bigint +): Promise { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.adminUtils.sudoSetSubnetTao(netuid, taoReserve)) + .signAsync(alice), + ]); + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.adminUtils.sudoSetSubnetAlphaIn(netuid, alphaIn)) + .signAsync(alice), + ]); +} + +export async function devExecuteOrders( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + orders: SignedOrder[]): Promise { + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeOrders(orders) + .signAsync(alice), + ]); +} \ No newline at end of file diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts new file mode 100644 index 0000000000..ec76f0877b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Separate file because a TakeProfit sell changes pool price. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_TP", + title: "execute_orders — TakeProfit execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "TakeProfit executes when price >= limit_price", + test: async () => { + const stakeBefore = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + const taoBalanceBefore = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + + // limit_price = 1 RAO — current price (~1 TAO/alpha) is always >= 1 + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(100), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]) + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + // Alpha stake should have decreased + const stakeAfter = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + expect(stakeAfter).toBeLessThan(stakeBefore); + + // TAO balance should have increased + const taoBalanceAfter = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts new file mode 100644 index 0000000000..4571c03cb4 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts @@ -0,0 +1,118 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao } from "../../../../utils"; +import { devForceSetBalance } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_STATUS", + title: "set_pallet_status", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + registerLimitOrderTypes(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(1_000)); + }); + + it({ + id: "T01", + title: "root can disable the pallet", + test: async () => { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.limitOrders.setPalletStatus(false)) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + const statusEvent = filterEvents(events, "LimitOrdersPalletStatusChanged"); + expect(statusEvent.length).toBe(1); + expect(statusEvent[0].event.data[0].isTrue).toBe(false); + }, + }); + + it({ + id: "T02", + title: "execute_orders is blocked when pallet is disabled", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid: 1, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("LimitOrdersDisabled"); + }, + }); + + it({ + id: "T03", + title: "execute_batched_orders is blocked when pallet is disabled", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid: 1, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(1, [signed]) + .signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("LimitOrdersDisabled"); + }, + }); + + it({ + id: "T04", + title: "root can re-enable the pallet", + test: async () => { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.limitOrders.setPalletStatus(true)) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + const statusEvent = filterEvents(events, "LimitOrdersPalletStatusChanged"); + expect(statusEvent.length).toBe(1); + expect(statusEvent[0].event.data[0].isTrue).toBe(true); + }, + }); + }, +}); From 152f3dfc2357f348725f5857259fee7b05fe03e9 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 6 Apr 2026 18:35:46 +0200 Subject: [PATCH 35/85] add more tests --- .../test-execute-orders-limit-buy.ts | 107 +++++++++++++++++ .../test-execute-orders-stop-loss.ts | 108 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts new file mode 100644 index 0000000000..2fdd9105c5 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -0,0 +1,107 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// One subnet per file — this test submits a real buy order that hits the pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BUY", + title: "execute_orders — LimitBuy execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy executes when price condition is met", + test: async () => { + const stakeBefore = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + const taoBalanceBefore = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + + // TODO: why here far future? + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + const executed = filterEvents(events, "OrderExecuted"); + expect(executed.length).toBe(1); + + // OrderId should be stored as Fulfilled + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + // Alpha stake should have increased + const stakeAfter = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + expect(stakeAfter).toBeGreaterThan(stakeBefore); + + // TAO balance should have decreased + const taoBalanceAfter = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + expect(taoBalanceAfter).toBeLessThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts new file mode 100644 index 0000000000..b198bf35d5 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; + +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Separate file — StopLoss sell changes pool price. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_SL", + title: "execute_orders — StopLoss execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "StopLoss executes when price <= limit_price", + test: async () => { + const stakeBefore = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + const taoBalanceBefore = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + + + // TODO: discover why limit price of 100 is enough here (I think its close to 1 the ratio?) + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "StopLoss", + amount: tao(100), + limitPrice: 100n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + const stakeAfter = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + expect(stakeAfter).toBeLessThan(stakeBefore); + + const taoBalanceAfter = ( + await polkadotJs.query.system.account(alice.address) + ).data.free.toBigInt(); + expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); + }, + }); + }, +}); From 38295015866f5169ea2ccd72949a29498653aaad Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 6 Apr 2026 18:47:40 +0200 Subject: [PATCH 36/85] more tests --- .../limit-orders/test-cancel-order.ts | 147 ++++++++++++++++++ .../limit-orders/test-execute-orders-fees.ts | 117 ++++++++++++++ .../test-execute-orders-limit-buy.ts | 2 +- .../test-execute-orders-sell-fees.ts | 86 ++++++++++ .../test-execute-orders-take-profit.ts | 2 +- 5 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts new file mode 100644 index 0000000000..fc26ffc1fa --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts @@ -0,0 +1,147 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao } from "../../../../utils"; +import { devForceSetBalance, devExecuteOrders } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_CANCEL", + title: "cancel_order", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + bob = context.keyring.bob; + + registerLimitOrderTypes(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(1_000)); + }); + + it({ + id: "T01", + title: "signer can cancel their own order", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx.signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderCancelled").length).toBe(1); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Cancelled"); + }, + }); + + it({ + id: "T02", + title: "non-signer cannot cancel another account's order", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Bob tries to cancel Alice's order + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + const { + result: [attempt], + } = await context.createBlock([await tx.signAsync(bob)]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("Unauthorized"); + }, + }); + + it({ + id: "T03", + title: "cancelling an already-cancelled order fails", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(3), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx.signAsync(alice)]); + + // Second cancel must fail + const tx2 = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx2.signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + const cancelled = filterEvents(events, "OrderCancelled"); + expect(cancelled.length).toBe(0); + }, + }); + + /*it({ + id: "T04", + title: "executing a cancelled order emits OrderSkipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(4), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Cancel first + await context.createBlock([ + await polkadotJs.tx.limitOrders.cancelOrder(signed.order).signAsync(alice), + ]); + + // Now try to execute + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + });*/ + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts new file mode 100644 index 0000000000..44d20bc50b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts @@ -0,0 +1,117 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { devForceSetBalance, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Each test hits the pool so each gets its own file. +// This file covers fee collection for a buy order only. +// Sell-order fee is covered in 07. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_FEE_BUY", + title: "execute_orders — buy order fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let feeRecipient: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair(); + bob = context.keyring.bob; + feeRecipient = generateKeyringPair(); + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "fee recipient receives TAO for a buy order with 1% fee", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const orderAmount = tao(100); + const expectedFee = orderAmount / 100n; // 1% + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + expect(recipientAfter - recipientBefore).toBe(expectedFee); + }, + }); + + it({ + id: "T02", + title: "zero fee rate — fee recipient balance unchanged", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(10), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + expect(recipientAfter).toBe(recipientBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts index 2fdd9105c5..973f74b78e 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts new file mode 100644 index 0000000000..970d312006 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -0,0 +1,86 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Sell order with fee — separate file, hits pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_FEE_SELL", + title: "execute_orders — sell order fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let feeRecipient: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair(); + bob = context.keyring.bob; + feeRecipient = generateKeyringPair(); + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "fee recipient receives TAO from sell order output with 1% fee", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(100), + limitPrice: 1n, // always met + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + // Fee recipient must have received something > 0 + expect(recipientAfter).toBeGreaterThan(recipientBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts index ec76f0877b..0f6a7a8232 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -79,7 +79,7 @@ describeSuite({ feeRecipient: alice.address, }); - await devExecuteOrders(polkadotJs, context, alice, [signed]) + await devExecuteOrders(polkadotJs, context, alice, [signed]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderExecuted").length).toBe(1); From 7283b51cbd41c2adb9288d32ac9f48949d88dfb2 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 09:49:56 +0200 Subject: [PATCH 37/85] weights used --- pallets/limit-orders/src/lib.rs | 17 ++++++++------- pallets/limit-orders/src/tests/mock.rs | 1 + pallets/limit-orders/src/weights.rs | 29 +++++++++++++++++++++++--- runtime/src/lib.rs | 1 + 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index d73a6cf0c5..c3628ad61e 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -6,6 +6,7 @@ pub use pallet::*; mod benchmarking; #[cfg(test)] mod tests; +pub mod weights; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; @@ -139,6 +140,7 @@ pub(crate) struct OrderEntry { #[frame_support::pallet] pub mod pallet { use super::*; + use crate::weights::WeightInfo as _; use frame_support::{ PalletId, pallet_prelude::*, @@ -179,6 +181,9 @@ pub mod pallet { /// this in the runtime configuration. #[pallet::constant] type PalletHotkey: Get; + + /// Weight information for the pallet's extrinsics. + type WeightInfo: crate::weights::WeightInfo; } // ── Storage ─────────────────────────────────────────────────────────────── @@ -271,9 +276,7 @@ pub mod pallet { /// Orders that fail for any other reason (expired, bad signature, etc.) /// are also skipped; the admin is expected to filter these off-chain. #[pallet::call_index(0)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add( - T::DbWeight::get().reads_writes(2, 1).saturating_mul(orders.len() as u64) - ))] + #[pallet::weight(T::WeightInfo::execute_orders(orders.len() as u32))] pub fn execute_orders( origin: OriginFor, orders: BoundedVec, T::MaxOrdersPerBatch>, @@ -315,9 +318,7 @@ pub mod pallet { /// All orders in the batch must target `netuid`. Orders for a different /// subnet are skipped. #[pallet::call_index(1)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add( - T::DbWeight::get().reads_writes(3, 2).saturating_mul(orders.len() as u64) - ))] + #[pallet::weight(T::WeightInfo::execute_batched_orders(orders.len() as u32))] pub fn execute_batched_orders( origin: OriginFor, netuid: NetUid, @@ -338,7 +339,7 @@ pub mod pallet { /// provided so the pallet can derive the `OrderId`. Once marked /// Cancelled, the order can never be executed. #[pallet::call_index(2)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + #[pallet::weight(T::WeightInfo::cancel_order())] pub fn cancel_order(origin: OriginFor, order: Order) -> DispatchResult { let who = ensure_signed(origin)?; ensure!(order.signer == who, Error::::Unauthorized); @@ -365,7 +366,7 @@ pub mod pallet { /// It allows disabling or enabling the pallet /// true means enabling, false means disabling #[pallet::call_index(3)] - #[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))] + #[pallet::weight(T::WeightInfo::set_pallet_status())] pub fn set_pallet_status(origin: OriginFor, enabled: bool) -> DispatchResult { ensure_root(origin)?; diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index b332a312bf..38e982d35e 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -422,6 +422,7 @@ impl pallet_limit_orders::Config for Test { type MaxOrdersPerBatch = ConstU32<64>; type PalletId = LimitOrdersPalletId; type PalletHotkey = PalletHotkeyAccount; + type WeightInfo = (); } // ── Shared test helpers ─────────────────────────────────────────────────────── diff --git a/pallets/limit-orders/src/weights.rs b/pallets/limit-orders/src/weights.rs index e7fa54a543..78e859e93b 100644 --- a/pallets/limit-orders/src/weights.rs +++ b/pallets/limit-orders/src/weights.rs @@ -32,9 +32,32 @@ use frame_support::{traits::Get, weights::Weight}; use core::marker::PhantomData; -/// Weight functions for `pallet_limit_orders`. -pub struct WeightInfo(PhantomData); -impl pallet_limit_orders::WeightInfo for WeightInfo { +/// Weight functions needed for `pallet_limit_orders`. +pub trait WeightInfo { + fn execute_orders(n: u32) -> Weight; + fn execute_batched_orders(n: u32) -> Weight; + fn cancel_order() -> Weight; + fn set_pallet_status() -> Weight; +} + +impl WeightInfo for () { + fn execute_orders(_n: u32) -> Weight { + Weight::zero() + } + fn execute_batched_orders(_n: u32) -> Weight { + Weight::zero() + } + fn cancel_order() -> Weight { + Weight::zero() + } + fn set_pallet_status() -> Weight { + Weight::zero() + } +} + +/// Benchmarked weight functions for `pallet_limit_orders`. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { /// Storage: `LimitOrders::Orders` (r:1 w:1) /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) fn cancel_order() -> Weight { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 0fe7a47573..aac4653451 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1555,6 +1555,7 @@ impl pallet_limit_orders::Config for Runtime { type MaxOrdersPerBatch = LimitOrdersMaxOrdersPerBatch; type PalletId = LimitOrdersPalletId; type PalletHotkey = LimitOrdersPalletHotkey; + type WeightInfo = pallet_limit_orders::weights::SubstrateWeight; } fn contracts_schedule() -> pallet_contracts::Schedule { From 968b2eebcf9bf6d768e89ec4c13ba3e77fade8fb Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 10:06:04 +0200 Subject: [PATCH 38/85] adapt to event thrown in execute_orders --- pallets/limit-orders/src/lib.rs | 13 +++++--- pallets/limit-orders/src/tests/extrinsics.rs | 33 +++++++++++++++++++ .../limit-orders/test-cancel-order.ts | 4 +-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index c3628ad61e..e77fb97935 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -214,9 +214,11 @@ pub mod pallet { /// Output amount: alpha (raw) received for Buy orders, TAO (raw) received for Sell orders (after fee). amount_out: u64, }, - /// An order was skipped during batch execution (invalid signature, - /// expired, already processed, wrong netuid, or price not met). - OrderSkipped { order_id: H256 }, + /// An order was skipped during execution. + OrderSkipped { + order_id: H256, + reason: sp_runtime::DispatchError, + }, /// A user registered a cancellation intent for their order. OrderCancelled { order_id: H256, @@ -289,7 +291,10 @@ pub mod pallet { for signed_order in orders { // Best-effort: individual order failures do not revert the batch. - let _ = Self::try_execute_order(signed_order); + let order_id = Self::derive_order_id(&signed_order.order); + if let Err(reason) = Self::try_execute_order(signed_order) { + Self::deposit_event(Event::OrderSkipped { order_id, reason }); + } } Ok(()) diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index bb38d8b218..cf9e5e225d 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -284,6 +284,10 @@ fn execute_orders_stop_loss_price_not_met_skipped() { )); assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); }); } @@ -312,6 +316,10 @@ fn execute_orders_expired_order_skipped() { // Skipped — storage untouched. assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderExpired.into(), + }); }); } @@ -339,6 +347,10 @@ fn execute_orders_price_not_met_skipped() { )); assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); }); } @@ -368,6 +380,10 @@ fn execute_orders_already_processed_skipped() { )); // Still Fulfilled (not changed). assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderAlreadyProcessed.into(), + }); }); } @@ -400,6 +416,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { fee_recipient(), ); let valid_id = order_id(&valid.order); + let expired_id = order_id(&expired.order); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie()), @@ -407,6 +424,10 @@ fn execute_orders_mixed_batch_valid_and_skipped() { )); assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderSkipped { + order_id: expired_id, + reason: Error::::OrderExpired.into(), + }); }); } @@ -545,6 +566,10 @@ mod execute_orders_skip_invalid { // Skipped — storage untouched. assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderExpired.into(), + }); }); } @@ -577,6 +602,10 @@ mod execute_orders_skip_invalid { // Skipped — storage untouched. assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); }); } @@ -623,6 +652,10 @@ mod execute_orders_skip_invalid { assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); // Expired order silently skipped — not written to storage. assert!(Orders::::get(expired_id).is_none()); + assert_event(Event::OrderSkipped { + order_id: expired_id, + reason: Error::::OrderExpired.into(), + }); }); } } diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts index fc26ffc1fa..cfaf2c2417 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts @@ -114,7 +114,7 @@ describeSuite({ }, }); - /*it({ + it({ id: "T04", title: "executing a cancelled order emits OrderSkipped", test: async () => { @@ -142,6 +142,6 @@ describeSuite({ expect(filterEvents(events, "OrderSkipped").length).toBe(1); expect(filterEvents(events, "OrderExecuted").length).toBe(0); }, - });*/ + }); }, }); From 83d15d4b9e82948f307a1f9d41d20e5026249143 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 10:20:42 +0200 Subject: [PATCH 39/85] make orders versioned --- pallets/limit-orders/README.md | 48 +++++++++++------ pallets/limit-orders/src/benchmarking.rs | 21 ++++---- pallets/limit-orders/src/lib.rs | 56 ++++++++++++++------ pallets/limit-orders/src/tests/auxiliary.rs | 14 ++--- pallets/limit-orders/src/tests/extrinsics.rs | 24 +++++---- pallets/limit-orders/src/tests/mock.rs | 13 +++-- runtime/tests/limit_orders.rs | 16 +++--- 7 files changed, 119 insertions(+), 73 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 24de94106e..59705b27cd 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -17,7 +17,7 @@ batch contents from the mempool until the block is proposed. ## Order lifecycle ``` -User signs Order off-chain +User signs VersionedOrder::V1(Order) off-chain │ ▼ Relayer submits via execute_orders Relayer submits via execute_batched_orders @@ -25,7 +25,8 @@ Relayer submits via execute_orders Relayer submits via execute_batched_or │ │ ├─ Invalid / expired / ├─ Any order invalid / expired / │ price-not-met → │ price-not-met / root netuid → - │ silently skipped (no state change) │ entire batch fails (DispatchError) + │ skipped, emits OrderSkipped │ entire batch fails (DispatchError) + │ with DispatchError reason │ │ │ └─ Valid → executed └─ All orders valid → net pool swap │ → distribute pro-rata @@ -40,10 +41,24 @@ User can cancel at any time via cancel_order ## Data structures +### `VersionedOrder` + +Versioned wrapper around an order payload. Currently has one variant: + +| Variant | Description | +|---------|-------------| +| `V1(Order)` | First version of the order schema. | + +Versioning lets the pallet accept orders signed against different schemas +simultaneously. When a new variant is added (`V2`, etc.), old `V1` signed orders +remain valid because the `OrderId` and signature both cover the full +`VersionedOrder` encoding (including the version discriminant byte). + ### `Order` -The payload that a user signs off-chain. Never stored in full on-chain — only -its `blake2_256` hash (`OrderId`) is persisted. +The payload that a user signs off-chain, wrapped inside `VersionedOrder`. Never +stored in full on-chain — only the `blake2_256` hash of the `VersionedOrder` +encoding (`OrderId`) is persisted. | Field | Type | Description | |-----------------|-------------|-------------| @@ -65,11 +80,12 @@ its `blake2_256` hash (`OrderId`) is persisted. | `TakeProfit` | Sell alpha | price ≥ `limit_price` | Exit a position once price rises to a profit target. | | `StopLoss` | Sell alpha | price ≤ `limit_price` | Exit a position to limit downside if price falls to a floor. | -### `SignedOrder` +### `SignedOrder` -Envelope submitted by the relayer: the `Order` payload plus the user's -sr25519/ed25519 signature over its SCALE encoding. Signature verification -uses `order.signer` as the expected public key. +Envelope submitted by the relayer: the `VersionedOrder` payload plus the user's +sr25519 signature over the SCALE encoding of the `VersionedOrder` (including the +version discriminant). Only sr25519 signatures are accepted. Signature +verification uses the inner `order.signer` as the expected public key. ### `OrderStatus` @@ -86,8 +102,8 @@ Terminal state of a processed order, stored under its `OrderId`. ### `Orders: StorageMap` -Maps an `OrderId` (blake2_256 of the SCALE-encoded `Order`) to its terminal -`OrderStatus`. Absence means the order has never been seen and is still +Maps an `OrderId` (blake2_256 of the SCALE-encoded `VersionedOrder`) to its +terminal `OrderStatus`. Absence means the order has never been seen and is still executable (provided it is valid). Presence means it is permanently closed — neither `Fulfilled` nor `Cancelled` orders can be re-executed. @@ -97,12 +113,12 @@ neither `Fulfilled` nor `Cancelled` orders can be re-executed. | Item | Type | Description | |-----------------------|---------------------------------------------------|-------------| -| `Signature` | `Verify + ...` | Signature type for off-chain order authorisation. Set to `sp_runtime::MultiSignature` in the subtensor runtime. | | `SwapInterface` | `OrderSwapInterface` | Full swap + balance execution interface. Implemented by `pallet_subtensor::Pallet`. Provides `buy_alpha`, `sell_alpha`, `transfer_tao`, `transfer_staked_alpha`, and `current_alpha_price`. | | `TimeProvider` | `UnixTime` | Current wall-clock time for expiry checks. | | `MaxOrdersPerBatch` | `Get` (constant) | Maximum number of orders accepted in a single `execute_orders` or `execute_batched_orders` call. Should equal `floor(max_block_weight / per_order_weight)`. | | `PalletId` | `Get` (constant) | Used to derive the pallet intermediary account (`PalletId::into_account_truncating`). This account temporarily holds pooled TAO and staked alpha during `execute_batched_orders`. | | `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated hotkey registered on every subnet the pallet may operate on. Operators should register it as a non-validator neuron. | +| `WeightInfo` | `weights::WeightInfo` | Benchmarked weight functions for each extrinsic. Use `weights::SubstrateWeight` in production and `()` in tests. | --- @@ -125,7 +141,7 @@ impact. --- -### `execute_batched_orders(netuid, orders)` — call index 4 +### `execute_batched_orders(netuid, orders)` — call index 1 **Origin:** any signed account (typically a relayer). @@ -175,13 +191,13 @@ interaction: --- -### `cancel_order(order)` — call index 1 +### `cancel_order(order)` — call index 2 **Origin:** the order's `signer` (coldkey). Registers a cancellation intent by writing the `OrderId` into `Orders` as -`Cancelled`. Once cancelled an order can never be executed. The full `Order` -payload is required so the pallet can derive the `OrderId`. +`Cancelled`. Once cancelled an order can never be executed. The full +`VersionedOrder` payload is required so the pallet can derive the `OrderId`. --- @@ -190,7 +206,7 @@ payload is required so the pallet can derive the `OrderId`. | Event | Fields | Emitted when | |-------|--------|--------------| | `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). | -| `OrderSkipped` | `order_id` | An order was skipped by `execute_orders` (bad signature, expired, wrong netuid, already processed, price condition not met, or root netuid). Not emitted by `execute_batched_orders` — invalid orders there cause the whole call to fail. | +| `OrderSkipped` | `order_id`, `reason` | An order was skipped by `execute_orders` (bad signature, expired, wrong netuid, already processed, price condition not met, or root netuid). `reason` is the `DispatchError` that caused the skip. Not emitted by `execute_batched_orders` — invalid orders there cause the whole call to fail. | | `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | | `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 8452435a39..0aa727f179 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -14,13 +14,13 @@ extern crate alloc; use crate::{Call, Config, Pallet}; use codec::Encode; -/// Sign an order using the runtime keystore (no `full_crypto` required). +/// Sign a versioned order using the runtime keystore (no `full_crypto` required). /// /// The key identified by `public` must already be registered in the keystore /// (e.g. via `sp_io::crypto::sr25519_generate`) before calling this. fn sign_order( public: sp_core::sr25519::Public, - order: &crate::Order, + order: &crate::VersionedOrder, ) -> crate::SignedOrder { let sig = sp_io::crypto::sr25519_sign( sp_core::crypto::key_types::ACCOUNT, @@ -38,13 +38,12 @@ fn sign_order( /// public key. The key is inserted into the runtime keystore so it can sign. fn benchmark_key(i: u32) -> (sp_core::sr25519::Public, AccountId32) { let seed = alloc::format!("//BenchSigner{}", i).into_bytes(); - let public = - sp_io::crypto::sr25519_generate(sp_core::crypto::key_types::ACCOUNT, Some(seed)); + let public = sp_io::crypto::sr25519_generate(sp_core::crypto::key_types::ACCOUNT, Some(seed)); let account = AccountId32::from(public); (public, account) } -pub fn order_id(order: &crate::Order) -> H256 { +pub fn order_id(order: &crate::VersionedOrder) -> H256 { crate::pallet::Pallet::::derive_order_id(order) } @@ -59,7 +58,7 @@ mod benchmarks { let (public, account_id) = benchmark_key(0); let account: T::AccountId = account_id.into(); - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer: account.clone(), hotkey: account.clone(), netuid: NetUid::from(1u16), @@ -69,7 +68,7 @@ mod benchmarks { expiry: 1_000_000_000, fee_rate: Perbill::zero(), fee_recipient: account.clone(), - }; + }); let signed = sign_order::(public, &order); #[extrinsic_call] @@ -103,7 +102,7 @@ mod benchmarks { T::SwapInterface::set_up_acc_for_benchmark(&account, &account); - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer: account.clone(), hotkey: account.clone(), netuid, @@ -113,7 +112,7 @@ mod benchmarks { expiry: u64::MAX, fee_rate: Perbill::from_percent(1), fee_recipient, - }; + }); orders.push(sign_order::(public, &order)); } @@ -148,7 +147,7 @@ mod benchmarks { T::SwapInterface::set_up_acc_for_benchmark(&account, &account); - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer: account.clone(), hotkey: account.clone(), netuid, @@ -158,7 +157,7 @@ mod benchmarks { expiry: u64::MAX, fee_rate: Perbill::from_percent(1), fee_recipient, - }; + }); orders.push(sign_order::(public, &order)); } diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index e77fb97935..47e22ca361 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -85,24 +85,45 @@ pub struct Order pub fee_recipient: AccountId, } -/// The envelope the admin submits on-chain: the order payload plus the user's -/// signature over the SCALE-encoded `Order`. +/// Versioned wrapper around an order payload. +/// +/// Adding a new variant in the future (e.g. `V2`) lets the pallet accept orders +/// signed against either schema simultaneously, preventing old signed orders from +/// being invalidated by a schema upgrade. +#[derive( + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, +)] +pub enum VersionedOrder { + V1(Order), +} + +impl VersionedOrder { + /// Returns a reference to the inner order regardless of version. + pub fn inner(&self) -> &Order { + match self { + VersionedOrder::V1(order) => order, + } + } +} + +/// The envelope the admin submits on-chain: the versioned order payload plus +/// the user's signature over the SCALE-encoded `VersionedOrder`. /// /// TODO: evaluate cross-chain replay protection. The signature covers only the -/// SCALE-encoded `Order` with no chain-specific domain separator (genesis hash, -/// chain ID, or pallet prefix). A signed order is therefore valid on any chain +/// SCALE-encoded `VersionedOrder` with no chain-specific domain separator (genesis +/// hash, chain ID, or pallet prefix). A signed order is therefore valid on any chain /// that shares the same runtime types (e.g. a testnet fork). Consider prepending /// a domain tag to the signed payload or adding the genesis hash as an `Order` field. /// -/// Signature verification is performed against `order.signer` (the AccountId) +/// Signature verification is performed against `order.inner().signer` (the AccountId) /// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants /// of `MultiSignature` are rejected at validation time. #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub struct SignedOrder { - pub order: Order, - /// Sr25519 signature over `SCALE_ENCODE(order)`. + pub order: VersionedOrder, + /// Sr25519 signature over `SCALE_ENCODE(VersionedOrder)`. pub signature: MultiSignature, } @@ -345,9 +366,12 @@ pub mod pallet { /// Cancelled, the order can never be executed. #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::cancel_order())] - pub fn cancel_order(origin: OriginFor, order: Order) -> DispatchResult { + pub fn cancel_order( + origin: OriginFor, + order: VersionedOrder, + ) -> DispatchResult { let who = ensure_signed(origin)?; - ensure!(order.signer == who, Error::::Unauthorized); + ensure!(order.inner().signer == who, Error::::Unauthorized); let order_id = Self::derive_order_id(&order); @@ -387,7 +411,7 @@ pub mod pallet { impl Pallet { /// Derive the on-chain `OrderId` as blake2_256 over the SCALE-encoded order. - pub fn derive_order_id(order: &Order) -> H256 { + pub fn derive_order_id(order: &VersionedOrder) -> H256 { H256(sp_core::hashing::blake2_256(&order.encode())) } @@ -406,13 +430,13 @@ pub mod pallet { now_ms: u64, current_price: U96F32, ) -> DispatchResult { - let order = &signed_order.order; + let order = signed_order.order.inner(); ensure!(!order.netuid.is_root(), Error::::RootNetUidNotAllowed); ensure!( matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order .signature - .verify(order.encode().as_slice(), &order.signer), + .verify(signed_order.order.encode().as_slice(), &order.signer), Error::::InvalidSignature ); ensure!( @@ -435,8 +459,8 @@ pub mod pallet { /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. fn try_execute_order(signed_order: SignedOrder) -> DispatchResult { - let order = &signed_order.order; - let order_id = Self::derive_order_id(order); + let order_id = Self::derive_order_id(&signed_order.order); + let order = signed_order.order.inner(); let now_ms = T::TimeProvider::now().as_millis() as u64; let current_price = T::SwapInterface::current_alpha_price(order.netuid); @@ -613,8 +637,8 @@ pub mod pallet { let mut sells = BoundedVec::new(); for signed_order in orders.iter() { - let order = &signed_order.order; - let order_id = Self::derive_order_id(order); + let order_id = Self::derive_order_id(&signed_order.order); + let order = signed_order.order.inner(); // Hard-fail if the order targets a different subnet than the batch netuid. ensure!(order.netuid == netuid, Error::::OrderNetUidMismatch); diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index abecea347c..c0ab160357 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -1126,7 +1126,7 @@ use subtensor_swap_interface::OrderSwapInterface; fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { let keyring = AccountKeyring::Alice; - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer: keyring.to_account_id(), hotkey: AccountKeyring::Bob.to_account_id(), netuid: netuid(), @@ -1136,7 +1136,7 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); let signed = crate::SignedOrder { @@ -1216,10 +1216,10 @@ fn is_order_valid_expired_order_returns_error() { // now_ms (2_000_001) > expiry (u64::MAX is fine, so use a low expiry order). // Re-build a signed order with a past expiry. let keyring = AccountKeyring::Alice; - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { expiry: 500_000, - ..signed.order.clone() - }; + ..signed.order.inner().clone() + }); let id2 = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); let signed2 = crate::SignedOrder { @@ -1241,7 +1241,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { // Price 5.0 > limit_price 2 → LimitBuy condition (price ≤ limit) not met. MockSwap::set_price(5.0); let keyring = AccountKeyring::Alice; - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer: keyring.to_account_id(), hotkey: AccountKeyring::Bob.to_account_id(), netuid: netuid(), @@ -1251,7 +1251,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); let signed = crate::SignedOrder { diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index cf9e5e225d..8fea34e7c9 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -9,7 +9,9 @@ use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{DispatchError, Perbill}; use subtensor_runtime_common::NetUid; -use crate::{Error, Order, OrderSide, OrderStatus, OrderType, Orders, pallet::Event}; +use crate::{ + Error, Order, OrderSide, OrderStatus, OrderType, Orders, VersionedOrder, pallet::Event, +}; type LimitOrders = crate::pallet::Pallet; @@ -32,7 +34,7 @@ fn assert_event(event: Event) { #[test] fn cancel_order_signer_can_cancel() { new_test_ext().execute_with(|| { - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice(), hotkey: bob(), netuid: netuid(), @@ -42,7 +44,7 @@ fn cancel_order_signer_can_cancel() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); let id = order_id(&order); assert_ok!(LimitOrders::cancel_order( @@ -60,7 +62,7 @@ fn cancel_order_signer_can_cancel() { #[test] fn cancel_order_non_signer_rejected() { new_test_ext().execute_with(|| { - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice(), hotkey: bob(), netuid: netuid(), @@ -70,7 +72,7 @@ fn cancel_order_non_signer_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); // Bob tries to cancel Alice's order. assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::signed(bob()), order), @@ -82,7 +84,7 @@ fn cancel_order_non_signer_rejected() { #[test] fn cancel_order_already_cancelled_rejected() { new_test_ext().execute_with(|| { - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice(), hotkey: bob(), netuid: netuid(), @@ -92,7 +94,7 @@ fn cancel_order_already_cancelled_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Cancelled); @@ -106,7 +108,7 @@ fn cancel_order_already_cancelled_rejected() { #[test] fn cancel_order_already_fulfilled_rejected() { new_test_ext().execute_with(|| { - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice(), hotkey: bob(), netuid: netuid(), @@ -116,7 +118,7 @@ fn cancel_order_already_fulfilled_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -130,7 +132,7 @@ fn cancel_order_already_fulfilled_rejected() { #[test] fn cancel_order_unsigned_rejected() { new_test_ext().execute_with(|| { - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice(), hotkey: bob(), netuid: netuid(), @@ -140,7 +142,7 @@ fn cancel_order_unsigned_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), - }; + }); assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::none(), order), DispatchError::BadOrigin diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 38e982d35e..3f935a50c7 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -326,7 +326,12 @@ impl OrderSwapInterface for MockSwap { MockSwap::set_buy_alpha_return(1_000_000); MockSwap::set_sell_tao_return(1_000_000); MockSwap::set_tao_balance(coldkey.clone(), u64::MAX / 2); - MockSwap::set_alpha_balance(coldkey.clone(), hotkey.clone(), NetUid::from(1u16), u64::MAX / 2); + MockSwap::set_alpha_balance( + coldkey.clone(), + hotkey.clone(), + NetUid::from(1u16), + u64::MAX / 2, + ); } fn transfer_staked_alpha( @@ -457,7 +462,7 @@ pub fn make_signed_order( fee_recipient: AccountId, ) -> crate::SignedOrder { let signer = keyring.to_account_id(); - let order = crate::Order { + let order = crate::VersionedOrder::V1(crate::Order { signer, hotkey, netuid, @@ -467,7 +472,7 @@ pub fn make_signed_order( expiry, fee_rate, fee_recipient, - }; + }); let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { order, @@ -481,7 +486,7 @@ pub fn bounded( BoundedVec::try_from(v).unwrap() } -pub fn order_id(order: &crate::Order) -> H256 { +pub fn order_id(order: &crate::VersionedOrder) -> H256 { crate::pallet::Pallet::::derive_order_id(order) } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 2d65583369..738bf77c21 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -6,7 +6,7 @@ use node_subtensor_runtime::{ BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, System, pallet_subtensor, }; -use pallet_limit_orders::{Order, OrderStatus, OrderType, Orders, SignedOrder}; +use pallet_limit_orders::{Order, OrderStatus, OrderType, Orders, SignedOrder, VersionedOrder}; use sp_core::{Get, H256, Pair}; use sp_keyring::Sr25519Keyring; use sp_runtime::{MultiSignature, Perbill}; @@ -34,7 +34,7 @@ fn setup_subnet(netuid: NetUid) { fn min_default_stake() -> TaoBalance { pallet_subtensor::DefaultMinStake::::get() } -fn order_id(order: &Order) -> H256 { +fn order_id(order: &VersionedOrder) -> H256 { H256(sp_io::hashing::blake2_256(&order.encode())) } @@ -49,7 +49,7 @@ fn make_signed_order( fee_rate: Perbill, fee_recipient: AccountId, ) -> SignedOrder { - let order = Order { + let order = VersionedOrder::V1(Order { signer: keyring.to_account_id(), hotkey, netuid, @@ -59,7 +59,7 @@ fn make_signed_order( expiry, fee_rate, fee_recipient, - }; + }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { order, @@ -79,7 +79,7 @@ fn cancel_order_works() { let bob_id = Sr25519Keyring::Bob.to_account_id(); let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice_id.clone(), hotkey: bob_id, netuid: NetUid::from(1u16), @@ -89,7 +89,7 @@ fn cancel_order_works() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient, - }; + }); let id = order_id(&order); assert_ok!(LimitOrders::cancel_order( @@ -111,7 +111,7 @@ fn execute_orders_ed25519_signature_rejected() { let bob_id = Sr25519Keyring::Bob.to_account_id(); let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); - let order = Order { + let order = VersionedOrder::V1(Order { signer: alice_id.clone(), hotkey: bob_id, netuid: NetUid::from(1u16), @@ -121,7 +121,7 @@ fn execute_orders_ed25519_signature_rejected() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient, - }; + }); let id = order_id(&order); // Sign with ed25519 — valid signature, wrong scheme. From db0e934a568c403808e3d1e1dbad53f1953f15c8 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 10:27:50 +0200 Subject: [PATCH 40/85] adapt ts-tests --- ts-tests/utils/limit-orders.ts | 221 +++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 ts-tests/utils/limit-orders.ts diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts new file mode 100644 index 0000000000..4d67a81a0e --- /dev/null +++ b/ts-tests/utils/limit-orders.ts @@ -0,0 +1,221 @@ +import type { KeyringPair } from "@moonwall/util"; +import type { TypedApi } from "polkadot-api"; +import type { subtensor } from "@polkadot-api/descriptors"; +import { Keyring } from "@polkadot/keyring"; +import { u8aToHex } from "@polkadot/util"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { waitForTransactionWithRetry } from "./transactions.js"; +import { MultiAddress } from "@polkadot-api/descriptors"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type OrderType = "LimitBuy" | "TakeProfit" | "StopLoss"; + +export interface OrderParams { + signer: KeyringPair; + hotkey: string; + netuid: number; + orderType: OrderType; + amount: bigint; + limitPrice: bigint; + expiry: bigint; + feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% + feeRecipient: string; +} + +export interface Order { + signer: string; + hotkey: string; + netuid: number; + order_type: OrderType; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; +} + +export interface VersionedOrder { + V1: Order; +} + +export interface SignedOrder { + order: VersionedOrder; + signature: { Sr25519: `0x${string}` } | { Ed25519: `0x${string}` } | { Ecdsa: `0x${string}` }; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const PERBILL_ONE_PERCENT = 10_000_000; +export const FAR_FUTURE = BigInt("18446744073709551615"); // u64::MAX +export const EXPIRED = BigInt(1); // 1ms — always in the past + +// ── Order building & signing ────────────────────────────────────────────────── + +/** + * Build a SignedOrder ready for submission to execute_orders / + * execute_batched_orders. The Order struct is SCALE-encoded via the + * polkadot.js registry and then signed with the signer's sr25519 key. + */ +export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { + const inner: Order = { + signer: params.signer.address, + hotkey: params.hotkey, + netuid: params.netuid, + order_type: params.orderType, + amount: params.amount, + limit_price: params.limitPrice, + expiry: params.expiry, + fee_rate: params.feeRate, + fee_recipient: params.feeRecipient, + }; + + const versionedOrder: VersionedOrder = { V1: inner }; + + // SCALE-encode the VersionedOrder so the signature covers the version tag. + const encoded = api.registry.createType("LimitVersionedOrder", versionedOrder); + const sig = params.signer.sign(encoded.toU8a()); + + return { + order: versionedOrder, + signature: { Sr25519: u8aToHex(sig) as `0x${string}` }, + }; +} + +/** + * Compute the on-chain OrderId (blake2_256 of SCALE-encoded VersionedOrder). + * Mirrors `Pallet::derive_order_id` in Rust. + */ +export function orderId(api: any, order: VersionedOrder): `0x${string}` { + const encoded = api.registry.createType("LimitVersionedOrder", order); + return blake2AsHex(encoded.toU8a(), 256) as `0x${string}`; +} + +// ── Registry ────────────────────────────────────────────────────────────────── + +/** + * Register the custom SCALE types used by pallet-limit-orders with the + * polkadot.js ApiPromise registry. Call this once after obtaining the api. + */ +export function registerLimitOrderTypes(api: any): void { + api.registry.register({ + LimitOrderType: { + _enum: ["LimitBuy", "TakeProfit", "StopLoss"], + }, + LimitOrder: { + signer: "AccountId", + hotkey: "AccountId", + netuid: "u16", + order_type: "LimitOrderType", + amount: "u64", + limit_price: "u64", + expiry: "u64", + fee_rate: "u32", // Perbill + fee_recipient: "AccountId", + }, + LimitVersionedOrder: { + _enum: { + V1: "LimitOrder", + }, + }, + LimitSignedOrder: { + order: "LimitVersionedOrder", + signature: "MultiSignature", + }, + LimitOrderStatus: { + _enum: ["Fulfilled", "Cancelled"], + }, + }); +} + +// ── Chain helpers ───────────────────────────────────────────────────────────── + +/** Read current SubnetTAO and SubnetAlphaIn to derive spot price (TAO per alpha). */ +export async function getAlphaPrice(api: TypedApi, netuid: number): Promise { + const taoReserve = await api.query.SubtensorModule.SubnetTAO.getValue(netuid); + const alphaIn = await api.query.SubtensorModule.SubnetAlphaIn.getValue(netuid); + if (alphaIn === 0n) return 0n; + return taoReserve / alphaIn; // integer approximation +} + +/** + * Sudo-set pool reserves directly so benchmarks and tests have a + * well-defined, non-zero starting price. + */ +export async function seedPoolReserves( + api: TypedApi, + polkadotJs: any, + netuid: number, + taoReserve: bigint, + alphaIn: bigint +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + + const setTao = polkadotJs.tx.sudo.sudo( + polkadotJs.tx.adminUtils.sudoSetSubnetTao(netuid, taoReserve) + ); + await setTao.signAndSend(alice, { nonce: -1 }); + + const setAlpha = polkadotJs.tx.sudo.sudo( + polkadotJs.tx.adminUtils.sudoSetSubnetAlphaIn(netuid, alphaIn) + ); + await setAlpha.signAndSend(alice, { nonce: -1 }); +} + +/** Enable the subtoken for a subnet (required for swaps to work). */ +export async function enableSubtoken( + api: TypedApi, + netuid: number +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ + netuid, + subtoken_enabled: true, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_subtoken_enabled"); +} + +/** Sudo-enable or disable the limit-orders pallet. */ +export async function setPalletStatus( + api: TypedApi, + enabled: boolean +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const tx = api.tx.Sudo.sudo({ + call: api.tx.LimitOrders.set_pallet_status({ enabled }).decodedCall, + }); + await waitForTransactionWithRetry(api, tx, alice, "set_pallet_status"); +} + +/** Read the on-chain OrderStatus for a given order id (hex). */ +export async function getOrderStatus( + polkadotJs: any, + id: `0x${string}` +): Promise<"Fulfilled" | "Cancelled" | undefined> { + const result = await polkadotJs.query.limitOrders.orders(id); + if (result.isNone) return undefined; + return result.unwrap().type as "Fulfilled" | "Cancelled"; +} + +/** Filter system events by method name. */ +export function filterEvents(events: any, method: string): any[] { + return (events as any[]).filter((e: any) => e.event.method === method); +} + +export async function executeBatchedOrders( + api: TypedApi, + netuid: number, + orders: SignedOrder[] +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const tx = api.tx.LimitOrders.execute_batched_orders({ + netuid, + orders, + }); + await waitForTransactionWithRetry(api, tx, alice, "execute_batched_orders"); +} \ No newline at end of file From b382e0a33021064b43311ace7acf14b89239591d Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 11:51:06 +0200 Subject: [PATCH 41/85] all tests working --- .../limit-orders/test-batched-all-buys.ts | 112 ++++++++ .../limit-orders/test-batched-all-sells.ts | 114 ++++++++ .../limit-orders/test-batched-fees.ts | 168 +++++++++++ .../limit-orders/test-batched-hardfail.ts | 164 +++++++++++ .../test-batched-mixed-buy-dominant.ts | 129 +++++++++ .../test-batched-mixed-sell-dominant.ts | 127 ++++++++ .../test-execute-orders-skip-conditions.ts | 271 ++++++++++++++++++ 7 files changed, 1085 insertions(+) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts new file mode 100644 index 0000000000..7295ba2ed4 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts @@ -0,0 +1,112 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// execute_batched_orders — all-buy batch. Own subnet, own file. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_BUY", + title: "execute_batched_orders — all-buy batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "all buyers receive alpha and GroupExecutionSummary is emitted", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + const bobStakeBefore = await devGetAlphaStake( + polkadotJs, bobHotKey.address, bob.address, netuid + ); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(50), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(50), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); + + const aliceStakeAfter = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobStakeAfter = await devGetAlphaStake( + polkadotJs, bobHotKey.address, bob.address, netuid + ); + expect(bobStakeAfter).toBeGreaterThan(bobStakeBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts new file mode 100644 index 0000000000..fecfb07952 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts @@ -0,0 +1,114 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_SELL", + title: "execute_batched_orders — all-sell batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Stake alpha for both sellers + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(200)); + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(200)); + }); + + it({ + id: "T01", + title: "all sellers receive TAO and GroupExecutionSummary is emitted", + test: async () => { + const aliceTaoBefore = ( + await polkadotJs.query.system.account(alice.address) as any + ).data.free.toBigInt(); + const bobTaoBefore = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(50), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(50), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); + + const aliceTaoAfter = ( + await polkadotJs.query.system.account(alice.address) as any + ).data.free.toBigInt(); + const bobTaoAfter = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + + expect(aliceTaoAfter).toBeGreaterThan(aliceTaoBefore); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts new file mode 100644 index 0000000000..891b34213d --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts @@ -0,0 +1,168 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Batched buy orders with fee recipients — own file, hits pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_FEES", + title: "execute_batched_orders — fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "unique fee recipients each receive their own fee", + test: async () => { + const feeRecipient1 = generateKeyringPair(); + const feeRecipient2 = generateKeyringPair(); + + const r1Before = ( + await polkadotJs.query.system.account(feeRecipient1.address) as any + ).data.free.toBigInt(); + const r2Before = ( + await polkadotJs.query.system.account(feeRecipient2.address) as any + ).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient1.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient2.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const r1After = ( + await polkadotJs.query.system.account(feeRecipient1.address) as any + ).data.free.toBigInt(); + const r2After = ( + await polkadotJs.query.system.account(feeRecipient2.address) as any + ).data.free.toBigInt(); + + // Both recipients must have received some fee + expect(r1After).toBeGreaterThan(r1Before); + expect(r2After).toBeGreaterThan(r2Before); + }, + }); + + it({ + id: "T02", + title: "shared fee recipient receives aggregated fee", + test: async () => { + const sharedRecipient = generateKeyringPair(); + + const recipientBefore = ( + await polkadotJs.query.system.account(sharedRecipient.address) as any + ).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: sharedRecipient.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: sharedRecipient.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const recipientAfter = ( + await polkadotJs.query.system.account(sharedRecipient.address) as any + ).data.free.toBigInt(); + + // Should have received fees from both orders in a single transfer + const expectedFee = tao(100) / 100n + tao(100) / 100n; // 1% * 2 + expect(recipientAfter - recipientBefore).toBe(expectedFee); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts new file mode 100644 index 0000000000..65662aa801 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts @@ -0,0 +1,164 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Hard-fail cases for execute_batched_orders — no pool interaction needed, +// all batches fail before reaching the swap step. Single subnet is fine. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_HARDFAIL", + title: "execute_batched_orders — hard-fail conditions", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "batch fails entirely when one order has an invalid signature", + test: async () => { + const valid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const badSig = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + // Tamper after signing — signature now covers different bytes + const tampered = { + ...badSig, + order: { V1: { ...badSig.order.V1, amount: tao(999) } }, + }; + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [valid, tampered]) + .signAsync(alice), + ]); + + // The whole extrinsic should fail — hard-fail on invalid signature + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("InvalidSignature"); + }, + }); + + it({ + id: "T02", + title: "batch fails when one order targets a different netuid", + test: async () => { + const correct = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const wrongNetuid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: netuid + 1, // different subnet + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [correct, wrongNetuid]) + .signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("OrderNetUidMismatch"); + }, + }); + + it({ + id: "T03", + title: "root netuid (0) as batch parameter fails immediately", + test: async () => { + const order = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: 0, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(0, [order]) + .signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("RootNetUidNotAllowed"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts new file mode 100644 index 0000000000..6bdcd261d7 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -0,0 +1,129 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Buy-dominant mixed batch — net buy hits the pool. Own file. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_MIX_BUY", + title: "execute_batched_orders — buy-dominant mixed batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Bob sells, needs alpha + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(200)); + }); + + it({ + id: "T01", + title: "buy side dominates: both orders fulfilled, net buy hits pool", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + const bobTaoBefore = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + + // Alice buys 200 TAO worth, Bob sells 10 alpha (~10 TAO equiv) + // → net buy ~190 TAO hits the pool + const buyOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(200), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const sellOrder = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(10), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [buyOrder, sellOrder]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const summary = filterEvents(events, "GroupExecutionSummary"); + expect(summary.length).toBe(1); + const summaryData = summary[0].event.data; + // net_side should be Buy (residual TAO sent to pool) + expect(summaryData[1].type).toBe("Buy"); + // net_amount > 0 proves the pool was actually touched + expect(summaryData[2].toBigInt()).toBeGreaterThan(0n); + // net_amount < total buy proves internal netting happened (sell side was matched directly) + expect(summaryData[2].toBigInt()).toBeLessThan(tao(200)); + // actual_out > 0 proves the pool returned alpha + expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); + + const aliceStakeAfter = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobTaoAfter = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts new file mode 100644 index 0000000000..f503ccc02d --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -0,0 +1,127 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_MIX_SELL", + title: "execute_batched_orders — sell-dominant mixed batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Bob sells a large amount, needs alpha + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(500)); + }); + + it({ + id: "T01", + title: "sell side dominates: both orders fulfilled, net sell hits pool", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + const bobTaoBefore = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + + // Alice buys 10 TAO, Bob sells 200 alpha (~200 TAO equiv) + // → net sell ~190 alpha hits the pool + const buyOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(10), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const sellOrder = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(200), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [buyOrder, sellOrder]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const summary = filterEvents(events, "GroupExecutionSummary"); + expect(summary.length).toBe(1); + const summaryData = summary[0].event.data; + // net_side should be Sell (residual alpha sent to pool) + expect(summaryData[1].type).toBe("Sell"); + // net_amount > 0 proves the pool was actually touched + expect(summaryData[2].toBigInt()).toBeGreaterThan(0n); + // net_amount < total sell proves internal netting happened (buy side was matched directly) + expect(summaryData[2].toBigInt()).toBeLessThan(tao(200)); + // actual_out > 0 proves the pool returned TAO + expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); + + const aliceStakeAfter = await devGetAlphaStake( + polkadotJs, aliceHotKey.address, alice.address, netuid + ); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobTaoAfter = ( + await polkadotJs.query.system.account(bob.address) as any + ).data.free.toBigInt(); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts new file mode 100644 index 0000000000..6ae6d79152 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -0,0 +1,271 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "./helpers.js"; +import { + buildSignedOrder, + EXPIRED, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests in this file do NOT interact with the pool (price-not-met, expired, +// bad-sig, root-netuid, already-processed). A single subnet in beforeAll is fine. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_SKIP", + title: "execute_orders — skip conditions", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy skipped when limit_price below current price", + test: async () => { + // Set limit_price = 1 RAO — almost certainly below any real price + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T02", + title: "TakeProfit skipped when price below limit_price", + test: async () => { + // limit_price = u64::MAX — price can never reach this + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T03", + title: "expired order is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: EXPIRED, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T04", + title: "order with invalid signature is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Tamper: change the amount inside the V1 inner order after signing. + // The signature now covers different bytes — validation must reject it. + const tampered = { + ...signed, + order: { V1: { ...signed.order.V1, amount: tao(999) } }, + }; + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([tampered]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T05", + title: "order targeting root netuid (0) is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: 0, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T06", + title: "already-fulfilled order is skipped on second execution attempt", + test: async () => { + // Use a price condition that is always met (limitPrice = u64::MAX for buy) + // so the first call succeeds and fulfils the order. + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // First execution — should succeed. + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + // Second attempt — order already Fulfilled, must be skipped. + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T07", + title: "mixed batch: valid orders execute, invalid ones are skipped", + test: async () => { + const valid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(4), // distinct from T06 to get a different OrderId + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const expired = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: EXPIRED, + feeRate: 0, + feeRecipient: alice.address, + }); + + const priceNotMet = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(3), + limitPrice: 1n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeOrders([valid, expired, priceNotMet]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(2); + }, + }); + }, +}); From 9bff196ac63727dd395d5c9bcfb7864849d691f8 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 14:08:25 +0200 Subject: [PATCH 42/85] be more precise in batched --- .../test-batched-mixed-buy-dominant.ts | 13 +++++-- .../test-batched-mixed-sell-dominant.ts | 13 +++++-- ts-tests/utils/limit-orders.ts | 39 +++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts index 6bdcd261d7..429b9a45d8 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -13,6 +13,7 @@ import { } from "./helpers.js"; import { buildSignedOrder, + computeNetAmount, FAR_FUTURE, filterEvents, registerLimitOrderTypes, @@ -93,6 +94,11 @@ describeSuite({ feeRecipient: bob.address, }); + // Read price before the swap — pallet uses pre-swap price for netting + const expectedNetAmount = await computeNetAmount( + polkadotJs, netuid, tao(200), tao(10), "Buy" + ); + await context.createBlock([ await polkadotJs.tx.limitOrders .executeBatchedOrders(netuid, [buyOrder, sellOrder]) @@ -107,10 +113,9 @@ describeSuite({ const summaryData = summary[0].event.data; // net_side should be Buy (residual TAO sent to pool) expect(summaryData[1].type).toBe("Buy"); - // net_amount > 0 proves the pool was actually touched - expect(summaryData[2].toBigInt()).toBeGreaterThan(0n); - // net_amount < total buy proves internal netting happened (sell side was matched directly) - expect(summaryData[2].toBigInt()).toBeLessThan(tao(200)); + // net_amount matches buy_tao - alpha_to_tao(sell_alpha, price) + const netAmountDiff = summaryData[2].toBigInt() - expectedNetAmount; + expect(netAmountDiff < 0n ? -netAmountDiff : netAmountDiff).toBeLessThanOrEqual(10n); // actual_out > 0 proves the pool returned alpha expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts index f503ccc02d..9b86971f57 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -13,6 +13,7 @@ import { } from "./helpers.js"; import { buildSignedOrder, + computeNetAmount, FAR_FUTURE, filterEvents, registerLimitOrderTypes, @@ -91,6 +92,11 @@ describeSuite({ feeRecipient: bob.address, }); + // Read price before the swap — pallet uses pre-swap price for netting + const expectedNetAmount = await computeNetAmount( + polkadotJs, netuid, tao(10), tao(200), "Sell" + ); + await context.createBlock([ await polkadotJs.tx.limitOrders .executeBatchedOrders(netuid, [buyOrder, sellOrder]) @@ -105,10 +111,9 @@ describeSuite({ const summaryData = summary[0].event.data; // net_side should be Sell (residual alpha sent to pool) expect(summaryData[1].type).toBe("Sell"); - // net_amount > 0 proves the pool was actually touched - expect(summaryData[2].toBigInt()).toBeGreaterThan(0n); - // net_amount < total sell proves internal netting happened (buy side was matched directly) - expect(summaryData[2].toBigInt()).toBeLessThan(tao(200)); + // net_amount matches sell_alpha - tao_to_alpha(buy_tao, price) + const netAmountDiff = summaryData[2].toBigInt() - expectedNetAmount; + expect(netAmountDiff < 0n ? -netAmountDiff : netAmountDiff).toBeLessThanOrEqual(10n); // actual_out > 0 proves the pool returned TAO expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 4d67a81a0e..4c16944b6e 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -206,6 +206,45 @@ export function filterEvents(events: any, method: string): any[] { return (events as any[]).filter((e: any) => e.event.method === method); } +/** + * Compute the expected `net_amount` field of `GroupExecutionSummary` for a + * mixed buy/sell batch, mirroring the pallet's netting logic. + * + * The runtime API returns `floor(price_actual * 1e9)` as a u64, so our + * bigint replication differs from the on-chain U96F32 result by at most a + * few RAO — use `toBeCloseTo` or a small tolerance window when asserting. + * + * @param polkadotJs polkadot-js ApiPromise + * @param netuid subnet id + * @param buySideTao total net TAO from buy orders (after fees, in RAO) + * @param sellSideAlpha total net alpha from sell orders (in RAO) + * @param side which side dominates ("Buy" | "Sell") + */ +export async function computeNetAmount( + polkadotJs: any, + netuid: number, + buySideTao: bigint, + sellSideAlpha: bigint, + side: "Buy" | "Sell", +): Promise { + // price_scaled = floor(price_actual * 1e9) [RAO per alpha * 1e9 / 1e9 = dimensionless] + const priceRaw = await polkadotJs.call.swapRuntimeApi.currentAlphaPrice(netuid); + const price = BigInt(priceRaw.toString()); + const SCALE = 1_000_000_000n; + + if (side === "Buy") { + // net_amount (TAO) = buy_tao - alpha_to_tao(sell_alpha, price) + // alpha_to_tao ≈ floor(price * sell_alpha / 1e9) + const sellTaoEquiv = (price * sellSideAlpha) / SCALE; + return buySideTao - sellTaoEquiv; + } else { + // net_amount (alpha) = sell_alpha - tao_to_alpha(buy_tao, price) + // tao_to_alpha ≈ floor(buy_tao * 1e9 / price) + const buyAlphaEquiv = (buySideTao * SCALE) / price; + return sellSideAlpha - buyAlphaEquiv; + } +} + export async function executeBatchedOrders( api: TypedApi, netuid: number, From bce34c9344255de8511a2c19a00d652c1735ae3c Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 14:11:48 +0200 Subject: [PATCH 43/85] move helpers to dev-helpers --- .../dev/subtensor/limit-orders/test-batched-all-buys.ts | 2 +- .../dev/subtensor/limit-orders/test-batched-all-sells.ts | 2 +- .../suites/dev/subtensor/limit-orders/test-batched-fees.ts | 2 +- .../dev/subtensor/limit-orders/test-batched-hardfail.ts | 2 +- .../limit-orders/test-batched-mixed-buy-dominant.ts | 2 +- .../limit-orders/test-batched-mixed-sell-dominant.ts | 2 +- .../suites/dev/subtensor/limit-orders/test-cancel-order.ts | 2 +- .../dev/subtensor/limit-orders/test-execute-orders-fees.ts | 2 +- .../limit-orders/test-execute-orders-limit-buy.ts | 2 +- .../limit-orders/test-execute-orders-sell-fees.ts | 2 +- .../limit-orders/test-execute-orders-skip-conditions.ts | 2 +- .../limit-orders/test-execute-orders-stop-loss.ts | 2 +- .../limit-orders/test-execute-orders-take-profit.ts | 2 +- .../dev/subtensor/limit-orders/test-pallet-status.ts | 2 +- .../limit-orders/helpers.ts => utils/dev-helpers.ts} | 7 +++---- 15 files changed, 17 insertions(+), 18 deletions(-) rename ts-tests/{suites/dev/subtensor/limit-orders/helpers.ts => utils/dev-helpers.ts} (94%) diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts index 7295ba2ed4..2f432ad66e 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts @@ -9,7 +9,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts index fecfb07952..4aea5cddce 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts @@ -9,7 +9,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts index 891b34213d..4bb26b8ba0 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts @@ -8,7 +8,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts index 65662aa801..61aeadd3b5 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts @@ -8,7 +8,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts index 429b9a45d8..21d41bbf50 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -10,7 +10,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, computeNetAmount, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts index 9b86971f57..559e61abe3 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -10,7 +10,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, computeNetAmount, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts index cfaf2c2417..c7c5591833 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao } from "../../../../utils"; -import { devForceSetBalance, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts index 44d20bc50b..b93b4879c9 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { generateKeyringPair, tao } from "../../../../utils"; -import { devForceSetBalance, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts index 973f74b78e..0121ef0e1d 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts index 970d312006..e2d0b5cf84 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { generateKeyringPair, tao } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts index 6ae6d79152..8ce5909ca8 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -8,7 +8,7 @@ import { devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, -} from "./helpers.js"; +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, EXPIRED, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts index b198bf35d5..7b4746f102 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts index 0f6a7a8232..044450e31a 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "./helpers.js"; +import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts index 4571c03cb4..68db98027b 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts @@ -2,7 +2,7 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao } from "../../../../utils"; -import { devForceSetBalance } from "./helpers.js"; +import { devForceSetBalance } from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/helpers.ts b/ts-tests/utils/dev-helpers.ts similarity index 94% rename from ts-tests/suites/dev/subtensor/limit-orders/helpers.ts rename to ts-tests/utils/dev-helpers.ts index 1de8a601c8..70bea7b770 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/helpers.ts +++ b/ts-tests/utils/dev-helpers.ts @@ -1,11 +1,10 @@ /** - * Polkadot.js (ApiPromise) compatible helpers for limit-orders dev tests. - * The utils/ directory uses PAPI TypedApi which is incompatible with the - * moonwall `dev` foundation that exposes context.polkadotJs(). + * Polkadot.js (ApiPromise) compatible helpers for dev tests. + * Uses ApiPromise, not PAPI TypedApi — keep them separate. */ import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; -import { SignedOrder } from "utils"; +import { SignedOrder } from "./index.js"; export async function devForceSetBalance( polkadotJs: ApiPromise, From 966bebdf66bb8149b8baa30c69603ff80e03aa47 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 7 Apr 2026 14:47:41 +0200 Subject: [PATCH 44/85] I few more tests and edge cases --- pallets/limit-orders/src/lib.rs | 36 ++++++-- pallets/limit-orders/src/tests/extrinsics.rs | 87 ++++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 6 ++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 47e22ca361..d5abe5b3c6 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -260,6 +260,13 @@ pub mod pallet { /// Number of orders that were successfully executed. executed_count: u32, }, + /// A fee transfer to a recipient failed. The fee remains with the + /// original sender. Emitted best-effort — does not revert the order. + FeeTransferFailed { + recipient: T::AccountId, + amount: u64, + reason: sp_runtime::DispatchError, + }, /// Root has either enabled(true) or disabled(false) the pallet LimitOrdersPalletStatusChanged { enabled: bool }, } @@ -484,8 +491,13 @@ pub mod pallet { // Forward the fee TAO to the order's fee recipient. if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) - .ok(); + if let Err(reason) = T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) { + Self::deposit_event(Event::FeeTransferFailed { + recipient: order.fee_recipient.clone(), + amount: fee_tao.to_u64(), + reason, + }); + } } (order.amount, alpha_out.to_u64()) } else { @@ -502,8 +514,13 @@ pub mod pallet { // Deduct fee from TAO output and forward to the order's fee recipient. let fee_tao = TaoBalance::from(order.fee_rate * tao_out.to_u64()); if !fee_tao.is_zero() { - T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) - .ok(); + if let Err(reason) = T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) { + Self::deposit_event(Event::FeeTransferFailed { + recipient: order.fee_recipient.clone(), + amount: fee_tao.to_u64(), + reason, + }); + } } (order.amount, tao_out.saturating_sub(fee_tao).to_u64()) }; @@ -897,12 +914,17 @@ pub mod pallet { // One transfer per unique fee recipient. for (recipient, amount) in fees { if amount > 0 { - T::SwapInterface::transfer_tao( + if let Err(reason) = T::SwapInterface::transfer_tao( pallet_acct, &recipient, TaoBalance::from(amount), - ) - .ok(); + ) { + Self::deposit_event(Event::FeeTransferFailed { + recipient, + amount, + reason, + }); + } } } diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 8fea34e7c9..2f27d8a83d 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -533,6 +533,61 @@ fn execute_orders_sell_with_fee_charges_fee() { }); } +#[test] +fn execute_orders_empty_batch_returns_ok() { + new_test_ext().execute_with(|| { + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![]) + )); + }); +} + +#[test] +fn execute_orders_fee_transfer_failure_emits_event() { + new_test_ext().execute_with(|| { + // Order executes successfully, but the fee transfer to the recipient fails. + // The order should still be marked Fulfilled and FeeTransferFailed emitted. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 10_000); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), + ); + + FAIL_FEE_TRANSFER.with(|f| *f.borrow_mut() = true); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed.clone()]) + )); + FAIL_FEE_TRANSFER.with(|f| *f.borrow_mut() = false); + + // Order was executed despite the failed fee transfer. + let id = crate::tests::mock::order_id(&signed.order); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // FeeTransferFailed was emitted with the correct recipient and error. + assert_event(Event::FeeTransferFailed { + recipient: fee_recipient(), + amount: 10, // 1% of 1_000 + reason: DispatchError::CannotLookup, + }); + + // fee_recipient received nothing. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 0); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // execute_orders — silent-skip behaviour // ───────────────────────────────────────────────────────────────────────────── @@ -734,6 +789,38 @@ fn execute_batched_orders_fails_for_wrong_netuid() { }); } +#[test] +fn execute_batched_orders_price_condition_not_met_fails_entire_batch() { + new_test_ext().execute_with(|| { + // Price condition not met is a hard-fail in execute_batched_orders — + // unlike execute_orders where it silently skips the order. + MockTime::set(1_000_000); + MockSwap::set_price(100.0); // current price = 100 + + // LimitBuy requires current_price <= limit_price; with limit_price=1 this fails. + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 1, // limit_price = 1, far below current price of 100 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]) + ), + Error::::PriceConditionNotMet + ); + }); +} + #[test] fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { new_test_ext().execute_with(|| { diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 3f935a50c7..4587aab8b0 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -104,6 +104,9 @@ thread_local! { /// on residual balances after distribution. pub static TAO_BALANCES: RefCell> = RefCell::new(HashMap::new()); + /// When set to `true`, `transfer_tao` returns `Err(CannotTransfer)` so + /// tests can exercise the `FeeTransferFailed` event path. + pub static FAIL_FEE_TRANSFER: RefCell = RefCell::new(false); /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. pub static MOCK_SWAP_FAIL: RefCell = RefCell::new(false); /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. @@ -295,6 +298,9 @@ impl OrderSwapInterface for MockSwap { to: &AccountId, amount: TaoBalance, ) -> frame_support::pallet_prelude::DispatchResult { + if FAIL_FEE_TRANSFER.with(|f| *f.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::CannotLookup); + } let amt = amount.to_u64(); TAO_BALANCES.with(|b| { let mut map = b.borrow_mut(); From 58d7865cadd34f24443991c2dc2ad909753d8d3c Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 8 Apr 2026 10:15:53 +0200 Subject: [PATCH 45/85] Relayer protection --- pallets/limit-orders/README.md | 2 + pallets/limit-orders/src/benchmarking.rs | 3 + pallets/limit-orders/src/lib.rs | 49 +++-- pallets/limit-orders/src/tests/auxiliary.rs | 104 +++++++++- pallets/limit-orders/src/tests/extrinsics.rs | 195 +++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 2 + runtime/tests/limit_orders.rs | 3 + ts-tests/utils/limit-orders.ts | 4 + 8 files changed, 344 insertions(+), 18 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 59705b27cd..5c6ce5e382 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -71,6 +71,7 @@ encoding (`OrderId`) is persisted. | `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. | | `fee_rate` | `Perbill` | Per-order fee as a fraction of the input amount. `Perbill::zero()` = no fee. | | `fee_recipient` | `AccountId` | Account that receives the fee collected for this order. | +| `relayer` | `Option` | If `Some`, restricts execution to a single designated relayer account. Any attempt by a different account to execute this order is rejected with `RelayerMissMatch`. `None` = any relayer may execute. | ### `OrderType` @@ -224,6 +225,7 @@ Registers a cancellation intent by writing the `OrderId` into `Orders` as | `RootNetUidNotAllowed` | The order or batch targets netuid 0 (root). Root uses a fixed 1:1 stable mechanism with no AMM — limit orders are not meaningful there. | | `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. | | `SwapReturnedZero` | The pool swap returned zero output for a non-zero residual input. | +| `RelayerMissMatch` | The caller is not the relayer designated in the order's `relayer` field. Only raised when the field is `Some`. | --- diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 0aa727f179..c433ca8b57 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -68,6 +68,7 @@ mod benchmarks { expiry: 1_000_000_000, fee_rate: Perbill::zero(), fee_recipient: account.clone(), + relayer: None, }); let signed = sign_order::(public, &order); @@ -112,6 +113,7 @@ mod benchmarks { expiry: u64::MAX, fee_rate: Perbill::from_percent(1), fee_recipient, + relayer: None, }); orders.push(sign_order::(public, &order)); } @@ -157,6 +159,7 @@ mod benchmarks { expiry: u64::MAX, fee_rate: Perbill::from_percent(1), fee_recipient, + relayer: None, }); orders.push(sign_order::(public, &order)); } diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index d5abe5b3c6..3fa950fb28 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -83,6 +83,8 @@ pub struct Order pub fee_rate: Perbill, /// Account that receives the fee collected from this order. pub fee_recipient: AccountId, + /// Account that should relay the transactions + pub relayer: Option, } /// Versioned wrapper around an order payload. @@ -293,6 +295,8 @@ pub mod pallet { OrderNetUidMismatch, /// Limit orders are disabled LimitOrdersDisabled, + /// Relayer not the same as specified in the order + RelayerMissMatch, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -311,7 +315,7 @@ pub mod pallet { origin: OriginFor, orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { - ensure_signed(origin)?; + let relayer = ensure_signed(origin)?; ensure!( LimitOrdersEnabled::::get(), Error::::LimitOrdersDisabled @@ -320,7 +324,7 @@ pub mod pallet { for signed_order in orders { // Best-effort: individual order failures do not revert the batch. let order_id = Self::derive_order_id(&signed_order.order); - if let Err(reason) = Self::try_execute_order(signed_order) { + if let Err(reason) = Self::try_execute_order(signed_order, &relayer) { Self::deposit_event(Event::OrderSkipped { order_id, reason }); } } @@ -357,13 +361,13 @@ pub mod pallet { netuid: NetUid, orders: BoundedVec, T::MaxOrdersPerBatch>, ) -> DispatchResult { - ensure_signed(origin)?; + let relayer = ensure_signed(origin)?; ensure!( LimitOrdersEnabled::::get(), Error::::LimitOrdersDisabled ); - Self::do_execute_batched_orders(netuid, orders) + Self::do_execute_batched_orders(netuid, orders, relayer) } /// Register a cancellation intent for an order. @@ -436,6 +440,7 @@ pub mod pallet { order_id: H256, now_ms: u64, current_price: U96F32, + relayer: &T::AccountId, ) -> DispatchResult { let order = signed_order.order.inner(); ensure!(!order.netuid.is_root(), Error::::RootNetUidNotAllowed); @@ -460,20 +465,34 @@ pub mod pallet { }, Error::::PriceConditionNotMet ); + if let Some(forced_relayer) = order.relayer.clone() { + ensure!(forced_relayer == *relayer, Error::::RelayerMissMatch); + } Ok(()) } /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. - fn try_execute_order(signed_order: SignedOrder) -> DispatchResult { + fn try_execute_order( + signed_order: SignedOrder, + relayer: &T::AccountId, + ) -> DispatchResult { let order_id = Self::derive_order_id(&signed_order.order); let order = signed_order.order.inner(); let now_ms = T::TimeProvider::now().as_millis() as u64; let current_price = T::SwapInterface::current_alpha_price(order.netuid); - Self::is_order_valid(&signed_order, order_id, now_ms, current_price)?; - - // 5. Execute the swap, taking the order's fee from the input (buys) or output (sells). + Self::is_order_valid(&signed_order, order_id, now_ms, current_price, relayer)?; + + // Execute the swap, taking the order's fee from the input (buys) or output (sells). + // + // NOTE: `order.limit_price` is intentionally used only as a trigger threshold + // in `is_order_valid` above, not as slippage protection for the swap. The + // V3 swap interprets `price_limit` in different units (price × 1e9 → sqrt), + // so passing `order.limit_price` directly into `buy_alpha` / `sell_alpha` + // does not produce a meaningful price floor/ceiling. Slippage protection + // is a known future improvement; for now the order executes at market once + // the trigger condition is satisfied. let (amount_in, amount_out) = if order.order_type.is_buy() { let tao_in = TaoBalance::from(order.amount); // Deduct fee from TAO input before swapping. @@ -491,7 +510,9 @@ pub mod pallet { // Forward the fee TAO to the order's fee recipient. if !fee_tao.is_zero() { - if let Err(reason) = T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) { + if let Err(reason) = + T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) + { Self::deposit_event(Event::FeeTransferFailed { recipient: order.fee_recipient.clone(), amount: fee_tao.to_u64(), @@ -514,7 +535,9 @@ pub mod pallet { // Deduct fee from TAO output and forward to the order's fee recipient. let fee_tao = TaoBalance::from(order.fee_rate * tao_out.to_u64()); if !fee_tao.is_zero() { - if let Err(reason) = T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) { + if let Err(reason) = + T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) + { Self::deposit_event(Event::FeeTransferFailed { recipient: order.fee_recipient.clone(), amount: fee_tao.to_u64(), @@ -543,6 +566,7 @@ pub mod pallet { fn do_execute_batched_orders( netuid: NetUid, orders: BoundedVec, T::MaxOrdersPerBatch>, + relayer: T::AccountId, ) -> DispatchResult { ensure!(!netuid.is_root(), Error::::RootNetUidNotAllowed); @@ -551,7 +575,7 @@ pub mod pallet { // Validate all orders; any invalid order causes the entire batch to fail. let (valid_buys, valid_sells) = - Self::validate_and_classify(netuid, &orders, now_ms, current_price)?; + Self::validate_and_classify(netuid, &orders, now_ms, current_price, relayer)?; let executed_count = (valid_buys.len() + valid_sells.len()) as u32; if executed_count == 0 { @@ -643,6 +667,7 @@ pub mod pallet { orders: &BoundedVec, T::MaxOrdersPerBatch>, now_ms: u64, current_price: U96F32, + relayer: T::AccountId, ) -> Result< ( BoundedVec, T::MaxOrdersPerBatch>, @@ -661,7 +686,7 @@ pub mod pallet { ensure!(order.netuid == netuid, Error::::OrderNetUidMismatch); // Hard-fail on any per-order validation error (signature, expiry, price, root). - Self::is_order_valid(signed_order, order_id, now_ms, current_price)?; + Self::is_order_valid(signed_order, order_id, now_ms, current_price, &relayer)?; let net = if order.order_type.is_buy() { // Buy: fee on TAO input — net is the amount that reaches the pool. diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index c0ab160357..27a98af741 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -91,6 +91,7 @@ fn validate_and_classify_separates_buys_and_sells() { 2_000_000u64, // expiry ms Perbill::zero(), fee_recipient(), + None, ); let sell_order = make_signed_order( AccountKeyring::Bob, @@ -102,6 +103,7 @@ fn validate_and_classify_separates_buys_and_sells() { 2_000_000u64, Perbill::zero(), fee_recipient(), + None, ); let orders = bounded(vec![buy_order, sell_order]); @@ -110,6 +112,7 @@ fn validate_and_classify_separates_buys_and_sells() { &orders, 1_000_000u64, U96F32::from_num(1u32), + bob(), ) .expect("validate_and_classify should succeed"); @@ -148,6 +151,7 @@ fn validate_and_classify_fails_for_wrong_netuid() { 2_000_000u64, Perbill::zero(), fee_recipient(), + None, ); let orders = bounded(vec![wrong_netuid_order]); @@ -157,6 +161,7 @@ fn validate_and_classify_fails_for_wrong_netuid() { &orders, 1_000_000u64, U96F32::from_num(1u32), + bob() ), crate::Error::::OrderNetUidMismatch ); @@ -180,6 +185,7 @@ fn validate_and_classify_fails_for_expired_order() { 2_000_000u64, // expiry already past Perbill::zero(), fee_recipient(), + None, ); let orders = bounded(vec![expired]); @@ -189,6 +195,7 @@ fn validate_and_classify_fails_for_expired_order() { &orders, 2_000_001u64, U96F32::from_num(1u32), + bob() ), crate::Error::::OrderExpired ); @@ -210,6 +217,7 @@ fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { 2_000_000u64, Perbill::zero(), fee_recipient(), + None, ); let orders = bounded(vec![order]); @@ -219,6 +227,7 @@ fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { &orders, 1_000_000u64, U96F32::from_num(3u32), // current price = 3 > limit 2 → fails + bob() ), crate::Error::::PriceConditionNotMet ); @@ -240,6 +249,7 @@ fn validate_and_classify_fails_for_already_processed_order() { 2_000_000u64, Perbill::zero(), fee_recipient(), + None, ); // Pre-mark as fulfilled on-chain. @@ -253,6 +263,7 @@ fn validate_and_classify_fails_for_already_processed_order() { &orders, 1_000_000u64, U96F32::from_num(1u32), + bob() ), crate::Error::::OrderAlreadyProcessed ); @@ -276,6 +287,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { 2_000_000u64, Perbill::from_parts(1_000_000), // 0.1% fee fee_recipient(), + None, ); let orders = bounded(vec![order]); @@ -284,6 +296,7 @@ fn validate_and_classify_applies_buy_fee_to_net() { &orders, 1_000_000u64, U96F32::from_num(1u32), + bob(), ) .expect("validate_and_classify should succeed"); @@ -295,6 +308,79 @@ fn validate_and_classify_applies_buy_fee_to_net() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// validate_and_classify — relayer enforcement +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn validate_and_classify_fails_for_wrong_relayer() { + new_test_ext().execute_with(|| { + // Order explicitly locks execution to charlie(); submitting as bob() must fail. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + u64::MAX, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // only charlie may relay this order + ); + + let orders = bounded(vec![order]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob() // wrong relayer + ), + crate::Error::::RelayerMissMatch + ); + }); +} + +#[test] +fn validate_and_classify_succeeds_for_correct_relayer() { + new_test_ext().execute_with(|| { + // Same setup as above but now the correct relayer (charlie) is used. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + u64::MAX, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // only charlie may relay this order + ); + + let orders = bounded(vec![order]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + charlie(), // correct relayer + ) + .expect("validate_and_classify should succeed"); + + assert_eq!(buys.len(), 1, "expected 1 valid buy"); + assert_eq!(sells.len(), 0); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // distribute_alpha_pro_rata // ───────────────────────────────────────────────────────────────────────────── @@ -1136,6 +1222,7 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); @@ -1154,7 +1241,11 @@ fn is_order_valid_returns_ok_for_well_formed_order() { let (signed, id) = make_valid_signed_order(); let price = MockSwap::current_alpha_price(netuid()); assert_ok!(LimitOrders::::is_order_valid( - &signed, id, 1_000_000, price + &signed, + id, + 1_000_000, + price, + &bob() )); }); } @@ -1170,7 +1261,7 @@ fn is_order_valid_invalid_signature_returns_error() { signed.signature = MultiSignature::Sr25519(wrong_sig); let price = MockSwap::current_alpha_price(netuid()); assert_noop!( - LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), Error::::InvalidSignature ); }); @@ -1187,7 +1278,7 @@ fn is_order_valid_non_sr25519_signature_returns_error() { signed.signature = MultiSignature::Ed25519(ed_sig); let price = MockSwap::current_alpha_price(netuid()); assert_noop!( - LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), Error::::InvalidSignature ); }); @@ -1202,7 +1293,7 @@ fn is_order_valid_already_processed_returns_error() { Orders::::insert(id, crate::OrderStatus::Fulfilled); let price = MockSwap::current_alpha_price(netuid()); assert_noop!( - LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), Error::::OrderAlreadyProcessed ); }); @@ -1228,7 +1319,7 @@ fn is_order_valid_expired_order_returns_error() { }; let price = MockSwap::current_alpha_price(netuid()); assert_noop!( - LimitOrders::::is_order_valid(&signed2, id2, 1_000_000, price), + LimitOrders::::is_order_valid(&signed2, id2, 1_000_000, price, &bob()), Error::::OrderExpired ); }); @@ -1251,6 +1342,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); @@ -1260,7 +1352,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { }; let price = MockSwap::current_alpha_price(netuid()); assert_noop!( - LimitOrders::::is_order_valid(&signed, id, 1_000_000, price), + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), Error::::PriceConditionNotMet ); }); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 2f27d8a83d..a6a4b1ad48 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -44,6 +44,7 @@ fn cancel_order_signer_can_cancel() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); let id = order_id(&order); @@ -72,6 +73,7 @@ fn cancel_order_non_signer_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); // Bob tries to cancel Alice's order. assert_noop!( @@ -94,6 +96,7 @@ fn cancel_order_already_cancelled_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Cancelled); @@ -118,6 +121,7 @@ fn cancel_order_already_fulfilled_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -142,6 +146,7 @@ fn cancel_order_unsigned_rejected() { expiry: FAR_FUTURE, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), + relayer: None, }); assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::none(), order), @@ -170,6 +175,7 @@ fn execute_orders_buy_order_fulfilled() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -206,6 +212,7 @@ fn execute_orders_sell_order_fulfilled() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -242,6 +249,7 @@ fn execute_orders_stop_loss_order_fulfilled() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -277,6 +285,7 @@ fn execute_orders_stop_loss_price_not_met_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -308,6 +317,7 @@ fn execute_orders_expired_order_skipped() { 2_000_000, // expiry in the past Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -340,6 +350,7 @@ fn execute_orders_price_not_met_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -371,6 +382,7 @@ fn execute_orders_already_processed_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -405,6 +417,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let expired = make_signed_order( AccountKeyring::Bob, @@ -416,6 +429,7 @@ fn execute_orders_mixed_batch_valid_and_skipped() { 500_000, // already expired Perbill::zero(), fee_recipient(), + None, ); let valid_id = order_id(&valid.order); let expired_id = order_id(&expired.order); @@ -460,6 +474,7 @@ fn execute_orders_buy_with_fee_charges_fee() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); MockSwap::set_tao_balance(alice(), 1_000); assert_ok!(LimitOrders::execute_orders( @@ -507,6 +522,7 @@ fn execute_orders_sell_with_fee_charges_fee() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie()), @@ -563,6 +579,7 @@ fn execute_orders_fee_transfer_failure_emits_event() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); FAIL_FEE_TRANSFER.with(|f| *f.borrow_mut() = true); @@ -613,6 +630,7 @@ mod execute_orders_skip_invalid { 2_000_000, // expiry in the past Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -649,6 +667,7 @@ mod execute_orders_skip_invalid { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); @@ -685,6 +704,7 @@ mod execute_orders_skip_invalid { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let expired = make_signed_order( AccountKeyring::Bob, @@ -696,6 +716,7 @@ mod execute_orders_skip_invalid { 500_000, // already expired Perbill::zero(), fee_recipient(), + None, ); let valid_id = order_id(&valid.order); let expired_id = order_id(&expired.order); @@ -746,6 +767,7 @@ fn execute_batched_orders_all_invalid_fails() { 1_000_000, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( LimitOrders::execute_batched_orders( @@ -776,6 +798,7 @@ fn execute_batched_orders_fails_for_wrong_netuid() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( @@ -808,6 +831,7 @@ fn execute_batched_orders_price_condition_not_met_fails_entire_batch() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( @@ -845,6 +869,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let bob_order = make_signed_order( AccountKeyring::Bob, @@ -856,6 +881,7 @@ fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -910,6 +936,7 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { FAR_FUTURE, // limit=0 → accept any price Perbill::zero(), fee_recipient(), + None, ); let bob_order = make_signed_order( AccountKeyring::Bob, @@ -921,6 +948,7 @@ fn execute_batched_orders_sell_only_fulfills_orders_and_distributes_tao() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let alice_id = order_id(&alice_order.order); let bob_id = order_id(&bob_order.order); @@ -980,6 +1008,7 @@ fn execute_batched_orders_buy_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let bob_buy = make_signed_order( AccountKeyring::Bob, @@ -991,6 +1020,7 @@ fn execute_batched_orders_buy_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let charlie_sell = make_signed_order( AccountKeyring::Charlie, @@ -1002,6 +1032,7 @@ fn execute_batched_orders_buy_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1056,6 +1087,7 @@ fn execute_batched_orders_sell_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let bob_sell = make_signed_order( AccountKeyring::Bob, @@ -1067,6 +1099,7 @@ fn execute_batched_orders_sell_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let charlie_sell = make_signed_order( AccountKeyring::Charlie, @@ -1078,6 +1111,7 @@ fn execute_batched_orders_sell_dominant_mixed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1121,6 +1155,7 @@ fn execute_batched_orders_fee_forwarded_to_collector() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1152,6 +1187,7 @@ fn execute_batched_orders_fails_for_cancelled_order() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let id = order_id(&signed.order); Orders::::insert(id, OrderStatus::Cancelled); @@ -1200,6 +1236,7 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); let bob_sell = make_signed_order( AccountKeyring::Bob, @@ -1211,6 +1248,7 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1249,6 +1287,7 @@ fn execute_batched_orders_buy_zero_alpha_returns_error() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( @@ -1281,6 +1320,7 @@ fn execute_batched_orders_sell_zero_tao_returns_error() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( @@ -1313,6 +1353,7 @@ fn execute_batched_orders_sell_alpha_respects_swap_fail() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); assert_noop!( @@ -1354,6 +1395,7 @@ fn execute_batched_orders_fees_routed_to_different_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% charlie(), + None, ); let bob_buy = make_signed_order( AccountKeyring::Bob, @@ -1365,6 +1407,7 @@ fn execute_batched_orders_fees_routed_to_different_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% dave(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1404,6 +1447,7 @@ fn execute_batched_orders_fees_batched_for_shared_recipient() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% charlie(), + None, ); let bob_buy = make_signed_order( AccountKeyring::Bob, @@ -1415,6 +1459,7 @@ fn execute_batched_orders_fees_batched_for_shared_recipient() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% charlie(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1484,6 +1529,7 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% ferdie.clone(), + None, ); let bob_buy = make_signed_order( AccountKeyring::Bob, @@ -1495,6 +1541,7 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% ferdie.clone(), + None, ); let charlie_sell = make_signed_order( AccountKeyring::Charlie, @@ -1506,6 +1553,7 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); let eve_sell = make_signed_order( AccountKeyring::Eve, @@ -1517,6 +1565,7 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { FAR_FUTURE, Perbill::from_parts(10_000_000), // 1% fee_recipient(), + None, ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1579,6 +1628,7 @@ fn execute_batched_orders_mixed_batch_does_not_rate_limit_pallet_intermediary() FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); let sell = make_signed_order( AccountKeyring::Bob, @@ -1590,6 +1640,7 @@ fn execute_batched_orders_mixed_batch_does_not_rate_limit_pallet_intermediary() FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); // Must succeed: collecting Bob's alpha must not rate-limit the pallet @@ -1635,6 +1686,7 @@ fn root_disables_and_extrinsics_are_filtered() { FAR_FUTURE, Perbill::zero(), fee_recipient(), + None, ); // Must succeed: collecting Bob's alpha must not rate-limit the pallet @@ -1650,6 +1702,149 @@ fn root_disables_and_extrinsics_are_filtered() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// relayer enforcement +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_orders_wrong_relayer_skipped() { + new_test_ext().execute_with(|| { + // Order locks execution to charlie(); submitting as bob() must be silently skipped. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // only charlie may relay this order + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(bob()), // wrong relayer + bounded(vec![signed]) + )); + + // Order not stored — it was skipped. + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::RelayerMissMatch.into(), + }); + }); +} + +#[test] +fn execute_orders_correct_relayer_executed() { + new_test_ext().execute_with(|| { + // Same order submitted by the designated relayer (charlie) — must succeed. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // charlie is the designated relayer + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), // correct relayer + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount_in: 1_000, + amount_out: 0, + }); + }); +} + +#[test] +fn execute_batched_orders_wrong_relayer_fails_entire_batch() { + new_test_ext().execute_with(|| { + // In execute_batched_orders a relayer mismatch is a hard failure — the + // whole call is reverted, unlike the best-effort skip in execute_orders. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // only charlie may relay this order + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(bob()), // wrong relayer + netuid(), + bounded(vec![signed]) + ), + Error::::RelayerMissMatch + ); + }); +} + +#[test] +fn execute_batched_orders_correct_relayer_succeeds() { + new_test_ext().execute_with(|| { + // Same order submitted by the designated relayer — must execute and + // distribute alpha to the buyer. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(1_000); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(charlie()), // charlie is the designated relayer + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), // correct relayer + netuid(), + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + /// Non-root origin cannot disable the pallet #[test] fn non_root_cannot_disable_the_pallet() { diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 4587aab8b0..a52ca9241b 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -466,6 +466,7 @@ pub fn make_signed_order( expiry: u64, fee_rate: sp_runtime::Perbill, fee_recipient: AccountId, + relayer: Option, ) -> crate::SignedOrder { let signer = keyring.to_account_id(); let order = crate::VersionedOrder::V1(crate::Order { @@ -478,6 +479,7 @@ pub fn make_signed_order( expiry, fee_rate, fee_recipient, + relayer, }); let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 738bf77c21..c0945a0776 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -59,6 +59,7 @@ fn make_signed_order( expiry, fee_rate, fee_recipient, + relayer: None, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { @@ -89,6 +90,7 @@ fn cancel_order_works() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient, + relayer: None, }); let id = order_id(&order); @@ -121,6 +123,7 @@ fn execute_orders_ed25519_signature_rejected() { expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient, + relayer: None, }); let id = order_id(&order); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 4c16944b6e..5549dd4fdc 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -21,6 +21,7 @@ export interface OrderParams { expiry: bigint; feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% feeRecipient: string; + relayer?: string | null; // Optional: if set, only this account may relay the order } export interface Order { @@ -33,6 +34,7 @@ export interface Order { expiry: bigint; fee_rate: number; fee_recipient: string; + relayer: string | null; } export interface VersionedOrder { @@ -68,6 +70,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { expiry: params.expiry, fee_rate: params.feeRate, fee_recipient: params.feeRecipient, + relayer: params.relayer ?? null, }; const versionedOrder: VersionedOrder = { V1: inner }; @@ -112,6 +115,7 @@ export function registerLimitOrderTypes(api: any): void { expiry: "u64", fee_rate: "u32", // Perbill fee_recipient: "AccountId", + relayer: "Option", }, LimitVersionedOrder: { _enum: { From 28a80ebc85e0078728b0ba597319f6fc1a7950cb Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 8 Apr 2026 14:59:54 +0200 Subject: [PATCH 46/85] slippage, transactional changes, and a few more dynamic tests --- pallets/limit-orders/README.md | 29 +- pallets/limit-orders/src/benchmarking.rs | 3 + pallets/limit-orders/src/lib.rs | 90 +++- pallets/limit-orders/src/tests/auxiliary.rs | 168 ++++++ pallets/limit-orders/src/tests/extrinsics.rs | 528 +++++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 95 +++- pallets/subtensor/src/staking/order_swap.rs | 18 +- primitives/swap-interface/src/lib.rs | 16 + runtime/tests/limit_orders.rs | 204 +++++++ ts-tests/utils/limit-orders.ts | 4 + 10 files changed, 1120 insertions(+), 35 deletions(-) diff --git a/pallets/limit-orders/README.md b/pallets/limit-orders/README.md index 5c6ce5e382..669980739a 100644 --- a/pallets/limit-orders/README.md +++ b/pallets/limit-orders/README.md @@ -72,6 +72,7 @@ encoding (`OrderId`) is persisted. | `fee_rate` | `Perbill` | Per-order fee as a fraction of the input amount. `Perbill::zero()` = no fee. | | `fee_recipient` | `AccountId` | Account that receives the fee collected for this order. | | `relayer` | `Option` | If `Some`, restricts execution to a single designated relayer account. Any attempt by a different account to execute this order is rejected with `RelayerMissMatch`. `None` = any relayer may execute. | +| `max_slippage` | `Option` | Maximum acceptable slippage in parts per billion applied to `limit_price` at swap time. `None` = no slippage protection (execute at market). When `Some(p)`: Buy ceiling = `limit_price + limit_price * p`; Sell floor = `limit_price - limit_price * p`. Both saturate at `u64` bounds. | ### `OrderType` @@ -155,7 +156,8 @@ interaction: corresponding error. All orders must be valid for execution to proceed. Valid orders are split into buy-side (`LimitBuy`) and sell-side (`TakeProfit`, `StopLoss`) groups. For buy orders the net TAO (after fee) is pre-computed - here. + here. Each order's `effective_swap_limit` (derived from `limit_price` and + `max_slippage`) is computed and stored for use in the pool swap. 2. **Collect assets** — gross TAO is pulled from each buyer's free balance into the pallet intermediary account. Gross alpha stake is moved from each seller's @@ -165,8 +167,8 @@ interaction: 3. **Net pool swap** — buy TAO and sell alpha are converted to a common TAO basis at the current spot price and offset against each other. Only the residual amount touches the pool in a single swap: - - Buy-dominant: residual TAO is sent to the pool; pool returns alpha. - - Sell-dominant: residual alpha is sent to the pool; pool returns TAO. + - Buy-dominant: residual TAO is sent to the pool; pool returns alpha. Price ceiling = `min(effective_swap_limit)` across all buy orders. + - Sell-dominant: residual alpha is sent to the pool; pool returns TAO. Price floor = `max(effective_swap_limit)` across all sell orders. - Perfectly offset: no pool interaction. 4. **Distribute alpha pro-rata** — every buyer receives their share of the total @@ -248,3 +250,24 @@ upcasts to u128 internally to avoid overflow). At the end of each batch, fees are accumulated per unique `fee_recipient` and forwarded in a single transfer per recipient. If multiple orders share the same `fee_recipient`, they result in exactly one transfer rather than one per order. + +--- + +## Known limitations + +### `max_slippage` is semantically inverted for `StopLoss` orders + +`StopLoss` sells are triggered when the spot price *falls* to `limit_price`. +`max_slippage` derives a sell floor as `limit_price - limit_price * slippage`, +which is computed from the (higher) trigger threshold. By the time the order +fires, the actual market price will typically be **below** `limit_price`, so +the derived floor will almost always exceed the real fill price, causing the +swap to be rejected. + +**Consequence:** Applying `max_slippage` to a `StopLoss` order will usually +prevent it from executing. In `execute_orders` the order is silently skipped; +in `execute_batched_orders` the entire batch fails. + +**Recommendation:** Relayers should set `max_slippage: None` on `StopLoss` +orders. If slippage protection is desired, apply it at the relayer layer by +choosing a conservative `limit_price` rather than relying on `max_slippage`. diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index c433ca8b57..87fc9d01ba 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -69,6 +69,7 @@ mod benchmarks { fee_rate: Perbill::zero(), fee_recipient: account.clone(), relayer: None, + max_slippage: None, }); let signed = sign_order::(public, &order); @@ -114,6 +115,7 @@ mod benchmarks { fee_rate: Perbill::from_percent(1), fee_recipient, relayer: None, + max_slippage: None, }); orders.push(sign_order::(public, &order)); } @@ -160,6 +162,7 @@ mod benchmarks { fee_rate: Perbill::from_percent(1), fee_recipient, relayer: None, + max_slippage: None, }); orders.push(sign_order::(public, &order)); } diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 3fa950fb28..18f91c9fac 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -85,6 +85,11 @@ pub struct Order pub fee_recipient: AccountId, /// Account that should relay the transactions pub relayer: Option, + /// Maximum slippage tolerance in parts per billion applied to `limit_price` + /// at execution time. `None` = no protection (execute at market). + /// - Buy: effective price ceiling = `limit_price + limit_price * max_slippage` + /// - Sell: effective price floor = `limit_price - limit_price * max_slippage` + pub max_slippage: Option, } /// Versioned wrapper around an order payload. @@ -156,6 +161,11 @@ pub(crate) struct OrderEntry { pub(crate) fee_rate: Perbill, /// Per-order fee recipient. pub(crate) fee_recipient: AccountId, + /// Effective price limit passed to the pool swap. + /// For buys: ceiling (max TAO per alpha the pool may charge). + /// For sells: floor (min TAO per alpha the pool must return). + /// Derived from `limit_price` and `max_slippage` during classification. + pub(crate) effective_swap_limit: u64, } // ── Pallet ─────────────────────────────────────────────────────────────────── @@ -421,6 +431,33 @@ pub mod pallet { // ── Internal helpers ────────────────────────────────────────────────────── impl Pallet { + /// Compute the effective price limit passed to the pool swap. + /// + /// - `None` slippage → no constraint: `u64::MAX` for buys (no ceiling), + /// `0` for sells (no floor). + /// - `Some(p)` → widens `limit_price` by the slippage fraction: + /// - Buy: ceiling = `limit_price + limit_price * p` (saturating) + /// - Sell: floor = `limit_price - limit_price * p` (saturating) + pub(crate) fn compute_effective_swap_limit( + is_buy: bool, + limit_price: u64, + max_slippage: Option, + ) -> u64 { + match max_slippage { + None => { + if is_buy { u64::MAX } else { 0 } + } + Some(slippage) => { + let delta = slippage * limit_price; + if is_buy { + limit_price.saturating_add(delta) + } else { + limit_price.saturating_sub(delta) + } + } + } + } + /// Derive the on-chain `OrderId` as blake2_256 over the SCALE-encoded order. pub fn derive_order_id(order: &VersionedOrder) -> H256 { H256(sp_core::hashing::blake2_256(&order.encode())) @@ -484,15 +521,16 @@ pub mod pallet { Self::is_order_valid(&signed_order, order_id, now_ms, current_price, relayer)?; + let effective_swap_limit = Self::compute_effective_swap_limit( + order.order_type.is_buy(), + order.limit_price, + order.max_slippage, + ); + // Execute the swap, taking the order's fee from the input (buys) or output (sells). - // - // NOTE: `order.limit_price` is intentionally used only as a trigger threshold - // in `is_order_valid` above, not as slippage protection for the swap. The - // V3 swap interprets `price_limit` in different units (price × 1e9 → sqrt), - // so passing `order.limit_price` directly into `buy_alpha` / `sell_alpha` - // does not produce a meaningful price floor/ceiling. Slippage protection - // is a known future improvement; for now the order executes at market once - // the trigger condition is satisfied. + // `effective_swap_limit` enforces slippage protection: for buys it caps the price + // ceiling; for sells it sets a minimum floor. When `max_slippage` is None the + // limit is u64::MAX (buys) or 0 (sells), matching previous market-order behaviour. let (amount_in, amount_out) = if order.order_type.is_buy() { let tao_in = TaoBalance::from(order.amount); // Deduct fee from TAO input before swapping. @@ -504,7 +542,7 @@ pub mod pallet { &order.hotkey, order.netuid, tao_after_fee, - TaoBalance::from(order.limit_price), + TaoBalance::from(effective_swap_limit), true, )?; @@ -528,7 +566,7 @@ pub mod pallet { &order.hotkey, order.netuid, AlphaBalance::from(order.amount), - TaoBalance::from(order.limit_price), + TaoBalance::from(effective_swap_limit), true, )?; @@ -598,6 +636,22 @@ pub mod pallet { netuid, )?; + // Derive the tightest slippage constraint from the dominant side: + // buy-dominant → min of all buy ceilings; sell-dominant → max of all sell floors. + let pool_price_limit = if total_buy_net >= total_sell_tao_equiv { + valid_buys + .iter() + .map(|e| e.effective_swap_limit) + .min() + .unwrap_or(u64::MAX) + } else { + valid_sells + .iter() + .map(|e| e.effective_swap_limit) + .max() + .unwrap_or(0) + }; + // Execute a single pool swap for the residual (buy TAO minus sell TAO-equiv, or vice versa). let (net_side, actual_out) = Self::net_pool_swap( total_buy_net, @@ -607,6 +661,7 @@ pub mod pallet { &pallet_acct, &pallet_hotkey, netuid, + pool_price_limit, )?; // Give every buyer their pro-rata share of (pool alpha output + offset sell alpha). @@ -697,6 +752,12 @@ pub mod pallet { order.amount }; + let effective_swap_limit = Self::compute_effective_swap_limit( + order.order_type.is_buy(), + order.limit_price, + order.max_slippage, + ); + let entry = OrderEntry { order_id, signer: order.signer.clone(), @@ -706,6 +767,7 @@ pub mod pallet { net, fee_rate: order.fee_rate, fee_recipient: order.fee_recipient.clone(), + effective_swap_limit, }; // try_push cannot fail: both vecs share the same bound as `orders`. @@ -749,6 +811,9 @@ pub mod pallet { /// Execute a single pool swap for the net (residual) amount. /// Returns `(net_side, actual_out)` where `actual_out` is in the output /// token units (alpha for Buy, TAO for Sell). + /// + /// `price_limit` encodes the tightest slippage constraint across all dominant-side + /// orders: a ceiling for buy-dominant swaps, a floor for sell-dominant swaps. fn net_pool_swap( total_buy_net: u128, total_sell_net: u128, @@ -757,6 +822,7 @@ pub mod pallet { pallet_acct: &T::AccountId, pallet_hotkey: &T::AccountId, netuid: NetUid, + price_limit: u64, ) -> Result<(OrderSide, u128), DispatchError> { if total_buy_net >= total_sell_tao_equiv { let net_tao = (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64; @@ -766,7 +832,7 @@ pub mod pallet { pallet_hotkey, netuid, TaoBalance::from(net_tao), - TaoBalance::from(u64::MAX), // no price ceiling for net pool swap + TaoBalance::from(price_limit), false, )? .to_u64() as u128; @@ -785,7 +851,7 @@ pub mod pallet { pallet_hotkey, netuid, AlphaBalance::from(net_alpha), - TaoBalance::ZERO, + TaoBalance::from(price_limit), false, )? .to_u64() as u128; diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 27a98af741..851d753d8f 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -308,6 +308,171 @@ fn validate_and_classify_applies_buy_fee_to_net() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// compute_effective_swap_limit +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compute_effective_swap_limit_buy_no_slippage() { + new_test_ext().execute_with(|| { + // No slippage → u64::MAX (no ceiling). + let limit = LimitOrders::::compute_effective_swap_limit(true, 1_000, None); + assert_eq!(limit, u64::MAX); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_no_slippage() { + new_test_ext().execute_with(|| { + // No slippage → 0 (no floor). + let limit = LimitOrders::::compute_effective_swap_limit(false, 1_000, None); + assert_eq!(limit, 0); + }); +} + +#[test] +fn compute_effective_swap_limit_buy_one_percent() { + new_test_ext().execute_with(|| { + // 1% slippage on a buy with limit_price=1000 → ceiling = 1010. + let limit = LimitOrders::::compute_effective_swap_limit( + true, + 1_000, + Some(Perbill::from_percent(1)), + ); + assert_eq!(limit, 1_010); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_one_percent() { + new_test_ext().execute_with(|| { + // 1% slippage on a sell with limit_price=1000 → floor = 990. + let limit = LimitOrders::::compute_effective_swap_limit( + false, + 1_000, + Some(Perbill::from_percent(1)), + ); + assert_eq!(limit, 990); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_saturates_at_zero() { + new_test_ext().execute_with(|| { + // 100% slippage on a sell with limit_price=500 → floor saturates at 0. + let limit = LimitOrders::::compute_effective_swap_limit( + false, + 500, + Some(Perbill::from_percent(100)), + ); + assert_eq!(limit, 0); + }); +} + +#[test] +fn compute_effective_swap_limit_buy_saturates_at_u64_max() { + new_test_ext().execute_with(|| { + // 100% slippage on a buy with limit_price=u64::MAX → ceiling saturates at u64::MAX. + let limit = LimitOrders::::compute_effective_swap_limit( + true, + u64::MAX, + Some(Perbill::from_percent(100)), + ); + assert_eq!(limit, u64::MAX); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// validate_and_classify — effective_swap_limit propagation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn validate_and_classify_stores_effective_swap_limit_for_buy() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // 1% slippage on limit_price=1000 → ceiling = 1010. + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 500u64, + 1_000u64, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + // Override max_slippage on the inner order after signing — we need to rebuild + // the signed order so the signature covers the updated payload. + let new_inner = { + let mut o = order.order.inner().clone(); + o.max_slippage = Some(Perbill::from_percent(1)); + o + }; + let versioned = crate::VersionedOrder::V1(new_inner.clone()); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed_with_slippage = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + }; + + let orders = bounded(vec![signed_with_slippage]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob(), + ) + .expect("should succeed"); + + assert_eq!(buys[0].effective_swap_limit, 1_010); + }); +} + +#[test] +fn validate_and_classify_stores_effective_swap_limit_for_sell() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price must be >= limit_price for TakeProfit to trigger. + // limit_price=1000, 1% slippage → floor = 990. + let new_inner = crate::Order { + signer: AccountKeyring::Alice.to_account_id(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::TakeProfit, + amount: 500u64, + limit_price: 1_000u64, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: Some(Perbill::from_percent(1)), + }; + let versioned = crate::VersionedOrder::V1(new_inner); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + }; + + let orders = bounded(vec![signed]); + let (_, sells) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(2_000u32), // current_price=2000 >= limit_price=1000 ✓ + bob(), + ) + .expect("should succeed"); + + assert_eq!(sells[0].effective_swap_limit, 990); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // validate_and_classify — relayer enforcement // ───────────────────────────────────────────────────────────────────────────── @@ -465,6 +630,7 @@ fn make_buy_entry( net, fee_rate, fee_recipient, + effective_swap_limit: u64::MAX, // no slippage constraint } } @@ -1223,6 +1389,7 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); @@ -1343,6 +1510,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index a6a4b1ad48..98540e800d 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -4,7 +4,9 @@ //! and event emission are all verified. SwapInterface calls are handled by //! `MockSwap`, which records calls and maintains in-memory balance ledgers. +use codec::Encode; use frame_support::{assert_noop, assert_ok}; +use sp_core::Pair; use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{DispatchError, Perbill}; use subtensor_runtime_common::NetUid; @@ -45,6 +47,7 @@ fn cancel_order_signer_can_cancel() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); let id = order_id(&order); @@ -74,6 +77,7 @@ fn cancel_order_non_signer_rejected() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); // Bob tries to cancel Alice's order. assert_noop!( @@ -97,6 +101,7 @@ fn cancel_order_already_cancelled_rejected() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Cancelled); @@ -122,6 +127,7 @@ fn cancel_order_already_fulfilled_rejected() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -147,6 +153,7 @@ fn cancel_order_unsigned_rejected() { fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), relayer: None, + max_slippage: None, }); assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::none(), order), @@ -1702,6 +1709,527 @@ fn root_disables_and_extrinsics_are_filtered() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — execute_orders passes effective_swap_limit to pool +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a signed order with a specific `max_slippage` value. +fn make_signed_order_with_slippage( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: subtensor_runtime_common::NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: sp_runtime::Perbill, + fee_recipient: AccountId, + max_slippage: Option, +) -> crate::SignedOrder { + let order = crate::VersionedOrder::V1(crate::Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage, + }); + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: sp_runtime::MultiSignature::Sr25519(sig), + } +} + +#[test] +fn execute_orders_buy_no_slippage_passes_u64_max_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // no slippage → u64::MAX ceiling + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Pool must have been called with u64::MAX as price ceiling. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![u64::MAX]); + }); +} + +#[test] +fn execute_orders_sell_no_slippage_passes_zero_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 1, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // no slippage → 0 floor + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![0]); + }); +} + +#[test] +fn execute_orders_buy_one_percent_slippage_passes_ceiling_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // limit_price=1000, 1% slippage → ceiling = 1010. + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010]); + }); +} + +#[test] +fn execute_orders_sell_one_percent_slippage_passes_floor_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price must be >= limit_price for TakeProfit to trigger. + MockSwap::set_price(2_000.0); + + // limit_price=1000, 1% slippage → floor = 990. + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — execute_batched_orders aggregates tightest constraint +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_buy_dominant_uses_min_ceiling() { + new_test_ext().execute_with(|| { + // 3 buy orders with different slippage constraints. + // Alice: limit=1000, 2% → ceiling=1020 + // Bob: limit=1000, 1% → ceiling=1010 ← tightest + // Charlie (as signer, not relayer): limit=1000, 3% → ceiling=1030 + // Expected pool price_limit = min(1020, 1010, 1030) = 1010. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 600); + MockSwap::set_tao_balance(bob(), 200); + MockSwap::set_tao_balance(dave(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 600, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), // ceiling = 1020 + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 200, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // ceiling = 1010 ← tightest + ); + let dave_order = make_signed_order_with_slippage( + AccountKeyring::Dave, + dave(), + netuid(), + OrderType::LimitBuy, + 200, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(3)), // ceiling = 1030 + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_order]), + )); + + // Net pool swap must have been called with the tightest ceiling = 1010. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010]); + }); +} + +#[test] +fn execute_batched_orders_sell_dominant_uses_max_floor() { + new_test_ext().execute_with(|| { + // 3 sell orders with different slippage constraints. + // Alice: limit=1000, 3% → floor=970 + // Bob: limit=1000, 1% → floor=990 ← tightest (highest floor) + // Dave: limit=1000, 2% → floor=980 + // Expected pool price_limit = max(970, 990, 980) = 990. + // Price must be >= limit_price=1000 for TakeProfit to trigger. + MockTime::set(1_000_000); + MockSwap::set_price(2_000.0); + MockSwap::set_sell_tao_return(500); + MockSwap::set_alpha_balance(alice(), dave(), netuid(), 600); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); + MockSwap::set_alpha_balance(dave(), dave(), netuid(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::TakeProfit, + 600, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(3)), // floor = 970 + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // floor = 990 ← tightest + ); + let dave_order = make_signed_order_with_slippage( + AccountKeyring::Dave, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), // floor = 980 + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_order]), + )); + + // Net pool swap must have been called with the tightest floor = 990. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + }); +} + +#[test] +fn execute_batched_orders_no_slippage_uses_unconstrained_limits() { + new_test_ext().execute_with(|| { + // Orders without max_slippage should pass u64::MAX (buy) or 0 (sell). + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + )); + + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![u64::MAX]); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — mixed order type coexistence +// ───────────────────────────────────────────────────────────────────────────── + +/// Sell-dominant batch: TakeProfit orders (with slippage) + StopLoss (no slippage). +/// +/// TakeProfit orders set meaningful floors; StopLoss contributes 0 (no constraint). +/// pool_price_limit = max(take_floors..., 0s) = max(take_floors). +/// All three orders are fulfilled. +#[test] +fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { + new_test_ext().execute_with(|| { + // Price = 2000 — above all TakeProfit limits (≥1000 ✓) and below StopLoss limit (≤5000 ✓). + MockTime::set(1_000_000); + MockSwap::set_price(2_000.0); + MockSwap::set_sell_tao_return(500); + + // Alice TakeProfit: limit=1000, 3% → floor=970. + // Bob TakeProfit: limit=1000, 1% → floor=990. ← tightest + // Dave StopLoss: limit=5000, None → floor=0. + MockSwap::set_alpha_balance(alice(), dave(), netuid(), 600); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, dave(), netuid(), + OrderType::TakeProfit, 600, 1_000, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + Some(Perbill::from_percent(3)), + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, dave(), netuid(), + OrderType::TakeProfit, 200, 1_000, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + Some(Perbill::from_percent(1)), + ); + let dave_stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, alice(), netuid(), + OrderType::StopLoss, 200, 5_000, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + None, // StopLoss: no slippage → floor=0, does not constrain pool + ); + + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_stoploss]), + )); + + // All three fulfilled. + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + + // Pool called once with the tightest TakeProfit floor (990), not 0 from StopLoss. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + }); +} + +/// Buy-dominant batch: LimitBuy orders (with slippage) dominant + StopLoss (no slippage) on offset side. +/// +/// The offset StopLoss is settled internally at spot price; it does not contribute +/// to the pool's price ceiling (which comes only from the dominant buy side). +/// pool_price_limit = min(buy_ceilings) = 101. +#[test] +fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { + new_test_ext().execute_with(|| { + // Price = 1. LimitBuy triggers (1 ≤ 100 ✓). StopLoss triggers (1 ≤ 5 ✓). + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(900); + + // Alice LimitBuy: limit=100, 2% → ceiling=102. + // Bob LimitBuy: limit=100, 1% → ceiling=101. ← tightest + // Dave StopLoss: limit=5, None → floor=0 (offset side, not used for pool limit). + 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, 100, 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, 100, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + Some(Perbill::from_percent(1)), + ); + let dave_stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, alice(), netuid(), + OrderType::StopLoss, 100, 5, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + None, // StopLoss: no slippage; settled at spot, never constrains pool ceiling + ); + + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_stoploss]), + )); + + // All three fulfilled. + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + + // Pool buy called with min(102, 101) = 101. StopLoss's floor (0) is ignored on buy side. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![101]); + }); +} + +/// StopLoss with a narrow slippage sets an effective floor above the current market price, +/// making the pool swap impossible and failing the entire batch. +/// +/// This demonstrates Issue 1 from the design: relayers should not apply max_slippage to +/// StopLoss orders. StopLoss triggers when price has already fallen; a floor derived from +/// the (higher) trigger threshold will almost always exceed the actual market price. +#[test] +fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { + new_test_ext().execute_with(|| { + // StopLoss: limit=100, triggers at price=50 (50 ≤ 100 ✓). + // 1% slippage → floor=99. Market is at 50 → pool cannot deliver ≥99. + MockTime::set(1_000_000); + MockSwap::set_price(50.0); + MockSwap::set_sell_tao_return(100); // non-zero so SwapReturnedZero is not the cause + MockSwap::set_enforce_price_limit(true); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); + + let stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, alice(), netuid(), + OrderType::StopLoss, 200, 100, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![stoploss]), + ), + DispatchError::Other("price limit exceeded") + ); + }); +} + +/// Same StopLoss scenario through execute_orders (best-effort): the order is silently +/// skipped rather than failing the whole call. +/// +/// Note: `DispatchError::Other` has `#[codec(skip)]` on its string field, so the reason +/// string is lost when stored in the event log. We verify the skip via storage absence +/// and by asserting the floor (99) was actually passed to the pool — which is what caused +/// the rejection. The `execute_batched_orders` variant below uses `assert_noop!` (checks +/// the return value directly, no storage round-trip) and can verify the string. +#[test] +fn execute_orders_stoploss_narrow_slippage_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(50.0); + MockSwap::set_sell_tao_return(100); + MockSwap::set_enforce_price_limit(true); + + let stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, alice(), netuid(), + OrderType::StopLoss, 200, 100, FAR_FUTURE, + Perbill::zero(), fee_recipient(), + Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects + ); + let id = order_id(&stoploss.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![stoploss]), + )); + + // Order not stored — pool rejected the floor. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + + // The sell was attempted with the correct floor (99 = 100 - 1%). + // This is the value that exceeded the market price and caused the rejection. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![99]); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // relayer enforcement // ───────────────────────────────────────────────────────────────────────────── diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index a52ca9241b..732ffce640 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -63,12 +63,14 @@ pub enum SwapCall { hotkey: AccountId, netuid: NetUid, tao: u64, + limit_price: u64, }, SellAlpha { coldkey: AccountId, hotkey: AccountId, netuid: NetUid, alpha: u64, + limit_price: u64, }, TransferTao { from: AccountId, @@ -109,6 +111,10 @@ thread_local! { pub static FAIL_FEE_TRANSFER: RefCell = RefCell::new(false); /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. pub static MOCK_SWAP_FAIL: RefCell = RefCell::new(false); + /// When `true`, swap calls enforce their `limit_price` argument against `MOCK_PRICE`: + /// `buy_alpha` fails if `market_price > limit_price` (ceiling exceeded); + /// `sell_alpha` fails if `market_price < limit_price` (floor not met). + pub static MOCK_ENFORCE_PRICE_LIMIT: RefCell = RefCell::new(false); /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. pub static RATE_LIMITS: RefCell> = @@ -130,11 +136,15 @@ impl MockSwap { pub fn set_swap_fail(fail: bool) { MOCK_SWAP_FAIL.with(|v| *v.borrow_mut() = fail); } + pub fn set_enforce_price_limit(enforce: bool) { + MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = enforce); + } pub fn clear_log() { SWAP_LOG.with(|l| l.borrow_mut().clear()); ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); TAO_BALANCES.with(|b| b.borrow_mut().clear()); RATE_LIMITS.with(|r| r.borrow_mut().clear()); + MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = false); } pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { RATE_LIMITS.with(|r| { @@ -169,6 +179,33 @@ impl MockSwap { pub fn log() -> Vec { SWAP_LOG.with(|l| l.borrow().clone()) } + /// Returns the `limit_price` argument from every `buy_alpha` call, in order. + pub fn buy_alpha_limit_prices() -> Vec { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::BuyAlpha { limit_price, .. } = c { + Some(limit_price) + } else { + None + } + }) + .collect() + } + /// Returns the `limit_price` argument from every `sell_alpha` call, in order. + pub fn sell_alpha_limit_prices() -> Vec { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::SellAlpha { limit_price, .. } = c { + Some(limit_price) + } else { + None + } + }) + .collect() + } + pub fn tao_transfers() -> Vec<(AccountId, AccountId, u64)> { Self::log() .into_iter() @@ -216,7 +253,7 @@ impl OrderSwapInterface for MockSwap { hotkey: &AccountId, netuid: NetUid, tao_amount: TaoBalance, - _limit_price: TaoBalance, + limit_price: TaoBalance, _apply_limits: bool, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { @@ -225,6 +262,24 @@ impl OrderSwapInterface for MockSwap { )); } let tao = tao_amount.to_u64(); + // Record the call (including rejected ones) so tests can verify the limit was passed. + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::BuyAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + tao, + limit_price: limit_price.to_u64(), + }) + }); + if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) { + let price = MOCK_PRICE.with(|p| p.borrow().to_num::()); + if price > limit_price.to_u64() { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "price limit exceeded", + )); + } + } let alpha_out = MOCK_BUY_ALPHA_RETURN.with(|v| *v.borrow()); // Debit TAO from coldkey, credit alpha to (coldkey, hotkey, netuid). TAO_BALANCES.with(|b| { @@ -239,14 +294,6 @@ impl OrderSwapInterface for MockSwap { .or_insert(0); *bal = bal.saturating_add(alpha_out); }); - SWAP_LOG.with(|l| { - l.borrow_mut().push(SwapCall::BuyAlpha { - coldkey: coldkey.clone(), - hotkey: hotkey.clone(), - netuid, - tao, - }) - }); Ok(AlphaBalance::from(alpha_out)) } @@ -255,7 +302,7 @@ impl OrderSwapInterface for MockSwap { hotkey: &AccountId, netuid: NetUid, alpha_amount: AlphaBalance, - _limit_price: TaoBalance, + limit_price: TaoBalance, _apply_limits: bool, ) -> Result { if MOCK_SWAP_FAIL.with(|v| *v.borrow()) { @@ -264,6 +311,25 @@ impl OrderSwapInterface for MockSwap { )); } let alpha = alpha_amount.to_u64(); + // Record the call (including rejected ones) so tests can verify the limit was passed. + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::SellAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + alpha, + limit_price: limit_price.to_u64(), + }) + }); + // Only enforce if a non-zero floor was requested (0 means no constraint). + if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) && limit_price.to_u64() > 0 { + let price = MOCK_PRICE.with(|p| p.borrow().to_num::()); + if price < limit_price.to_u64() { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "price limit exceeded", + )); + } + } let tao_out = MOCK_SELL_TAO_RETURN.with(|v| *v.borrow()); // Debit alpha from (coldkey, hotkey, netuid), credit TAO to coldkey. ALPHA_BALANCES.with(|b| { @@ -278,14 +344,6 @@ impl OrderSwapInterface for MockSwap { let bal = map.entry(coldkey.clone()).or_insert(0); *bal = bal.saturating_add(tao_out); }); - SWAP_LOG.with(|l| { - l.borrow_mut().push(SwapCall::SellAlpha { - coldkey: coldkey.clone(), - hotkey: hotkey.clone(), - netuid, - alpha, - }) - }); Ok(TaoBalance::from(tao_out)) } @@ -480,6 +538,7 @@ pub fn make_signed_order( fee_rate, fee_recipient, relayer, + max_slippage: None, }); let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 6da4a51dac..afb39e6d4f 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -1,4 +1,5 @@ use super::*; +use frame_support::transactional; use frame_support::traits::fungible::Mutate; use frame_support::traits::tokens::Preservation; use substrate_fixed::types::U96F32; @@ -6,6 +7,7 @@ use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; impl OrderSwapInterface for Pallet { + #[transactional] fn buy_alpha( coldkey: &T::AccountId, hotkey: &T::AccountId, @@ -34,12 +36,19 @@ impl OrderSwapInterface for Pallet { // intermediary account (and individual buyers in execute_orders) cannot // stake more TAO than they actually hold. let actual_tao = Self::remove_balance_from_coldkey_account(coldkey, tao_amount)?; + // `limit_price` arrives in the same units as `current_alpha_price()` (a raw ratio + // where 1.0 ≈ 1 unit/alpha). The AMM encodes its price_limit as `price × 10⁹` + // (matching the rao-per-TAO precision convention), so we scale up here before + // handing off to `stake_into_subnet`. saturating_mul handles the no-ceiling case + // (limit_price = u64::MAX) by saturating to u64::MAX, which the AMM interprets as + // an astronomically high ceiling that current prices never reach. + let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); let alpha_out = Self::stake_into_subnet( hotkey, coldkey, netuid, actual_tao, - limit_price, + amm_limit, false, false, )?; @@ -49,6 +58,7 @@ impl OrderSwapInterface for Pallet { Ok(alpha_out) } + #[transactional] fn sell_alpha( coldkey: &T::AccountId, hotkey: &T::AccountId, @@ -81,8 +91,12 @@ impl OrderSwapInterface for Pallet { ); Self::ensure_stake_operation_limit_not_exceeded(hotkey, coldkey, netuid)?; } + // Same ×10⁹ scaling as in buy_alpha: limit_price is in current_alpha_price() units; + // the AMM expects price × 10⁹. For the no-floor case (limit_price = 0) the result + // is 0, which the AMM treats as "no lower bound". + let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); let tao_out = - Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, limit_price, false)?; + Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, amm_limit, false)?; // Credit TAO proceeds to the seller so the pallet's intermediary account // (and individual sellers in execute_orders) have real balance to // distribute or forward to the fee collector. diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 8f3a502ce4..8940757eef 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -65,6 +65,14 @@ pub trait OrderSwapInterface { /// coldkey balance, and sets the staking rate-limit flag for `(hotkey, /// coldkey, netuid)` after a successful stake. Pass `false` for internal /// pallet-intermediary swaps that must bypass these user-facing guards. + /// Buy alpha with TAO: debit `tao_amount` from `coldkey`'s free balance, + /// credit resulting alpha as stake at `hotkey` on `netuid`. + /// + /// **Implementations MUST be transactional** (wrap in + /// `frame_support::storage::with_transaction` or annotate with + /// `#[frame_support::transactional]`). The implementation debits the + /// caller's balance before the pool swap; if the swap fails the debit + /// must be rolled back to leave the caller's state unchanged. fn buy_alpha( coldkey: &AccountId, hotkey: &AccountId, @@ -82,6 +90,14 @@ pub trait OrderSwapInterface { /// balance, and checks that the staking rate-limit flag is not set for /// `(hotkey, coldkey, netuid)` (i.e. the account did not stake this /// block). Pass `false` for internal pallet-intermediary swaps. + /// Sell alpha for TAO: remove `alpha_amount` from `coldkey`'s stake at + /// `hotkey` on `netuid`, credit resulting TAO to `coldkey`'s free balance. + /// + /// **Implementations MUST be transactional** (wrap in + /// `frame_support::storage::with_transaction` or annotate with + /// `#[frame_support::transactional]`). The implementation decrements the + /// caller's stake before the pool swap; if the swap fails the decrement + /// must be rolled back to leave the caller's state unchanged. fn sell_alpha( coldkey: &AccountId, hotkey: &AccountId, diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index c0945a0776..b2b8fa8fb4 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -7,6 +7,7 @@ use node_subtensor_runtime::{ System, pallet_subtensor, }; use pallet_limit_orders::{Order, OrderStatus, OrderType, Orders, SignedOrder, VersionedOrder}; +use pallet_subtensor::{SubnetAlphaIn, SubnetMechanism, SubnetTAO}; use sp_core::{Get, H256, Pair}; use sp_keyring::Sr25519Keyring; use sp_runtime::{MultiSignature, Perbill}; @@ -60,6 +61,7 @@ fn make_signed_order( fee_rate, fee_recipient, relayer: None, + max_slippage: None, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { @@ -91,6 +93,7 @@ fn cancel_order_works() { fee_rate: Perbill::zero(), fee_recipient, relayer: None, + max_slippage: None, }); let id = order_id(&order); @@ -124,6 +127,7 @@ fn execute_orders_ed25519_signature_rejected() { fee_rate: Perbill::zero(), fee_recipient, relayer: None, + max_slippage: None, }); let id = order_id(&order); @@ -1492,3 +1496,203 @@ fn batched_multiple_fee_recipients_each_receive_correct_amount() { ); }); } + +// ── max_slippage enforcement against the real dynamic-mechanism AMM ─────────── + +/// Set up a dynamic-mechanism (Uniswap v3-style) subnet with equal TAO and +/// alpha reserves, giving an initial pool price of exactly 1.0 TAO/alpha. +/// +/// The stable mechanism (mechanism_id = 0) ignores the `price_limit` parameter +/// entirely and always executes at 1:1, so slippage enforcement can only be +/// tested against a dynamic subnet. +fn setup_dynamic_subnet(netuid: NetUid) { + SubtensorModule::init_new_network(netuid, 0); + // Override the mechanism to 1 (dynamic / Uniswap v3). + SubnetMechanism::::insert(netuid, 1u16); + pallet_subtensor::SubtokenEnabled::::insert(netuid, true); + // Equal reserves → price = tao_reserve / alpha_reserve = 1.0 + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); +} + +/// Build a signed order with an explicit `max_slippage` value. +fn make_signed_order_with_slippage_rt( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, + max_slippage: Option, +) -> SignedOrder { + let order = VersionedOrder::V1(Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage, + }); + let sig = keyring.pair().sign(&order.encode()); + SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + } +} + +/// A StopLoss order whose price condition is met (`current_price ≤ limit_price`) +/// but whose `max_slippage`-derived floor exceeds the pool's actual price is +/// silently skipped by `execute_orders`. +/// +/// Setup: +/// Dynamic subnet, equal reserves → pool price = 1.0 (raw ratio, i.e. 1 rao/alpha). +/// limit_price = 2 → StopLoss trigger: 1.0 ≤ 2.0 ✓ (price has fallen to the trigger) +/// max_slippage = 10 % → floor = 2 − 10% × 2. +/// Note: `Perbill::from_percent(10) * 2 = 0` (integer truncation), so floor = 2. +/// After the ×10⁹ scale in `order_swap.rs`: +/// AMM price_limit = 2 × 10⁹ = 2_000_000_000 +/// limit_sqrt_price = √(2_000_000_000 / 10⁹) = √2 ≈ 1.414 +/// Pool sqrt_price = √1.0 = 1.0 → 1.0 > 1.414 is false → PriceLimitExceeded +/// `execute_orders` catches the error and skips the order (no storage write). +/// Because `sell_alpha` is `#[transactional]`, the stake decrement is rolled back. +#[test] +fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs staked alpha so the sell can debit her position. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 2: StopLoss triggers when price ≤ 2.0; pool is at 1.0 → met. + // max_slippage sets a floor: Perbill integer truncation gives floor = 2 - 0 = 2. + // After ×10⁹ scaling, AMM limit_sqrt = √2 ≈ 1.414 > pool sqrt 1.0 → rejected. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + min_default_stake().into(), + 2, // trigger at price 2.0; pool is at 1.0 — condition met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_percent(10)), + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + // execute_orders is best-effort: the call succeeds even though the order + // is rejected by the AMM. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // `try_execute_order` is #[transactional]: the stake decrement inside + // `unstake_from_subnet` is rolled back when the AMM rejects the swap, + // so alice's alpha is unchanged. + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, + initial_alpha, + "alice's staked alpha should be unchanged when the order is rolled back" + ); + }); +} + +/// Contrasting test: the same StopLoss order without `max_slippage` executes +/// successfully against the dynamic-mechanism pool. +/// +/// This confirms that the price condition alone is not the blocker and that +/// the previous test's skip is genuinely caused by the slippage floor. +#[test] +fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // Same limit_price — trigger still met. max_slippage = None → floor = 0 + // → AMM limit = 0 → no floor constraint → pool executes the sell. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + min_default_stake().into(), + 2_000_000_000, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + None, + ); + let id = order_id(&signed.order); + + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must be marked as fulfilled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be fulfilled when no slippage floor is set" + ); + + // Alice's staked alpha must have decreased by exactly min_default_stake. + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, + AlphaBalance::from(min_default_stake().to_u64() * 9u64), + "alice's staked alpha should decrease by min_default_stake after StopLoss executes" + ); + }); +} diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 5549dd4fdc..9cd25da1f5 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -22,6 +22,7 @@ export interface OrderParams { feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% feeRecipient: string; relayer?: string | null; // Optional: if set, only this account may relay the order + maxSlippage?: number | null; // Optional: Perbill (ppb). When set, effective swap limit = limit_price ± limit_price * maxSlippage / 1e9 } export interface Order { @@ -35,6 +36,7 @@ export interface Order { fee_rate: number; fee_recipient: string; relayer: string | null; + max_slippage: number | null; } export interface VersionedOrder { @@ -71,6 +73,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { fee_rate: params.feeRate, fee_recipient: params.feeRecipient, relayer: params.relayer ?? null, + max_slippage: params.maxSlippage ?? null, }; const versionedOrder: VersionedOrder = { V1: inner }; @@ -116,6 +119,7 @@ export function registerLimitOrderTypes(api: any): void { fee_rate: "u32", // Perbill fee_recipient: "AccountId", relayer: "Option", + max_slippage: "Option", }, LimitVersionedOrder: { _enum: { From e85911cccaeedcea8948efd97ffdb75612629996 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 8 Apr 2026 19:51:02 +0200 Subject: [PATCH 47/85] new tests for partial fills --- pallets/limit-orders/src/lib.rs | 113 +++++- pallets/limit-orders/src/tests/auxiliary.rs | 81 ++++ pallets/limit-orders/src/tests/extrinsics.rs | 357 ++++++++++++++++-- pallets/limit-orders/src/tests/mock.rs | 38 ++ pallets/subtensor/src/staking/order_swap.rs | 13 +- runtime/tests/limit_orders.rs | 221 ++++++++++- .../limit-orders/test-batched-partial-fill.ts | 153 ++++++++ .../test-execute-orders-partial-fill.ts | 151 ++++++++ .../test-execute-orders-skip-conditions.ts | 6 + ts-tests/utils/limit-orders.ts | 29 +- 10 files changed, 1100 insertions(+), 62 deletions(-) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 18f91c9fac..b41bddbccf 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -90,6 +90,8 @@ pub struct Order /// - Buy: effective price ceiling = `limit_price + limit_price * max_slippage` /// - Sell: effective price floor = `limit_price - limit_price * max_slippage` pub max_slippage: Option, + /// Wether partial fills are enabled + pub partial_fills_enabled: bool, } /// Versioned wrapper around an order payload. @@ -132,6 +134,8 @@ pub struct SignedOrder, /// Sr25519 signature over `SCALE_ENCODE(VersionedOrder)`. pub signature: MultiSignature, + /// Whether we want a partial fill for this order + pub partial_fill: Option, } #[derive( @@ -140,6 +144,8 @@ pub struct SignedOrder { pub(crate) signer: AccountId, pub(crate) hotkey: AccountId, pub(crate) side: OrderType, - /// Gross input amount (before fee). + /// Actual input amount being processed this execution (partial or full, before fee). pub(crate) gross: u64, + /// Full order amount as signed by the user. Used to determine terminal status. + pub(crate) order_amount: u64, /// Net input amount (after fee). /// For buys: `gross - fee_rate * gross`. For sells: equals `gross` (fee on TAO output). pub(crate) net: u64, @@ -166,6 +174,8 @@ pub(crate) struct OrderEntry { /// For sells: floor (min TAO per alpha the pool must return). /// Derived from `limit_price` and `max_slippage` during classification. pub(crate) effective_swap_limit: u64, + /// Present when this execution covers only part of the order. + pub(crate) partial_fill: Option, } // ── Pallet ─────────────────────────────────────────────────────────────────── @@ -291,6 +301,8 @@ pub mod pallet { InvalidSignature, /// The order has already been Fulfilled or Cancelled. OrderAlreadyProcessed, + /// Order has been cancelled + OrderCancelled, /// The order's expiry timestamp is in the past. OrderExpired, /// The current market price does not satisfy the order's limit price. @@ -307,6 +319,12 @@ pub mod pallet { LimitOrdersDisabled, /// Relayer not the same as specified in the order RelayerMissMatch, + /// Partial fills not enabled for this order + PartialFillsNotEnabled, + /// Incorrect partial fill amount provided + IncorrectPartialFillAmount, + /// A relayer must be set on the order when using partial fills + RelayerRequiredForPartialFill, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -445,7 +463,11 @@ pub mod pallet { ) -> u64 { match max_slippage { None => { - if is_buy { u64::MAX } else { 0 } + if is_buy { + u64::MAX + } else { + 0 + } } Some(slippage) => { let delta = slippage * limit_price; @@ -488,10 +510,15 @@ pub mod pallet { .verify(signed_order.order.encode().as_slice(), &order.signer), Error::::InvalidSignature ); + let order_status = Orders::::get(order_id); ensure!( - Orders::::get(order_id).is_none(), + order_status != Some(OrderStatus::Fulfilled), Error::::OrderAlreadyProcessed ); + ensure!( + order_status != Some(OrderStatus::Cancelled), + Error::::OrderCancelled + ); ensure!(now_ms <= order.expiry, Error::::OrderExpired); ensure!( match order.order_type { @@ -505,9 +532,56 @@ pub mod pallet { if let Some(forced_relayer) = order.relayer.clone() { ensure!(forced_relayer == *relayer, Error::::RelayerMissMatch); } + if let Some(partial_fill) = signed_order.partial_fill { + ensure!( + order.relayer.is_some(), + Error::::RelayerRequiredForPartialFill + ); + ensure!( + order.partial_fills_enabled, + Error::::PartialFillsNotEnabled + ); + let max_fill = + if let Some(OrderStatus::PartiallyFilled(already_filled)) = order_status { + order.amount.saturating_sub(already_filled) + } else { + order.amount + }; + ensure!( + partial_fill > 0 && partial_fill <= max_fill, + Error::::IncorrectPartialFillAmount + ); + } Ok(()) } + /// Compute the new `OrderStatus` to write after filling `fill_amount` of an order. + /// + /// Reads the current on-chain status to find any already-filled amount, adds + /// `fill_amount`, and returns `Fulfilled` when the total reaches `order_amount`. + /// Pass `None` for `fill_amount` when the order is being fully executed in one shot. + pub(crate) fn compute_order_status( + order_id: H256, + fill_amount: Option, + order_amount: u64, + ) -> OrderStatus { + let Some(fill) = fill_amount else { + return OrderStatus::Fulfilled; + }; + let already_filled = + if let Some(OrderStatus::PartiallyFilled(n)) = Orders::::get(order_id) { + n + } else { + 0 + }; + let new_total = already_filled.saturating_add(fill); + if new_total >= order_amount { + OrderStatus::Fulfilled + } else { + OrderStatus::PartiallyFilled(new_total) + } + } + /// Attempt to execute one signed order. Returns an error on any /// validation or execution failure without panicking. fn try_execute_order( @@ -532,7 +606,8 @@ pub mod pallet { // ceiling; for sells it sets a minimum floor. When `max_slippage` is None the // limit is u64::MAX (buys) or 0 (sells), matching previous market-order behaviour. let (amount_in, amount_out) = if order.order_type.is_buy() { - let tao_in = TaoBalance::from(order.amount); + // partial fill validations have passed, it is safe here to do this + let tao_in = TaoBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); // Deduct fee from TAO input before swapping. let fee_tao = TaoBalance::from(order.fee_rate * tao_in.to_u64()); let tao_after_fee = tao_in.saturating_sub(fee_tao); @@ -558,14 +633,17 @@ pub mod pallet { }); } } - (order.amount, alpha_out.to_u64()) + (tao_after_fee.to_u64(), alpha_out.to_u64()) } else { + // partial fill validations have passed, it is safe here to do this + let alpha_in = AlphaBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); + // Sell the full alpha amount; fee is taken from the TAO output. let tao_out = T::SwapInterface::sell_alpha( &order.signer, &order.hotkey, order.netuid, - AlphaBalance::from(order.amount), + alpha_in, TaoBalance::from(effective_swap_limit), true, )?; @@ -583,11 +661,13 @@ pub mod pallet { }); } } - (order.amount, tao_out.saturating_sub(fee_tao).to_u64()) + (alpha_in.to_u64(), tao_out.saturating_sub(fee_tao).to_u64()) }; - // 6. Mark as fulfilled and emit event. - Orders::::insert(order_id, OrderStatus::Fulfilled); + // Mark as fulfilled or partially filled and emit event. + let status = + Self::compute_order_status(order_id, signed_order.partial_fill, order.amount); + Orders::::insert(order_id, status); Self::deposit_event(Event::OrderExecuted { order_id, signer: order.signer.clone(), @@ -743,13 +823,14 @@ pub mod pallet { // Hard-fail on any per-order validation error (signature, expiry, price, root). Self::is_order_valid(signed_order, order_id, now_ms, current_price, &relayer)?; + let amount_in = signed_order.partial_fill.unwrap_or(order.amount); let net = if order.order_type.is_buy() { // Buy: fee on TAO input — net is the amount that reaches the pool. - order.amount.saturating_sub(order.fee_rate * order.amount) + amount_in.saturating_sub(order.fee_rate * amount_in) } else { // Sell: fee on TAO output — full alpha enters the pool; the fee is // deducted from the TAO payout later in `distribute_tao_pro_rata`. - order.amount + amount_in }; let effective_swap_limit = Self::compute_effective_swap_limit( @@ -763,11 +844,13 @@ pub mod pallet { signer: order.signer.clone(), hotkey: order.hotkey.clone(), side: order.order_type.clone(), - gross: order.amount, + gross: amount_in, + order_amount: order.amount, net, fee_rate: order.fee_rate, fee_recipient: order.fee_recipient.clone(), effective_swap_limit, + partial_fill: signed_order.partial_fill, }; // try_push cannot fail: both vecs share the same bound as `orders`. @@ -902,7 +985,8 @@ pub mod pallet { true, // set_receiver_limit: rate-limit the buyer after they receive stake )?; } - Orders::::insert(e.order_id, OrderStatus::Fulfilled); + let status = Self::compute_order_status(e.order_id, e.partial_fill, e.order_amount); + Orders::::insert(e.order_id, status); Self::deposit_event(Event::OrderExecuted { order_id: e.order_id, signer: e.signer.clone(), @@ -963,7 +1047,8 @@ pub mod pallet { &e.signer, TaoBalance::from(net_share), )?; - Orders::::insert(e.order_id, OrderStatus::Fulfilled); + let status = Self::compute_order_status(e.order_id, e.partial_fill, e.order_amount); + Orders::::insert(e.order_id, status); Self::deposit_event(Event::OrderExecuted { order_id: e.order_id, signer: e.signer.clone(), diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 851d753d8f..878ec960e6 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -417,6 +417,7 @@ fn validate_and_classify_stores_effective_swap_limit_for_buy() { let signed_with_slippage = crate::SignedOrder { order: versioned, signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, }; let orders = bounded(vec![signed_with_slippage]); @@ -451,12 +452,14 @@ fn validate_and_classify_stores_effective_swap_limit_for_sell() { fee_recipient: fee_recipient(), relayer: None, max_slippage: Some(Perbill::from_percent(1)), + partial_fills_enabled: false, }; let versioned = crate::VersionedOrder::V1(new_inner); let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); let signed = crate::SignedOrder { order: versioned, signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, }; let orders = bounded(vec![signed]); @@ -627,10 +630,12 @@ fn make_buy_entry( hotkey, side: OrderType::LimitBuy, gross, + order_amount: gross, net, fee_rate, fee_recipient, effective_swap_limit: u64::MAX, // no slippage constraint + partial_fill: None, } } @@ -1390,12 +1395,14 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); let signed = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, }; (signed, id) } @@ -1483,6 +1490,7 @@ fn is_order_valid_expired_order_returns_error() { let signed2 = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, }; let price = MockSwap::current_alpha_price(netuid()); assert_noop!( @@ -1511,12 +1519,14 @@ fn is_order_valid_price_condition_not_met_returns_error() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); let sig = keyring.pair().sign(&order.encode()); let signed = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, }; let price = MockSwap::current_alpha_price(netuid()); assert_noop!( @@ -1525,3 +1535,74 @@ fn is_order_valid_price_condition_not_met_returns_error() { ); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// compute_order_status +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compute_order_status_no_partial_fill_returns_fulfilled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(1); + // No existing state, no partial fill → Fulfilled immediately. + let status = LimitOrders::::compute_order_status(id, None, 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_partial_fill_below_total_returns_partially_filled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(2); + // First partial fill of 400 on a 1000-unit order → PartiallyFilled(400). + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::PartiallyFilled(400)); + }); +} + +#[test] +fn compute_order_status_partial_fill_exact_total_returns_fulfilled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(3); + // Single partial fill that equals the full order amount → Fulfilled. + let status = LimitOrders::::compute_order_status(id, Some(1_000), 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_accumulates_previous_partial_fill() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(4); + // Pre-seed storage as if a prior partial fill of 300 already happened. + Orders::::insert(id, OrderStatus::PartiallyFilled(300)); + + // Second fill of 400 → 300 + 400 = 700, still below 1000. + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::PartiallyFilled(700)); + }); +} + +#[test] +fn compute_order_status_completes_order_when_accumulated_total_reaches_amount() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(5); + Orders::::insert(id, OrderStatus::PartiallyFilled(600)); + + // Fill the remaining 400 → 600 + 400 = 1000 = order_amount → Fulfilled. + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_ignores_fulfilled_storage_when_no_partial_fill() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(6); + // If somehow called with no partial_fill regardless of what's in storage + // (should not happen in practice) it still returns Fulfilled. + Orders::::insert(id, OrderStatus::PartiallyFilled(500)); + let status = LimitOrders::::compute_order_status(id, None, 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 98540e800d..79fd928822 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -48,6 +48,7 @@ fn cancel_order_signer_can_cancel() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = order_id(&order); @@ -78,6 +79,7 @@ fn cancel_order_non_signer_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); // Bob tries to cancel Alice's order. assert_noop!( @@ -102,6 +104,7 @@ fn cancel_order_already_cancelled_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Cancelled); @@ -128,6 +131,7 @@ fn cancel_order_already_fulfilled_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = order_id(&order); Orders::::insert(id, OrderStatus::Fulfilled); @@ -154,6 +158,7 @@ fn cancel_order_unsigned_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); assert_noop!( LimitOrders::cancel_order(RuntimeOrigin::none(), order), @@ -1205,7 +1210,7 @@ fn execute_batched_orders_fails_for_cancelled_order() { netuid(), bounded(vec![signed]), ), - Error::::OrderAlreadyProcessed + Error::::OrderCancelled ); // Still cancelled, not changed to Fulfilled. @@ -1738,11 +1743,13 @@ fn make_signed_order_with_slippage( fee_recipient, relayer: None, max_slippage, + partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { order, signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, } } @@ -2050,27 +2057,45 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); let alice_order = make_signed_order_with_slippage( - AccountKeyring::Alice, dave(), netuid(), - OrderType::TakeProfit, 600, 1_000, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::TakeProfit, + 600, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), Some(Perbill::from_percent(3)), ); let bob_order = make_signed_order_with_slippage( - AccountKeyring::Bob, dave(), netuid(), - OrderType::TakeProfit, 200, 1_000, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), Some(Perbill::from_percent(1)), ); let dave_stoploss = make_signed_order_with_slippage( - AccountKeyring::Dave, alice(), netuid(), - OrderType::StopLoss, 200, 5_000, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 5_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), None, // StopLoss: no slippage → floor=0, does not constrain pool ); let alice_id = order_id(&alice_order.order); - let bob_id = order_id(&bob_order.order); - let dave_id = order_id(&dave_stoploss.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie()), @@ -2080,8 +2105,8 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { // All three fulfilled. assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); - assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); - assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); // Pool called once with the tightest TakeProfit floor (990), not 0 from StopLoss. assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); @@ -2109,27 +2134,45 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { MockSwap::set_alpha_balance(dave(), alice(), netuid(), 100); let alice_order = make_signed_order_with_slippage( - AccountKeyring::Alice, bob(), netuid(), - OrderType::LimitBuy, 600, 100, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 600, + 100, + 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, 100, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Bob, + bob(), + netuid(), + OrderType::LimitBuy, + 400, + 100, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), Some(Perbill::from_percent(1)), ); let dave_stoploss = make_signed_order_with_slippage( - AccountKeyring::Dave, alice(), netuid(), - OrderType::StopLoss, 100, 5, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 100, + 5, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), None, // StopLoss: no slippage; settled at spot, never constrains pool ceiling ); let alice_id = order_id(&alice_order.order); - let bob_id = order_id(&bob_order.order); - let dave_id = order_id(&dave_stoploss.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie()), @@ -2139,8 +2182,8 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { // All three fulfilled. assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); - assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); - assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); // Pool buy called with min(102, 101) = 101. StopLoss's floor (0) is ignored on buy side. assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![101]); @@ -2165,9 +2208,15 @@ fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); let stoploss = make_signed_order_with_slippage( - AccountKeyring::Dave, alice(), netuid(), - OrderType::StopLoss, 200, 100, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 100, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects ); @@ -2199,9 +2248,15 @@ fn execute_orders_stoploss_narrow_slippage_skips_order() { MockSwap::set_enforce_price_limit(true); let stoploss = make_signed_order_with_slippage( - AccountKeyring::Dave, alice(), netuid(), - OrderType::StopLoss, 200, 100, FAR_FUTURE, - Perbill::zero(), fee_recipient(), + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 100, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects ); let id = order_id(&stoploss.order); @@ -2373,6 +2428,242 @@ fn execute_batched_orders_correct_relayer_succeeds() { }); } +// ───────────────────────────────────────────────────────────────────────────── +// Partial fills — execute_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_orders_partial_fill_sets_partially_filled_status() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Order for 1000 TAO; relayer is charlie (required for partial fills). + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 400, // fill 400 out of 1000 + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]), + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(400))); + }); +} + +#[test] +fn execute_orders_second_partial_fill_completes_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed_first = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 600, + ); + let id = order_id(&signed_first.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed_first.clone()]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(600))); + + // Re-submit the same signed order payload with a different partial_fill amount. + let mut signed_second = signed_first.clone(); + signed_second.partial_fill = Some(400); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed_second]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn execute_orders_partial_fill_without_relayer_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Build an order with partial_fills_enabled but no relayer set. + let inner = crate::Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, // <-- no relayer + max_slippage: None, + partial_fills_enabled: true, + }; + let versioned = VersionedOrder::V1(inner); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: Some(400), + }; + let id = order_id(&signed.order); + + // The order is skipped (best-effort), not reverting the batch. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]), + )); + + // Nothing written to storage. + assert_eq!(Orders::::get(id), None); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::RelayerRequiredForPartialFill.into(), + }); + }); +} + +#[test] +fn execute_orders_partial_fill_exceeding_remaining_is_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Pre-fill 700 of 1000. + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 700, + ); + let id = order_id(&signed.order); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed.clone()]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(700))); + + // Try to fill 500 more, but only 300 remain → should be skipped. + let mut over_fill = signed.clone(); + over_fill.partial_fill = Some(500); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![over_fill]), + )); + + // Status unchanged. + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(700))); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::IncorrectPartialFillAmount.into(), + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Partial fills — execute_batched_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_partial_fill_sets_partially_filled_status() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(400); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 400, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed]), + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(400))); + }); +} + +#[test] +fn execute_batched_orders_second_partial_fill_completes_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(600); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed_first = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 600, + ); + let id = order_id(&signed_first.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed_first.clone()]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(600))); + + let mut signed_second = signed_first.clone(); + signed_second.partial_fill = Some(400); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed_second]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + /// Non-root origin cannot disable the pallet #[test] fn non_root_cannot_disable_the_pallet() { diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 732ffce640..efec5ba251 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -539,11 +539,49 @@ pub fn make_signed_order( fee_recipient, relayer, max_slippage: None, + partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +/// Build a signed order with partial fills enabled and a relayer set. +/// `partial_fill` is the fill amount to inject into the `SignedOrder` envelope. +pub fn make_partial_fill_order( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: NetUid, + order_type: crate::OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + relayer: AccountId, + partial_fill: u64, +) -> crate::SignedOrder { + let signer = keyring.to_account_id(); + let order = crate::VersionedOrder::V1(crate::Order { + signer, + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate: sp_runtime::Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: Some(relayer), + max_slippage: None, + partial_fills_enabled: true, + }); + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: Some(partial_fill), } } diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index afb39e6d4f..1d9baf06bf 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -1,7 +1,7 @@ use super::*; -use frame_support::transactional; use frame_support::traits::fungible::Mutate; use frame_support::traits::tokens::Preservation; +use frame_support::transactional; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; @@ -43,15 +43,8 @@ impl OrderSwapInterface for Pallet { // (limit_price = u64::MAX) by saturating to u64::MAX, which the AMM interprets as // an astronomically high ceiling that current prices never reach. let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); - let alpha_out = Self::stake_into_subnet( - hotkey, - coldkey, - netuid, - actual_tao, - amm_limit, - false, - false, - )?; + let alpha_out = + Self::stake_into_subnet(hotkey, coldkey, netuid, actual_tao, amm_limit, false, false)?; if validate { Self::set_stake_operation_limit(hotkey, coldkey, netuid); } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index b2b8fa8fb4..245171108c 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -62,11 +62,13 @@ fn make_signed_order( fee_recipient, relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, } } @@ -94,6 +96,7 @@ fn cancel_order_works() { fee_recipient, relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = order_id(&order); @@ -128,6 +131,7 @@ fn execute_orders_ed25519_signature_rejected() { fee_recipient, relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let id = order_id(&order); @@ -137,6 +141,7 @@ fn execute_orders_ed25519_signature_rejected() { let signed = SignedOrder { order, signature: MultiSignature::Ed25519(ed_sig), + partial_fill: None, }; let orders: BoundedVec<_, ::MaxOrdersPerBatch> = @@ -1540,11 +1545,13 @@ fn make_signed_order_with_slippage_rt( fee_recipient, relayer: None, max_slippage, + partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { order, signature: MultiSignature::Sr25519(sig), + partial_fill: None, } } @@ -1623,8 +1630,7 @@ fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { let remaining = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); assert_eq!( - remaining, - initial_alpha, + remaining, initial_alpha, "alice's staked alpha should be unchanged when the order is rolled back" ); }); @@ -1696,3 +1702,214 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { ); }); } + +// ── Partial fill tests ──────────────────────────────────────────────────────── + +/// Build a `SignedOrder` with `partial_fills_enabled = true` and the relayer set +/// to `relayer`. The `partial_fill` field on the envelope is supplied separately +/// by each test so that the *same* `VersionedOrder` payload (and therefore the +/// same order-id) can be re-used across multiple submissions. +fn make_partial_fill_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_recipient: AccountId, + relayer: AccountId, + partial_fill: Option, +) -> SignedOrder { + let order = VersionedOrder::V1(Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate: Perbill::zero(), + fee_recipient, + relayer: Some(relayer), + max_slippage: None, + partial_fills_enabled: true, + }); + let sig = keyring.pair().sign(&order.encode()); + SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill, + } +} + +/// A LimitBuy order with `partial_fills_enabled` is partially filled on the +/// first `execute_orders` call, then fully filled (Fulfilled) on a second call +/// carrying the remaining amount. +/// +/// The signed payload (`VersionedOrder`) is identical in both submissions so +/// both calls share the same order-id. Only `SignedOrder::partial_fill` changes. +#[test] +fn execute_orders_partial_fill_then_complete() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Alice funds two fills: partial_amount + remaining_amount = order amount. + let order_amount = min_default_stake().to_u64() * 4u64; + let partial_amount = min_default_stake().to_u64() * 3u64; + let remaining_amount = order_amount - partial_amount; + + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + TaoBalance::from(order_amount * 2u64), + ); + + // Create the hotkey association Alice → Bob. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Build the base signed order — this exact payload is re-used for both submissions. + let first_signed = make_partial_fill_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + order_amount, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + charlie_id.clone(), + charlie_id.clone(), // relayer = caller + Some(partial_amount), + ); + let id = order_id(&first_signed.order); + + // ── First submission: partial fill ──────────────────────────────────── + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![first_signed.clone()].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id.clone()), + orders, + )); + + // After the first execution the order must be partially filled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(partial_amount)), + "order should be PartiallyFilled({partial_amount}) after first execution" + ); + + // ── Second submission: fill the remainder ───────────────────────────── + // Clone the order payload from the first signed order (same VersionedOrder, + // same order-id) but set partial_fill to the remaining amount. + let second_signed = SignedOrder { + order: first_signed.order.clone(), + signature: first_signed.signature.clone(), + partial_fill: Some(remaining_amount), + }; + + let orders2: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![second_signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id.clone()), + orders2, + )); + + // After the second execution the order must be fulfilled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be Fulfilled after the remaining amount is filled" + ); + }); +} + +/// Same partial-fill-then-complete scenario exercised through +/// `execute_batched_orders`. +/// +/// The buy order is the only order in the batch both times, so the batch is +/// buy-dominant and routes all TAO through the pool. The signed payload is +/// identical between submissions; only `SignedOrder::partial_fill` changes. +#[test] +fn execute_batched_orders_partial_fill_then_complete() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + let order_amount = min_default_stake().to_u64() * 4u64; + let partial_amount = min_default_stake().to_u64() * 3u64; + let remaining_amount = order_amount - partial_amount; + + SubtensorModule::add_balance_to_coldkey_account( + &alice_id, + TaoBalance::from(order_amount * 2u64), + ); + + // Create the hotkey association Alice → Bob. + SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Build the base signed order — identical payload reused in both batches. + let first_signed = make_partial_fill_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + order_amount, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + charlie_id.clone(), + charlie_id.clone(), // relayer = caller + Some(partial_amount), + ); + let id = order_id(&first_signed.order); + + // ── First batch: partial fill ───────────────────────────────────────── + let orders: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![first_signed.clone()].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(partial_amount)), + "order should be PartiallyFilled({partial_amount}) after first batch" + ); + + // ── Second batch: fill the remainder ────────────────────────────────── + let second_signed = SignedOrder { + order: first_signed.order.clone(), + signature: first_signed.signature.clone(), + partial_fill: Some(remaining_amount), + }; + + let orders2: BoundedVec<_, ::MaxOrdersPerBatch> = + vec![second_signed].try_into().unwrap(); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders2, + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be Fulfilled after the remaining amount is filled in the second batch" + ); + }); +} diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts new file mode 100644 index 0000000000..109629d022 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts @@ -0,0 +1,153 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + getPartiallyFilledAmount, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests for partial fill via execute_batched_orders. +// Same semantics as the execute_orders variant: the signed VersionedOrder +// payload is reused unchanged; only partial_fill on the envelope changes. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_PARTIAL_FILL", + title: "execute_batched_orders — partial fill", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "first batched partial fill sets status to PartiallyFilled", + test: async () => { + const orderAmount = tao(100); + const firstFill = Number(tao(50)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: alice.address, + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // Submit first partial fill (50 out of 100 TAO) via execute_batched_orders. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [firstEnvelope]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + const filled = await getPartiallyFilledAmount(polkadotJs, id); + expect(filled).toBe(BigInt(firstFill)); + + // Alpha stake should have increased from the partial buy. + const stakeAfter = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + expect(stakeAfter).toBeGreaterThan(0n); + }, + }); + + it({ + id: "T02", + title: "second batched partial fill completing the order sets status to Fulfilled", + test: async () => { + const orderAmount = tao(200); + const firstFill = Number(tao(100)); + const secondFill = Number(tao(100)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: alice.address, + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // First fill: 100 / 200. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [firstEnvelope]) + .signAsync(alice), + ]); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + expect(await getPartiallyFilledAmount(polkadotJs, id)).toBe(BigInt(firstFill)); + + // Second fill: the remaining 100 — completes the order. + const secondEnvelope = { ...signed, partial_fill: secondFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [secondEnvelope]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts new file mode 100644 index 0000000000..6080326899 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts @@ -0,0 +1,151 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + getPartiallyFilledAmount, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests for partial fill via execute_orders. +// The relayer (alice) submits the same signed payload twice with different +// partial_fill values on the envelope. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_PARTIAL_FILL", + title: "execute_orders — partial fill", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "first partial fill sets status to PartiallyFilled", + test: async () => { + const orderAmount = tao(100); + const firstFill = Number(tao(60)); + + // Build a partial-fills-enabled order with alice as relayer. + // The signed VersionedOrder payload is the same for both fills. + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: alice.address, + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // Submit first partial fill (60 out of 100 TAO). + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([firstEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + const filled = await getPartiallyFilledAmount(polkadotJs, id); + expect(filled).toBe(BigInt(firstFill)); + + // Alpha stake should have increased (partial buy occurred). + const stakeAfter = await devGetAlphaStake( + polkadotJs, + aliceHotKey.address, + alice.address, + netuid + ); + expect(stakeAfter).toBeGreaterThan(0n); + }, + }); + + it({ + id: "T02", + title: "second partial fill completing the order sets status to Fulfilled", + test: async () => { + const orderAmount = tao(200); + const firstFill = Number(tao(120)); + const secondFill = Number(tao(80)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: alice.address, + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // First fill: 120 / 200. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([firstEnvelope]).signAsync(alice), + ]); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + expect(await getPartiallyFilledAmount(polkadotJs, id)).toBe(BigInt(firstFill)); + + // Second fill: the remaining 80 — completes the order. + // The signed VersionedOrder payload is identical; only partial_fill on the + // envelope changes, per the Rust design. + const secondEnvelope = { ...signed, partial_fill: secondFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([secondEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts index 8ce5909ca8..79fb34730c 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -15,6 +15,7 @@ import { FAR_FUTURE, filterEvents, registerLimitOrderTypes, + seedPoolReserves, } from "../../../../utils/limit-orders.js"; // Tests in this file do NOT interact with the pool (price-not-met, expired, @@ -44,6 +45,11 @@ describeSuite({ await devEnableSubtoken(polkadotJs, context, alice, netuid); await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + + // Seed pool reserves so the spot price is well above 1n RAO/alpha. + // taoReserve = tao(1_000), alphaIn = tao(1_000) → price ≈ 1 TAO/alpha = 1_000_000_000n RAO/alpha. + // This ensures LimitBuy orders with limitPrice = 1n are correctly skipped (price not met). + await seedPoolReserves(null as any, polkadotJs, netuid, tao(1_000), tao(1_000)); }); it({ diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 9cd25da1f5..a7f2531a06 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -23,6 +23,7 @@ export interface OrderParams { feeRecipient: string; relayer?: string | null; // Optional: if set, only this account may relay the order maxSlippage?: number | null; // Optional: Perbill (ppb). When set, effective swap limit = limit_price ± limit_price * maxSlippage / 1e9 + partialFillsEnabled?: boolean; // Optional: if true, order can be partially filled (requires relayer) } export interface Order { @@ -37,6 +38,7 @@ export interface Order { fee_recipient: string; relayer: string | null; max_slippage: number | null; + partial_fills_enabled: boolean; } export interface VersionedOrder { @@ -46,6 +48,7 @@ export interface VersionedOrder { export interface SignedOrder { order: VersionedOrder; signature: { Sr25519: `0x${string}` } | { Ed25519: `0x${string}` } | { Ecdsa: `0x${string}` }; + partial_fill: number | null; } // ── Constants ───────────────────────────────────────────────────────────────── @@ -74,6 +77,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { fee_recipient: params.feeRecipient, relayer: params.relayer ?? null, max_slippage: params.maxSlippage ?? null, + partial_fills_enabled: params.partialFillsEnabled ?? false, }; const versionedOrder: VersionedOrder = { V1: inner }; @@ -85,6 +89,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { return { order: versionedOrder, signature: { Sr25519: u8aToHex(sig) as `0x${string}` }, + partial_fill: null, }; } @@ -120,6 +125,7 @@ export function registerLimitOrderTypes(api: any): void { fee_recipient: "AccountId", relayer: "Option", max_slippage: "Option", + partial_fills_enabled: "bool", }, LimitVersionedOrder: { _enum: { @@ -129,9 +135,14 @@ export function registerLimitOrderTypes(api: any): void { LimitSignedOrder: { order: "LimitVersionedOrder", signature: "MultiSignature", + partial_fill: "Option", }, LimitOrderStatus: { - _enum: ["Fulfilled", "Cancelled"], + _enum: { + Fulfilled: null, + PartiallyFilled: "u64", + Cancelled: null, + }, }, }); } @@ -203,10 +214,22 @@ export async function setPalletStatus( export async function getOrderStatus( polkadotJs: any, id: `0x${string}` -): Promise<"Fulfilled" | "Cancelled" | undefined> { +): Promise<"Fulfilled" | "PartiallyFilled" | "Cancelled" | undefined> { const result = await polkadotJs.query.limitOrders.orders(id); if (result.isNone) return undefined; - return result.unwrap().type as "Fulfilled" | "Cancelled"; + return result.unwrap().type as "Fulfilled" | "PartiallyFilled" | "Cancelled"; +} + +/** Read the on-chain OrderStatus and return the PartiallyFilled amount, or null. */ +export async function getPartiallyFilledAmount( + polkadotJs: any, + id: `0x${string}` +): Promise { + const result = await polkadotJs.query.limitOrders.orders(id); + if (result.isNone) return null; + const status = result.unwrap(); + if (status.type !== "PartiallyFilled") return null; + return BigInt(status.asPartiallyFilled.toString()); } /** Filter system events by method name. */ From f4360d18c7d7813b4089870a94b61525ef71eaf3 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 8 Apr 2026 19:53:56 +0200 Subject: [PATCH 48/85] fix benchmarks --- pallets/limit-orders/src/benchmarking.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 87fc9d01ba..a059104db1 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -31,6 +31,7 @@ fn sign_order( crate::SignedOrder { order: order.clone(), signature: MultiSignature::Sr25519(sig), + partial_fill: None, } } @@ -70,6 +71,7 @@ mod benchmarks { fee_recipient: account.clone(), relayer: None, max_slippage: None, + partial_fills_enabled: false, }); let signed = sign_order::(public, &order); @@ -116,6 +118,7 @@ mod benchmarks { fee_recipient, relayer: None, max_slippage: None, + partial_fills_enabled: false, }); orders.push(sign_order::(public, &order)); } @@ -163,6 +166,7 @@ mod benchmarks { fee_recipient, relayer: None, max_slippage: None, + partial_fills_enabled: false, }); orders.push(sign_order::(public, &order)); } From fe9c0398d259a0a2c0ad07fc026457d94c17f1b5 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 10:06:27 +0200 Subject: [PATCH 49/85] fix failing test --- .../test-execute-orders-skip-conditions.ts | 17 ++++++--------- ts-tests/utils/dev-helpers.ts | 21 ------------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts index 79fb34730c..59636a5086 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -15,11 +15,11 @@ import { FAR_FUTURE, filterEvents, registerLimitOrderTypes, - seedPoolReserves, } from "../../../../utils/limit-orders.js"; -// Tests in this file do NOT interact with the pool (price-not-met, expired, -// bad-sig, root-netuid, already-processed). A single subnet in beforeAll is fine. +// Tests in this file cover skip conditions: price-not-met, expired, bad-sig, +// root-netuid, already-processed. Pool price after devEnableSubtoken is ~1 TAO/alpha, +// so LimitBuy with limitPrice=1n is always skipped and TakeProfit with limitPrice=FAR_FUTURE too. describeSuite({ id: "DEV_SUB_LIMIT_ORDERS_SKIP", @@ -45,25 +45,20 @@ describeSuite({ await devEnableSubtoken(polkadotJs, context, alice, netuid); await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); - - // Seed pool reserves so the spot price is well above 1n RAO/alpha. - // taoReserve = tao(1_000), alphaIn = tao(1_000) → price ≈ 1 TAO/alpha = 1_000_000_000n RAO/alpha. - // This ensures LimitBuy orders with limitPrice = 1n are correctly skipped (price not met). - await seedPoolReserves(null as any, polkadotJs, netuid, tao(1_000), tao(1_000)); }); it({ id: "T01", title: "LimitBuy skipped when limit_price below current price", test: async () => { - // Set limit_price = 1 RAO — almost certainly below any real price + // limit_price = 0: current_price (1.0 TAO/alpha) > 0 → condition never met const signed = buildSignedOrder(polkadotJs, { signer: alice, hotkey: aliceHotKey.address, netuid, orderType: "LimitBuy", amount: tao(1), - limitPrice: 1n, + limitPrice: 0n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, @@ -256,7 +251,7 @@ describeSuite({ netuid, orderType: "LimitBuy", amount: tao(3), - limitPrice: 1n, + limitPrice: 0n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, diff --git a/ts-tests/utils/dev-helpers.ts b/ts-tests/utils/dev-helpers.ts index 70bea7b770..f1cbf095cf 100644 --- a/ts-tests/utils/dev-helpers.ts +++ b/ts-tests/utils/dev-helpers.ts @@ -123,27 +123,6 @@ export async function devEnableSubtoken( .signAsync(alice), ]); } - -export async function devSeedPool( - polkadotJs: ApiPromise, - context: any, - alice: KeyringPair, - netuid: number, - taoReserve: bigint, - alphaIn: bigint -): Promise { - await context.createBlock([ - await polkadotJs.tx.sudo - .sudo(polkadotJs.tx.adminUtils.sudoSetSubnetTao(netuid, taoReserve)) - .signAsync(alice), - ]); - await context.createBlock([ - await polkadotJs.tx.sudo - .sudo(polkadotJs.tx.adminUtils.sudoSetSubnetAlphaIn(netuid, alphaIn)) - .signAsync(alice), - ]); -} - export async function devExecuteOrders( polkadotJs: ApiPromise, context: any, From 5ec50875911712664e9114c63a8cf201a08e4571 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 10:08:20 +0200 Subject: [PATCH 50/85] dev helpers cleanup --- ts-tests/utils/dev-helpers.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ts-tests/utils/dev-helpers.ts b/ts-tests/utils/dev-helpers.ts index f1cbf095cf..03a838fe4d 100644 --- a/ts-tests/utils/dev-helpers.ts +++ b/ts-tests/utils/dev-helpers.ts @@ -73,13 +73,6 @@ export async function devGetAlphaStake( return result; } -export async function devGetBalance( - polkadotJs: ApiPromise, - address: string -): Promise { - const account = (await polkadotJs.query.system.account(address)) as any; - return account.data.free.toBigInt(); -} export async function devSudoSetLockReductionInterval( polkadotJs: ApiPromise, From 70a02fd79a5d5fe8a85cbde4180fb8d267284f41 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 10:18:11 +0200 Subject: [PATCH 51/85] fee event refactor --- pallets/limit-orders/src/lib.rs | 54 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index b41bddbccf..13a90adc12 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -490,6 +490,22 @@ pub mod pallet { T::PalletId::get().into_account_truncating() } + /// Transfer `fee_tao` from `signer` to `recipient`, emitting + /// `FeeTransferFailed` best-effort on failure without reverting the + /// surrounding operation. Does nothing when `fee_tao` is zero. + fn forward_fee(signer: &T::AccountId, recipient: &T::AccountId, fee_tao: TaoBalance) { + if fee_tao.is_zero() { + return; + } + if let Err(reason) = T::SwapInterface::transfer_tao(signer, recipient, fee_tao) { + Self::deposit_event(Event::FeeTransferFailed { + recipient: recipient.clone(), + amount: fee_tao.to_u64(), + reason, + }); + } + } + /// Validates all execution preconditions for a signed order. /// Checks that the order's netuid is not root (0), that the signature is valid, /// the order has not been processed, is not expired, and the price condition is met. @@ -622,17 +638,7 @@ pub mod pallet { )?; // Forward the fee TAO to the order's fee recipient. - if !fee_tao.is_zero() { - if let Err(reason) = - T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) - { - Self::deposit_event(Event::FeeTransferFailed { - recipient: order.fee_recipient.clone(), - amount: fee_tao.to_u64(), - reason, - }); - } - } + Self::forward_fee(&order.signer, &order.fee_recipient, fee_tao); (tao_after_fee.to_u64(), alpha_out.to_u64()) } else { // partial fill validations have passed, it is safe here to do this @@ -650,17 +656,7 @@ pub mod pallet { // Deduct fee from TAO output and forward to the order's fee recipient. let fee_tao = TaoBalance::from(order.fee_rate * tao_out.to_u64()); - if !fee_tao.is_zero() { - if let Err(reason) = - T::SwapInterface::transfer_tao(&order.signer, &order.fee_recipient, fee_tao) - { - Self::deposit_event(Event::FeeTransferFailed { - recipient: order.fee_recipient.clone(), - amount: fee_tao.to_u64(), - reason, - }); - } - } + Self::forward_fee(&order.signer, &order.fee_recipient, fee_tao); (alpha_in.to_u64(), tao_out.saturating_sub(fee_tao).to_u64()) }; @@ -1089,19 +1085,7 @@ pub mod pallet { // One transfer per unique fee recipient. for (recipient, amount) in fees { - if amount > 0 { - if let Err(reason) = T::SwapInterface::transfer_tao( - pallet_acct, - &recipient, - TaoBalance::from(amount), - ) { - Self::deposit_event(Event::FeeTransferFailed { - recipient, - amount, - reason, - }); - } - } + Self::forward_fee(pallet_acct, &recipient, TaoBalance::from(amount)); } // TODO: sweep rounding dust and any emissions accrued on the pallet account. From a5cff7fa8da80682e1c58d5f51bb5869b49aed73 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 10:33:01 +0200 Subject: [PATCH 52/85] changes to make things a bit more efficeint --- pallets/limit-orders/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 13a90adc12..e1f6cbb40a 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -352,7 +352,7 @@ pub mod pallet { for signed_order in orders { // Best-effort: individual order failures do not revert the batch. let order_id = Self::derive_order_id(&signed_order.order); - if let Err(reason) = Self::try_execute_order(signed_order, &relayer) { + if let Err(reason) = Self::try_execute_order(signed_order, order_id, &relayer) { Self::deposit_event(Event::OrderSkipped { order_id, reason }); } } @@ -602,9 +602,9 @@ pub mod pallet { /// validation or execution failure without panicking. fn try_execute_order( signed_order: SignedOrder, + order_id: H256, relayer: &T::AccountId, ) -> DispatchResult { - let order_id = Self::derive_order_id(&signed_order.order); let order = signed_order.order.inner(); let now_ms = T::TimeProvider::now().as_millis() as u64; let current_price = T::SwapInterface::current_alpha_price(order.netuid); From 05ba26eebce26baf8629adca73cc8905b8e72b32 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 11:35:40 +0200 Subject: [PATCH 53/85] more refactor, specialyl in testing --- pallets/limit-orders/src/benchmarking.rs | 96 +++-- runtime/tests/limit_orders.rs | 495 ++++++++++------------- 2 files changed, 254 insertions(+), 337 deletions(-) diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index a059104db1..ebfe422758 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -48,6 +48,50 @@ pub fn order_id(order: &crate::VersionedOrder) - crate::pallet::Pallet::::derive_order_id(order) } +/// Build `n` signed benchmark orders for `netuid`, one per distinct signer. +/// +/// For each index `i` in `0..n` the function: +/// - derives a deterministic sr25519 key via `benchmark_key(i)`, +/// - calls `T::SwapInterface::set_up_acc_for_benchmark` so the account has +/// sufficient balance / stake, +/// - constructs a worst-case `LimitBuy` order (amount = 1 TAO, price = u64::MAX, +/// expiry = u64::MAX, fee 1 %, distinct fee recipient), and +/// - signs it with the generated key. +fn make_benchmark_orders( + n: u32, + netuid: NetUid, +) -> alloc::vec::Vec> { + use subtensor_swap_interface::OrderSwapInterface; + + let mut orders = alloc::vec::Vec::new(); + + for i in 0..n { + let (public, account_id) = benchmark_key(i); + let account: T::AccountId = account_id.into(); + let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); + + T::SwapInterface::set_up_acc_for_benchmark(&account, &account); + + let order = crate::VersionedOrder::V1(crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000_000_000u64, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::from_percent(1), + fee_recipient, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + }); + orders.push(sign_order::(public, &order)); + } + + orders +} + #[benchmarks] mod benchmarks { use super::*; @@ -97,31 +141,7 @@ mod benchmarks { let netuid = NetUid::from(1u16); T::SwapInterface::set_up_netuid_for_benchmark(netuid); - let mut orders = alloc::vec::Vec::new(); - - for i in 0..n { - let (public, account_id) = benchmark_key(i); - let account: T::AccountId = account_id.into(); - let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); - - T::SwapInterface::set_up_acc_for_benchmark(&account, &account); - - let order = crate::VersionedOrder::V1(crate::Order { - signer: account.clone(), - hotkey: account.clone(), - netuid, - order_type: OrderType::LimitBuy, - amount: 1_000_000_000u64, - limit_price: u64::MAX, - expiry: u64::MAX, - fee_rate: Perbill::from_percent(1), - fee_recipient, - relayer: None, - max_slippage: None, - partial_fills_enabled: false, - }); - orders.push(sign_order::(public, &order)); - } + let orders = make_benchmark_orders::(n, netuid); let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = frame_support::BoundedVec::try_from(orders).unwrap(); @@ -145,31 +165,7 @@ mod benchmarks { let pallet_hotkey: T::AccountId = T::PalletHotkey::get(); T::SwapInterface::set_up_acc_for_benchmark(&pallet_hotkey, &pallet_acct); - let mut orders = alloc::vec::Vec::new(); - - for i in 0..n { - let (public, account_id) = benchmark_key(i); - let account: T::AccountId = account_id.into(); - let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); - - T::SwapInterface::set_up_acc_for_benchmark(&account, &account); - - let order = crate::VersionedOrder::V1(crate::Order { - signer: account.clone(), - hotkey: account.clone(), - netuid, - order_type: OrderType::LimitBuy, - amount: 1_000_000_000u64, - limit_price: u64::MAX, - expiry: u64::MAX, - fee_rate: Perbill::from_percent(1), - fee_recipient, - relayer: None, - max_slippage: None, - partial_fills_enabled: false, - }); - orders.push(sign_order::(public, &order)); - } + let orders = make_benchmark_orders::(n, netuid); let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = frame_support::BoundedVec::try_from(orders).unwrap(); diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 245171108c..bab45d59be 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -35,34 +35,76 @@ fn setup_subnet(netuid: NetUid) { fn min_default_stake() -> TaoBalance { pallet_subtensor::DefaultMinStake::::get() } + +fn fund_account(id: &AccountId) { + SubtensorModule::add_balance_to_coldkey_account(id, min_default_stake() * 10u64.into()); +} + fn order_id(order: &VersionedOrder) -> H256 { H256(sp_io::hashing::blake2_256(&order.encode())) } -fn make_signed_order( - keyring: Sr25519Keyring, - hotkey: AccountId, +fn make_order_batch( + orders: Vec>, +) -> BoundedVec, ::MaxOrdersPerBatch> +{ + orders.try_into().unwrap() +} + +fn setup_buyer_seller( netuid: NetUid, + alice_id: &AccountId, + charlie_id: &AccountId, + bob_id: &AccountId, + dave_id: &AccountId, +) { + fund_account(alice_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + dave_id, + bob_id, + netuid, + initial_alpha, + ); + SubtensorModule::create_account_if_non_existent(alice_id, charlie_id); + SubtensorModule::create_account_if_non_existent(bob_id, dave_id); +} + +struct OrderParams { order_type: OrderType, amount: u64, limit_price: u64, expiry: u64, fee_rate: Perbill, fee_recipient: AccountId, + relayer: Option, + max_slippage: Option, + partial_fills_enabled: bool, +} + +/// Shared implementation: constructs and signs a `VersionedOrder::V1` from an +/// `OrderParams` and returns a `SignedOrder` with `partial_fill = None`. +/// All three public factory functions delegate here so that adding a new field +/// to `Order` requires updating only this function. +fn make_signed_order_inner( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + params: OrderParams, ) -> SignedOrder { let order = VersionedOrder::V1(Order { signer: keyring.to_account_id(), hotkey, netuid, - order_type, - amount, - limit_price, - expiry, - fee_rate, - fee_recipient, - relayer: None, - max_slippage: None, - partial_fills_enabled: false, + order_type: params.order_type, + amount: params.amount, + limit_price: params.limit_price, + expiry: params.expiry, + fee_rate: params.fee_rate, + fee_recipient: params.fee_recipient, + relayer: params.relayer, + max_slippage: params.max_slippage, + partial_fills_enabled: params.partial_fills_enabled, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { @@ -72,6 +114,118 @@ fn make_signed_order( } } +fn make_signed_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, +) -> SignedOrder { + make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + }, + ) +} + +/// Set up a dynamic-mechanism (Uniswap v3-style) subnet with equal TAO and +/// alpha reserves, giving an initial pool price of exactly 1.0 TAO/alpha. +/// +/// The stable mechanism (mechanism_id = 0) ignores the `price_limit` parameter +/// entirely and always executes at 1:1, so slippage enforcement can only be +/// tested against a dynamic subnet. +fn setup_dynamic_subnet(netuid: NetUid) { + SubtensorModule::init_new_network(netuid, 0); + // Override the mechanism to 1 (dynamic / Uniswap v3). + SubnetMechanism::::insert(netuid, 1u16); + pallet_subtensor::SubtokenEnabled::::insert(netuid, true); + // Equal reserves → price = tao_reserve / alpha_reserve = 1.0 + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); +} + +/// Build a signed order with an explicit `max_slippage` value. +fn make_signed_order_with_slippage_rt( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, + max_slippage: Option, +) -> SignedOrder { + make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage, + partial_fills_enabled: false, + }, + ) +} + +/// Build a `SignedOrder` with `partial_fills_enabled = true` and the relayer set +/// to `relayer`. The `partial_fill` field on the envelope is supplied separately +/// by each test so that the *same* `VersionedOrder` payload (and therefore the +/// same order-id) can be re-used across multiple submissions. +fn make_partial_fill_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_recipient: AccountId, + relayer: AccountId, + partial_fill: Option, +) -> SignedOrder { + let mut signed = make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate: Perbill::zero(), + fee_recipient, + relayer: Some(relayer), + max_slippage: None, + partial_fills_enabled: true, + }, + ); + signed.partial_fill = partial_fill; + signed +} + // ───────────────────────────────────────────────────────────────────────────── /// Signing and cancelling an order writes the order id to storage as Cancelled @@ -144,8 +298,7 @@ fn execute_orders_ed25519_signature_rejected() { partial_fill: None, }; - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(alice_id), @@ -171,10 +324,7 @@ fn limit_buy_order_executes_and_stakes_alpha() { setup_subnet(netuid); // Fund Alice so buy_alpha can debit her balance. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Create the hot-key association. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -193,8 +343,7 @@ fn limit_buy_order_executes_and_stakes_alpha() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id), @@ -256,8 +405,7 @@ fn take_profit_order_executes_and_unstakes_alpha() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id), @@ -322,8 +470,7 @@ fn stop_loss_order_executes_and_unstakes_alpha() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id), @@ -378,26 +525,7 @@ fn batched_buy_dominant_executes_correctly() { setup_subnet(netuid); - // Alice has free TAO to spend on a buy order. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); - - // Seed Bob with staked alph so he has something to sell. - let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); - - // Bob has staked alpha (through Dave) to sell. - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &dave_id, - &bob_id, - netuid, - initial_alpha, - ); - - // Create the hot-key association. Alice-> Charlie, Bob -> Dave - SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); - SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); let buy = make_signed_order( alice, @@ -422,8 +550,7 @@ fn batched_buy_dominant_executes_correctly() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -483,24 +610,7 @@ fn batched_sell_dominant_executes_correctly() { setup_subnet(netuid); - // Create the hot-key association. Alice-> Charlie, Bob -> Dave - SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); - SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); - - // Alice has free TAO to spend on a buy order. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); - - // Seed Bob with staked alph so he has something to sell. - let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &dave_id, - &bob_id, - netuid, - initial_alpha, - ); + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); let buy = make_signed_order( alice, @@ -525,8 +635,7 @@ fn batched_sell_dominant_executes_correctly() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -575,24 +684,7 @@ fn batched_fails_if_executing_below_minimum_on_sell() { setup_subnet(netuid); - // Create the hot-key association. Alice-> Charlie, Bob -> Dave - SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); - SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); - - // Alice has free TAO to spend on a buy order. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); - - // Seed Bob with staked alph so he has something to sell. - let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &dave_id, - &bob_id, - netuid, - initial_alpha, - ); + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); let buy = make_signed_order( alice, @@ -617,8 +709,7 @@ fn batched_fails_if_executing_below_minimum_on_sell() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_noop!( LimitOrders::execute_batched_orders( @@ -647,10 +738,7 @@ fn batched_fails_if_executing_without_hot_key_association() { // Create the hot-key association. Alice is not associating to charlie // Alice has free TAO to spend on a buy order. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Seed Bob with staked alph so he has something to sell. let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); @@ -684,8 +772,7 @@ fn batched_fails_if_executing_without_hot_key_association() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_noop!( LimitOrders::execute_batched_orders( @@ -712,10 +799,7 @@ fn batched_fails_for_nonexistent_subnet() { // Fund Alice so that `transfer_tao` succeeds; the subnet check happens // later inside `buy_alpha`. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); let buy = make_signed_order( alice, @@ -729,8 +813,7 @@ fn batched_fails_for_nonexistent_subnet() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy].try_into().unwrap(); + let orders = make_order_batch(vec![buy]); assert_noop!( LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), @@ -755,10 +838,7 @@ fn batched_fails_if_subtoken_not_enabled() { SubtensorModule::init_new_network(netuid, 0); // Fund Alice so that the TAO transfer in `collect_assets` succeeds. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); let buy = make_signed_order( alice, @@ -772,8 +852,7 @@ fn batched_fails_if_subtoken_not_enabled() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy].try_into().unwrap(); + let orders = make_order_batch(vec![buy]); assert_noop!( LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), @@ -811,8 +890,7 @@ fn batched_fails_for_expired_order() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_noop!( LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), @@ -848,8 +926,7 @@ fn batched_fails_if_price_condition_not_met() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_noop!( LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), @@ -870,10 +947,7 @@ fn batched_fails_for_root_netuid() { let charlie_id = Sr25519Keyring::Charlie.to_account_id(); // Fund Alice so the call gets past any balance checks before hitting the root guard. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); let buy = make_signed_order( alice, @@ -887,8 +961,7 @@ fn batched_fails_for_root_netuid() { charlie_id.clone(), ); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy].try_into().unwrap(); + let orders = make_order_batch(vec![buy]); assert_noop!( LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), @@ -928,8 +1001,7 @@ fn execute_orders_skips_expired_order() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); // The call must succeed even though the order is expired. assert_ok!(LimitOrders::execute_orders( @@ -958,10 +1030,7 @@ fn execute_orders_valid_and_invalid_mixed() { setup_subnet(netuid); // Fund Alice so that her LimitBuy order can execute. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Create the hotkey association for Alice so buy_alpha succeeds. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -996,8 +1065,7 @@ fn execute_orders_valid_and_invalid_mixed() { let valid_id = order_id(&valid.order); let expired_id = order_id(&expired.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![valid, expired].try_into().unwrap(); + let orders = make_order_batch(vec![valid, expired]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id), @@ -1029,10 +1097,7 @@ fn execute_orders_skips_order_with_unassociated_hotkey() { setup_subnet(netuid); // Fund Alice so that any balance check is not the reason for skipping. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Deliberately do NOT call create_account_if_non_existent — Alice has no // hotkey association, so the order should be silently skipped. @@ -1050,8 +1115,7 @@ fn execute_orders_skips_order_with_unassociated_hotkey() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); // The call must succeed even though the hotkey association is missing. assert_ok!(LimitOrders::execute_orders( @@ -1079,10 +1143,7 @@ fn execute_orders_skips_order_below_minimum_stake() { setup_subnet(netuid); // Fund Alice so that any balance check is not the reason for skipping. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Create the hotkey association so that is not the reason for skipping. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -1101,8 +1162,7 @@ fn execute_orders_skips_order_below_minimum_stake() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); // The call must succeed even though the amount is below the minimum. assert_ok!(LimitOrders::execute_orders( @@ -1129,10 +1189,7 @@ fn execute_orders_skips_order_for_nonexistent_subnet() { let charlie_id = Sr25519Keyring::Charlie.to_account_id(); // Fund Alice so that any balance check is not the reason for skipping. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Create the hotkey association so that is not the reason for skipping. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -1150,8 +1207,7 @@ fn execute_orders_skips_order_for_nonexistent_subnet() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); // The call must succeed even though the subnet does not exist. assert_ok!(LimitOrders::execute_orders( @@ -1192,10 +1248,7 @@ fn execute_orders_fee_forwarded_to_recipient() { setup_subnet(netuid); // Fund Alice with 10× min_default_stake so she can cover the order amount and a margin. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); + fund_account(&alice_id); // Create the hotkey association Alice → Bob. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -1224,8 +1277,7 @@ fn execute_orders_fee_forwarded_to_recipient() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1286,24 +1338,7 @@ fn batched_fee_forwarded_to_recipient() { setup_subnet(netuid); - // Alice (buyer) funded with free TAO. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); - - // Bob (seller) seeded with staked alpha through Dave. - let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &dave_id, - &bob_id, - netuid, - initial_alpha, - ); - - // Create hotkey associations: Alice → Charlie, Bob → Dave. - SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); - SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); // Eve (shared fee recipient) starts with zero balance. assert_eq!( @@ -1337,8 +1372,7 @@ fn batched_fee_forwarded_to_recipient() { let buy_id = order_id(&buy.order); let sell_id = order_id(&sell.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1393,24 +1427,7 @@ fn batched_multiple_fee_recipients_each_receive_correct_amount() { setup_subnet(netuid); - // Alice (buyer) funded with free TAO. - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - min_default_stake() * 10u64.into(), - ); - - // Bob (seller) seeded with staked alpha through Dave. - let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &dave_id, - &bob_id, - netuid, - initial_alpha, - ); - - // Create hotkey associations: Alice → Charlie, Bob → Dave. - SubtensorModule::create_account_if_non_existent(&alice_id, &charlie_id); - SubtensorModule::create_account_if_non_existent(&bob_id, &dave_id); + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); // Charlie and Dave start with zero free balance (they are hotkeys; no initial funding). assert_eq!( @@ -1451,8 +1468,7 @@ fn batched_multiple_fee_recipients_each_receive_correct_amount() { let buy_id = order_id(&buy.order); let sell_id = order_id(&sell.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![buy, sell].try_into().unwrap(); + let orders = make_order_batch(vec![buy, sell]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1504,57 +1520,6 @@ fn batched_multiple_fee_recipients_each_receive_correct_amount() { // ── max_slippage enforcement against the real dynamic-mechanism AMM ─────────── -/// Set up a dynamic-mechanism (Uniswap v3-style) subnet with equal TAO and -/// alpha reserves, giving an initial pool price of exactly 1.0 TAO/alpha. -/// -/// The stable mechanism (mechanism_id = 0) ignores the `price_limit` parameter -/// entirely and always executes at 1:1, so slippage enforcement can only be -/// tested against a dynamic subnet. -fn setup_dynamic_subnet(netuid: NetUid) { - SubtensorModule::init_new_network(netuid, 0); - // Override the mechanism to 1 (dynamic / Uniswap v3). - SubnetMechanism::::insert(netuid, 1u16); - pallet_subtensor::SubtokenEnabled::::insert(netuid, true); - // Equal reserves → price = tao_reserve / alpha_reserve = 1.0 - SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); - SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); -} - -/// Build a signed order with an explicit `max_slippage` value. -fn make_signed_order_with_slippage_rt( - keyring: Sr25519Keyring, - hotkey: AccountId, - netuid: NetUid, - order_type: OrderType, - amount: u64, - limit_price: u64, - expiry: u64, - fee_rate: Perbill, - fee_recipient: AccountId, - max_slippage: Option, -) -> SignedOrder { - let order = VersionedOrder::V1(Order { - signer: keyring.to_account_id(), - hotkey, - netuid, - order_type, - amount, - limit_price, - expiry, - fee_rate, - fee_recipient, - relayer: None, - max_slippage, - partial_fills_enabled: false, - }); - let sig = keyring.pair().sign(&order.encode()); - SignedOrder { - order, - signature: MultiSignature::Sr25519(sig), - partial_fill: None, - } -} - /// A StopLoss order whose price condition is met (`current_price ≤ limit_price`) /// but whose `max_slippage`-derived floor exceeds the pool's actual price is /// silently skipped by `execute_orders`. @@ -1608,8 +1573,7 @@ fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); // execute_orders is best-effort: the call succeeds even though the order // is rejected by the AMM. @@ -1677,8 +1641,7 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { ); let id = order_id(&signed.order); - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![signed].try_into().unwrap(); + let orders = make_order_batch(vec![signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id), @@ -1705,44 +1668,6 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { // ── Partial fill tests ──────────────────────────────────────────────────────── -/// Build a `SignedOrder` with `partial_fills_enabled = true` and the relayer set -/// to `relayer`. The `partial_fill` field on the envelope is supplied separately -/// by each test so that the *same* `VersionedOrder` payload (and therefore the -/// same order-id) can be re-used across multiple submissions. -fn make_partial_fill_order( - keyring: Sr25519Keyring, - hotkey: AccountId, - netuid: NetUid, - order_type: OrderType, - amount: u64, - limit_price: u64, - expiry: u64, - fee_recipient: AccountId, - relayer: AccountId, - partial_fill: Option, -) -> SignedOrder { - let order = VersionedOrder::V1(Order { - signer: keyring.to_account_id(), - hotkey, - netuid, - order_type, - amount, - limit_price, - expiry, - fee_rate: Perbill::zero(), - fee_recipient, - relayer: Some(relayer), - max_slippage: None, - partial_fills_enabled: true, - }); - let sig = keyring.pair().sign(&order.encode()); - SignedOrder { - order, - signature: MultiSignature::Sr25519(sig), - partial_fill, - } -} - /// A LimitBuy order with `partial_fills_enabled` is partially filled on the /// first `execute_orders` call, then fully filled (Fulfilled) on a second call /// carrying the remaining amount. @@ -1789,8 +1714,7 @@ fn execute_orders_partial_fill_then_complete() { let id = order_id(&first_signed.order); // ── First submission: partial fill ──────────────────────────────────── - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![first_signed.clone()].try_into().unwrap(); + let orders = make_order_batch(vec![first_signed.clone()]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1813,8 +1737,7 @@ fn execute_orders_partial_fill_then_complete() { partial_fill: Some(remaining_amount), }; - let orders2: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![second_signed].try_into().unwrap(); + let orders2 = make_order_batch(vec![second_signed]); assert_ok!(LimitOrders::execute_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1875,8 +1798,7 @@ fn execute_batched_orders_partial_fill_then_complete() { let id = order_id(&first_signed.order); // ── First batch: partial fill ───────────────────────────────────────── - let orders: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![first_signed.clone()].try_into().unwrap(); + let orders = make_order_batch(vec![first_signed.clone()]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), @@ -1897,8 +1819,7 @@ fn execute_batched_orders_partial_fill_then_complete() { partial_fill: Some(remaining_amount), }; - let orders2: BoundedVec<_, ::MaxOrdersPerBatch> = - vec![second_signed].try_into().unwrap(); + let orders2 = make_order_batch(vec![second_signed]); assert_ok!(LimitOrders::execute_batched_orders( RuntimeOrigin::signed(charlie_id.clone()), From 42d22b23e9b62c172c5b5c2f5a6d07ecf86df6b4 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 12:27:12 +0200 Subject: [PATCH 54/85] commit Cargo.lock --- Cargo.lock | 1 + pallets/limit-orders/Cargo.toml | 1 + pallets/limit-orders/src/lib.rs | 3 +++ 3 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 0f90555b8f..bc877c073e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9983,6 +9983,7 @@ dependencies = [ "sp-runtime", "sp-std", "substrate-fixed", + "subtensor-macros", "subtensor-runtime-common", "subtensor-swap-interface", ] diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 3c9c99a5a0..cd032ce7b8 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -16,6 +16,7 @@ sp-runtime.workspace = true sp-std.workspace = true substrate-fixed.workspace = true subtensor-runtime-common.workspace = true +subtensor-macros.workspace = true subtensor-swap-interface.workspace = true [dev-dependencies] diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index e1f6cbb40a..f427cf7df3 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -17,6 +17,7 @@ use sp_runtime::{ }; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_macros::freeze_struct; use subtensor_swap_interface::OrderSwapInterface; // ── Data structures ────────────────────────────────────────────────────────── @@ -58,6 +59,7 @@ impl OrderType { /// The canonical order payload that users sign off-chain. /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). +#[freeze_struct("e64b59c23fbce993")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -127,6 +129,7 @@ impl VersionedOrd /// Signature verification is performed against `order.inner().signer` (the AccountId) /// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants /// of `MultiSignature` are rejected at validation time. +#[freeze_struct("13d20c29e7ce8917")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] From d767749f7962abd5cf269a0de5a18cc7cce46586 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 12:32:43 +0200 Subject: [PATCH 55/85] cargo clippy --- pallets/limit-orders/src/tests/mock.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index efec5ba251..6a514f39a3 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -89,13 +89,13 @@ pub enum SwapCall { thread_local! { /// Log of every `OrderSwapInterface` call made during a test. - pub static SWAP_LOG: RefCell> = RefCell::new(Vec::new()); + pub static SWAP_LOG: RefCell> = const { RefCell::new(Vec::new()) }; /// Fixed price returned by `current_alpha_price` (default 1.0). pub static MOCK_PRICE: RefCell = RefCell::new(U96F32::from_num(1u32)); /// Fixed alpha returned by `buy_alpha` (default 0 — tests override as needed). - pub static MOCK_BUY_ALPHA_RETURN: RefCell = RefCell::new(0u64); + pub static MOCK_BUY_ALPHA_RETURN: RefCell = const { RefCell::new(0u64) }; /// Fixed TAO returned by `sell_alpha` (default 0 — tests override as needed). - pub static MOCK_SELL_TAO_RETURN: RefCell = RefCell::new(0u64); + pub static MOCK_SELL_TAO_RETURN: RefCell = const { RefCell::new(0u64) }; /// In-memory staked alpha ledger: (coldkey, hotkey, netuid) → balance. /// `transfer_staked_alpha` debits/credits this map so tests can assert /// on residual balances after distribution. @@ -108,13 +108,13 @@ thread_local! { RefCell::new(HashMap::new()); /// When set to `true`, `transfer_tao` returns `Err(CannotTransfer)` so /// tests can exercise the `FeeTransferFailed` event path. - pub static FAIL_FEE_TRANSFER: RefCell = RefCell::new(false); + pub static FAIL_FEE_TRANSFER: RefCell = const { RefCell::new(false) }; /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. - pub static MOCK_SWAP_FAIL: RefCell = RefCell::new(false); + pub static MOCK_SWAP_FAIL: RefCell = const { RefCell::new(false) }; /// When `true`, swap calls enforce their `limit_price` argument against `MOCK_PRICE`: /// `buy_alpha` fails if `market_price > limit_price` (ceiling exceeded); /// `sell_alpha` fails if `market_price < limit_price` (floor not met). - pub static MOCK_ENFORCE_PRICE_LIMIT: RefCell = RefCell::new(false); + pub static MOCK_ENFORCE_PRICE_LIMIT: RefCell = const { RefCell::new(false) }; /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. pub static RATE_LIMITS: RefCell> = @@ -454,7 +454,7 @@ impl OrderSwapInterface for MockSwap { // ── MockTime ───────────────────────────────────────────────────────────────── thread_local! { - pub static MOCK_TIME_MS: RefCell = RefCell::new(1_000_000u64); + pub static MOCK_TIME_MS: RefCell = const { RefCell::new(1_000_000u64) }; } pub struct MockTime; From a26c508e52423e8c63679e58d79eac285b79cba6 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 12:34:14 +0200 Subject: [PATCH 56/85] cargo fmt --- pallets/limit-orders/src/lib.rs | 5 ++-- pallets/limit-orders/src/tests/extrinsics.rs | 30 ++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f427cf7df3..f5519467ab 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -16,8 +16,8 @@ use sp_runtime::{ traits::{ConstBool, Verify}, }; use substrate_fixed::types::U96F32; -use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_macros::freeze_struct; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; // ── Data structures ────────────────────────────────────────────────────────── @@ -645,7 +645,8 @@ pub mod pallet { (tao_after_fee.to_u64(), alpha_out.to_u64()) } else { // partial fill validations have passed, it is safe here to do this - let alpha_in = AlphaBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); + let alpha_in = + AlphaBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); // Sell the full alpha amount; fee is taken from the TAO output. let tao_out = T::SwapInterface::sell_alpha( diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 79fd928822..f1e360cbdf 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -2458,7 +2458,10 @@ fn execute_orders_partial_fill_sets_partially_filled_status() { bounded(vec![signed]), )); - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(400))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(400)) + ); }); } @@ -2486,7 +2489,10 @@ fn execute_orders_second_partial_fill_completes_order() { RuntimeOrigin::signed(charlie()), bounded(vec![signed_first.clone()]), )); - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(600))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(600)) + ); // Re-submit the same signed order payload with a different partial_fill amount. let mut signed_second = signed_first.clone(); @@ -2570,7 +2576,10 @@ fn execute_orders_partial_fill_exceeding_remaining_is_skipped() { RuntimeOrigin::signed(charlie()), bounded(vec![signed.clone()]), )); - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(700))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(700)) + ); // Try to fill 500 more, but only 300 remain → should be skipped. let mut over_fill = signed.clone(); @@ -2581,7 +2590,10 @@ fn execute_orders_partial_fill_exceeding_remaining_is_skipped() { )); // Status unchanged. - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(700))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(700)) + ); assert_event(Event::OrderSkipped { order_id: id, reason: Error::::IncorrectPartialFillAmount.into(), @@ -2620,7 +2632,10 @@ fn execute_batched_orders_partial_fill_sets_partially_filled_status() { bounded(vec![signed]), )); - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(400))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(400)) + ); }); } @@ -2650,7 +2665,10 @@ fn execute_batched_orders_second_partial_fill_completes_order() { netuid(), bounded(vec![signed_first.clone()]), )); - assert_eq!(Orders::::get(id), Some(OrderStatus::PartiallyFilled(600))); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(600)) + ); let mut signed_second = signed_first.clone(); signed_second.partial_fill = Some(400); From 8e284e2968ebceb498b598706cddf1d400a4579c Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 12:36:00 +0200 Subject: [PATCH 57/85] commit Cargo.lock --- pallets/admin-utils/Cargo.toml | 1 + pallets/limit-orders/Cargo.toml | 3 +++ pallets/swap/Cargo.toml | 1 + pallets/transaction-fee/Cargo.toml | 1 + primitives/swap-interface/Cargo.toml | 5 ++++- runtime/Cargo.toml | 1 + 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pallets/admin-utils/Cargo.toml b/pallets/admin-utils/Cargo.toml index a97ef6fabc..e1ff41d91a 100644 --- a/pallets/admin-utils/Cargo.toml +++ b/pallets/admin-utils/Cargo.toml @@ -90,6 +90,7 @@ runtime-benchmarks = [ "pallet-subtensor-swap/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] try-runtime = [ "frame-support/try-runtime", diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index cd032ce7b8..57dacdc879 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -31,11 +31,14 @@ workspace = true default = ["std"] std = [ "codec/std", + "frame-benchmarking?/std", "frame-support/std", "frame-system/std", "scale-info/std", "sp-core/std", "sp-io?/std", + "sp-keyring?/std", + "sp-keystore/std", "sp-runtime/std", "sp-std/std", "substrate-fixed/std", diff --git a/pallets/swap/Cargo.toml b/pallets/swap/Cargo.toml index c50d1d4f78..2b54449388 100644 --- a/pallets/swap/Cargo.toml +++ b/pallets/swap/Cargo.toml @@ -61,4 +61,5 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] diff --git a/pallets/transaction-fee/Cargo.toml b/pallets/transaction-fee/Cargo.toml index d5a5c2f418..36129db5e0 100644 --- a/pallets/transaction-fee/Cargo.toml +++ b/pallets/transaction-fee/Cargo.toml @@ -84,4 +84,5 @@ runtime-benchmarks = [ "pallet-subtensor/runtime-benchmarks", "pallet-subtensor-swap/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] diff --git a/primitives/swap-interface/Cargo.toml b/primitives/swap-interface/Cargo.toml index 06623a310b..5d4020edc2 100644 --- a/primitives/swap-interface/Cargo.toml +++ b/primitives/swap-interface/Cargo.toml @@ -16,7 +16,10 @@ workspace = true [features] default = ["std"] -runtime-benchmarks = [] +runtime-benchmarks = [ + "frame-support/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", +] std = [ "codec/std", "frame-support/std", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index dad5c56377..add0f615f8 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -222,6 +222,7 @@ std = [ "subtensor-transaction-fee/std", "serde_json/std", "sp-io/std", + "sp-keyring/std", "sp-tracing/std", "log/std", "safe-math/std", From e294f88076427854e120e6d672a6a55c47edae4e Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 13 Apr 2026 14:28:07 +0200 Subject: [PATCH 58/85] cargo fmt --- pallets/limit-orders/src/lib.rs | 10 ++++++++-- pallets/limit-orders/src/tests/auxiliary.rs | 1 + pallets/limit-orders/src/tests/extrinsics.rs | 1 + pallets/limit-orders/src/tests/mock.rs | 1 + primitives/swap-interface/src/lib.rs | 1 + runtime/tests/limit_orders.rs | 6 +++++- 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f5519467ab..3632766cc9 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -969,7 +969,10 @@ pub mod pallet { for e in buys.iter() { let share: u64 = if total_buy_net > 0 { - (total_alpha.saturating_mul(e.net as u128) / total_buy_net) as u64 + total_alpha + .saturating_mul(e.net as u128) + .checked_div(total_buy_net) + .unwrap_or(0) as u64 } else { 0 }; @@ -1027,7 +1030,10 @@ pub mod pallet { for e in sells.iter() { 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.saturating_mul(sell_tao_equiv) / total_sell_tao_equiv) as u64 + total_tao + .saturating_mul(sell_tao_equiv) + .checked_div(total_sell_tao_equiv) + .unwrap_or(0) as u64 } else { 0u64 }; diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 878ec960e6..6a9cde90c4 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -1,3 +1,4 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] //! Unit tests for the auxiliary helper functions in `pallet-limit-orders`. //! //! Extrinsics are NOT tested here. Each section focuses on one helper. diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index f1e360cbdf..31c17045a3 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1,3 +1,4 @@ +#![allow(clippy::indexing_slicing)] //! Integration tests for `pallet-limit-orders` extrinsics. //! //! Tests go through the full dispatch path: origin enforcement, storage changes, diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 6a514f39a3..07df0a3e5c 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] //! Minimal mock runtime for `pallet-limit-orders` unit tests. //! //! `AccountId` is `sp_runtime::AccountId32` so that `MultiSignature` works diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 33b5ab4917..75ddfac194 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -1,4 +1,5 @@ #![cfg_attr(not(feature = "std"), no_std)] +#![allow(clippy::too_many_arguments)] use core::ops::Neg; use frame_support::pallet_prelude::*; diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index bab45d59be..5a9d871e6a 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -1,4 +1,8 @@ -#![allow(clippy::unwrap_used)] +#![allow( + clippy::unwrap_used, + clippy::arithmetic_side_effects, + clippy::too_many_arguments +)] use codec::Encode; use frame_support::{BoundedVec, assert_noop, assert_ok}; From 383ab80a8f70096b8134801e0f5f0256fdf514a0 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 14 Apr 2026 10:20:12 +0200 Subject: [PATCH 59/85] add chain-id to avoid replay protection across networks --- pallets/limit-orders/src/benchmarking.rs | 2 + pallets/limit-orders/src/lib.rs | 24 ++++++--- pallets/limit-orders/src/tests/auxiliary.rs | 29 ++++++++++ pallets/limit-orders/src/tests/extrinsics.rs | 7 +++ pallets/limit-orders/src/tests/mock.rs | 5 +- runtime/src/lib.rs | 1 + runtime/tests/limit_orders.rs | 54 +++++++++++++++++++ .../test-execute-orders-limit-buy.ts | 6 ++- ts-tests/utils/limit-orders.ts | 10 ++++ 9 files changed, 128 insertions(+), 10 deletions(-) diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index ebfe422758..04fe734b67 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -84,6 +84,7 @@ fn make_benchmark_orders( fee_recipient, relayer: None, max_slippage: None, + chain_id: T::ChainId::get(), partial_fills_enabled: false, }); orders.push(sign_order::(public, &order)); @@ -115,6 +116,7 @@ mod benchmarks { fee_recipient: account.clone(), relayer: None, max_slippage: None, + chain_id: T::ChainId::get(), partial_fills_enabled: false, }); let signed = sign_order::(public, &order); diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 3632766cc9..f6a86c6480 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -59,7 +59,8 @@ impl OrderType { /// The canonical order payload that users sign off-chain. /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). -#[freeze_struct("e64b59c23fbce993")] +#[allow(clippy::multiple_bound_locations)] // bounds on AccountId required by FRAME derives +#[freeze_struct("bb268090054f462e")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -92,6 +93,9 @@ pub struct Order /// - Buy: effective price ceiling = `limit_price + limit_price * max_slippage` /// - Sell: effective price floor = `limit_price - limit_price * max_slippage` pub max_slippage: Option, + /// EVM-compatible chain ID that this order is bound to. + /// Prevents replay of testnet-signed orders on mainnet and vice versa. + pub chain_id: u64, /// Wether partial fills are enabled pub partial_fills_enabled: bool, } @@ -120,16 +124,10 @@ impl VersionedOrd /// The envelope the admin submits on-chain: the versioned order payload plus /// the user's signature over the SCALE-encoded `VersionedOrder`. /// -/// TODO: evaluate cross-chain replay protection. The signature covers only the -/// SCALE-encoded `VersionedOrder` with no chain-specific domain separator (genesis -/// hash, chain ID, or pallet prefix). A signed order is therefore valid on any chain -/// that shares the same runtime types (e.g. a testnet fork). Consider prepending -/// a domain tag to the signed payload or adding the genesis hash as an `Order` field. -/// /// Signature verification is performed against `order.inner().signer` (the AccountId) /// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants /// of `MultiSignature` are rejected at validation time. -#[freeze_struct("13d20c29e7ce8917")] +#[freeze_struct("9dd5a8ac812dc504")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -230,6 +228,10 @@ pub mod pallet { /// Weight information for the pallet's extrinsics. type WeightInfo: crate::weights::WeightInfo; + + /// EVM-compatible chain ID used to bind orders to a specific chain. + /// Wire to `pallet_evm_chain_id` in the runtime via `ConfigurableChainId`. + type ChainId: Get; } // ── Storage ─────────────────────────────────────────────────────────────── @@ -328,6 +330,8 @@ pub mod pallet { IncorrectPartialFillAmount, /// A relayer must be set on the order when using partial fills RelayerRequiredForPartialFill, + /// The order's chain_id does not match the current chain. + ChainIdMismatch, } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -522,6 +526,10 @@ pub mod pallet { ) -> DispatchResult { let order = signed_order.order.inner(); ensure!(!order.netuid.is_root(), Error::::RootNetUidNotAllowed); + ensure!( + order.chain_id == T::ChainId::get(), + Error::::ChainIdMismatch + ); ensure!( matches!(signed_order.signature, MultiSignature::Sr25519(_)) && signed_order diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 6a9cde90c4..29fb6705f1 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -453,6 +453,7 @@ fn validate_and_classify_stores_effective_swap_limit_for_sell() { fee_recipient: fee_recipient(), relayer: None, max_slippage: Some(Perbill::from_percent(1)), + chain_id: 945, partial_fills_enabled: false, }; let versioned = crate::VersionedOrder::V1(new_inner); @@ -1396,6 +1397,7 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); @@ -1520,6 +1522,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); @@ -1537,6 +1540,32 @@ fn is_order_valid_price_condition_not_met_returns_error() { }); } +#[test] +fn is_order_valid_wrong_chain_id_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let keyring = AccountKeyring::Alice; + // Build an order with a chain_id that doesn't match the mock config (945). + let order = crate::VersionedOrder::V1(crate::Order { + chain_id: 9999, + ..make_valid_signed_order().0.order.inner().clone() + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::ChainIdMismatch + ); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // compute_order_status // ───────────────────────────────────────────────────────────────────────────── diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 31c17045a3..9356c636de 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -49,6 +49,7 @@ fn cancel_order_signer_can_cancel() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let id = order_id(&order); @@ -80,6 +81,7 @@ fn cancel_order_non_signer_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); // Bob tries to cancel Alice's order. @@ -105,6 +107,7 @@ fn cancel_order_already_cancelled_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let id = order_id(&order); @@ -132,6 +135,7 @@ fn cancel_order_already_fulfilled_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let id = order_id(&order); @@ -159,6 +163,7 @@ fn cancel_order_unsigned_rejected() { fee_recipient: fee_recipient(), relayer: None, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); assert_noop!( @@ -1744,6 +1749,7 @@ fn make_signed_order_with_slippage( fee_recipient, relayer: None, max_slippage, + chain_id: 945, partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); @@ -2527,6 +2533,7 @@ fn execute_orders_partial_fill_without_relayer_skipped() { fee_recipient: fee_recipient(), relayer: None, // <-- no relayer max_slippage: None, + chain_id: 945, partial_fills_enabled: true, }; let versioned = VersionedOrder::V1(inner); diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 07df0a3e5c..514d9af1a5 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use codec::Encode; use frame_support::{ BoundedVec, PalletId, construct_runtime, derive_impl, parameter_types, - traits::{ConstU32, Everything}, + traits::{ConstU32, ConstU64, Everything}, }; use frame_system as system; use sp_core::{H256, Pair}; @@ -493,6 +493,7 @@ impl pallet_limit_orders::Config for Test { type PalletId = LimitOrdersPalletId; type PalletHotkey = PalletHotkeyAccount; type WeightInfo = (); + type ChainId = ConstU64<945>; } // ── Shared test helpers ─────────────────────────────────────────────────────── @@ -540,6 +541,7 @@ pub fn make_signed_order( fee_recipient, relayer, max_slippage: None, + chain_id: 945, partial_fills_enabled: false, }); let sig = keyring.pair().sign(&order.encode()); @@ -576,6 +578,7 @@ pub fn make_partial_fill_order( fee_recipient: fee_recipient(), relayer: Some(relayer), max_slippage: None, + chain_id: 945, partial_fills_enabled: true, }); let sig = keyring.pair().sign(&order.encode()); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 8e47c24a92..0390c1bb14 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1579,6 +1579,7 @@ impl pallet_limit_orders::Config for Runtime { type PalletId = LimitOrdersPalletId; type PalletHotkey = LimitOrdersPalletHotkey; type WeightInfo = pallet_limit_orders::weights::SubstrateWeight; + type ChainId = ConfigurableChainId; } fn contracts_schedule() -> pallet_contracts::Schedule { diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 5a9d871e6a..94030bce88 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -109,6 +109,8 @@ fn make_signed_order_inner( relayer: params.relayer, max_slippage: params.max_slippage, partial_fills_enabled: params.partial_fills_enabled, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, }); let sig = keyring.pair().sign(&order.encode()); SignedOrder { @@ -255,6 +257,8 @@ fn cancel_order_works() { relayer: None, max_slippage: None, partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, }); let id = order_id(&order); @@ -290,6 +294,8 @@ fn execute_orders_ed25519_signature_rejected() { relayer: None, max_slippage: None, partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, }); let id = order_id(&order); @@ -314,6 +320,54 @@ fn execute_orders_ed25519_signature_rejected() { }); } +/// An order carrying a wrong chain_id is silently skipped by `execute_orders` +/// (the per-order error path) and must not appear in the Orders storage map. +#[test] +fn execute_orders_chain_id_mismatch_rejected() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + setup_subnet(netuid); + + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); + fund_account(&alice_id); + + // Build an order with a chain_id that doesn't match the runtime (0). + let order = VersionedOrder::V1(Order { + signer: alice_id.clone(), + hotkey: bob_id, + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + chain_id: 9999, // wrong chain — should be rejected + }); + let id = order_id(&order); + let sig = alice.pair().sign(&order.encode()); + let signed = SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(alice_id), + make_order_batch(vec![signed]), + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + /// A LimitBuy order whose price condition is satisfied executes against the pool, /// marks the order as Fulfilled, and credits staked alpha to the buyer. #[test] diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts index 0121ef0e1d..a72cc1b2b4 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -6,6 +6,7 @@ import { devForceSetBalance, devGetAlphaStake, devAssociateHotKey, devEnableSubt import { buildSignedOrder, FAR_FUTURE, + fetchChainId, filterEvents, getOrderStatus, orderId, @@ -25,6 +26,7 @@ describeSuite({ let bob: KeyringPair; let bobHotKey: KeyringPair; let netuid: number; + let chainId: bigint; beforeAll(async () => { polkadotJs = context.polkadotJs(); @@ -35,7 +37,8 @@ describeSuite({ bobHotKey = generateKeyringPair("sr25519"); registerLimitOrderTypes(polkadotJs); - + chainId = await fetchChainId(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); @@ -75,6 +78,7 @@ describeSuite({ expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, + chainId, }); await devExecuteOrders(polkadotJs, context, alice, [signed]); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index a7f2531a06..e33a13f212 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -21,6 +21,7 @@ export interface OrderParams { expiry: bigint; feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% feeRecipient: string; + chainId?: bigint; // defaults to 42n (the dev node's EVM chain ID) relayer?: string | null; // Optional: if set, only this account may relay the order maxSlippage?: number | null; // Optional: Perbill (ppb). When set, effective swap limit = limit_price ± limit_price * maxSlippage / 1e9 partialFillsEnabled?: boolean; // Optional: if true, order can be partially filled (requires relayer) @@ -38,6 +39,7 @@ export interface Order { fee_recipient: string; relayer: string | null; max_slippage: number | null; + chain_id: bigint; partial_fills_enabled: boolean; } @@ -77,6 +79,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { fee_recipient: params.feeRecipient, relayer: params.relayer ?? null, max_slippage: params.maxSlippage ?? null, + chain_id: params.chainId ?? 42n, partial_fills_enabled: params.partialFillsEnabled ?? false, }; @@ -125,6 +128,7 @@ export function registerLimitOrderTypes(api: any): void { fee_recipient: "AccountId", relayer: "Option", max_slippage: "Option", + chain_id: "u64", partial_fills_enabled: "bool", }, LimitVersionedOrder: { @@ -237,6 +241,12 @@ export function filterEvents(events: any, method: string): any[] { return (events as any[]).filter((e: any) => e.event.method === method); } +/** Read the EVM chain ID from pallet_evm_chain_id storage. */ +export async function fetchChainId(api: any): Promise { + const result = await api.query.evmChainId.chainId(); + return BigInt(result.toString()); +} + /** * Compute the expected `net_amount` field of `GroupExecutionSummary` for a * mixed buy/sell batch, mirroring the pallet's netting logic. From b4985ab4f645ccb055e15425a82d4b94a35a2f06 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 14 Apr 2026 10:40:51 +0200 Subject: [PATCH 60/85] remove non-used func --- ts-tests/utils/limit-orders.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index e33a13f212..7c19f3e2d4 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -161,31 +161,6 @@ export async function getAlphaPrice(api: TypedApi, netuid: num return taoReserve / alphaIn; // integer approximation } -/** - * Sudo-set pool reserves directly so benchmarks and tests have a - * well-defined, non-zero starting price. - */ -export async function seedPoolReserves( - api: TypedApi, - polkadotJs: any, - netuid: number, - taoReserve: bigint, - alphaIn: bigint -): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); - - const setTao = polkadotJs.tx.sudo.sudo( - polkadotJs.tx.adminUtils.sudoSetSubnetTao(netuid, taoReserve) - ); - await setTao.signAndSend(alice, { nonce: -1 }); - - const setAlpha = polkadotJs.tx.sudo.sudo( - polkadotJs.tx.adminUtils.sudoSetSubnetAlphaIn(netuid, alphaIn) - ); - await setAlpha.signAndSend(alice, { nonce: -1 }); -} - /** Enable the subtoken for a subnet (required for swaps to work). */ export async function enableSubtoken( api: TypedApi, From c377bfdb4c36c575a49d521500f0fa41f5b674da Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 5 May 2026 16:05:01 +0200 Subject: [PATCH 61/85] fix tests and compilation --- pallets/subtensor/src/staking/order_swap.rs | 24 +++++++++--------- runtime/tests/limit_orders.rs | 27 ++++++++++++++------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 1d9baf06bf..4c22b54e43 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -32,10 +32,6 @@ impl OrderSwapInterface for Pallet { Error::::NotEnoughBalanceToStake ); } - // Debit TAO from the buyer before the pool swap so the pallet's - // intermediary account (and individual buyers in execute_orders) cannot - // stake more TAO than they actually hold. - let actual_tao = Self::remove_balance_from_coldkey_account(coldkey, tao_amount)?; // `limit_price` arrives in the same units as `current_alpha_price()` (a raw ratio // where 1.0 ≈ 1 unit/alpha). The AMM encodes its price_limit as `price × 10⁹` // (matching the rao-per-TAO precision convention), so we scale up here before @@ -44,7 +40,7 @@ impl OrderSwapInterface for Pallet { // an astronomically high ceiling that current prices never reach. let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); let alpha_out = - Self::stake_into_subnet(hotkey, coldkey, netuid, actual_tao, amm_limit, false, false)?; + Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, amm_limit, false, false)?; if validate { Self::set_stake_operation_limit(hotkey, coldkey, netuid); } @@ -88,12 +84,15 @@ impl OrderSwapInterface for Pallet { // the AMM expects price × 10⁹. For the no-floor case (limit_price = 0) the result // is 0, which the AMM treats as "no lower bound". let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); - let tao_out = - Self::unstake_from_subnet(hotkey, coldkey, netuid, alpha_amount, amm_limit, false)?; - // Credit TAO proceeds to the seller so the pallet's intermediary account - // (and individual sellers in execute_orders) have real balance to - // distribute or forward to the fee collector. - Self::add_balance_to_coldkey_account(coldkey, tao_out); + let tao_out = Self::unstake_from_subnet( + hotkey, + coldkey, + coldkey, + netuid, + alpha_amount, + amm_limit, + false, + )?; Ok(tao_out) } @@ -175,6 +174,7 @@ impl OrderSwapInterface for Pallet { #[cfg(feature = "runtime-benchmarks")] fn set_up_acc_for_benchmark(hotkey: &T::AccountId, coldkey: &T::AccountId) { Self::create_account_if_non_existent(coldkey, hotkey); - Self::add_balance_to_coldkey_account(coldkey, TaoBalance::from(1_000_000_000_000_u64)); + let credit = Self::mint_tao(TaoBalance::from(1_000_000_000_000_u64)); + let _ = Self::spend_tao(coldkey, credit, TaoBalance::from(1_000_000_000_000_u64)); } } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 94030bce88..d0d8934915 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -40,8 +40,18 @@ fn min_default_stake() -> TaoBalance { pallet_subtensor::DefaultMinStake::::get() } +fn add_balance_to_coldkey_account(coldkey: &AccountId, tao: TaoBalance) { + let credit = SubtensorModule::mint_tao(tao); + let _ = SubtensorModule::spend_tao(coldkey, credit, tao); +} + +fn seed_subnet_tao(netuid: NetUid, amount: TaoBalance) { + let subnet_account = SubtensorModule::get_subnet_account_id(netuid).unwrap(); + add_balance_to_coldkey_account(&subnet_account, amount); +} + fn fund_account(id: &AccountId) { - SubtensorModule::add_balance_to_coldkey_account(id, min_default_stake() * 10u64.into()); + add_balance_to_coldkey_account(id, min_default_stake() * 10u64.into()); } fn order_id(order: &VersionedOrder) -> H256 { @@ -70,6 +80,7 @@ fn setup_buyer_seller( netuid, initial_alpha, ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); SubtensorModule::create_account_if_non_existent(alice_id, charlie_id); SubtensorModule::create_account_if_non_existent(bob_id, dave_id); } @@ -163,6 +174,7 @@ fn setup_dynamic_subnet(netuid: NetUid) { // Equal reserves → price = tao_reserve / alpha_reserve = 1.0 SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); + seed_subnet_tao(netuid, TaoBalance::from(1_000_000_000_000_u64)); } /// Build a signed order with an explicit `max_slippage` value. @@ -448,6 +460,7 @@ fn take_profit_order_executes_and_unstakes_alpha() { netuid, initial_alpha, ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); // limit_price = 0 → current_price (1.0) ≥ 0 → condition always met. let signed = make_signed_order( @@ -511,6 +524,7 @@ fn stop_loss_order_executes_and_unstakes_alpha() { netuid, initial_alpha, ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); // limit_price = 1 → current_price (1.0) ≤ 1.0 → StopLoss condition always met. // Using 1 (not u64::MAX) because limit_price also acts as the minimum TAO output @@ -806,6 +820,7 @@ fn batched_fails_if_executing_without_hot_key_association() { netuid, initial_alpha, ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); let buy = make_signed_order( alice, @@ -1748,10 +1763,7 @@ fn execute_orders_partial_fill_then_complete() { let partial_amount = min_default_stake().to_u64() * 3u64; let remaining_amount = order_amount - partial_amount; - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - TaoBalance::from(order_amount * 2u64), - ); + add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); // Create the hotkey association Alice → Bob. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); @@ -1832,10 +1844,7 @@ fn execute_batched_orders_partial_fill_then_complete() { let partial_amount = min_default_stake().to_u64() * 3u64; let remaining_amount = order_amount - partial_amount; - SubtensorModule::add_balance_to_coldkey_account( - &alice_id, - TaoBalance::from(order_amount * 2u64), - ); + add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); // Create the hotkey association Alice → Bob. SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); From 64ec16a275870f50608db5f080c4f945f52fa125 Mon Sep 17 00:00:00 2001 From: girazoki Date: Fri, 8 May 2026 09:50:56 +0200 Subject: [PATCH 62/85] zepter and fmt --- chain-extensions/Cargo.toml | 3 ++- pallets/limit-orders/src/benchmarking.rs | 2 +- precompiles/Cargo.toml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/chain-extensions/Cargo.toml b/chain-extensions/Cargo.toml index ecc30878b5..74fa71e78a 100644 --- a/chain-extensions/Cargo.toml +++ b/chain-extensions/Cargo.toml @@ -84,5 +84,6 @@ runtime-benchmarks = [ "pallet-subtensor-proxy/runtime-benchmarks", "pallet-subtensor-utility/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", - "subtensor-runtime-common/runtime-benchmarks" + "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks" ] diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 04fe734b67..5ef6d50d9b 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -8,7 +8,7 @@ use crate::{NetUid, OrderType, Orders}; use frame_benchmarking::v2::*; use frame_system::RawOrigin; -use sp_core::H256; +use sp_core::{Get, H256}; use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::AccountIdConversion}; extern crate alloc; use crate::{Call, Config, Pallet}; diff --git a/precompiles/Cargo.toml b/precompiles/Cargo.toml index c896ecb731..dd5e20dfd0 100644 --- a/precompiles/Cargo.toml +++ b/precompiles/Cargo.toml @@ -99,6 +99,7 @@ runtime-benchmarks = [ "pallet-timestamp/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks" ] [dev-dependencies] From 89cacb08693052cad825aeee9fd5be7056c9740e Mon Sep 17 00:00:00 2001 From: girazoki Date: Fri, 8 May 2026 11:17:14 +0200 Subject: [PATCH 63/85] Let's register coldkey-hotkey on genesis and on_runtime_upgrade --- pallets/limit-orders/src/lib.rs | 63 ++++++++++++++++++-- pallets/limit-orders/src/tests/extrinsics.rs | 22 +++---- pallets/limit-orders/src/tests/mock.rs | 23 ++++++- pallets/subtensor/src/staking/order_swap.rs | 8 +++ primitives/swap-interface/src/lib.rs | 10 ++++ 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f6a86c6480..9c752e05e6 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -332,6 +332,47 @@ pub mod pallet { RelayerRequiredForPartialFill, /// The order's chain_id does not match the current chain. ChainIdMismatch, + /// The pallet hotkey has not been registered to the pallet account. + /// Call on_runtime_upgrade or wait for genesis to complete registration + /// before enabling the pallet. + PalletHotkeyNotRegistered, + } + + // ── Hooks ───────────────────────────────────────────────────────────────── + + // ── Genesis ─────────────────────────────────────────────────────────────── + + #[pallet::genesis_config] + #[derive(frame_support::DefaultNoBound)] + pub struct GenesisConfig { + #[serde(skip)] + pub _phantom: core::marker::PhantomData, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + let _ = T::SwapInterface::register_pallet_hotkey( + &Pallet::::pallet_account(), + &T::PalletHotkey::get(), + ); + } + } + + // ── Hooks ───────────────────────────────────────────────────────────────── + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_runtime_upgrade() -> Weight { + let pallet_acct = Self::pallet_account(); + let pallet_hotkey = T::PalletHotkey::get(); + if T::SwapInterface::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey) { + return T::DbWeight::get().reads(1); + } + let _ = T::SwapInterface::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + // 1 read (already-registered check) + 3 writes (Owner, OwnedHotkeys, StakingHotkeys) + T::DbWeight::get().reads_writes(1, 3) + } } // ── Extrinsics ──────────────────────────────────────────────────────────── @@ -445,6 +486,16 @@ pub mod pallet { pub fn set_pallet_status(origin: OriginFor, enabled: bool) -> DispatchResult { ensure_root(origin)?; + if enabled { + ensure!( + T::SwapInterface::pallet_hotkey_registered( + &Self::pallet_account(), + &T::PalletHotkey::get(), + ), + Error::::PalletHotkeyNotRegistered + ); + } + LimitOrdersEnabled::::set(enabled); Self::deposit_event(Event::LimitOrdersPalletStatusChanged { enabled }); @@ -477,7 +528,7 @@ pub mod pallet { } } Some(slippage) => { - let delta = slippage * limit_price; + let delta = slippage.mul_floor(limit_price); if is_buy { limit_price.saturating_add(delta) } else { @@ -636,7 +687,7 @@ pub mod pallet { // partial fill validations have passed, it is safe here to do this let tao_in = TaoBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); // Deduct fee from TAO input before swapping. - let fee_tao = TaoBalance::from(order.fee_rate * tao_in.to_u64()); + let fee_tao = TaoBalance::from(order.fee_rate.mul_floor(tao_in.to_u64())); let tao_after_fee = tao_in.saturating_sub(fee_tao); let alpha_out = T::SwapInterface::buy_alpha( @@ -667,7 +718,7 @@ pub mod pallet { )?; // Deduct fee from TAO output and forward to the order's fee recipient. - let fee_tao = TaoBalance::from(order.fee_rate * tao_out.to_u64()); + let fee_tao = TaoBalance::from(order.fee_rate.mul_floor(tao_out.to_u64())); Self::forward_fee(&order.signer, &order.fee_recipient, fee_tao); (alpha_in.to_u64(), tao_out.saturating_sub(fee_tao).to_u64()) }; @@ -703,7 +754,7 @@ pub mod pallet { let (valid_buys, valid_sells) = Self::validate_and_classify(netuid, &orders, now_ms, current_price, relayer)?; - let executed_count = (valid_buys.len() + valid_sells.len()) as u32; + let executed_count = valid_buys.len().saturating_add(valid_sells.len()) as u32; if executed_count == 0 { return Ok(()); } @@ -834,7 +885,7 @@ pub mod pallet { let amount_in = signed_order.partial_fill.unwrap_or(order.amount); let net = if order.order_type.is_buy() { // Buy: fee on TAO input — net is the amount that reaches the pool. - amount_in.saturating_sub(order.fee_rate * amount_in) + amount_in.saturating_sub(order.fee_rate.mul_floor(amount_in)) } else { // Sell: fee on TAO output — full alpha enters the pool; the fee is // deducted from the TAO payout later in `distribute_tao_pro_rata`. @@ -1045,7 +1096,7 @@ pub mod pallet { } else { 0u64 }; - let fee = e.fee_rate * gross_share; + let fee = e.fee_rate.mul_floor(gross_share); let net_share = gross_share.saturating_sub(fee); if fee > 0 { diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 9356c636de..e68e316c7b 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1236,8 +1236,8 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { // Pool returns 9 TAO (mocked) for that residual. // total_tao for sellers = 9 (pool) + 990 (buy passthrough) = 999. // Bob gross_share = 999 * 1_000/1_000 = 999. - // Sell fee = 1% of 999 = 9.99 → rounds to 10 TAO; Bob nets 989 TAO. - // fee_recipient total = buy_fee(10) + sell_fee(10) = 20 TAO. + // Sell fee = mul_floor(1%, 999) = floor(9.99) = 9; Bob nets 990 TAO. + // fee_recipient total = buy_fee(10) + sell_fee(9) = 19 TAO. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_sell_tao_return(9); @@ -1275,10 +1275,10 @@ fn execute_batched_orders_fees_charged_on_both_sides_when_matched_internally() { bounded(vec![alice_buy, bob_sell]), )); - // Both sides charged: fee_recipient gets buy fee (10) + sell fee (10) = 20. - assert_eq!(MockSwap::tao_balance(&fee_recipient()), 20); - // Bob receives 989 TAO after sell-side fee. - assert_eq!(MockSwap::tao_balance(&bob()), 989); + // Both sides charged: fee_recipient gets buy fee (10) + sell fee (9) = 19. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 19); + // Bob receives 990 TAO after sell-side fee (999 gross - 9 fee). + assert_eq!(MockSwap::tao_balance(&bob()), 990); }); } @@ -1518,11 +1518,11 @@ fn execute_batched_orders_fees_batched_for_shared_recipient() { /// pool returns 18 TAO for residual /// total TAO for sellers = 18 + 1_980 = 1_998 /// each seller gross_share = 1_998 * 1_000 / 2_000 = 999 -/// sell fee = 1% * 999 = 10 TAO each +/// sell fee = mul_floor(1%, 999) = floor(9.99) = 9 TAO each /// /// Expected: -/// ferdie receives 10 (Alice) + 10 (Bob) = 20 TAO (1 transfer) -/// fee_recipient() receives 10 (Charlie) + 10 (Eve) = 20 TAO (1 transfer) +/// ferdie receives 10 (Alice) + 10 (Bob) = 20 TAO (1 transfer) +/// fee_recipient() receives 9 (Charlie) + 9 (Eve) = 18 TAO (1 transfer) #[test] fn execute_batched_orders_four_orders_two_fee_recipients() { new_test_ext().execute_with(|| { @@ -1610,8 +1610,8 @@ fn execute_batched_orders_four_orders_two_fee_recipients() { .collect(); assert_eq!(fp_transfers.len(), 1, "single transfer to fee_recipient"); assert_eq!( - fp_transfers[0].2, 20, - "fee_recipient receives 20 TAO in sell fees" + fp_transfers[0].2, 18, + "fee_recipient receives 18 TAO in sell fees" ); }); } diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 514d9af1a5..9a9bf1b738 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -17,7 +17,7 @@ use sp_core::{H256, Pair}; use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{ AccountId32, BuildStorage, MultiSignature, - traits::{BlakeTwo256, IdentityLookup}, + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, }; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; @@ -120,6 +120,9 @@ thread_local! { /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. pub static RATE_LIMITS: RefCell> = RefCell::new(std::collections::HashSet::new()); + /// Registered (coldkey, hotkey) ownership pairs — mirrors `Owner` storage in subtensor. + pub static HOTKEY_REGISTRATIONS: RefCell> = + RefCell::new(std::collections::HashSet::new()); } pub struct MockSwap; @@ -145,6 +148,7 @@ impl MockSwap { ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); TAO_BALANCES.with(|b| b.borrow_mut().clear()); RATE_LIMITS.with(|r| r.borrow_mut().clear()); + HOTKEY_REGISTRATIONS.with(|r| r.borrow_mut().clear()); MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = false); } pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { @@ -399,6 +403,20 @@ impl OrderSwapInterface for MockSwap { ); } + fn register_pallet_hotkey( + coldkey: &AccountId, + hotkey: &AccountId, + ) -> frame_support::pallet_prelude::DispatchResult { + HOTKEY_REGISTRATIONS.with(|r| { + r.borrow_mut().insert((coldkey.clone(), hotkey.clone())); + }); + Ok(()) + } + + fn pallet_hotkey_registered(coldkey: &AccountId, hotkey: &AccountId) -> bool { + HOTKEY_REGISTRATIONS.with(|r| r.borrow().contains(&(coldkey.clone(), hotkey.clone()))) + } + fn transfer_staked_alpha( from_coldkey: &AccountId, from_hotkey: &AccountId, @@ -612,6 +630,9 @@ pub fn new_test_ext() -> sp_io::TestExternalities { ext.execute_with(|| { System::set_block_number(1); MockSwap::clear_log(); + // Simulate genesis_build: claim pallet hotkey ownership so set_pallet_status(true) succeeds. + let pallet_acct: AccountId = LimitOrdersPalletId::get().into_account_truncating(); + let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &PalletHotkeyAccount::get()); }); ext } diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 4c22b54e43..4cca50a441 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -160,6 +160,14 @@ impl OrderSwapInterface for Pallet { Ok(()) } + fn register_pallet_hotkey(coldkey: &T::AccountId, hotkey: &T::AccountId) -> DispatchResult { + Self::create_account_if_non_existent(coldkey, hotkey) + } + + fn pallet_hotkey_registered(coldkey: &T::AccountId, hotkey: &T::AccountId) -> bool { + Self::coldkey_owns_hotkey(coldkey, hotkey) + } + #[cfg(feature = "runtime-benchmarks")] fn set_up_netuid_for_benchmark(netuid: NetUid) { if !Self::if_subnet_exist(netuid) { diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 75ddfac194..3267e0f205 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -165,6 +165,16 @@ pub trait OrderSwapInterface { #[cfg(feature = "runtime-benchmarks")] fn set_up_netuid_for_benchmark(_netuid: NetUid) {} + /// Register `hotkey` as owned by `coldkey`. + /// + /// Called during `on_genesis` and `on_runtime_upgrade` to claim ownership of + /// the pallet's hotkey before any external actor can register it. Safe to call + /// multiple times — is a no-op if the hotkey account already exists. + fn register_pallet_hotkey(coldkey: &AccountId, hotkey: &AccountId) -> DispatchResult; + + /// Returns `true` if `coldkey` is the registered owner of `hotkey`. + fn pallet_hotkey_registered(coldkey: &AccountId, hotkey: &AccountId) -> bool; + /// Set up accounts for benchmark execution. /// /// Called once per order before the benchmarked extrinsic runs. Implementations From 9420e91595f301916e9f48210aad9bc8764d8d1f Mon Sep 17 00:00:00 2001 From: girazoki Date: Fri, 8 May 2026 11:49:36 +0200 Subject: [PATCH 64/85] fix ecotest --- eco-tests/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eco-tests/Cargo.toml b/eco-tests/Cargo.toml index f93c81386a..b00e2ced42 100644 --- a/eco-tests/Cargo.toml +++ b/eco-tests/Cargo.toml @@ -38,7 +38,7 @@ pallet-subtensor-proxy = { path = "../pallets/proxy", default-features = false, pallet-subtensor-utility = { path = "../pallets/utility", default-features = false, features = ["std"] } pallet-shield = { path = "../pallets/shield", default-features = false, features = ["std"] } subtensor-runtime-common = { path = "../common", default-features = false, features = ["std"] } -subtensor-swap-interface = { path = "../pallets/swap-interface", default-features = false, features = ["std"] } +subtensor-swap-interface = { path = "../primitives/swap-interface", default-features = false, features = ["std"] } share-pool = { path = "../primitives/share-pool", default-features = false, features = ["std"] } safe-math = { path = "../primitives/safe-math", default-features = false, features = ["std"] } log = { version = "0.4.21", default-features = false, features = ["std"] } From 28bd3c1a5a3f81442da2e1aabae6df4664b66c8f Mon Sep 17 00:00:00 2001 From: girazoki Date: Fri, 8 May 2026 11:51:48 +0200 Subject: [PATCH 65/85] fmt --- .../limit-orders/test-batched-all-buys.ts | 23 ++------ .../limit-orders/test-batched-all-sells.ts | 19 ++----- .../limit-orders/test-batched-fees.ts | 12 ++--- .../limit-orders/test-batched-hardfail.ts | 14 ++--- .../test-batched-mixed-buy-dominant.ts | 20 ++----- .../test-batched-mixed-sell-dominant.ts | 20 ++----- .../limit-orders/test-batched-partial-fill.ts | 19 ++----- .../limit-orders/test-cancel-order.ts | 4 +- .../limit-orders/test-execute-orders-fees.ts | 17 ++++-- .../test-execute-orders-limit-buy.ts | 40 ++++++-------- .../test-execute-orders-partial-fill.ts | 7 +-- .../test-execute-orders-sell-fees.ts | 23 +++++--- .../test-execute-orders-skip-conditions.ts | 32 +++-------- .../test-execute-orders-stop-loss.ts | 34 +++++------- .../test-execute-orders-take-profit.ts | 35 ++++++------ .../limit-orders/test-pallet-status.ts | 19 ++----- ts-tests/utils/dev-helpers.ts | 53 +++++-------------- ts-tests/utils/limit-orders.ts | 19 ++----- 18 files changed, 140 insertions(+), 270 deletions(-) diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts index 2f432ad66e..1d2bf9b4a8 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts @@ -10,12 +10,7 @@ import { devRegisterSubnet, devSudoSetLockReductionInterval, } from "../../../../utils/dev-helpers.js"; -import { - buildSignedOrder, - FAR_FUTURE, - filterEvents, - registerLimitOrderTypes, -} from "../../../../utils/limit-orders.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; // execute_batched_orders — all-buy batch. Own subnet, own file. @@ -56,12 +51,8 @@ describeSuite({ id: "T01", title: "all buyers receive alpha and GroupExecutionSummary is emitted", test: async () => { - const aliceStakeBefore = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); - const bobStakeBefore = await devGetAlphaStake( - polkadotJs, bobHotKey.address, bob.address, netuid - ); + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobStakeBefore = await devGetAlphaStake(polkadotJs, bobHotKey.address, bob.address, netuid); const orderAlice = buildSignedOrder(polkadotJs, { signer: alice, @@ -97,14 +88,10 @@ describeSuite({ expect(filterEvents(events, "OrderExecuted").length).toBe(2); expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); - const aliceStakeAfter = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); - const bobStakeAfter = await devGetAlphaStake( - polkadotJs, bobHotKey.address, bob.address, netuid - ); + const bobStakeAfter = await devGetAlphaStake(polkadotJs, bobHotKey.address, bob.address, netuid); expect(bobStakeAfter).toBeGreaterThan(bobStakeBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts index 4aea5cddce..a149f7219f 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts @@ -10,12 +10,7 @@ import { devRegisterSubnet, devSudoSetLockReductionInterval, } from "../../../../utils/dev-helpers.js"; -import { - buildSignedOrder, - FAR_FUTURE, - filterEvents, - registerLimitOrderTypes, -} from "../../../../utils/limit-orders.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; describeSuite({ id: "DEV_SUB_LIMIT_ORDERS_BATCH_SELL", @@ -59,11 +54,9 @@ describeSuite({ title: "all sellers receive TAO and GroupExecutionSummary is emitted", test: async () => { const aliceTaoBefore = ( - await polkadotJs.query.system.account(alice.address) as any - ).data.free.toBigInt(); - const bobTaoBefore = ( - await polkadotJs.query.system.account(bob.address) as any + (await polkadotJs.query.system.account(alice.address)) as any ).data.free.toBigInt(); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); const orderAlice = buildSignedOrder(polkadotJs, { signer: alice, @@ -100,11 +93,9 @@ describeSuite({ expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); const aliceTaoAfter = ( - await polkadotJs.query.system.account(alice.address) as any - ).data.free.toBigInt(); - const bobTaoAfter = ( - await polkadotJs.query.system.account(bob.address) as any + (await polkadotJs.query.system.account(alice.address)) as any ).data.free.toBigInt(); + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); expect(aliceTaoAfter).toBeGreaterThan(aliceTaoBefore); expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts index 4bb26b8ba0..48be9461c4 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts @@ -60,10 +60,10 @@ describeSuite({ const feeRecipient2 = generateKeyringPair(); const r1Before = ( - await polkadotJs.query.system.account(feeRecipient1.address) as any + (await polkadotJs.query.system.account(feeRecipient1.address)) as any ).data.free.toBigInt(); const r2Before = ( - await polkadotJs.query.system.account(feeRecipient2.address) as any + (await polkadotJs.query.system.account(feeRecipient2.address)) as any ).data.free.toBigInt(); const orderAlice = buildSignedOrder(polkadotJs, { @@ -100,10 +100,10 @@ describeSuite({ expect(filterEvents(events, "OrderExecuted").length).toBe(2); const r1After = ( - await polkadotJs.query.system.account(feeRecipient1.address) as any + (await polkadotJs.query.system.account(feeRecipient1.address)) as any ).data.free.toBigInt(); const r2After = ( - await polkadotJs.query.system.account(feeRecipient2.address) as any + (await polkadotJs.query.system.account(feeRecipient2.address)) as any ).data.free.toBigInt(); // Both recipients must have received some fee @@ -119,7 +119,7 @@ describeSuite({ const sharedRecipient = generateKeyringPair(); const recipientBefore = ( - await polkadotJs.query.system.account(sharedRecipient.address) as any + (await polkadotJs.query.system.account(sharedRecipient.address)) as any ).data.free.toBigInt(); const orderAlice = buildSignedOrder(polkadotJs, { @@ -156,7 +156,7 @@ describeSuite({ expect(filterEvents(events, "OrderExecuted").length).toBe(2); const recipientAfter = ( - await polkadotJs.query.system.account(sharedRecipient.address) as any + (await polkadotJs.query.system.account(sharedRecipient.address)) as any ).data.free.toBigInt(); // Should have received fees from both orders in a single transfer diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts index 61aeadd3b5..f36f845efe 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts @@ -9,11 +9,7 @@ import { devRegisterSubnet, devSudoSetLockReductionInterval, } from "../../../../utils/dev-helpers.js"; -import { - buildSignedOrder, - FAR_FUTURE, - registerLimitOrderTypes, -} from "../../../../utils/limit-orders.js"; +import { buildSignedOrder, FAR_FUTURE, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; // Hard-fail cases for execute_batched_orders — no pool interaction needed, // all batches fail before reaching the swap step. Single subnet is fine. @@ -80,9 +76,7 @@ describeSuite({ const { result: [attempt], } = await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(netuid, [valid, tampered]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [valid, tampered]).signAsync(alice), ]); // The whole extrinsic should fail — hard-fail on invalid signature @@ -151,9 +145,7 @@ describeSuite({ const { result: [attempt], } = await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(0, [order]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(0, [order]).signAsync(alice), ]); expect(attempt.successful).toEqual(false); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts index 21d41bbf50..05a7930080 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -61,12 +61,8 @@ describeSuite({ id: "T01", title: "buy side dominates: both orders fulfilled, net buy hits pool", test: async () => { - const aliceStakeBefore = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); - const bobTaoBefore = ( - await polkadotJs.query.system.account(bob.address) as any - ).data.free.toBigInt(); + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); // Alice buys 200 TAO worth, Bob sells 10 alpha (~10 TAO equiv) // → net buy ~190 TAO hits the pool @@ -95,9 +91,7 @@ describeSuite({ }); // Read price before the swap — pallet uses pre-swap price for netting - const expectedNetAmount = await computeNetAmount( - polkadotJs, netuid, tao(200), tao(10), "Buy" - ); + const expectedNetAmount = await computeNetAmount(polkadotJs, netuid, tao(200), tao(10), "Buy"); await context.createBlock([ await polkadotJs.tx.limitOrders @@ -119,14 +113,10 @@ describeSuite({ // actual_out > 0 proves the pool returned alpha expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); - const aliceStakeAfter = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); - const bobTaoAfter = ( - await polkadotJs.query.system.account(bob.address) as any - ).data.free.toBigInt(); + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts index 559e61abe3..94ababe7af 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -59,12 +59,8 @@ describeSuite({ id: "T01", title: "sell side dominates: both orders fulfilled, net sell hits pool", test: async () => { - const aliceStakeBefore = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); - const bobTaoBefore = ( - await polkadotJs.query.system.account(bob.address) as any - ).data.free.toBigInt(); + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); // Alice buys 10 TAO, Bob sells 200 alpha (~200 TAO equiv) // → net sell ~190 alpha hits the pool @@ -93,9 +89,7 @@ describeSuite({ }); // Read price before the swap — pallet uses pre-swap price for netting - const expectedNetAmount = await computeNetAmount( - polkadotJs, netuid, tao(10), tao(200), "Sell" - ); + const expectedNetAmount = await computeNetAmount(polkadotJs, netuid, tao(10), tao(200), "Sell"); await context.createBlock([ await polkadotJs.tx.limitOrders @@ -117,14 +111,10 @@ describeSuite({ // actual_out > 0 proves the pool returned TAO expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); - const aliceStakeAfter = await devGetAlphaStake( - polkadotJs, aliceHotKey.address, alice.address, netuid - ); + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); - const bobTaoAfter = ( - await polkadotJs.query.system.account(bob.address) as any - ).data.free.toBigInt(); + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts index 109629d022..7506e8433d 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts @@ -76,9 +76,7 @@ describeSuite({ // Submit first partial fill (50 out of 100 TAO) via execute_batched_orders. const firstEnvelope = { ...signed, partial_fill: firstFill }; await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(netuid, [firstEnvelope]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [firstEnvelope]).signAsync(alice), ]); const events = await polkadotJs.query.system.events(); @@ -90,12 +88,7 @@ describeSuite({ expect(filled).toBe(BigInt(firstFill)); // Alpha stake should have increased from the partial buy. - const stakeAfter = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(stakeAfter).toBeGreaterThan(0n); }, }); @@ -127,9 +120,7 @@ describeSuite({ // First fill: 100 / 200. const firstEnvelope = { ...signed, partial_fill: firstFill }; await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(netuid, [firstEnvelope]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [firstEnvelope]).signAsync(alice), ]); expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); @@ -138,9 +129,7 @@ describeSuite({ // Second fill: the remaining 100 — completes the order. const secondEnvelope = { ...signed, partial_fill: secondFill }; await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(netuid, [secondEnvelope]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [secondEnvelope]).signAsync(alice), ]); const events = await polkadotJs.query.system.events(); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts index c7c5591833..11c72eaf12 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts @@ -131,9 +131,7 @@ describeSuite({ }); // Cancel first - await context.createBlock([ - await polkadotJs.tx.limitOrders.cancelOrder(signed.order).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.cancelOrder(signed.order).signAsync(alice)]); // Now try to execute await devExecuteOrders(polkadotJs, context, alice, [signed]); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts index b93b4879c9..2945ecb535 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts @@ -2,7 +2,14 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { generateKeyringPair, tao } from "../../../../utils"; -import { devForceSetBalance, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, @@ -34,14 +41,14 @@ describeSuite({ bob = context.keyring.bob; feeRecipient = generateKeyringPair(); registerLimitOrderTypes(polkadotJs); - + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); - + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); - + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); - + // ENable subtoken await devEnableSubtoken(polkadotJs, context, alice, netuid); // associate hotkeys diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts index a72cc1b2b4..c1d43601ae 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -2,7 +2,15 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, @@ -35,17 +43,17 @@ describeSuite({ aliceHotKey = generateKeyringPair("sr25519"); bob = context.keyring.bob; bobHotKey = generateKeyringPair("sr25519"); - + registerLimitOrderTypes(polkadotJs); chainId = await fetchChainId(polkadotJs); await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); - + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); - + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); - + // ENable subtoken await devEnableSubtoken(polkadotJs, context, alice, netuid); // associate hotkeys @@ -57,15 +65,8 @@ describeSuite({ id: "T01", title: "LimitBuy executes when price condition is met", test: async () => { - const stakeBefore = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); - const taoBalanceBefore = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); // TODO: why here far future? const signed = buildSignedOrder(polkadotJs, { @@ -92,18 +93,11 @@ describeSuite({ expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); // Alpha stake should have increased - const stakeAfter = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(stakeAfter).toBeGreaterThan(stakeBefore); // TAO balance should have decreased - const taoBalanceAfter = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); expect(taoBalanceAfter).toBeLessThan(taoBalanceBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts index 6080326899..bf4bfb6c28 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts @@ -90,12 +90,7 @@ describeSuite({ expect(filled).toBe(BigInt(firstFill)); // Alpha stake should have increased (partial buy occurred). - const stakeAfter = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(stakeAfter).toBeGreaterThan(0n); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts index e2d0b5cf84..10b9ec22cd 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -2,7 +2,16 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { generateKeyringPair, tao } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, @@ -31,20 +40,20 @@ describeSuite({ aliceHotKey = generateKeyringPair(); bob = context.keyring.bob; feeRecipient = generateKeyringPair(); - registerLimitOrderTypes(polkadotJs); - + registerLimitOrderTypes(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); - + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); - + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); - + // ENable subtoken await devEnableSubtoken(polkadotJs, context, alice, netuid); // associate hotkeys await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); - + // Give Alice some alpha stake to sell await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts index 59636a5086..0d5de67b24 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -64,9 +64,7 @@ describeSuite({ feeRecipient: alice.address, }); - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -91,9 +89,7 @@ describeSuite({ feeRecipient: alice.address, }); - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -117,9 +113,7 @@ describeSuite({ feeRecipient: alice.address, }); - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -149,9 +143,7 @@ describeSuite({ order: { V1: { ...signed.order.V1, amount: tao(999) } }, }; - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([tampered]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([tampered]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -174,9 +166,7 @@ describeSuite({ feeRecipient: alice.address, }); - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -202,14 +192,10 @@ describeSuite({ }); // First execution — should succeed. - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); // Second attempt — order already Fulfilled, must be skipped. - await context.createBlock([ - await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderSkipped").length).toBe(1); @@ -258,9 +244,7 @@ describeSuite({ }); await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeOrders([valid, expired, priceNotMet]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeOrders([valid, expired, priceNotMet]).signAsync(alice), ]); const events = await polkadotJs.query.system.events(); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts index 7b4746f102..a580bd0a0d 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts @@ -2,7 +2,16 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, @@ -57,16 +66,8 @@ describeSuite({ id: "T01", title: "StopLoss executes when price <= limit_price", test: async () => { - const stakeBefore = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); - const taoBalanceBefore = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); - + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); // TODO: discover why limit price of 100 is enough here (I think its close to 1 the ratio?) const signed = buildSignedOrder(polkadotJs, { @@ -90,17 +91,10 @@ describeSuite({ const id = orderId(polkadotJs, signed.order); expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); - const stakeAfter = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(stakeAfter).toBeLessThan(stakeBefore); - const taoBalanceAfter = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts index 044450e31a..67654fc4c9 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -2,7 +2,16 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao, generateKeyringPair } from "../../../../utils"; -import { devForceSetBalance, devAddStake, devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, devSudoSetLockReductionInterval, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; import { buildSignedOrder, FAR_FUTURE, @@ -56,15 +65,8 @@ describeSuite({ id: "T01", title: "TakeProfit executes when price >= limit_price", test: async () => { - const stakeBefore = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); - const taoBalanceBefore = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); // limit_price = 1 RAO — current price (~1 TAO/alpha) is always >= 1 const signed = buildSignedOrder(polkadotJs, { @@ -80,7 +82,7 @@ describeSuite({ }); await devExecuteOrders(polkadotJs, context, alice, [signed]); - + const events = await polkadotJs.query.system.events(); expect(filterEvents(events, "OrderExecuted").length).toBe(1); expect(filterEvents(events, "OrderSkipped").length).toBe(0); @@ -89,18 +91,11 @@ describeSuite({ expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); // Alpha stake should have decreased - const stakeAfter = await devGetAlphaStake( - polkadotJs, - aliceHotKey.address, - alice.address, - netuid - ); + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); expect(stakeAfter).toBeLessThan(stakeBefore); // TAO balance should have increased - const taoBalanceAfter = ( - await polkadotJs.query.system.account(alice.address) - ).data.free.toBigInt(); + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); }, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts index 68db98027b..64423152ee 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts @@ -3,12 +3,7 @@ import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; import { tao } from "../../../../utils"; import { devForceSetBalance } from "../../../../utils/dev-helpers.js"; -import { - buildSignedOrder, - FAR_FUTURE, - filterEvents, - registerLimitOrderTypes, -} from "../../../../utils/limit-orders.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; describeSuite({ id: "DEV_SUB_LIMIT_ORDERS_STATUS", @@ -30,9 +25,7 @@ describeSuite({ title: "root can disable the pallet", test: async () => { await context.createBlock([ - await polkadotJs.tx.sudo - .sudo(polkadotJs.tx.limitOrders.setPalletStatus(false)) - .signAsync(alice), + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.limitOrders.setPalletStatus(false)).signAsync(alice), ]); const events = await polkadotJs.query.system.events(); @@ -88,9 +81,7 @@ describeSuite({ const { result: [attempt], } = await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeBatchedOrders(1, [signed]) - .signAsync(alice), + await polkadotJs.tx.limitOrders.executeBatchedOrders(1, [signed]).signAsync(alice), ]); expect(attempt.successful).toEqual(false); @@ -103,9 +94,7 @@ describeSuite({ title: "root can re-enable the pallet", test: async () => { await context.createBlock([ - await polkadotJs.tx.sudo - .sudo(polkadotJs.tx.limitOrders.setPalletStatus(true)) - .signAsync(alice), + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.limitOrders.setPalletStatus(true)).signAsync(alice), ]); const events = await polkadotJs.query.system.events(); diff --git a/ts-tests/utils/dev-helpers.ts b/ts-tests/utils/dev-helpers.ts index 03a838fe4d..470b98be8e 100644 --- a/ts-tests/utils/dev-helpers.ts +++ b/ts-tests/utils/dev-helpers.ts @@ -28,9 +28,7 @@ export async function devAddStake( amount: bigint ): Promise { await context.createBlock([ - await polkadotJs.tx.subtensorModule - .addStake(hotkey, netuid, amount) - .signAsync(coldkey), + await polkadotJs.tx.subtensorModule.addStake(hotkey, netuid, amount).signAsync(coldkey), ]); } @@ -38,13 +36,9 @@ export async function devAssociateHotKey( polkadotJs: ApiPromise, context: any, coldkey: KeyringPair, - hotkey: string, + hotkey: string ): Promise { - await context.createBlock([ - await polkadotJs.tx.subtensorModule - .tryAssociateHotkey(hotkey) - .signAsync(coldkey), - ]); + await context.createBlock([await polkadotJs.tx.subtensorModule.tryAssociateHotkey(hotkey).signAsync(coldkey)]); } export async function devGetAlphaStake( @@ -53,11 +47,7 @@ export async function devGetAlphaStake( coldkey: string, netuid: number ): Promise { - const value = (await polkadotJs.query.subtensorModule.alphaV2( - hotkey, - coldkey, - netuid - )); + const value = await polkadotJs.query.subtensorModule.alphaV2(hotkey, coldkey, netuid); const mantissa = value.mantissa; const exponent = value.exponent; @@ -73,17 +63,13 @@ export async function devGetAlphaStake( return result; } - export async function devSudoSetLockReductionInterval( polkadotJs: ApiPromise, context: any, alice: KeyringPair, - interval: number): Promise { - await context.createBlock([ - await polkadotJs.tx.adminUtils - .sudoSetLockReductionInterval(interval) - .signAsync(alice), - ]); + interval: number +): Promise { + await context.createBlock([await polkadotJs.tx.adminUtils.sudoSetLockReductionInterval(interval).signAsync(alice)]); } export async function devRegisterSubnet( @@ -92,15 +78,9 @@ export async function devRegisterSubnet( alice: KeyringPair, hotkey: KeyringPair ): Promise { - await context.createBlock([ - await polkadotJs.tx.subtensorModule - .registerNetwork(hotkey.address) - .signAsync(alice), - ]); + await context.createBlock([await polkadotJs.tx.subtensorModule.registerNetwork(hotkey.address).signAsync(alice)]); const events = (await polkadotJs.query.system.events()) as any; - const netuid = (events as any[]) - .filter((e: any) => e.event.method === "NetworkAdded")[0] - .event.data[0].toNumber(); + const netuid = (events as any[]).filter((e: any) => e.event.method === "NetworkAdded")[0].event.data[0].toNumber(); return netuid; } @@ -111,19 +91,14 @@ export async function devEnableSubtoken( netuid: number ): Promise { await context.createBlock([ - await polkadotJs.tx.sudo - .sudo(polkadotJs.tx.adminUtils.sudoSetSubtokenEnabled(netuid, true)) - .signAsync(alice), + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.adminUtils.sudoSetSubtokenEnabled(netuid, true)).signAsync(alice), ]); } export async function devExecuteOrders( polkadotJs: ApiPromise, context: any, alice: KeyringPair, - orders: SignedOrder[]): Promise { - await context.createBlock([ - await polkadotJs.tx.limitOrders - .executeOrders(orders) - .signAsync(alice), - ]); -} \ No newline at end of file + orders: SignedOrder[] +): Promise { + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders(orders).signAsync(alice)]); +} diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 7c19f3e2d4..6389a4b180 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -162,10 +162,7 @@ export async function getAlphaPrice(api: TypedApi, netuid: num } /** Enable the subtoken for a subnet (required for swaps to work). */ -export async function enableSubtoken( - api: TypedApi, - netuid: number -): Promise { +export async function enableSubtoken(api: TypedApi, netuid: number): Promise { const keyring = new Keyring({ type: "sr25519" }); const alice = keyring.addFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ @@ -177,10 +174,7 @@ export async function enableSubtoken( } /** Sudo-enable or disable the limit-orders pallet. */ -export async function setPalletStatus( - api: TypedApi, - enabled: boolean -): Promise { +export async function setPalletStatus(api: TypedApi, enabled: boolean): Promise { const keyring = new Keyring({ type: "sr25519" }); const alice = keyring.addFromUri("//Alice"); const tx = api.tx.Sudo.sudo({ @@ -200,10 +194,7 @@ export async function getOrderStatus( } /** Read the on-chain OrderStatus and return the PartiallyFilled amount, or null. */ -export async function getPartiallyFilledAmount( - polkadotJs: any, - id: `0x${string}` -): Promise { +export async function getPartiallyFilledAmount(polkadotJs: any, id: `0x${string}`): Promise { const result = await polkadotJs.query.limitOrders.orders(id); if (result.isNone) return null; const status = result.unwrap(); @@ -241,7 +232,7 @@ export async function computeNetAmount( netuid: number, buySideTao: bigint, sellSideAlpha: bigint, - side: "Buy" | "Sell", + side: "Buy" | "Sell" ): Promise { // price_scaled = floor(price_actual * 1e9) [RAO per alpha * 1e9 / 1e9 = dimensionless] const priceRaw = await polkadotJs.call.swapRuntimeApi.currentAlphaPrice(netuid); @@ -273,4 +264,4 @@ export async function executeBatchedOrders( orders, }); await waitForTransactionWithRetry(api, tx, alice, "execute_batched_orders"); -} \ No newline at end of file +} From a7a7fba7e1e297f6386a63de2142f428f930f80b Mon Sep 17 00:00:00 2001 From: girazoki Date: Fri, 8 May 2026 12:41:17 +0200 Subject: [PATCH 66/85] clippy --- pallets/limit-orders/src/lib.rs | 5 ++++ pallets/limit-orders/src/tests/extrinsics.rs | 1 + pallets/limit-orders/src/tests/mock.rs | 2 ++ pallets/subtensor/src/staking/order_swap.rs | 2 +- runtime/tests/limit_orders.rs | 26 ++++++++++---------- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 9c752e05e6..30e1ef8691 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -182,6 +182,7 @@ pub(crate) struct OrderEntry { // ── Pallet ─────────────────────────────────────────────────────────────────── #[frame_support::pallet] +#[allow(clippy::expect_used)] pub mod pallet { use super::*; use crate::weights::WeightInfo as _; @@ -956,6 +957,7 @@ pub mod pallet { /// /// `price_limit` encodes the tightest slippage constraint across all dominant-side /// orders: a ceiling for buy-dominant swaps, a floor for sell-dominant swaps. + #[allow(clippy::too_many_arguments)] fn net_pool_swap( total_buy_net: u128, total_sell_net: u128, @@ -1010,6 +1012,7 @@ pub mod pallet { /// /// - Buy-dominant: total alpha = pool output + sell-side alpha (passed through). /// - Sell-dominant: total alpha = buy-side TAO converted at `current_price`. + #[allow(clippy::too_many_arguments)] pub(crate) fn distribute_alpha_pro_rata( buys: &BoundedVec, T::MaxOrdersPerBatch>, actual_out: u128, @@ -1068,6 +1071,7 @@ pub mod pallet { /// /// Fee on TAO output: `ppb(share)` is withheld from each seller's payout and /// left in the pallet account. Returns the total sell-side fee TAO accumulated. + #[allow(clippy::too_many_arguments)] pub(crate) fn distribute_tao_pro_rata( sells: &BoundedVec, T::MaxOrdersPerBatch>, actual_out: u128, @@ -1185,6 +1189,7 @@ pub mod pallet { /// Convert a TAO amount to alpha at `price` (TAO/alpha). /// Returns 0 when `price` is zero. + #[allow(clippy::arithmetic_side_effects)] fn tao_to_alpha(tao: u128, price: U96F32) -> u128 { if price == U96F32::from_num(0u32) { return 0u128; diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index e68e316c7b..44d61463cb 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -1725,6 +1725,7 @@ fn root_disables_and_extrinsics_are_filtered() { // ───────────────────────────────────────────────────────────────────────────── /// Build a signed order with a specific `max_slippage` value. +#[allow(clippy::too_many_arguments)] fn make_signed_order_with_slippage( keyring: AccountKeyring, hotkey: AccountId, diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 9a9bf1b738..14a34ff2c8 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -534,6 +534,7 @@ pub fn netuid() -> NetUid { pub const FAR_FUTURE: u64 = u64::MAX; +#[allow(clippy::too_many_arguments)] pub fn make_signed_order( keyring: AccountKeyring, hotkey: AccountId, @@ -572,6 +573,7 @@ pub fn make_signed_order( /// Build a signed order with partial fills enabled and a relayer set. /// `partial_fill` is the fill amount to inject into the `SignedOrder` envelope. +#[allow(clippy::too_many_arguments)] pub fn make_partial_fill_order( keyring: AccountKeyring, hotkey: AccountId, diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 4cca50a441..a64a1c791c 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -181,7 +181,7 @@ impl OrderSwapInterface for Pallet { #[cfg(feature = "runtime-benchmarks")] fn set_up_acc_for_benchmark(hotkey: &T::AccountId, coldkey: &T::AccountId) { - Self::create_account_if_non_existent(coldkey, hotkey); + let _ = Self::create_account_if_non_existent(coldkey, hotkey); let credit = Self::mint_tao(TaoBalance::from(1_000_000_000_000_u64)); let _ = Self::spend_tao(coldkey, credit, TaoBalance::from(1_000_000_000_000_u64)); } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index d0d8934915..3f0a203b17 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -81,8 +81,8 @@ fn setup_buyer_seller( initial_alpha, ); seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); - SubtensorModule::create_account_if_non_existent(alice_id, charlie_id); - SubtensorModule::create_account_if_non_existent(bob_id, dave_id); + let _ = SubtensorModule::create_account_if_non_existent(alice_id, charlie_id); + let _ = SubtensorModule::create_account_if_non_existent(bob_id, dave_id); } struct OrderParams { @@ -397,7 +397,7 @@ fn limit_buy_order_executes_and_stakes_alpha() { fund_account(&alice_id); // Create the hot-key association. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // limit_price = u64::MAX → current_price (1.0) ≤ MAX → condition always met. let signed = make_signed_order( @@ -450,7 +450,7 @@ fn take_profit_order_executes_and_unstakes_alpha() { setup_subnet(netuid); // Create the hot-key association. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Seed Alice with staked alpha through Bob so she has something to sell. let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); @@ -514,7 +514,7 @@ fn stop_loss_order_executes_and_unstakes_alpha() { setup_subnet(netuid); // Create the hot-key association. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Seed Alice with staked alpha through Bob so she has something to sell. let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); @@ -1106,7 +1106,7 @@ fn execute_orders_valid_and_invalid_mixed() { fund_account(&alice_id); // Create the hotkey association for Alice so buy_alpha succeeds. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Timestamp at 100_000 ms — Bob's order (expiry 50_000) will be expired. pallet_timestamp::Now::::put(100_000u64); @@ -1219,7 +1219,7 @@ fn execute_orders_skips_order_below_minimum_stake() { fund_account(&alice_id); // Create the hotkey association so that is not the reason for skipping. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // amount = 1 is well below min_default_stake(), triggering AmountTooLow. let signed = make_signed_order( @@ -1265,7 +1265,7 @@ fn execute_orders_skips_order_for_nonexistent_subnet() { fund_account(&alice_id); // Create the hotkey association so that is not the reason for skipping. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); let signed = make_signed_order( alice, @@ -1324,7 +1324,7 @@ fn execute_orders_fee_forwarded_to_recipient() { fund_account(&alice_id); // Create the hotkey association Alice → Bob. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Charlie starts with zero balance — verify before submitting. assert_eq!( @@ -1620,7 +1620,7 @@ fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { setup_dynamic_subnet(netuid); // Alice needs staked alpha so the sell can debit her position. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &bob_id, @@ -1689,7 +1689,7 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { setup_dynamic_subnet(netuid); - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &bob_id, @@ -1766,7 +1766,7 @@ fn execute_orders_partial_fill_then_complete() { add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); // Create the hotkey association Alice → Bob. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Build the base signed order — this exact payload is re-used for both submissions. let first_signed = make_partial_fill_order( @@ -1847,7 +1847,7 @@ fn execute_batched_orders_partial_fill_then_complete() { add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); // Create the hotkey association Alice → Bob. - SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); // Build the base signed order — identical payload reused in both batches. let first_signed = make_partial_fill_order( From 9eddf6675f44ca422890ef931c65616bcbc60824 Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 12 May 2026 15:38:20 +0200 Subject: [PATCH 67/85] do not require ownership of coldkey and hotkey when buying or selling alpha! that is not needed --- pallets/subtensor/src/staking/order_swap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index a64a1c791c..da7a633cb3 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -20,7 +20,7 @@ impl OrderSwapInterface for Pallet { Self::ensure_subtoken_enabled(netuid)?; if validate { ensure!( - Self::coldkey_owns_hotkey(coldkey, hotkey), + Self::hotkey_account_exists(hotkey), Error::::HotKeyAccountNotExists ); ensure!( @@ -60,7 +60,7 @@ impl OrderSwapInterface for Pallet { Self::ensure_subtoken_enabled(netuid)?; if validate { ensure!( - Self::coldkey_owns_hotkey(coldkey, hotkey), + Self::hotkey_account_exists(hotkey), Error::::HotKeyAccountNotExists ); From d4d136e922b081b112697e8861b5fb59fbc020aa Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 12 May 2026 18:48:42 +0200 Subject: [PATCH 68/85] limit price should come as amm price --- pallets/limit-orders/src/lib.rs | 21 +- pallets/limit-orders/src/tests/auxiliary.rs | 41 ++-- pallets/limit-orders/src/tests/extrinsics.rs | 225 ++++++++++++------ pallets/limit-orders/src/tests/mock.rs | 12 +- pallets/subtensor/src/staking/order_swap.rs | 19 +- runtime/tests/limit_orders.rs | 26 +- .../limit-orders/test-batched-all-sells.ts | 4 +- .../test-batched-mixed-buy-dominant.ts | 2 +- .../test-batched-mixed-sell-dominant.ts | 2 +- .../test-execute-orders-sell-fees.ts | 2 +- .../test-execute-orders-stop-loss.ts | 7 +- .../test-execute-orders-take-profit.ts | 5 +- 12 files changed, 229 insertions(+), 137 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 30e1ef8691..c018902efb 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -60,7 +60,7 @@ impl OrderType { /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). #[allow(clippy::multiple_bound_locations)] // bounds on AccountId required by FRAME derives -#[freeze_struct("bb268090054f462e")] +#[freeze_struct("b5e575cbffa6c1d6")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -76,9 +76,11 @@ pub struct Order pub order_type: OrderType, /// Input amount: TAO (raw) for Buy, alpha (raw) for Sell. pub amount: u64, - /// Price threshold in TAO/alpha (raw units, same scale as - /// `OrderSwapInterface::current_alpha_price`). + /// Price threshold in ×10⁹ scale (same as the `current_alpha_price` RPC endpoint). + /// A value of `1_000_000_000` represents a price of 1.0 TAO/alpha. + /// Sub-unity prices (e.g. 0.5 TAO/alpha) are expressed as `500_000_000`. /// Buy: maximum acceptable price. Sell: minimum acceptable price. + /// `u64::MAX` means no ceiling (buy at any price); `0` means no floor (sell at any price). pub limit_price: u64, /// Unix timestamp in milliseconds after which this order must not be executed. pub expiry: u64, @@ -599,12 +601,17 @@ pub mod pallet { Error::::OrderCancelled ); ensure!(now_ms <= order.expiry, Error::::OrderExpired); + // Scale current_price to ×10⁹ to match the limit_price field, which is + // expressed in the same ×10⁹ scale as the `current_alpha_price` RPC endpoint. + // This allows sub-unity prices (e.g. 0.5 TAO/alpha = 500_000_000) to be + // represented and compared correctly. + let scaled_price = current_price + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::(); ensure!( match order.order_type { - OrderType::TakeProfit => - current_price >= U96F32::saturating_from_num(order.limit_price), - OrderType::StopLoss | OrderType::LimitBuy => - current_price <= U96F32::saturating_from_num(order.limit_price), + OrderType::TakeProfit => scaled_price >= order.limit_price, + OrderType::StopLoss | OrderType::LimitBuy => scaled_price <= order.limit_price, }, Error::::PriceConditionNotMet ); diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 29fb6705f1..913c863458 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -87,9 +87,9 @@ fn validate_and_classify_separates_buys_and_sells() { bob(), netuid(), OrderType::LimitBuy, - 1_000u64, // amount in TAO - 2_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha (price=1 < 2 ✓) - 2_000_000u64, // expiry ms + 1_000u64, // amount in TAO + 2_000_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha in ×10⁹ scale (scaled=1_000_000_000 ≤ 2_000_000_000 ✓) + 2_000_000u64, // expiry ms Perbill::zero(), fee_recipient(), None, @@ -99,8 +99,8 @@ fn validate_and_classify_separates_buys_and_sells() { alice(), netuid(), OrderType::TakeProfit, - 500u64, // amount in alpha - 1u64, // limit_price: sell if price >= 1 TAO/alpha (price=1 >= 1 ✓) + 500u64, // amount in alpha + 1_000_000_000u64, // limit_price: sell if price >= 1 TAO/alpha in ×10⁹ scale (scaled=1_000_000_000 >= 1_000_000_000 ✓) 2_000_000u64, Perbill::zero(), fee_recipient(), @@ -148,7 +148,7 @@ fn validate_and_classify_fails_for_wrong_netuid() { NetUid::from(99u16), // different netuid OrderType::LimitBuy, 1_000u64, - 2_000_000u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale 2_000_000u64, Perbill::zero(), fee_recipient(), @@ -182,7 +182,7 @@ fn validate_and_classify_fails_for_expired_order() { netuid(), OrderType::LimitBuy, 1_000u64, - 2_000_000u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale 2_000_000u64, // expiry already past Perbill::zero(), fee_recipient(), @@ -206,7 +206,7 @@ fn validate_and_classify_fails_for_expired_order() { #[test] fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { new_test_ext().execute_with(|| { - // Price = 3.0 TAO/alpha, buyer's limit = 2.0 → price > limit → hard failure. + // Price = 3.0 TAO/alpha, scaled = 3_000_000_000, buyer's limit = 2_000_000_000 (2.0 in ×10⁹) → scaled > limit → hard failure. MockTime::set(1_000_000); let order = make_signed_order( AccountKeyring::Alice, @@ -214,7 +214,7 @@ fn validate_and_classify_fails_for_price_condition_not_met_for_buy() { netuid(), OrderType::LimitBuy, 1_000u64, - 2u64, // limit_price = 2 TAO/alpha + 2_000_000_000u64, // 2.0 in ×10⁹ scale 2_000_000u64, Perbill::zero(), fee_recipient(), @@ -246,7 +246,7 @@ fn validate_and_classify_fails_for_already_processed_order() { netuid(), OrderType::LimitBuy, 1_000u64, - 2_000_000u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale 2_000_000u64, Perbill::zero(), fee_recipient(), @@ -393,14 +393,15 @@ fn validate_and_classify_stores_effective_swap_limit_for_buy() { MockTime::set(1_000_000); MockSwap::set_price(1.0); - // 1% slippage on limit_price=1000 → ceiling = 1010. + // 1% slippage on limit_price=2_000_000_000 (2.0 in ×10⁹) → ceiling = 2_020_000_000. + // price=1.0, scaled=1_000_000_000 <= 2_000_000_000 ✓. let order = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::LimitBuy, 500u64, - 1_000u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale 2_000_000u64, Perbill::zero(), fee_recipient(), @@ -431,7 +432,7 @@ fn validate_and_classify_stores_effective_swap_limit_for_buy() { ) .expect("should succeed"); - assert_eq!(buys[0].effective_swap_limit, 1_010); + assert_eq!(buys[0].effective_swap_limit, 2_020_000_000); }); } @@ -439,15 +440,15 @@ fn validate_and_classify_stores_effective_swap_limit_for_buy() { fn validate_and_classify_stores_effective_swap_limit_for_sell() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); - // Price must be >= limit_price for TakeProfit to trigger. - // limit_price=1000, 1% slippage → floor = 990. + // Price must be >= limit_price (in ×10⁹ scale) for TakeProfit to trigger. + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → floor = 990_000_000. let new_inner = crate::Order { signer: AccountKeyring::Alice.to_account_id(), hotkey: bob(), netuid: netuid(), order_type: OrderType::TakeProfit, amount: 500u64, - limit_price: 1_000u64, + limit_price: 1_000_000_000u64, // 1.0 in ×10⁹ scale expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), @@ -469,12 +470,12 @@ fn validate_and_classify_stores_effective_swap_limit_for_sell() { netuid(), &orders, 1_000_000u64, - U96F32::from_num(2_000u32), // current_price=2000 >= limit_price=1000 ✓ + U96F32::from_num(2u32), // current_price=2.0, scaled=2_000_000_000 >= limit_price=1_000_000_000 ✓ bob(), ) .expect("should succeed"); - assert_eq!(sells[0].effective_swap_limit, 990); + assert_eq!(sells[0].effective_swap_limit, 990_000_000); }); } @@ -1507,7 +1508,7 @@ fn is_order_valid_expired_order_returns_error() { fn is_order_valid_price_condition_not_met_returns_error() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); - // Price 5.0 > limit_price 2 → LimitBuy condition (price ≤ limit) not met. + // Price 5.0, scaled = 5_000_000_000 > limit_price 2_000_000_000 (2.0 in ×10⁹) → LimitBuy condition (scaled ≤ limit) not met. MockSwap::set_price(5.0); let keyring = AccountKeyring::Alice; let order = crate::VersionedOrder::V1(crate::Order { @@ -1516,7 +1517,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { netuid: netuid(), order_type: OrderType::LimitBuy, amount: 1_000, - limit_price: 2, + limit_price: 2_000_000_000, // 2.0 in ×10⁹ scale expiry: u64::MAX, fee_rate: Perbill::zero(), fee_recipient: fee_recipient(), diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 44d61463cb..215a859e28 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -219,14 +219,14 @@ fn execute_orders_sell_order_fulfilled() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(2.0); - // Price = 2.0 ≥ limit = 1 → condition met. + // Price = 2.0, scaled = 2_000_000_000 ≥ limit = 1_000_000_000 → condition met. let signed = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::TakeProfit, 500, - 1, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -256,14 +256,14 @@ fn execute_orders_stop_loss_order_fulfilled() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(0.5); - // Price = 0.5 ≤ limit = 1.0 → condition met. + // Price = 0.5, scaled = 500_000_000 ≤ limit = 1_000_000_000 → condition met. let signed = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::StopLoss, 500, - 1, // raw limit_price = 1 TAO/alpha + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -292,14 +292,14 @@ fn execute_orders_stop_loss_order_fulfilled() { fn execute_orders_stop_loss_price_not_met_skipped() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); - MockSwap::set_price(2.0); // price 2.0 > limit 1.0 → stop loss condition not met + MockSwap::set_price(2.0); // price 2.0, scaled=2_000_000_000 > limit 1_000_000_000 → stop loss condition not met let signed = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::StopLoss, 500, - 1, // raw limit_price = 1 TAO/alpha + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -357,14 +357,86 @@ fn execute_orders_expired_order_skipped() { fn execute_orders_price_not_met_skipped() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); - MockSwap::set_price(5.0); // price 5.0 > limit 2 → buy condition not met + MockSwap::set_price(5.0); // price 5.0, scaled=5_000_000_000 > limit 2_000_000_000 → buy condition not met let signed = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::LimitBuy, 1_000, - 2, + 2_000_000_000, // 2.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); + }); +} + +// Regression tests: with the ×10⁹ scale fix, sub-unity prices can be meaningfully +// expressed as limit_price values. A price of 0.5 TAO/alpha is represented as +// 500_000_000 in ×10⁹ scale, enabling fine-grained TakeProfit thresholds below 1.0. +#[test] +fn take_profit_sub_unity_price_executes_when_limit_met() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Market price = 0.5 TAO/alpha → scaled = 500_000_000. + MockSwap::set_price(0.5); + + // limit_price = 400_000_000 (0.4 in ×10⁹ scale). + // TakeProfit condition: scaled_price (500_000_000) >= limit_price (400_000_000) ✓ + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 400_000_000, // 0.4 in ×10⁹ scale — below current price of 0.5 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Executes: 500_000_000 >= 400_000_000 → condition met. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn take_profit_sub_unity_price_skipped_when_limit_not_met() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Market price = 0.5 TAO/alpha → scaled = 500_000_000. + MockSwap::set_price(0.5); + + // limit_price = 600_000_000 (0.6 in ×10⁹ scale). + // TakeProfit condition: scaled_price (500_000_000) >= limit_price (600_000_000) → FALSE. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 600_000_000, // 0.6 in ×10⁹ scale — above current price of 0.5 FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -377,6 +449,7 @@ fn execute_orders_price_not_met_skipped() { bounded(vec![signed]) )); + // Skipped: 500_000_000 >= 600_000_000 is false. assert!(Orders::::get(id).is_none()); assert_event(Event::OrderSkipped { order_id: id, @@ -836,16 +909,16 @@ fn execute_batched_orders_price_condition_not_met_fails_entire_batch() { // Price condition not met is a hard-fail in execute_batched_orders — // unlike execute_orders where it silently skips the order. MockTime::set(1_000_000); - MockSwap::set_price(100.0); // current price = 100 + MockSwap::set_price(100.0); // current price = 100, scaled = 100_000_000_000 - // LimitBuy requires current_price <= limit_price; with limit_price=1 this fails. + // LimitBuy requires scaled_price <= limit_price; with limit_price=1_000_000_000 (1.0) this fails. let order = make_signed_order( AccountKeyring::Alice, bob(), netuid(), OrderType::LimitBuy, 1_000, - 1, // limit_price = 1, far below current price of 100 + 1_000_000_000, // 1.0 in ×10⁹ scale, far below scaled price of 100_000_000_000 FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -1802,7 +1875,7 @@ fn execute_orders_sell_no_slippage_passes_zero_to_pool() { netuid(), OrderType::TakeProfit, 500, - 1, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=2.0 (scaled=2_000_000_000) >= 1_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -1824,14 +1897,14 @@ fn execute_orders_buy_one_percent_slippage_passes_ceiling_to_pool() { MockTime::set(1_000_000); MockSwap::set_price(1.0); - // limit_price=1000, 1% slippage → ceiling = 1010. + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → ceiling = 1_010_000_000. let signed = make_signed_order_with_slippage( AccountKeyring::Alice, bob(), netuid(), OrderType::LimitBuy, 1_000, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=1.0 (scaled=1_000_000_000) <= 1_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -1843,7 +1916,7 @@ fn execute_orders_buy_one_percent_slippage_passes_ceiling_to_pool() { bounded(vec![signed]) )); - assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010]); + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); }); } @@ -1854,14 +1927,14 @@ fn execute_orders_sell_one_percent_slippage_passes_floor_to_pool() { // Price must be >= limit_price for TakeProfit to trigger. MockSwap::set_price(2_000.0); - // limit_price=1000, 1% slippage → floor = 990. + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → floor = 990_000_000. let signed = make_signed_order_with_slippage( AccountKeyring::Alice, bob(), netuid(), OrderType::TakeProfit, 500, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=2000.0 (scaled=2T) >= 1_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -1873,7 +1946,7 @@ fn execute_orders_sell_one_percent_slippage_passes_floor_to_pool() { bounded(vec![signed]) )); - assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); }); } @@ -1885,10 +1958,11 @@ fn execute_orders_sell_one_percent_slippage_passes_floor_to_pool() { fn execute_batched_orders_buy_dominant_uses_min_ceiling() { new_test_ext().execute_with(|| { // 3 buy orders with different slippage constraints. - // Alice: limit=1000, 2% → ceiling=1020 - // Bob: limit=1000, 1% → ceiling=1010 ← tightest - // Charlie (as signer, not relayer): limit=1000, 3% → ceiling=1030 - // Expected pool price_limit = min(1020, 1010, 1030) = 1010. + // Alice: limit=1_000_000_000, 2% → ceiling=1_020_000_000 + // Bob: limit=1_000_000_000, 1% → ceiling=1_010_000_000 ← tightest + // Charlie (as signer, not relayer): limit=1_000_000_000, 3% → ceiling=1_030_000_000 + // Expected pool price_limit = min(1_020_000_000, 1_010_000_000, 1_030_000_000) = 1_010_000_000. + // price=1.0, scaled=1_000_000_000 <= 1_000_000_000 ✓ for all LimitBuy orders. MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_buy_alpha_return(500); @@ -1902,11 +1976,11 @@ fn execute_batched_orders_buy_dominant_uses_min_ceiling() { netuid(), OrderType::LimitBuy, 600, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(2)), // ceiling = 1020 + Some(Perbill::from_percent(2)), // ceiling = 1_020_000_000 ); let bob_order = make_signed_order_with_slippage( AccountKeyring::Bob, @@ -1914,11 +1988,11 @@ fn execute_batched_orders_buy_dominant_uses_min_ceiling() { netuid(), OrderType::LimitBuy, 200, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(1)), // ceiling = 1010 ← tightest + Some(Perbill::from_percent(1)), // ceiling = 1_010_000_000 ← tightest ); let dave_order = make_signed_order_with_slippage( AccountKeyring::Dave, @@ -1926,11 +2000,11 @@ fn execute_batched_orders_buy_dominant_uses_min_ceiling() { netuid(), OrderType::LimitBuy, 200, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(3)), // ceiling = 1030 + Some(Perbill::from_percent(3)), // ceiling = 1_030_000_000 ); assert_ok!(LimitOrders::execute_batched_orders( @@ -1939,8 +2013,8 @@ fn execute_batched_orders_buy_dominant_uses_min_ceiling() { bounded(vec![alice_order, bob_order, dave_order]), )); - // Net pool swap must have been called with the tightest ceiling = 1010. - assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010]); + // Net pool swap must have been called with the tightest ceiling = 1_010_000_000. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); }); } @@ -1948,11 +2022,12 @@ fn execute_batched_orders_buy_dominant_uses_min_ceiling() { fn execute_batched_orders_sell_dominant_uses_max_floor() { new_test_ext().execute_with(|| { // 3 sell orders with different slippage constraints. - // Alice: limit=1000, 3% → floor=970 - // Bob: limit=1000, 1% → floor=990 ← tightest (highest floor) - // Dave: limit=1000, 2% → floor=980 - // Expected pool price_limit = max(970, 990, 980) = 990. - // Price must be >= limit_price=1000 for TakeProfit to trigger. + // Alice: limit=1_000_000_000, 3% → floor=970_000_000 + // Bob: limit=1_000_000_000, 1% → floor=990_000_000 ← tightest (highest floor) + // Dave: limit=1_000_000_000, 2% → floor=980_000_000 + // Expected pool price_limit = max(970_000_000, 990_000_000, 980_000_000) = 990_000_000. + // Price must be >= limit_price=1_000_000_000 (1.0 in ×10⁹) for TakeProfit to trigger. + // price=2000.0, scaled=2_000_000_000_000 >= 1_000_000_000 ✓. MockTime::set(1_000_000); MockSwap::set_price(2_000.0); MockSwap::set_sell_tao_return(500); @@ -1966,11 +2041,11 @@ fn execute_batched_orders_sell_dominant_uses_max_floor() { netuid(), OrderType::TakeProfit, 600, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(3)), // floor = 970 + Some(Perbill::from_percent(3)), // floor = 970_000_000 ); let bob_order = make_signed_order_with_slippage( AccountKeyring::Bob, @@ -1978,11 +2053,11 @@ fn execute_batched_orders_sell_dominant_uses_max_floor() { netuid(), OrderType::TakeProfit, 200, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(1)), // floor = 990 ← tightest + Some(Perbill::from_percent(1)), // floor = 990_000_000 ← tightest ); let dave_order = make_signed_order_with_slippage( AccountKeyring::Dave, @@ -1990,11 +2065,11 @@ fn execute_batched_orders_sell_dominant_uses_max_floor() { netuid(), OrderType::TakeProfit, 200, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(2)), // floor = 980 + Some(Perbill::from_percent(2)), // floor = 980_000_000 ); assert_ok!(LimitOrders::execute_batched_orders( @@ -2003,8 +2078,8 @@ fn execute_batched_orders_sell_dominant_uses_max_floor() { bounded(vec![alice_order, bob_order, dave_order]), )); - // Net pool swap must have been called with the tightest floor = 990. - assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + // Net pool swap must have been called with the tightest floor = 990_000_000. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); }); } @@ -2052,14 +2127,16 @@ fn execute_batched_orders_no_slippage_uses_unconstrained_limits() { #[test] fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { new_test_ext().execute_with(|| { - // Price = 2000 — above all TakeProfit limits (≥1000 ✓) and below StopLoss limit (≤5000 ✓). + // Price = 2000 — scaled = 2_000_000_000_000. + // TakeProfit triggers when scaled_price >= limit_price (2T >= 1_000_000_000 ✓). + // StopLoss triggers when scaled_price <= limit_price (2T <= 5_000_000_000_000 ✓). MockTime::set(1_000_000); MockSwap::set_price(2_000.0); MockSwap::set_sell_tao_return(500); - // Alice TakeProfit: limit=1000, 3% → floor=970. - // Bob TakeProfit: limit=1000, 1% → floor=990. ← tightest - // Dave StopLoss: limit=5000, None → floor=0. + // Alice TakeProfit: limit=1_000_000_000 (1.0), 3% → floor=970_000_000. + // Bob TakeProfit: limit=1_000_000_000 (1.0), 1% → floor=990_000_000. ← tightest + // Dave StopLoss: limit=5_000_000_000_000 (5000.0), None → floor=0. MockSwap::set_alpha_balance(alice(), dave(), netuid(), 600); MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); @@ -2070,7 +2147,7 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { netuid(), OrderType::TakeProfit, 600, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2082,7 +2159,7 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { netuid(), OrderType::TakeProfit, 200, - 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2094,7 +2171,7 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { netuid(), OrderType::StopLoss, 200, - 5_000, + 5_000_000_000_000, // 5000.0 in ×10⁹ scale; scaled_price 2T <= 5T ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2116,8 +2193,8 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); - // Pool called once with the tightest TakeProfit floor (990), not 0 from StopLoss. - assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990]); + // Pool called once with the tightest TakeProfit floor (990_000_000), not 0 from StopLoss. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); }); } @@ -2125,18 +2202,19 @@ fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { /// /// The offset StopLoss is settled internally at spot price; it does not contribute /// to the pool's price ceiling (which comes only from the dominant buy side). -/// pool_price_limit = min(buy_ceilings) = 101. +/// pool_price_limit = min(buy_ceilings) = 1_010_000_000. #[test] fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { new_test_ext().execute_with(|| { - // Price = 1. LimitBuy triggers (1 ≤ 100 ✓). StopLoss triggers (1 ≤ 5 ✓). + // Price = 1.0, scaled = 1_000_000_000. + // LimitBuy triggers (scaled <= limit ✓). StopLoss triggers (scaled <= limit ✓). MockTime::set(1_000_000); MockSwap::set_price(1.0); MockSwap::set_buy_alpha_return(900); - // Alice LimitBuy: limit=100, 2% → ceiling=102. - // Bob LimitBuy: limit=100, 1% → ceiling=101. ← tightest - // Dave StopLoss: limit=5, None → floor=0 (offset side, not used for pool limit). + // Alice LimitBuy: limit=1_000_000_000 (1.0), 2% → ceiling=1_020_000_000. + // Bob LimitBuy: limit=1_000_000_000 (1.0), 1% → ceiling=1_010_000_000. ← tightest + // Dave StopLoss: limit=2_000_000_000 (2.0), None → floor=0 (offset side, not used for pool limit). MockSwap::set_tao_balance(alice(), 600); MockSwap::set_tao_balance(bob(), 400); MockSwap::set_alpha_balance(dave(), alice(), netuid(), 100); @@ -2147,7 +2225,7 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { netuid(), OrderType::LimitBuy, 600, - 100, + 1_000_000_000, // 1.0 in ×10⁹ scale; scaled=1_000_000_000 <= 1_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2159,7 +2237,7 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { netuid(), OrderType::LimitBuy, 400, - 100, + 1_000_000_000, // 1.0 in ×10⁹ scale; scaled=1_000_000_000 <= 1_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2171,7 +2249,7 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { netuid(), OrderType::StopLoss, 100, - 5, + 2_000_000_000, // 2.0 in ×10⁹ scale; scaled=1_000_000_000 <= 2_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), @@ -2193,8 +2271,8 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); - // Pool buy called with min(102, 101) = 101. StopLoss's floor (0) is ignored on buy side. - assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![101]); + // Pool buy called with min(1_020_000_000, 1_010_000_000) = 1_010_000_000. StopLoss's floor (0) is ignored on buy side. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); }); } @@ -2207,8 +2285,8 @@ fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { #[test] fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { new_test_ext().execute_with(|| { - // StopLoss: limit=100, triggers at price=50 (50 ≤ 100 ✓). - // 1% slippage → floor=99. Market is at 50 → pool cannot deliver ≥99. + // StopLoss: limit=100_000_000_000 (100.0 in ×10⁹), triggers at price=50 (scaled=50_000_000_000 ≤ 100_000_000_000 ✓). + // 1% slippage → floor=99_000_000_000. Market is at 50 → pool cannot deliver ≥99_000_000_000. MockTime::set(1_000_000); MockSwap::set_price(50.0); MockSwap::set_sell_tao_return(100); // non-zero so SwapReturnedZero is not the cause @@ -2221,11 +2299,11 @@ fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { netuid(), OrderType::StopLoss, 200, - 100, + 100_000_000_000, // 100.0 in ×10⁹ scale; scaled=50_000_000_000 <= 100_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects + Some(Perbill::from_percent(1)), // floor=99_000_000_000, but market=50 → pool rejects ); assert_noop!( @@ -2244,9 +2322,10 @@ fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { /// /// Note: `DispatchError::Other` has `#[codec(skip)]` on its string field, so the reason /// string is lost when stored in the event log. We verify the skip via storage absence -/// and by asserting the floor (99) was actually passed to the pool — which is what caused -/// the rejection. The `execute_batched_orders` variant below uses `assert_noop!` (checks -/// the return value directly, no storage round-trip) and can verify the string. +/// and by asserting the floor (99_000_000_000 = 100_000_000_000 - 1%) was actually passed +/// to the pool — which is what caused the rejection. The `execute_batched_orders` variant +/// below uses `assert_noop!` (checks the return value directly, no storage round-trip) and +/// can verify the string. #[test] fn execute_orders_stoploss_narrow_slippage_skips_order() { new_test_ext().execute_with(|| { @@ -2261,11 +2340,11 @@ fn execute_orders_stoploss_narrow_slippage_skips_order() { netuid(), OrderType::StopLoss, 200, - 100, + 100_000_000_000, // 100.0 in ×10⁹ scale; scaled=50_000_000_000 <= 100_000_000_000 ✓ FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(Perbill::from_percent(1)), // floor=99, but market=50 → pool rejects + Some(Perbill::from_percent(1)), // floor=99_000_000_000, but market=50 → pool rejects ); let id = order_id(&stoploss.order); @@ -2287,9 +2366,9 @@ fn execute_orders_stoploss_narrow_slippage_skips_order() { "expected OrderSkipped event for this order" ); - // The sell was attempted with the correct floor (99 = 100 - 1%). + // The sell was attempted with the correct floor (99_000_000_000 = 100_000_000_000 - 1%). // This is the value that exceeded the market price and caused the rejection. - assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![99]); + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![99_000_000_000]); }); } diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 14a34ff2c8..06e7952655 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -278,7 +278,11 @@ impl OrderSwapInterface for MockSwap { }) }); if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) { - let price = MOCK_PRICE.with(|p| p.borrow().to_num::()); + let price = MOCK_PRICE.with(|p| { + p.borrow() + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::() + }); if price > limit_price.to_u64() { return Err(frame_support::pallet_prelude::DispatchError::Other( "price limit exceeded", @@ -328,7 +332,11 @@ impl OrderSwapInterface for MockSwap { }); // Only enforce if a non-zero floor was requested (0 means no constraint). if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) && limit_price.to_u64() > 0 { - let price = MOCK_PRICE.with(|p| p.borrow().to_num::()); + let price = MOCK_PRICE.with(|p| { + p.borrow() + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::() + }); if price < limit_price.to_u64() { return Err(frame_support::pallet_prelude::DispatchError::Other( "price limit exceeded", diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index da7a633cb3..25327f47a3 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -32,13 +32,10 @@ impl OrderSwapInterface for Pallet { Error::::NotEnoughBalanceToStake ); } - // `limit_price` arrives in the same units as `current_alpha_price()` (a raw ratio - // where 1.0 ≈ 1 unit/alpha). The AMM encodes its price_limit as `price × 10⁹` - // (matching the rao-per-TAO precision convention), so we scale up here before - // handing off to `stake_into_subnet`. saturating_mul handles the no-ceiling case - // (limit_price = u64::MAX) by saturating to u64::MAX, which the AMM interprets as - // an astronomically high ceiling that current prices never reach. - let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); + // `limit_price` is already in ×10⁹ scale (same as the `current_alpha_price` RPC + // endpoint), which is also the scale the AMM uses for its price_limit argument. + // Pass it directly without any scaling. u64::MAX means "no ceiling". + let amm_limit = limit_price; let alpha_out = Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, amm_limit, false, false)?; if validate { @@ -80,10 +77,10 @@ impl OrderSwapInterface for Pallet { ); Self::ensure_stake_operation_limit_not_exceeded(hotkey, coldkey, netuid)?; } - // Same ×10⁹ scaling as in buy_alpha: limit_price is in current_alpha_price() units; - // the AMM expects price × 10⁹. For the no-floor case (limit_price = 0) the result - // is 0, which the AMM treats as "no lower bound". - let amm_limit = TaoBalance::from(limit_price.to_u64().saturating_mul(1_000_000_000)); + // `limit_price` is already in ×10⁹ scale (same as the `current_alpha_price` RPC + // endpoint), which is also the scale the AMM uses for its price_limit argument. + // Pass it directly without any scaling. 0 means "no floor". + let amm_limit = limit_price; let tao_out = Self::unstake_from_subnet( hotkey, coldkey, diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 3f0a203b17..bffc42b7f0 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -526,16 +526,16 @@ fn stop_loss_order_executes_and_unstakes_alpha() { ); seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); - // limit_price = 1 → current_price (1.0) ≤ 1.0 → StopLoss condition always met. - // Using 1 (not u64::MAX) because limit_price also acts as the minimum TAO output - // in sell_alpha — u64::MAX would make the swap always fail. + // limit_price = 1_000_000_000 (1.0 × 10⁹) → scaled_price (1_000_000_000) ≤ 1_000_000_000 + // → StopLoss condition always met. Stable mechanism ignores the AMM floor, so any + // value ≥ 1_000_000_000 works here. let signed = make_signed_order( alice, bob_id.clone(), netuid, OrderType::StopLoss, min_default_stake().into(), // sell min_default_stake alpha units - 1, // price floor — current price 1.0 ≤ 1.0, always met + 1_000_000_000, // price ceiling in ×10⁹ scale (1.0) — always met u64::MAX, Perbill::zero(), charlie_id.clone(), @@ -1599,13 +1599,9 @@ fn batched_multiple_fee_recipients_each_receive_correct_amount() { /// /// Setup: /// Dynamic subnet, equal reserves → pool price = 1.0 (raw ratio, i.e. 1 rao/alpha). -/// limit_price = 2 → StopLoss trigger: 1.0 ≤ 2.0 ✓ (price has fallen to the trigger) -/// max_slippage = 10 % → floor = 2 − 10% × 2. -/// Note: `Perbill::from_percent(10) * 2 = 0` (integer truncation), so floor = 2. -/// After the ×10⁹ scale in `order_swap.rs`: -/// AMM price_limit = 2 × 10⁹ = 2_000_000_000 -/// limit_sqrt_price = √(2_000_000_000 / 10⁹) = √2 ≈ 1.414 -/// Pool sqrt_price = √1.0 = 1.0 → 1.0 > 1.414 is false → PriceLimitExceeded +/// limit_price = 2_000_000_000 (2.0 × 10⁹) → StopLoss trigger: 1.0 ≤ 2.0 ✓ +/// max_slippage = 10% → effective AMM floor = 2_000_000_000 − 10% × 2_000_000_000 = 1_800_000_000. +/// Pool price = 1_000_000_000 (1.0 × 10⁹) < 1_800_000_000 → PriceLimitExceeded. /// `execute_orders` catches the error and skips the order (no storage write). /// Because `sell_alpha` is `#[transactional]`, the stake decrement is rolled back. #[test] @@ -1629,16 +1625,16 @@ fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { initial_alpha, ); - // limit_price = 2: StopLoss triggers when price ≤ 2.0; pool is at 1.0 → met. - // max_slippage sets a floor: Perbill integer truncation gives floor = 2 - 0 = 2. - // After ×10⁹ scaling, AMM limit_sqrt = √2 ≈ 1.414 > pool sqrt 1.0 → rejected. + // limit_price = 2_000_000_000 (2.0 × 10⁹): StopLoss triggers when price ≤ 2.0; pool is at 1.0 → met. + // max_slippage = 10% → effective AMM floor = 1_800_000_000. + // Pool price = 1_000_000_000 < 1_800_000_000 → PriceLimitExceeded → order skipped. let signed = make_signed_order_with_slippage_rt( alice, bob_id.clone(), netuid, OrderType::StopLoss, min_default_stake().into(), - 2, // trigger at price 2.0; pool is at 1.0 — condition met + 2_000_000_000, // trigger at price 2.0 × 10⁹; pool is at 1.0 — condition met u64::MAX, Perbill::zero(), charlie_id.clone(), diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts index a149f7219f..9ce3fa0c2e 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts @@ -64,7 +64,7 @@ describeSuite({ netuid, orderType: "TakeProfit", amount: tao(50), - limitPrice: 1n, + limitPrice: 1_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, @@ -76,7 +76,7 @@ describeSuite({ netuid, orderType: "TakeProfit", amount: tao(50), - limitPrice: 1n, + limitPrice: 1_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: bob.address, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts index 05a7930080..ed846b0b07 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -84,7 +84,7 @@ describeSuite({ netuid, orderType: "TakeProfit", amount: tao(10), - limitPrice: 1n, + limitPrice: 1_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: bob.address, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts index 94ababe7af..b4eb8b19d2 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -82,7 +82,7 @@ describeSuite({ netuid, orderType: "TakeProfit", amount: tao(200), - limitPrice: 1n, + limitPrice: 1_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: bob.address, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts index 10b9ec22cd..761af62de8 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -72,7 +72,7 @@ describeSuite({ netuid, orderType: "TakeProfit", amount: tao(100), - limitPrice: 1n, // always met + limitPrice: 1_000_000_000n, // always met when price >= 1 TAO/alpha (×10⁹ scale) expiry: FAR_FUTURE, feeRate: PERBILL_ONE_PERCENT, feeRecipient: feeRecipient.address, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts index a580bd0a0d..6f32bbb17b 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts @@ -69,14 +69,17 @@ describeSuite({ const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); - // TODO: discover why limit price of 100 is enough here (I think its close to 1 the ratio?) + // limit_price = 100_000_000_000 (100.0 TAO/alpha in ×10⁹ scale) — safely above the + // actual pool price on the freshly registered dynamic subnet after devAddStake(tao(1000)). + // max_slippage is unset (None) so the effective AMM floor is 0; the limit_price here + // only controls the StopLoss trigger condition, not the swap execution price. const signed = buildSignedOrder(polkadotJs, { signer: alice, hotkey: aliceHotKey.address, netuid, orderType: "StopLoss", amount: tao(100), - limitPrice: 100n, + limitPrice: 100_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts index 67654fc4c9..338bc075eb 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -68,14 +68,15 @@ describeSuite({ const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); - // limit_price = 1 RAO — current price (~1 TAO/alpha) is always >= 1 + // limit_price = 1_000_000_000 (1.0 TAO/alpha in ×10⁹ scale) — current price after + // devAddStake(tao(1000)) is above 1.0 TAO/alpha, so this condition is always met const signed = buildSignedOrder(polkadotJs, { signer: alice, hotkey: aliceHotKey.address, netuid, orderType: "TakeProfit", amount: tao(100), - limitPrice: 1n, + limitPrice: 1_000_000_000n, expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, From 0fcc91d21c97d93929985661df8b72360536bd91 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 13 May 2026 19:39:59 +0200 Subject: [PATCH 69/85] make pallet limit orders be disabled on-rt-upgrade --- pallets/limit-orders/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index c018902efb..8e0364f76e 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -367,14 +367,15 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { fn on_runtime_upgrade() -> Weight { + LimitOrdersEnabled::::set(false); let pallet_acct = Self::pallet_account(); let pallet_hotkey = T::PalletHotkey::get(); if T::SwapInterface::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey) { - return T::DbWeight::get().reads(1); + return T::DbWeight::get().reads_writes(1, 1); } let _ = T::SwapInterface::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); - // 1 read (already-registered check) + 3 writes (Owner, OwnedHotkeys, StakingHotkeys) - T::DbWeight::get().reads_writes(1, 3) + // 1 read (already-registered check) + 1 write (LimitOrdersEnabled) + 3 writes (Owner, OwnedHotkeys, StakingHotkeys) + T::DbWeight::get().reads_writes(1, 4) } } From 5c4b5a74dcffd0bb816f1c8da9bb02b2d63542c8 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 13 May 2026 20:07:46 +0200 Subject: [PATCH 70/85] change also validation in swap --- pallets/subtensor/src/staking/order_swap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 25327f47a3..eac7316613 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -116,7 +116,7 @@ impl OrderSwapInterface for Pallet { Self::ensure_subtoken_enabled(netuid)?; if validate_sender { ensure!( - Self::coldkey_owns_hotkey(from_coldkey, from_hotkey), + Self::hotkey_account_exists(from_hotkey), Error::::HotKeyAccountNotExists ); ensure!(!amount.is_zero(), Error::::AmountTooLow); @@ -149,7 +149,7 @@ impl OrderSwapInterface for Pallet { ); if validate_receiver { ensure!( - Self::coldkey_owns_hotkey(to_coldkey, to_hotkey), + Self::hotkey_account_exists(to_hotkey), Error::::HotKeyAccountNotExists ); Self::set_stake_operation_limit(to_hotkey, to_coldkey, netuid); From 8cfc63503a947ff118150835703498fc423951c9 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 18 May 2026 14:36:08 +0200 Subject: [PATCH 71/85] add sim_swap to avoid slippage-caused errors --- pallets/limit-orders/src/tests/auxiliary.rs | 2 +- pallets/limit-orders/src/tests/extrinsics.rs | 163 ++++++++++++++++ pallets/limit-orders/src/tests/mock.rs | 17 ++ pallets/subtensor/src/staking/order_swap.rs | 22 ++- runtime/tests/limit_orders.rs | 189 +++++++++++++++++++ 5 files changed, 391 insertions(+), 2 deletions(-) diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 913c863458..f2433b0d5b 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -183,7 +183,7 @@ fn validate_and_classify_fails_for_expired_order() { OrderType::LimitBuy, 1_000u64, 2_000_000_000u64, // 2.0 in ×10⁹ scale - 2_000_000u64, // expiry already past + 2_000_000u64, // expiry already past Perbill::zero(), fee_recipient(), None, diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 215a859e28..71179308f7 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -2781,3 +2781,166 @@ fn non_root_cannot_disable_the_pallet() { ); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// MOCK_SIMULATE_PARTIAL_FILL — sim-swap detects partial fill before funds move +// ───────────────────────────────────────────────────────────────────────────── + +/// `execute_batched_orders` hard-fails the whole batch when the sim-swap for a +/// `LimitBuy` order detects a partial fill (price limit would stop the AMM +/// before consuming the full input). +#[test] +fn execute_batched_orders_buy_partial_fill_fails_batch() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, // limit_price always passes for a buy + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("slippage too high") + ); + }); +} + +/// `execute_orders` silently skips a `LimitBuy` order when the sim-swap detects +/// a partial fill: the order must not appear in storage and an `OrderSkipped` +/// event must be emitted. +#[test] +fn execute_orders_buy_partial_fill_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, // limit_price always passes for a buy + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + let id = order_id(&order.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![order]), + )); + + // Order must not be stored — it was skipped, not fulfilled. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + }); +} + +/// `execute_batched_orders` hard-fails the whole batch when the sim-swap for a +/// `TakeProfit` (sell) order detects a partial fill. +#[test] +fn execute_batched_orders_sell_partial_fill_fails_batch() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + // Seed alpha so the order passes the balance check before reaching the swap. + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, // limit_price = 0 → floor always passes for a TakeProfit + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("slippage too high") + ); + }); +} + +/// `execute_orders` silently skips a `TakeProfit` order when the sim-swap +/// detects a partial fill: the order must not appear in storage and an +/// `OrderSkipped` event must be emitted. +#[test] +fn execute_orders_sell_partial_fill_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + // Seed alpha so the order passes the balance check before reaching the swap. + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, // limit_price = 0 → floor always passes for a TakeProfit + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + let id = order_id(&order.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![order]), + )); + + // Order must not be stored — it was skipped, not fulfilled. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 06e7952655..80e941d129 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -116,6 +116,9 @@ thread_local! { /// `buy_alpha` fails if `market_price > limit_price` (ceiling exceeded); /// `sell_alpha` fails if `market_price < limit_price` (floor not met). pub static MOCK_ENFORCE_PRICE_LIMIT: RefCell = const { RefCell::new(false) }; + /// When `true`, `buy_alpha` and `sell_alpha` return a slippage error to simulate + /// the case where the AMM price limit stops the swap before the full amount is consumed. + pub static MOCK_SIMULATE_PARTIAL_FILL: RefCell = const { RefCell::new(false) }; /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. pub static RATE_LIMITS: RefCell> = @@ -143,6 +146,9 @@ impl MockSwap { pub fn set_enforce_price_limit(enforce: bool) { MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = enforce); } + pub fn set_simulate_partial_fill(val: bool) { + MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow_mut() = val); + } pub fn clear_log() { SWAP_LOG.with(|l| l.borrow_mut().clear()); ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); @@ -150,6 +156,7 @@ impl MockSwap { RATE_LIMITS.with(|r| r.borrow_mut().clear()); HOTKEY_REGISTRATIONS.with(|r| r.borrow_mut().clear()); MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = false); + MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow_mut() = false); } pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { RATE_LIMITS.with(|r| { @@ -266,6 +273,11 @@ impl OrderSwapInterface for MockSwap { "pool error", )); } + if MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "slippage too high", + )); + } let tao = tao_amount.to_u64(); // Record the call (including rejected ones) so tests can verify the limit was passed. SWAP_LOG.with(|l| { @@ -319,6 +331,11 @@ impl OrderSwapInterface for MockSwap { "pool error", )); } + if MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "slippage too high", + )); + } let alpha = alpha_amount.to_u64(); // Record the call (including rejected ones) so tests can verify the limit was passed. SWAP_LOG.with(|l| { diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index eac7316613..49f4b0f531 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -4,7 +4,7 @@ use frame_support::traits::tokens::Preservation; use frame_support::transactional; use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; -use subtensor_swap_interface::{OrderSwapInterface, SwapHandler}; +use subtensor_swap_interface::{Order, OrderSwapInterface, SwapHandler, SwapResult}; impl OrderSwapInterface for Pallet { #[transactional] @@ -36,6 +36,16 @@ impl OrderSwapInterface for Pallet { // endpoint), which is also the scale the AMM uses for its price_limit argument. // Pass it directly without any scaling. u64::MAX means "no ceiling". let amm_limit = limit_price; + // Stable subnets (mechanism_id != 1) are always 1:1 and never stop early. + if SubnetMechanism::::get(netuid) == 1 { + let sim_order = GetAlphaForTao::::with_amount(tao_amount); + let sim: SwapResult = + T::SwapInterface::swap(netuid.into(), sim_order, amm_limit, false, true)?; + ensure!( + sim.amount_paid_in.saturating_add(sim.fee_paid) >= tao_amount, + Error::::SlippageTooHigh + ); + } let alpha_out = Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, amm_limit, false, false)?; if validate { @@ -81,6 +91,16 @@ impl OrderSwapInterface for Pallet { // endpoint), which is also the scale the AMM uses for its price_limit argument. // Pass it directly without any scaling. 0 means "no floor". let amm_limit = limit_price; + // Stable subnets (mechanism_id != 1) are always 1:1 and never stop early. + if SubnetMechanism::::get(netuid) == 1 { + let sim_order = GetTaoForAlpha::::with_amount(alpha_amount); + let sim: SwapResult = + T::SwapInterface::swap(netuid.into(), sim_order, amm_limit, false, true)?; + ensure!( + sim.amount_paid_in.saturating_add(sim.fee_paid) >= alpha_amount, + Error::::SlippageTooHigh + ); + } let tao_out = Self::unstake_from_subnet( hotkey, coldkey, diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index bffc42b7f0..99ec7afe3d 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -1897,3 +1897,192 @@ fn execute_batched_orders_partial_fill_then_complete() { ); }); } + +// ── sim-swap partial-fill guard ─────────────────────────────────────────────── + +/// A LimitBuy order with 1 ppb max_slippage is silently skipped by +/// `execute_orders` because the sim-swap detects that the AMM would only +/// consume a microscopic fraction of the input before the price ceiling is +/// breached (partial fill). +/// +/// Setup: dynamic subnet, equal 1T TAO / 1T alpha reserves → pool price = 1.0. +/// limit_price = 1_000_000_000 (1.0 × 10⁹): LimitBuy triggers when price ≤ 1.0 — met. +/// max_slippage = 1 ppb → ceiling = 1_000_000_001, barely above pool price. +/// Sending any real TAO amount immediately pushes the price above the ceiling, +/// so sim.amount_paid_in + sim.fee_paid < input_amount → SlippageTooHigh. +/// `execute_orders` is best-effort: it catches the error and skips the order. +#[test] +fn execute_orders_buy_tight_slippage_partial_fill_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs a hotkey association for the buy to validate. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + // Alice needs TAO to fund the buy. + fund_account(&alice_id); + + let initial_balance = SubtensorModule::get_coldkey_balance(&alice_id); + + // limit_price = 1_000_000_000 (= 1.0 × 10⁹): LimitBuy trigger (spot ≤ 1.0) met. + // max_slippage = 1 ppb → price ceiling = 1_000_000_001, just above pool price. + // Any real TAO amount pushes the price above the ceiling → partial fill detected. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + 1_000_000_000, // price ceiling at exactly 1.0 × 10⁹ — trigger met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), // 1 ppb — ceiling barely above spot + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // execute_orders is best-effort: the call succeeds even though the guard + // rejects the order due to partial fill. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // No funds should have been debited from Alice — the rollback guard + // prevents any state change when partial fill is detected. + let final_balance = SubtensorModule::get_coldkey_balance(&alice_id); + assert_eq!( + final_balance, initial_balance, + "alice's TAO balance should be unchanged when the order is rolled back" + ); + }); +} + +/// Same setup as `execute_orders_buy_tight_slippage_partial_fill_skipped` but +/// submitted via `execute_batched_orders`. The batch hard-fails with +/// `SlippageTooHigh` because batched execution is not best-effort. +#[test] +fn execute_batched_orders_buy_tight_slippage_partial_fill_fails() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + fund_account(&alice_id); + + // Identical order to the execute_orders variant above. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + 1_000_000_000, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), + ); + + let orders = make_order_batch(vec![signed]); + + // Batched execution hard-fails: the partial-fill guard surfaces the error + // directly to the caller instead of silently skipping. + assert_noop!( + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), + pallet_subtensor::Error::::SlippageTooHigh + ); + }); +} + +/// A TakeProfit order with 1 ppb max_slippage is silently skipped by +/// `execute_orders` because the sim-swap detects that selling any real alpha +/// amount immediately pushes the pool price below the 1 ppb floor. +/// +/// Setup: dynamic subnet, equal 1T TAO / 1T alpha reserves → pool price = 1.0. +/// limit_price = 1_000_000_000 (1.0 × 10⁹): TakeProfit triggers when price ≥ 1.0 — met. +/// max_slippage = 1 ppb → floor = 999_999_999, barely below pool price. +/// Selling any real alpha amount moves the price below the floor, +/// so sim.amount_paid_in + sim.fee_paid < input_amount → SlippageTooHigh. +/// `execute_orders` is best-effort: it catches the error and skips the order. +#[test] +fn execute_orders_sell_tight_slippage_partial_fill_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs a hotkey association and staked alpha for the sell. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 1_000_000_000 (= 1.0 × 10⁹): TakeProfit trigger (spot ≥ 1.0) met. + // max_slippage = 1 ppb → price floor = 999_999_999, just below pool price. + // Any real alpha sale pushes the price below the floor → partial fill detected. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), + 1_000_000_000, // price floor at exactly 1.0 × 10⁹ — trigger met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), // 1 ppb — floor barely below spot + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // execute_orders is best-effort: the call succeeds even though the guard + // rejects the order due to partial fill. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // Alice's staked alpha must be unchanged — the rollback guard prevents + // any state change when partial fill is detected. + let remaining_alpha = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining_alpha, initial_alpha, + "alice's staked alpha should be unchanged when the order is rolled back" + ); + }); +} From 2784086dc7691a843ac4113c978259265da243c5 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 20 May 2026 11:07:00 +0200 Subject: [PATCH 72/85] allow a set of relayers to be whitelisted --- pallets/limit-orders/src/lib.rs | 12 +++++++----- pallets/limit-orders/src/tests/auxiliary.rs | 4 ++-- pallets/limit-orders/src/tests/extrinsics.rs | 10 +++++----- pallets/limit-orders/src/tests/mock.rs | 4 ++-- runtime/tests/limit_orders.rs | 6 +++--- .../limit-orders/test-batched-partial-fill.ts | 4 ++-- .../limit-orders/test-execute-orders-partial-fill.ts | 4 ++-- ts-tests/utils/limit-orders.ts | 6 +++--- 8 files changed, 26 insertions(+), 24 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 8e0364f76e..2c2fd662bd 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -11,6 +11,7 @@ pub mod weights; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_core::H256; +use frame_support::{BoundedVec, traits::ConstU32}; use sp_runtime::{ AccountId32, MultiSignature, Perbill, traits::{ConstBool, Verify}, @@ -60,7 +61,7 @@ impl OrderType { /// Only its H256 hash is stored on-chain; the full struct is submitted by the /// admin at execution time (or by the user at cancellation time). #[allow(clippy::multiple_bound_locations)] // bounds on AccountId required by FRAME derives -#[freeze_struct("b5e575cbffa6c1d6")] +#[freeze_struct("27c7eedb92261456")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] @@ -88,8 +89,9 @@ pub struct Order pub fee_rate: Perbill, /// Account that receives the fee collected from this order. pub fee_recipient: AccountId, - /// Account that should relay the transactions - pub relayer: Option, + /// Accounts authorized to relay this order. When set, only an account present + /// in this list may submit the execution transaction. Supports up to 10 relayers. + pub relayer: Option>>, /// Maximum slippage tolerance in parts per billion applied to `limit_price` /// at execution time. `None` = no protection (execute at market). /// - Buy: effective price ceiling = `limit_price + limit_price * max_slippage` @@ -616,8 +618,8 @@ pub mod pallet { }, Error::::PriceConditionNotMet ); - if let Some(forced_relayer) = order.relayer.clone() { - ensure!(forced_relayer == *relayer, Error::::RelayerMissMatch); + if let Some(forced_relayers) = order.relayer.as_ref() { + ensure!(forced_relayers.contains(relayer), Error::::RelayerMissMatch); } if let Some(partial_fill) = signed_order.partial_fill { ensure!( diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index f2433b0d5b..ef6594e08f 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -500,7 +500,7 @@ fn validate_and_classify_fails_for_wrong_relayer() { 2_000_000u64, Perbill::zero(), fee_recipient(), - Some(charlie()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order ); let orders = bounded(vec![order]); @@ -534,7 +534,7 @@ fn validate_and_classify_succeeds_for_correct_relayer() { 2_000_000u64, Perbill::zero(), fee_recipient(), - Some(charlie()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order ); let orders = bounded(vec![order]); diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 71179308f7..d774f64628 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -6,7 +6,7 @@ //! `MockSwap`, which records calls and maintains in-memory balance ledgers. use codec::Encode; -use frame_support::{assert_noop, assert_ok}; +use frame_support::{BoundedVec, assert_noop, assert_ok}; use sp_core::Pair; use sp_keyring::Sr25519Keyring as AccountKeyring; use sp_runtime::{DispatchError, Perbill}; @@ -2393,7 +2393,7 @@ fn execute_orders_wrong_relayer_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(charlie()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order ); let id = order_id(&signed.order); @@ -2428,7 +2428,7 @@ fn execute_orders_correct_relayer_executed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(charlie()), // charlie is the designated relayer + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // charlie is the designated relayer ); let id = order_id(&signed.order); @@ -2467,7 +2467,7 @@ fn execute_batched_orders_wrong_relayer_fails_entire_batch() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(charlie()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order ); assert_noop!( @@ -2501,7 +2501,7 @@ fn execute_batched_orders_correct_relayer_succeeds() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(charlie()), // charlie is the designated relayer + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // charlie is the designated relayer ); let id = order_id(&signed.order); diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 80e941d129..eef35a2cb4 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -570,7 +570,7 @@ pub fn make_signed_order( expiry: u64, fee_rate: sp_runtime::Perbill, fee_recipient: AccountId, - relayer: Option, + relayer: Option>>, ) -> crate::SignedOrder { let signer = keyring.to_account_id(); let order = crate::VersionedOrder::V1(crate::Order { @@ -621,7 +621,7 @@ pub fn make_partial_fill_order( expiry, fee_rate: sp_runtime::Perbill::zero(), fee_recipient: fee_recipient(), - relayer: Some(relayer), + relayer: Some(BoundedVec::try_from(vec![relayer]).unwrap()), max_slippage: None, chain_id: 945, partial_fills_enabled: true, diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 99ec7afe3d..71463bdfb2 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -5,7 +5,7 @@ )] use codec::Encode; -use frame_support::{BoundedVec, assert_noop, assert_ok}; +use frame_support::{BoundedVec, assert_noop, assert_ok, traits::ConstU32}; use node_subtensor_runtime::{ BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, System, pallet_subtensor, @@ -92,7 +92,7 @@ struct OrderParams { expiry: u64, fee_rate: Perbill, fee_recipient: AccountId, - relayer: Option, + relayer: Option>>, max_slippage: Option, partial_fills_enabled: bool, } @@ -235,7 +235,7 @@ fn make_partial_fill_order( expiry, fee_rate: Perbill::zero(), fee_recipient, - relayer: Some(relayer), + relayer: Some(BoundedVec::try_from(vec![relayer]).unwrap()), max_slippage: None, partial_fills_enabled: true, }, diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts index 7506e8433d..6d1a4637e9 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts @@ -67,7 +67,7 @@ describeSuite({ expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, - relayer: alice.address, + relayer: [alice.address], partialFillsEnabled: true, }); @@ -111,7 +111,7 @@ describeSuite({ expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, - relayer: alice.address, + relayer: [alice.address], partialFillsEnabled: true, }); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts index bf4bfb6c28..2b2d8d295f 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts @@ -69,7 +69,7 @@ describeSuite({ expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, - relayer: alice.address, + relayer: [alice.address], partialFillsEnabled: true, }); @@ -113,7 +113,7 @@ describeSuite({ expiry: FAR_FUTURE, feeRate: 0, feeRecipient: alice.address, - relayer: alice.address, + relayer: [alice.address], partialFillsEnabled: true, }); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 6389a4b180..0ffbe177e0 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -22,7 +22,7 @@ export interface OrderParams { feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% feeRecipient: string; chainId?: bigint; // defaults to 42n (the dev node's EVM chain ID) - relayer?: string | null; // Optional: if set, only this account may relay the order + relayer?: string[] | null; // Optional: if set, only these accounts may relay the order maxSlippage?: number | null; // Optional: Perbill (ppb). When set, effective swap limit = limit_price ± limit_price * maxSlippage / 1e9 partialFillsEnabled?: boolean; // Optional: if true, order can be partially filled (requires relayer) } @@ -37,7 +37,7 @@ export interface Order { expiry: bigint; fee_rate: number; fee_recipient: string; - relayer: string | null; + relayer: string[] | null; max_slippage: number | null; chain_id: bigint; partial_fills_enabled: boolean; @@ -126,7 +126,7 @@ export function registerLimitOrderTypes(api: any): void { expiry: "u64", fee_rate: "u32", // Perbill fee_recipient: "AccountId", - relayer: "Option", + relayer: "Option>", max_slippage: "Option", chain_id: "u64", partial_fills_enabled: "bool", From 3123f9d2ce0ca0ef0448ef82e485a63a3581c302 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 20 May 2026 11:27:48 +0200 Subject: [PATCH 73/85] mevshield tests --- runtime/tests/limit_orders.rs | 146 ++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 71463bdfb2..f1e2265c3a 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -2086,3 +2086,149 @@ fn execute_orders_sell_tight_slippage_partial_fill_skipped() { ); }); } + +/// Documents the MEVShield usage pattern: an order with `relayer: None` can be +/// submitted by any account on-chain, not just a pinned relayer. +/// +/// Alice signs a LimitBuy order with `relayer: None`. Dave — an account with no +/// relationship to the order — submits the `execute_orders` transaction. +/// +/// On-chain there is no relayer restriction, so the call succeeds and the order +/// is stored as `Fulfilled`. MEVShield protection operates purely at the mempool +/// level: validators running MEVShield refuse to propagate or include +/// `execute_orders` transactions that are not signed by an authorised relayer +/// account, but the pallet itself imposes no such constraint. +#[test] +fn execute_orders_no_relayer_any_account_can_relay() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + setup_subnet(netuid); + + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + let dave_id = Sr25519Keyring::Dave.to_account_id(); + + // Fund Alice so the buy can debit her balance. + fund_account(&alice_id); + + // Associate Alice (coldkey) with Bob (hotkey) so staking goes through Bob. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // A current timestamp is required; the order carries expiry = u64::MAX so + // it will never be considered expired regardless of this value. + pallet_timestamp::Now::::put(100_000u64); + + // Build the order via `make_signed_order_inner` with an explicit + // `relayer: None` so the intent — no pinned relayer — is visible in the + // test body rather than hidden inside a convenience wrapper. + let signed = make_signed_order_inner( + alice, + bob_id.clone(), + netuid, + OrderParams { + order_type: OrderType::LimitBuy, + amount: min_default_stake().into(), + limit_price: u64::MAX, // price ceiling always satisfied + expiry: u64::MAX, // never expires + fee_rate: Perbill::zero(), + fee_recipient: charlie_id.clone(), + relayer: None, // no pinned relayer — any account may submit + max_slippage: None, + partial_fills_enabled: false, + }, + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // Dave submits the transaction — he is not Alice, Bob, or Charlie and has + // no special relationship to the order. The call must succeed because + // `relayer: None` imposes no restriction on the origin. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(dave_id), + orders, + )); + + // The order must be written to storage as Fulfilled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order signed by Alice with relayer=None should be fulfilled when Dave submits it" + ); + }); +} + +/// Documents MEVShield usage with an explicit `relayer` field. +/// +/// MEVShield is an encrypted mempool: the original submitter's origin is +/// preserved when the transaction is decrypted and included. This means the +/// relayer does NOT need to be a MEVShield validator — it can be any account. +/// The user pins their order to a specific relayer account; that account +/// encrypts its `execute_orders` call via MEVShield; when included, the origin +/// is still that account and the pallet's `relayer` check passes. +/// +/// Simulation: `RuntimeOrigin::signed(charlie_id)` is identical to what +/// MEVShield would deliver on-chain for a tx submitted by Charlie. +#[test] +fn execute_orders_mevshield_with_pinned_relayer() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + setup_subnet(netuid); + + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + fund_account(&alice_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + pallet_timestamp::Now::::put(100_000u64); + + // Alice pins the order to Charlie — a specific account, not a validator. + // Charlie will submit via MEVShield; the origin is preserved as Charlie. + let signed = make_signed_order_inner( + alice, + bob_id.clone(), + netuid, + OrderParams { + order_type: OrderType::LimitBuy, + amount: min_default_stake().into(), + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: charlie_id.clone(), + relayer: Some(BoundedVec::try_from(vec![charlie_id.clone()]).unwrap()), + max_slippage: None, + partial_fills_enabled: false, + }, + ); + let id = order_id(&signed.order); + + // Dave attempts to relay — must be skipped because he is not in the relayer set. + let dave_id = Sr25519Keyring::Dave.to_account_id(); + let orders = make_order_batch(vec![signed.clone()]); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(dave_id), + orders, + )); + assert!( + Orders::::get(id).is_none(), + "order should be skipped when submitted by an account not in the relayer set" + ); + + // Charlie submits — simulates MEVShield decrypting Charlie's tx and + // including it with Charlie's origin intact. Must execute. + let orders = make_order_batch(vec![signed]); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be fulfilled when submitted by the pinned relayer via MEVShield" + ); + }); +} From 91886d72a0c8fb246d2ba6873c13633623e4c136 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 20 May 2026 13:09:51 +0200 Subject: [PATCH 74/85] fixes for checking dev node mevshield --- Cargo.lock | 2 + node/Cargo.toml | 2 + node/src/dev_keystore.rs | 40 ++++++++++ node/src/lib.rs | 1 + node/src/main.rs | 1 + node/src/service.rs | 27 +++++-- runtime/tests/limit_orders.rs | 145 ---------------------------------- 7 files changed, 66 insertions(+), 152 deletions(-) create mode 100644 node/src/dev_keystore.rs diff --git a/Cargo.lock b/Cargo.lock index 3cce88c43d..dc715d9ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8299,6 +8299,7 @@ dependencies = [ "jsonrpsee", "log", "memmap2 0.9.8", + "ml-kem", "node-subtensor-runtime", "num-traits", "pallet-balances", @@ -8312,6 +8313,7 @@ dependencies = [ "pallet-transaction-payment-rpc", "pallet-transaction-payment-rpc-runtime-api", "polkadot-sdk", + "rand_core 0.6.4", "sc-basic-authorship", "sc-chain-spec", "sc-chain-spec-derive", diff --git a/node/Cargo.toml b/node/Cargo.toml index d067eb19c8..735e2e1fa9 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -122,6 +122,8 @@ num-traits = { workspace = true, features = ["std"] } pallet-shield.workspace = true stp-shield.workspace = true stc-shield.workspace = true +ml-kem = { workspace = true, features = ["std"] } +rand_core = { version = "0.6.4", features = ["std", "getrandom"] } # Local Dependencies node-subtensor-runtime = { workspace = true, features = ["std"] } diff --git a/node/src/dev_keystore.rs b/node/src/dev_keystore.rs new file mode 100644 index 0000000000..9712238816 --- /dev/null +++ b/node/src/dev_keystore.rs @@ -0,0 +1,40 @@ +use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; +use rand_core::OsRng; +use stp_shield::{Result as TraitResult, ShieldKeystore}; + +/// A fixed (non-rotating) shield keystore for single-validator dev/manual-seal nodes. +/// +/// Uses the same ML-KEM-768 keypair for both `next_enc_key()` and `current_dec_key()`, +/// bypassing the multi-validator key-rotation timing assumption. In a real multi-validator +/// AURA chain, each validator builds every Kth block, so the keystore rolls at the same +/// cadence as the on-chain PendingKey pipeline (2 blocks). In single-validator manual-seal +/// mode the keystore would roll on every block, drifting 2 pairs ahead of PendingKey. +/// This keystore avoids that by keeping both keys from the same generated pair. +pub struct DevShieldKeystore { + enc_key_bytes: Vec, + dec_key_bytes: Vec, +} + +impl DevShieldKeystore { + pub fn new() -> Self { + let (dec_key, enc_key) = MlKem768::generate(&mut OsRng); + Self { + enc_key_bytes: enc_key.as_bytes().to_vec(), + dec_key_bytes: dec_key.as_bytes().to_vec(), + } + } +} + +impl ShieldKeystore for DevShieldKeystore { + fn roll_for_next_slot(&self) -> TraitResult<()> { + Ok(()) + } + + fn next_enc_key(&self) -> TraitResult> { + Ok(self.enc_key_bytes.clone()) + } + + fn current_dec_key(&self) -> TraitResult> { + Ok(self.dec_key_bytes.clone()) + } +} diff --git a/node/src/lib.rs b/node/src/lib.rs index 4740155f5e..d269fe583d 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -4,6 +4,7 @@ pub mod client; pub mod clone_spec; pub mod conditional_evm_block_import; pub mod consensus; +pub mod dev_keystore; pub mod ethereum; pub mod rpc; pub mod service; diff --git a/node/src/main.rs b/node/src/main.rs index 2766b93054..a6aa15038f 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -10,6 +10,7 @@ mod clone_spec; mod command; mod conditional_evm_block_import; mod consensus; +mod dev_keystore; mod ethereum; mod rpc; mod service; diff --git a/node/src/service.rs b/node/src/service.rs index d07671f81f..624f63b968 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -544,10 +544,14 @@ where .await; if role.is_authority() { - let shield_keystore = Arc::new(MemoryShieldKeystore::new()); - - // manual-seal authorship + // manual-seal authorship — use a fixed keystore so the single-validator dev + // node doesn't drift: MemoryShieldKeystore rolls on every own-block import + // (every block in single-validator mode), advancing current_dec_key() 2 pairs + // ahead of PendingKey on-chain. DevShieldKeystore avoids this by keeping the + // same keypair for both next_enc_key() and current_dec_key(). if let Some(sealing) = sealing { + let dev_shield_keystore: stp_shield::ShieldKeystorePtr = + Arc::new(crate::dev_keystore::DevShieldKeystore::new()); run_manual_seal_authorship( sealing, client, @@ -558,12 +562,14 @@ where prometheus_registry.as_ref(), telemetry.as_ref(), commands_stream, - shield_keystore.clone(), + dev_shield_keystore, )?; log::info!("Manual Seal Ready"); return Ok(task_manager); } + let shield_keystore = Arc::new(MemoryShieldKeystore::new()); + stc_shield::spawn_key_rotation_on_own_import( &task_manager.spawn_handle(), client.clone(), @@ -749,7 +755,7 @@ fn run_manual_seal_authorship( transaction_pool.clone(), prometheus_registry, telemetry.as_ref().map(|x| x.handle()), - shield_keystore, + shield_keystore.clone(), ); thread_local!(static TIMESTAMP: RefCell = const { RefCell::new(0) }); @@ -781,8 +787,15 @@ fn run_manual_seal_authorship( } } - let create_inherent_data_providers = - move |_, ()| async move { Ok(MockTimestampInherentDataProvider) }; + let create_inherent_data_providers = move |_, ()| { + let keystore = shield_keystore.clone(); + async move { + Ok(( + MockTimestampInherentDataProvider, + stc_shield::InherentDataProvider::new(keystore), + )) + } + }; let aura_data_provider = sc_consensus_manual_seal::consensus::aura::AuraConsensusDataProvider::new(client.clone()); diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index f1e2265c3a..331c721a79 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -2087,148 +2087,3 @@ fn execute_orders_sell_tight_slippage_partial_fill_skipped() { }); } -/// Documents the MEVShield usage pattern: an order with `relayer: None` can be -/// submitted by any account on-chain, not just a pinned relayer. -/// -/// Alice signs a LimitBuy order with `relayer: None`. Dave — an account with no -/// relationship to the order — submits the `execute_orders` transaction. -/// -/// On-chain there is no relayer restriction, so the call succeeds and the order -/// is stored as `Fulfilled`. MEVShield protection operates purely at the mempool -/// level: validators running MEVShield refuse to propagate or include -/// `execute_orders` transactions that are not signed by an authorised relayer -/// account, but the pallet itself imposes no such constraint. -#[test] -fn execute_orders_no_relayer_any_account_can_relay() { - new_test_ext().execute_with(|| { - let netuid = NetUid::from(1u16); - setup_subnet(netuid); - - let alice = Sr25519Keyring::Alice; - let alice_id = alice.to_account_id(); - let bob_id = Sr25519Keyring::Bob.to_account_id(); - let charlie_id = Sr25519Keyring::Charlie.to_account_id(); - let dave_id = Sr25519Keyring::Dave.to_account_id(); - - // Fund Alice so the buy can debit her balance. - fund_account(&alice_id); - - // Associate Alice (coldkey) with Bob (hotkey) so staking goes through Bob. - let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); - - // A current timestamp is required; the order carries expiry = u64::MAX so - // it will never be considered expired regardless of this value. - pallet_timestamp::Now::::put(100_000u64); - - // Build the order via `make_signed_order_inner` with an explicit - // `relayer: None` so the intent — no pinned relayer — is visible in the - // test body rather than hidden inside a convenience wrapper. - let signed = make_signed_order_inner( - alice, - bob_id.clone(), - netuid, - OrderParams { - order_type: OrderType::LimitBuy, - amount: min_default_stake().into(), - limit_price: u64::MAX, // price ceiling always satisfied - expiry: u64::MAX, // never expires - fee_rate: Perbill::zero(), - fee_recipient: charlie_id.clone(), - relayer: None, // no pinned relayer — any account may submit - max_slippage: None, - partial_fills_enabled: false, - }, - ); - let id = order_id(&signed.order); - - let orders = make_order_batch(vec![signed]); - - // Dave submits the transaction — he is not Alice, Bob, or Charlie and has - // no special relationship to the order. The call must succeed because - // `relayer: None` imposes no restriction on the origin. - assert_ok!(LimitOrders::execute_orders( - RuntimeOrigin::signed(dave_id), - orders, - )); - - // The order must be written to storage as Fulfilled. - assert_eq!( - Orders::::get(id), - Some(OrderStatus::Fulfilled), - "order signed by Alice with relayer=None should be fulfilled when Dave submits it" - ); - }); -} - -/// Documents MEVShield usage with an explicit `relayer` field. -/// -/// MEVShield is an encrypted mempool: the original submitter's origin is -/// preserved when the transaction is decrypted and included. This means the -/// relayer does NOT need to be a MEVShield validator — it can be any account. -/// The user pins their order to a specific relayer account; that account -/// encrypts its `execute_orders` call via MEVShield; when included, the origin -/// is still that account and the pallet's `relayer` check passes. -/// -/// Simulation: `RuntimeOrigin::signed(charlie_id)` is identical to what -/// MEVShield would deliver on-chain for a tx submitted by Charlie. -#[test] -fn execute_orders_mevshield_with_pinned_relayer() { - new_test_ext().execute_with(|| { - let netuid = NetUid::from(1u16); - setup_subnet(netuid); - - let alice = Sr25519Keyring::Alice; - let alice_id = alice.to_account_id(); - let bob_id = Sr25519Keyring::Bob.to_account_id(); - let charlie_id = Sr25519Keyring::Charlie.to_account_id(); - - fund_account(&alice_id); - let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); - pallet_timestamp::Now::::put(100_000u64); - - // Alice pins the order to Charlie — a specific account, not a validator. - // Charlie will submit via MEVShield; the origin is preserved as Charlie. - let signed = make_signed_order_inner( - alice, - bob_id.clone(), - netuid, - OrderParams { - order_type: OrderType::LimitBuy, - amount: min_default_stake().into(), - limit_price: u64::MAX, - expiry: u64::MAX, - fee_rate: Perbill::zero(), - fee_recipient: charlie_id.clone(), - relayer: Some(BoundedVec::try_from(vec![charlie_id.clone()]).unwrap()), - max_slippage: None, - partial_fills_enabled: false, - }, - ); - let id = order_id(&signed.order); - - // Dave attempts to relay — must be skipped because he is not in the relayer set. - let dave_id = Sr25519Keyring::Dave.to_account_id(); - let orders = make_order_batch(vec![signed.clone()]); - assert_ok!(LimitOrders::execute_orders( - RuntimeOrigin::signed(dave_id), - orders, - )); - assert!( - Orders::::get(id).is_none(), - "order should be skipped when submitted by an account not in the relayer set" - ); - - // Charlie submits — simulates MEVShield decrypting Charlie's tx and - // including it with Charlie's origin intact. Must execute. - let orders = make_order_batch(vec![signed]); - assert_ok!(LimitOrders::execute_orders( - RuntimeOrigin::signed(charlie_id), - orders, - )); - assert_eq!( - Orders::::get(id), - Some(OrderStatus::Fulfilled), - "order should be fulfilled when submitted by the pinned relayer via MEVShield" - ); - }); -} From 48c8a28aa53db21bc68a755a8667e998c27497a8 Mon Sep 17 00:00:00 2001 From: girazoki Date: Wed, 20 May 2026 13:20:17 +0200 Subject: [PATCH 75/85] mevshield dev node and tests limit orders --- Cargo.lock | 2 - node/Cargo.toml | 2 - node/src/dev_keystore.rs | 32 +-- .../test-mevshield-execute-orders.ts | 183 ++++++++++++++++++ 4 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts diff --git a/Cargo.lock b/Cargo.lock index dc715d9ae6..3cce88c43d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8299,7 +8299,6 @@ dependencies = [ "jsonrpsee", "log", "memmap2 0.9.8", - "ml-kem", "node-subtensor-runtime", "num-traits", "pallet-balances", @@ -8313,7 +8312,6 @@ dependencies = [ "pallet-transaction-payment-rpc", "pallet-transaction-payment-rpc-runtime-api", "polkadot-sdk", - "rand_core 0.6.4", "sc-basic-authorship", "sc-chain-spec", "sc-chain-spec-derive", diff --git a/node/Cargo.toml b/node/Cargo.toml index 735e2e1fa9..d067eb19c8 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -122,8 +122,6 @@ num-traits = { workspace = true, features = ["std"] } pallet-shield.workspace = true stp-shield.workspace = true stc-shield.workspace = true -ml-kem = { workspace = true, features = ["std"] } -rand_core = { version = "0.6.4", features = ["std", "getrandom"] } # Local Dependencies node-subtensor-runtime = { workspace = true, features = ["std"] } diff --git a/node/src/dev_keystore.rs b/node/src/dev_keystore.rs index 9712238816..8f15aaa1d0 100644 --- a/node/src/dev_keystore.rs +++ b/node/src/dev_keystore.rs @@ -1,27 +1,33 @@ -use ml_kem::{EncodedSizeUser, KemCore, MlKem768}; -use rand_core::OsRng; +use stc_shield::MemoryShieldKeystore; use stp_shield::{Result as TraitResult, ShieldKeystore}; /// A fixed (non-rotating) shield keystore for single-validator dev/manual-seal nodes. /// /// Uses the same ML-KEM-768 keypair for both `next_enc_key()` and `current_dec_key()`, /// bypassing the multi-validator key-rotation timing assumption. In a real multi-validator -/// AURA chain, each validator builds every Kth block, so the keystore rolls at the same -/// cadence as the on-chain PendingKey pipeline (2 blocks). In single-validator manual-seal -/// mode the keystore would roll on every block, drifting 2 pairs ahead of PendingKey. -/// This keystore avoids that by keeping both keys from the same generated pair. +/// AURA chain, each validator builds every Kth block (K≥3), so the keystore rolls at the +/// same cadence as the on-chain PendingKey pipeline (2-block delay). In single-validator +/// manual-seal mode the keystore would roll on every block, drifting 2 pairs ahead of +/// PendingKey. This keystore avoids that by keeping both keys from the same generated pair. +/// +/// Construction: capture `next_enc_key()` from a fresh `MemoryShieldKeystore`, roll once +/// so that key becomes current, then freeze. `current_dec_key()` delegates to the inner +/// store (which now holds the matching pair), and `roll_for_next_slot()` is a no-op. pub struct DevShieldKeystore { enc_key_bytes: Vec, - dec_key_bytes: Vec, + inner: MemoryShieldKeystore, } impl DevShieldKeystore { pub fn new() -> Self { - let (dec_key, enc_key) = MlKem768::generate(&mut OsRng); - Self { - enc_key_bytes: enc_key.as_bytes().to_vec(), - dec_key_bytes: dec_key.as_bytes().to_vec(), - } + let inner = MemoryShieldKeystore::new(); + let enc_key_bytes = inner + .next_enc_key() + .expect("MemoryShieldKeystore always has a next key"); + inner + .roll_for_next_slot() + .expect("initial roll should not fail"); + Self { enc_key_bytes, inner } } } @@ -35,6 +41,6 @@ impl ShieldKeystore for DevShieldKeystore { } fn current_dec_key(&self) -> TraitResult> { - Ok(self.dec_key_bytes.clone()) + self.inner.current_dec_key() } } diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts new file mode 100644 index 0000000000..b10bea01e3 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts @@ -0,0 +1,183 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + fetchChainId, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; +import { encryptTransaction } from "../../../../utils/shield_helpers.js"; +import { u8aToHex } from "@polkadot/util"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_MEVSHIELD", + title: "execute_orders via MEVShield submit_encrypted", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + let chainId: bigint; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + chainId = await fetchChainId(polkadotJs); + + // Create 3+ blocks so PendingKey is populated (needs 2 blocks for the + // AuthorKeys → NextKey → PendingKey pipeline to fill). The subsequent setup + // transactions each create additional blocks, so 2 here is sufficient. + await context.createBlock([]); + await context.createBlock([]); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy submitted via MEVShield submit_encrypted is decrypted and executed in the same block", + test: async () => { + // Use PendingKey — this is the key the current block's proposer checks against. + // NextKey is one rotation ahead; encrypting with it would require waiting an extra + // block for it to advance to PendingKey, which doesn't happen automatically in + // manual-seal mode. + const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); + if ((pendingKeyRaw as any).isNone) throw new Error("MEVShield PendingKey not available — create more blocks first"); + const nextKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); + + const signedOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: null, + chainId, + }); + + // Get alice's current nonce so we can pre-sign the inner tx at nonce+1 + const aliceNonce = ((await polkadotJs.query.system.account(alice.address)) as any).nonce.toNumber() as number; + + // Sign the inner execute_orders tx at nonce+1, then get its raw bytes + const innerTx = await polkadotJs.tx.limitOrders + .executeOrders([signedOrder]) + .signAsync(alice, { nonce: aliceNonce + 1 }); + const innerTxBytes = innerTx.toU8a(); + + // Encrypt the inner tx with the MEVShield NextKey + const ciphertext = await encryptTransaction(innerTxBytes, nextKeyBytes); + + // submit_encrypted requires a mortal era — immortal is rejected by CheckMortality. + // Anchor to the PARENT block, not the current best block. + // + // try_decode_shielded_tx is a runtime API call executed at parent_hash (block B's + // state). CheckMortality::implicit() looks up BlockHash[birth]. In block B's state, + // only blocks 0..B-1 are stored — BlockHash[B] is populated when block B+1 + // initializes. If we sign with { current: B }, birth = B and the lookup fails + // (AncientBirthBlock), check() returns Err, and try_decode_shielded_tx returns None, + // so the outer tx is included as a plain tx with no inner tx extracted. + // Anchoring to B-1 (the parent) means birth = B-1, which IS in BlockHash at block + // B's state, so implicit() succeeds and the signature verifies correctly. + const header = await polkadotJs.rpc.chain.getHeader(); + const blockNumber = header.number.toNumber() - 1; + const blockHash = header.parentHash; + const era = polkadotJs.createType("ExtrinsicEra", { current: blockNumber, period: 8 }); + + // Submit the wrapper directly to the pool (not via createBlock) so the proposer + // scans the pool naturally and runs shielded-tx detection. + const signedWrapper = await polkadotJs.tx.mevShield + .submitEncrypted(u8aToHex(ciphertext)) + .signAsync(alice, { nonce: aliceNonce, era, blockHash }); + await polkadotJs.rpc.author.submitExtrinsic(signedWrapper.toHex()); + + // Seal a block — the proposer detects the shielded tx in the pool, decrypts the + // inner execute_orders, and includes both in the same block. + await context.createBlock([]); + + // Assert the order is Fulfilled + const id = orderId(polkadotJs, signedOrder.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + + it({ + id: "T02", + title: "LimitBuy with a designated relayer is executed when the relayer submits via MEVShield", + test: async () => { + const relayer = generateKeyringPair("sr25519"); + await devForceSetBalance(polkadotJs, context, relayer.address, tao(100)); + + const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); + if ((pendingKeyRaw as any).isNone) throw new Error("MEVShield PendingKey not available — create more blocks first"); + const pendingKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); + + const signedOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [relayer.address], + chainId, + }); + + // The relayer submits the encrypted execute_orders tx on Alice's behalf. + // relayerNonce+0 = outer submit_encrypted, relayerNonce+1 = inner execute_orders. + const relayerNonce = ((await polkadotJs.query.system.account(relayer.address)) as any).nonce.toNumber() as number; + + const innerTx = await polkadotJs.tx.limitOrders + .executeOrders([signedOrder]) + .signAsync(relayer, { nonce: relayerNonce + 1 }); + const innerTxBytes = innerTx.toU8a(); + + const ciphertext = await encryptTransaction(innerTxBytes, pendingKeyBytes); + + const header = await polkadotJs.rpc.chain.getHeader(); + const blockNumber = header.number.toNumber() - 1; + const blockHash = header.parentHash; + const era = polkadotJs.createType("ExtrinsicEra", { current: blockNumber, period: 8 }); + + const signedWrapper = await polkadotJs.tx.mevShield + .submitEncrypted(u8aToHex(ciphertext)) + .signAsync(relayer, { nonce: relayerNonce, era, blockHash }); + await polkadotJs.rpc.author.submitExtrinsic(signedWrapper.toHex()); + + await context.createBlock([]); + + const id = orderId(polkadotJs, signedOrder.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); From 9f625c151b2efecd0b38e6432b10e42323dda092 Mon Sep 17 00:00:00 2001 From: open-junius Date: Thu, 21 May 2026 20:35:21 +0800 Subject: [PATCH 76/85] precompile for limit order pallet --- Cargo.lock | 1 + contract-tests/src/contracts/limitOrders.ts | 263 +++++++++++ .../test/limitOrders.precompile.test.ts | 52 +++ pallets/admin-utils/src/lib.rs | 2 + precompiles/Cargo.toml | 3 + precompiles/src/lib.rs | 17 +- precompiles/src/limit_orders.rs | 317 +++++++++++++ precompiles/src/solidity/limitOrders.abi | 429 ++++++++++++++++++ precompiles/src/solidity/limitOrders.sol | 66 +++ 9 files changed, 1149 insertions(+), 1 deletion(-) create mode 100644 contract-tests/src/contracts/limitOrders.ts create mode 100644 contract-tests/test/limitOrders.precompile.test.ts create mode 100644 precompiles/src/limit_orders.rs create mode 100644 precompiles/src/solidity/limitOrders.abi create mode 100644 precompiles/src/solidity/limitOrders.sol diff --git a/Cargo.lock b/Cargo.lock index 3cce88c43d..cc54718b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18287,6 +18287,7 @@ dependencies = [ "pallet-evm-precompile-modexp", "pallet-evm-precompile-sha3fips", "pallet-evm-precompile-simple", + "pallet-limit-orders", "pallet-preimage", "pallet-scheduler", "pallet-shield", diff --git a/contract-tests/src/contracts/limitOrders.ts b/contract-tests/src/contracts/limitOrders.ts new file mode 100644 index 0000000000..b1bf0b9d1c --- /dev/null +++ b/contract-tests/src/contracts/limitOrders.ts @@ -0,0 +1,263 @@ +export const ILIMITORDERS_ADDRESS = + "0x000000000000000000000000000000000000080e"; + +export const ILimitOrdersABI = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { internalType: "uint64", name: "limit_price", type: "uint64" }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { internalType: "address", name: "fee_recipient", type: "address" }, + { internalType: "address[]", name: "relayer", type: "address[]" }, + { internalType: "bool", name: "has_max_slippage", type: "bool" }, + { internalType: "uint32", name: "max_slippage", type: "uint32" }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + ], + name: "cancelOrder", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { internalType: "uint64", name: "limit_price", type: "uint64" }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { internalType: "address", name: "fee_recipient", type: "address" }, + { internalType: "address[]", name: "relayer", type: "address[]" }, + { internalType: "bool", name: "has_max_slippage", type: "bool" }, + { internalType: "uint32", name: "max_slippage", type: "uint32" }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + ], + name: "deriveOrderId", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint16", name: "netuid", type: "uint16" }, + { + components: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { + internalType: "uint64", + name: "limit_price", + type: "uint64", + }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { + internalType: "address", + name: "fee_recipient", + type: "address", + }, + { + internalType: "address[]", + name: "relayer", + type: "address[]", + }, + { + internalType: "bool", + name: "has_max_slippage", + type: "bool", + }, + { + internalType: "uint32", + name: "max_slippage", + type: "uint32", + }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { + internalType: "bool", + name: "has_partial_fill", + type: "bool", + }, + { internalType: "uint64", name: "partial_fill", type: "uint64" }, + ], + internalType: "struct SignedOrderInput[]", + name: "orders", + type: "tuple[]", + }, + ], + name: "executeBatchedOrders", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { + internalType: "uint64", + name: "limit_price", + type: "uint64", + }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { + internalType: "address", + name: "fee_recipient", + type: "address", + }, + { + internalType: "address[]", + name: "relayer", + type: "address[]", + }, + { + internalType: "bool", + name: "has_max_slippage", + type: "bool", + }, + { + internalType: "uint32", + name: "max_slippage", + type: "uint32", + }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { + internalType: "bool", + name: "has_partial_fill", + type: "bool", + }, + { internalType: "uint64", name: "partial_fill", type: "uint64" }, + ], + internalType: "struct SignedOrderInput[]", + name: "orders", + type: "tuple[]", + }, + ], + name: "executeOrders", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "getLimitOrdersEnabled", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "bytes32", name: "orderId", type: "bytes32" }], + name: "getOrderStatus", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export type OrderInput = { + signer: string; + hotkey: string; + netuid: number; + order_type: number; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; + relayer: string[]; + has_max_slippage: boolean; + max_slippage: number; + chain_id: bigint; + partial_fills_enabled: boolean; +}; + +export const FAR_FUTURE = BigInt("18446744073709551615"); + +export function buildOrderInput( + signer: string, + hotkey: string, + overrides: Partial = {}, +): OrderInput { + return { + signer, + hotkey, + netuid: 1, + order_type: 0, + amount: BigInt(1_000), + limit_price: BigInt(1_000_000_000), + expiry: FAR_FUTURE, + fee_rate: 0, + fee_recipient: signer, + relayer: [], + has_max_slippage: false, + max_slippage: 0, + chain_id: BigInt(42), + partial_fills_enabled: false, + ...overrides, + }; +} diff --git a/contract-tests/test/limitOrders.precompile.test.ts b/contract-tests/test/limitOrders.precompile.test.ts new file mode 100644 index 0000000000..ecb44a98db --- /dev/null +++ b/contract-tests/test/limitOrders.precompile.test.ts @@ -0,0 +1,52 @@ +import * as assert from "assert"; + +import { ethers } from "ethers"; +import { TypedApi } from "polkadot-api"; +import { devnet } from "@polkadot-api/descriptors"; +import { + buildOrderInput, + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, +} from "../src/contracts/limitOrders"; +import { generateRandomEthersWallet } from "../src/utils"; +import { getDevnetApi } from "../src/substrate"; +import { forceSetBalanceToEthAddress } from "../src/subtensor"; + +describe("Limit orders precompile E2E smoke", () => { + let api: TypedApi; + let wallet1: ethers.Wallet; + let wallet2: ethers.Wallet; + let limitOrdersContract: ethers.Contract; + + beforeEach(async () => { + api = await getDevnetApi(); + + wallet1 = generateRandomEthersWallet(); + wallet2 = generateRandomEthersWallet(); + limitOrdersContract = new ethers.Contract( + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, + wallet1, + ); + + await forceSetBalanceToEthAddress(api, wallet1.address); + await forceSetBalanceToEthAddress(api, wallet2.address); + }); + + it("reads pallet status through the precompile", async () => { + const enabled = await limitOrdersContract.getLimitOrdersEnabled(); + assert.strictEqual(enabled, true); + }); + + it("derives order ids and cancels orders through the precompile", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address); + + const orderId = await limitOrdersContract.deriveOrderId(order); + assert.strictEqual(await limitOrdersContract.getOrderStatus(orderId), 0); + + const tx = await limitOrdersContract.cancelOrder(order); + await tx.wait(); + + assert.strictEqual(await limitOrdersContract.getOrderStatus(orderId), 3); + }); +}); diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index ec633178bd..a182ecb9ba 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -181,6 +181,8 @@ pub mod pallet { AddressMapping, /// Voting power precompile VotingPower, + /// Limit orders precompile + LimitOrders, } #[pallet::type_value] diff --git a/precompiles/Cargo.toml b/precompiles/Cargo.toml index dd5e20dfd0..7f124f843d 100644 --- a/precompiles/Cargo.toml +++ b/precompiles/Cargo.toml @@ -39,6 +39,7 @@ pallet-subtensor-swap.workspace = true pallet-admin-utils.workspace = true subtensor-swap-interface.workspace = true pallet-crowdloan.workspace = true +pallet-limit-orders.workspace = true pallet-shield.workspace = true [lints] @@ -57,6 +58,7 @@ std = [ "pallet-alpha-assets/std", "pallet-balances/std", "pallet-crowdloan/std", + "pallet-limit-orders/std", "pallet-drand/std", "pallet-evm-precompile-bn128/std", "pallet-evm-chain-id/std", @@ -88,6 +90,7 @@ runtime-benchmarks = [ "pallet-admin-utils/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-crowdloan/runtime-benchmarks", + "pallet-limit-orders/runtime-benchmarks", "pallet-drand/runtime-benchmarks", "pallet-evm/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 39815a6946..9c7ec8a4fa 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -34,6 +34,7 @@ pub use crowdloan::CrowdloanPrecompile; pub use ed25519::Ed25519Verify; pub use extensions::PrecompileExt; pub use leasing::LeasingPrecompile; +pub use limit_orders::LimitOrdersPrecompile; pub use metagraph::MetagraphPrecompile; pub use neuron::NeuronPrecompile; pub use proxy::ProxyPrecompile; @@ -51,6 +52,7 @@ mod crowdloan; mod ed25519; mod extensions; mod leasing; +mod limit_orders; mod metagraph; mod neuron; mod proxy; @@ -76,6 +78,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -88,6 +91,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -113,6 +117,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -125,6 +130,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -139,7 +145,7 @@ where Self(Default::default()) } - pub fn used_addresses() -> [H160; 27] { + pub fn used_addresses() -> [H160; 28] { [ hash(1), hash(2), @@ -168,6 +174,7 @@ where hash(VotingPowerPrecompile::::INDEX), hash(ProxyPrecompile::::INDEX), hash(AddressMappingPrecompile::::INDEX), + hash(LimitOrdersPrecompile::::INDEX), ] } } @@ -181,6 +188,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -193,6 +201,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -276,6 +285,12 @@ where PrecompileEnum::AddressMapping, ) } + a if a == hash(LimitOrdersPrecompile::::INDEX) => { + LimitOrdersPrecompile::::try_execute::( + handle, + PrecompileEnum::LimitOrders, + ) + } _ => None, } } diff --git a/precompiles/src/limit_orders.rs b/precompiles/src/limit_orders.rs new file mode 100644 index 0000000000..c25f3e3ed6 --- /dev/null +++ b/precompiles/src/limit_orders.rs @@ -0,0 +1,317 @@ +use core::marker::PhantomData; + +use alloc::string::String; +use fp_evm::{ExitError, PrecompileFailure}; +use frame_support::dispatch::{DispatchInfo, GetDispatchInfo, PostDispatchInfo}; +use frame_support::traits::ConstU32; +use frame_support::{BoundedVec, traits::IsSubType}; +use frame_system::RawOrigin; +use pallet_evm::{AddressMapping, PrecompileHandle}; +use pallet_limit_orders::{Order, OrderStatus, OrderType, SignedOrder, VersionedOrder}; +use precompile_utils::prelude::Address; +use precompile_utils::{EvmResult, solidity::Codec}; +use sp_core::{ByteArray, H256, sr25519}; +use sp_runtime::{MultiSignature, Perbill, traits::AsSystemOriginSigner, traits::Dispatchable}; +use subtensor_runtime_common::NetUid; + +use crate::{PrecompileExt, PrecompileHandleExt}; + +pub struct LimitOrdersPrecompile(PhantomData); + +impl PrecompileExt for LimitOrdersPrecompile +where + R: frame_system::Config + + pallet_balances::Config + + pallet_evm::Config + + pallet_limit_orders::Config + + pallet_subtensor::Config + + pallet_shield::Config + + pallet_subtensor_proxy::Config + + Send + + Sync + + scale_info::TypeInfo, + R::AccountId: From<[u8; 32]> + ByteArray, + ::RuntimeOrigin: AsSystemOriginSigner + Clone, + ::RuntimeCall: From> + + GetDispatchInfo + + Dispatchable + + IsSubType> + + IsSubType> + + IsSubType> + + IsSubType>, + ::AddressMapping: AddressMapping, +{ + const INDEX: u64 = 2062; +} + +#[precompile_utils::precompile] +impl LimitOrdersPrecompile +where + R: frame_system::Config + + pallet_balances::Config + + pallet_evm::Config + + pallet_limit_orders::Config + + pallet_subtensor::Config + + pallet_shield::Config + + pallet_subtensor_proxy::Config + + Send + + Sync + + scale_info::TypeInfo, + R::AccountId: From<[u8; 32]> + ByteArray, + ::RuntimeOrigin: AsSystemOriginSigner + Clone, + ::RuntimeCall: From> + + GetDispatchInfo + + Dispatchable + + IsSubType> + + IsSubType> + + IsSubType> + + IsSubType>, + ::AddressMapping: AddressMapping, +{ + #[precompile::public("getLimitOrdersEnabled()")] + #[precompile::view] + fn get_limit_orders_enabled(_handle: &mut impl PrecompileHandle) -> EvmResult { + Ok(pallet_limit_orders::LimitOrdersEnabled::::get()) + } + + #[precompile::public("getOrderStatus(bytes32)")] + #[precompile::view] + fn get_order_status(_handle: &mut impl PrecompileHandle, order_id: H256) -> EvmResult { + Ok(order_status_to_u8(pallet_limit_orders::Orders::::get( + order_id, + ))) + } + + #[precompile::public( + "deriveOrderId((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool))" + )] + #[precompile::view] + fn derive_order_id(_handle: &mut impl PrecompileHandle, order: OrderInput) -> EvmResult { + let versioned = versioned_order_from_input::(order)?; + Ok(pallet_limit_orders::Pallet::::derive_order_id( + &versioned, + )) + } + + #[precompile::public( + "executeOrders(((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool),bytes,bool,uint64)[])" + )] + #[precompile::payable] + fn execute_orders( + handle: &mut impl PrecompileHandle, + orders: alloc::vec::Vec, + ) -> EvmResult<()> { + let batch = signed_orders_batch::(orders)?; + let call = pallet_limit_orders::Call::::execute_orders { orders: batch }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } + + #[precompile::public( + "executeBatchedOrders(uint16,((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool),bytes,bool,uint64)[])" + )] + #[precompile::payable] + fn execute_batched_orders( + handle: &mut impl PrecompileHandle, + netuid: u16, + orders: alloc::vec::Vec, + ) -> EvmResult<()> { + let batch = signed_orders_batch::(orders)?; + let call = pallet_limit_orders::Call::::execute_batched_orders { + netuid: netuid.into(), + orders: batch, + }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } + + #[precompile::public( + "cancelOrder((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool))" + )] + #[precompile::payable] + fn cancel_order(handle: &mut impl PrecompileHandle, order: OrderInput) -> EvmResult<()> { + let versioned = versioned_order_from_input::(order)?; + let call = pallet_limit_orders::Call::::cancel_order { order: versioned }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } +} + +#[derive(Codec)] +pub struct OrderInput { + signer: Address, + hotkey: Address, + netuid: u16, + order_type: u8, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: u32, + fee_recipient: Address, + relayer: alloc::vec::Vec
, + has_max_slippage: bool, + max_slippage: u32, + chain_id: u64, + partial_fills_enabled: bool, +} + +#[derive(Codec)] +pub struct SignedOrderInput { + order: OrderInput, + signature: alloc::vec::Vec, + has_partial_fill: bool, + partial_fill: u64, +} + +fn account_from_address(address: Address) -> R::AccountId +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + ::AddressMapping::into_account_id(address.0) +} + +fn order_type_from_u8(order_type: u8) -> Result { + match order_type { + 0 => Ok(OrderType::LimitBuy), + 1 => Ok(OrderType::TakeProfit), + 2 => Ok(OrderType::StopLoss), + _ => Err(PrecompileFailure::Error { + exit_status: ExitError::Other("invalid order type".into()), + }), + } +} + +fn signature_from_bytes( + signature: alloc::vec::Vec, +) -> Result { + let sig: [u8; 64] = signature + .as_slice() + .try_into() + .map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("sr25519 signature must be 64 bytes".into()), + })?; + Ok(MultiSignature::Sr25519(sr25519::Signature::from_raw(sig))) +} + +fn relayer_from_input( + relayer: alloc::vec::Vec
, +) -> Result>>, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + if relayer.is_empty() { + return Ok(None); + } + + let accounts = relayer + .into_iter() + .map(account_from_address::) + .collect::>(); + + Ok(Some(BoundedVec::try_from(accounts).map_err(|_| { + PrecompileFailure::Error { + exit_status: ExitError::Other("relayer list exceeds maximum of 10".into()), + } + })?)) +} + +fn order_from_input(order: OrderInput) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(Order { + signer: account_from_address::(order.signer), + hotkey: account_from_address::(order.hotkey), + netuid: NetUid::from(order.netuid), + order_type: order_type_from_u8(order.order_type)?, + amount: order.amount, + limit_price: order.limit_price, + expiry: order.expiry, + fee_rate: Perbill::from_parts(order.fee_rate), + fee_recipient: account_from_address::(order.fee_recipient), + relayer: relayer_from_input::(order.relayer)?, + max_slippage: if order.has_max_slippage { + Some(Perbill::from_parts(order.max_slippage)) + } else { + None + }, + chain_id: order.chain_id, + partial_fills_enabled: order.partial_fills_enabled, + }) +} + +fn versioned_order_from_input( + order: OrderInput, +) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(VersionedOrder::V1(order_from_input::(order)?)) +} + +fn signed_order_from_input( + input: SignedOrderInput, +) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(SignedOrder { + order: versioned_order_from_input::(input.order)?, + signature: signature_from_bytes(input.signature)?, + partial_fill: if input.has_partial_fill { + Some(input.partial_fill) + } else { + None + }, + }) +} + +fn signed_orders_batch( + orders: alloc::vec::Vec, +) -> Result< + BoundedVec, ::MaxOrdersPerBatch>, + PrecompileFailure, +> +where + R: frame_system::Config + pallet_evm::Config + pallet_limit_orders::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + orders + .into_iter() + .map(signed_order_from_input::) + .collect::, _>>() + .and_then(|converted| { + BoundedVec::try_from(converted).map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("orders batch exceeds maximum size".into()), + }) + }) +} + +fn order_status_to_u8(status: Option) -> u8 { + match status { + None => 0, + Some(OrderStatus::Fulfilled) => 1, + Some(OrderStatus::PartiallyFilled(_)) => 2, + Some(OrderStatus::Cancelled) => 3, + } +} diff --git a/precompiles/src/solidity/limitOrders.abi b/precompiles/src/solidity/limitOrders.abi new file mode 100644 index 0000000000..7b6802df48 --- /dev/null +++ b/precompiles/src/solidity/limitOrders.abi @@ -0,0 +1,429 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + } + ], + "name": "deriveOrderId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "has_partial_fill", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "partial_fill", + "type": "uint64" + } + ], + "internalType": "struct SignedOrderInput[]", + "name": "orders", + "type": "tuple[]" + } + ], + "name": "executeBatchedOrders", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "has_partial_fill", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "partial_fill", + "type": "uint64" + } + ], + "internalType": "struct SignedOrderInput[]", + "name": "orders", + "type": "tuple[]" + } + ], + "name": "executeOrders", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "getLimitOrdersEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "getOrderStatus", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/precompiles/src/solidity/limitOrders.sol b/precompiles/src/solidity/limitOrders.sol new file mode 100644 index 0000000000..67ac2c3b6a --- /dev/null +++ b/precompiles/src/solidity/limitOrders.sol @@ -0,0 +1,66 @@ +pragma solidity ^0.8.0; + +address constant ILIMITORDERS_ADDRESS = 0x000000000000000000000000000000000000080e; + +struct OrderInput { + address signer; + address hotkey; + uint16 netuid; + uint8 order_type; + uint64 amount; + uint64 limit_price; + uint64 expiry; + uint32 fee_rate; + address fee_recipient; + address[] relayer; + bool has_max_slippage; + uint32 max_slippage; + uint64 chain_id; + bool partial_fills_enabled; +} + +struct SignedOrderInput { + OrderInput order; + bytes signature; + bool has_partial_fill; + uint64 partial_fill; +} + +interface ILimitOrders { + /** + * @dev Returns whether the limit orders pallet is enabled. + */ + function getLimitOrdersEnabled() external view returns (bool); + + /** + * @dev Returns the on-chain status for an order id. + * 0 = none, 1 = fulfilled, 2 = partially filled, 3 = cancelled. + */ + function getOrderStatus(bytes32 orderId) external view returns (uint8); + + /** + * @dev Derives the order id from an order payload. + */ + function deriveOrderId(OrderInput calldata order) external view returns (bytes32); + + /** + * @dev Executes a batch of signed limit orders. + * The EVM caller is treated as the relayer. + */ + function executeOrders(SignedOrderInput[] calldata orders) external payable; + + /** + * @dev Executes signed limit orders for a single subnet. + * The EVM caller is treated as the relayer. + */ + function executeBatchedOrders( + uint16 netuid, + SignedOrderInput[] calldata orders + ) external payable; + + /** + * @dev Registers a cancellation intent for an order. + * The EVM caller must match the order signer. + */ + function cancelOrder(OrderInput calldata order) external payable; +} From 374d8977906020987ea7554519b3ac55dc3fcc56 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 09:52:00 +0200 Subject: [PATCH 77/85] formatting --- node/src/dev_keystore.rs | 5 ++++- pallets/limit-orders/src/benchmarking.rs | 1 - pallets/limit-orders/src/lib.rs | 7 +++++-- pallets/limit-orders/src/tests/extrinsics.rs | 8 ++++---- runtime/tests/limit_orders.rs | 1 - .../limit-orders/test-mevshield-execute-orders.ts | 14 ++++++++++---- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/node/src/dev_keystore.rs b/node/src/dev_keystore.rs index 8f15aaa1d0..e21aaa4d93 100644 --- a/node/src/dev_keystore.rs +++ b/node/src/dev_keystore.rs @@ -27,7 +27,10 @@ impl DevShieldKeystore { inner .roll_for_next_slot() .expect("initial roll should not fail"); - Self { enc_key_bytes, inner } + Self { + enc_key_bytes, + inner, + } } } diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 5ef6d50d9b..d360c2f9d5 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -1,5 +1,4 @@ //! Benchmarks for Limit Orders Pallet -#![cfg(feature = "runtime-benchmarks")] #![allow( clippy::arithmetic_side_effects, clippy::indexing_slicing, diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 2c2fd662bd..f9903b383e 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -9,9 +9,9 @@ mod tests; pub mod weights; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{BoundedVec, traits::ConstU32}; use scale_info::TypeInfo; use sp_core::H256; -use frame_support::{BoundedVec, traits::ConstU32}; use sp_runtime::{ AccountId32, MultiSignature, Perbill, traits::{ConstBool, Verify}, @@ -619,7 +619,10 @@ pub mod pallet { Error::::PriceConditionNotMet ); if let Some(forced_relayers) = order.relayer.as_ref() { - ensure!(forced_relayers.contains(relayer), Error::::RelayerMissMatch); + ensure!( + forced_relayers.contains(relayer), + Error::::RelayerMissMatch + ); } if let Some(partial_fill) = signed_order.partial_fill { ensure!( diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index d774f64628..f8f99efd3f 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -2393,7 +2393,7 @@ fn execute_orders_wrong_relayer_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // only charlie may relay this order ); let id = order_id(&signed.order); @@ -2428,7 +2428,7 @@ fn execute_orders_correct_relayer_executed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // charlie is the designated relayer + Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // charlie is the designated relayer ); let id = order_id(&signed.order); @@ -2467,7 +2467,7 @@ fn execute_batched_orders_wrong_relayer_fails_entire_batch() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order + Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // only charlie may relay this order ); assert_noop!( @@ -2501,7 +2501,7 @@ fn execute_batched_orders_correct_relayer_succeeds() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // charlie is the designated relayer + Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // charlie is the designated relayer ); let id = order_id(&signed.order); diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 331c721a79..71463bdfb2 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -2086,4 +2086,3 @@ fn execute_orders_sell_tight_slippage_partial_fill_skipped() { ); }); } - diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts index b10bea01e3..3e51be83a8 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts @@ -65,7 +65,8 @@ describeSuite({ // block for it to advance to PendingKey, which doesn't happen automatically in // manual-seal mode. const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); - if ((pendingKeyRaw as any).isNone) throw new Error("MEVShield PendingKey not available — create more blocks first"); + if ((pendingKeyRaw as any).isNone) + throw new Error("MEVShield PendingKey not available — create more blocks first"); const nextKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); const signedOrder = buildSignedOrder(polkadotJs, { @@ -83,7 +84,9 @@ describeSuite({ }); // Get alice's current nonce so we can pre-sign the inner tx at nonce+1 - const aliceNonce = ((await polkadotJs.query.system.account(alice.address)) as any).nonce.toNumber() as number; + const aliceNonce = ( + (await polkadotJs.query.system.account(alice.address)) as any + ).nonce.toNumber() as number; // Sign the inner execute_orders tx at nonce+1, then get its raw bytes const innerTx = await polkadotJs.tx.limitOrders @@ -135,7 +138,8 @@ describeSuite({ await devForceSetBalance(polkadotJs, context, relayer.address, tao(100)); const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); - if ((pendingKeyRaw as any).isNone) throw new Error("MEVShield PendingKey not available — create more blocks first"); + if ((pendingKeyRaw as any).isNone) + throw new Error("MEVShield PendingKey not available — create more blocks first"); const pendingKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); const signedOrder = buildSignedOrder(polkadotJs, { @@ -154,7 +158,9 @@ describeSuite({ // The relayer submits the encrypted execute_orders tx on Alice's behalf. // relayerNonce+0 = outer submit_encrypted, relayerNonce+1 = inner execute_orders. - const relayerNonce = ((await polkadotJs.query.system.account(relayer.address)) as any).nonce.toNumber() as number; + const relayerNonce = ( + (await polkadotJs.query.system.account(relayer.address)) as any + ).nonce.toNumber() as number; const innerTx = await polkadotJs.tx.limitOrders .executeOrders([signedOrder]) From c4f5bd60afa14c9dc1ac0004473eab8190a358b4 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 11:52:17 +0200 Subject: [PATCH 78/85] change imports --- ts-tests/utils/balance.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ts-tests/utils/balance.ts b/ts-tests/utils/balance.ts index f6fe83d3b0..b172bf1546 100644 --- a/ts-tests/utils/balance.ts +++ b/ts-tests/utils/balance.ts @@ -1,6 +1,6 @@ import { waitForTransactionWithRetry } from "./transactions.js"; import type { TypedApi } from "polkadot-api"; -import { type subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import type { subtensor } from "@polkadot-api/descriptors"; import { Keyring } from "@polkadot/keyring"; export const TAO = BigInt(1000000000); // 10^9 RAO per TAO @@ -19,6 +19,7 @@ export async function forceSetBalance( ss58Address: string, amount: bigint = tao(1e10) ): Promise { + const { MultiAddress } = await import("@polkadot-api/descriptors"); const keyring = new Keyring({ type: "sr25519" }); const alice = keyring.addFromUri("//Alice"); const internalCall = api.tx.Balances.force_set_balance({ From 55f1f2e94efb4847c2f164727055973bde9e8a42 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 14:20:42 +0200 Subject: [PATCH 79/85] clippy fixes --- node/src/dev_keystore.rs | 7 +++++++ pallets/limit-orders/src/tests/extrinsics.rs | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/node/src/dev_keystore.rs b/node/src/dev_keystore.rs index e21aaa4d93..6011021bff 100644 --- a/node/src/dev_keystore.rs +++ b/node/src/dev_keystore.rs @@ -19,6 +19,7 @@ pub struct DevShieldKeystore { } impl DevShieldKeystore { + #[allow(clippy::expect_used)] pub fn new() -> Self { let inner = MemoryShieldKeystore::new(); let enc_key_bytes = inner @@ -34,6 +35,12 @@ impl DevShieldKeystore { } } +impl Default for DevShieldKeystore { + fn default() -> Self { + Self::new() + } +} + impl ShieldKeystore for DevShieldKeystore { fn roll_for_next_slot(&self) -> TraitResult<()> { Ok(()) diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index f8f99efd3f..bb92a1744e 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -2393,7 +2393,7 @@ fn execute_orders_wrong_relayer_skipped() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // only charlie may relay this order + Some(BoundedVec::truncate_from(vec![charlie()])), // only charlie may relay this order ); let id = order_id(&signed.order); @@ -2428,7 +2428,7 @@ fn execute_orders_correct_relayer_executed() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // charlie is the designated relayer + Some(BoundedVec::truncate_from(vec![charlie()])), // charlie is the designated relayer ); let id = order_id(&signed.order); @@ -2467,7 +2467,7 @@ fn execute_batched_orders_wrong_relayer_fails_entire_batch() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // only charlie may relay this order + Some(BoundedVec::truncate_from(vec![charlie()])), // only charlie may relay this order ); assert_noop!( @@ -2501,7 +2501,7 @@ fn execute_batched_orders_correct_relayer_succeeds() { FAR_FUTURE, Perbill::zero(), fee_recipient(), - Some(BoundedVec::try_from(vec![charlie()]).expect("single-element vec fits")), // charlie is the designated relayer + Some(BoundedVec::truncate_from(vec![charlie()])), // charlie is the designated relayer ); let id = order_id(&signed.order); From 752998a3a2232f58cb0eff62a5d1f1fb78508de6 Mon Sep 17 00:00:00 2001 From: open-junius Date: Mon, 25 May 2026 22:37:23 +0800 Subject: [PATCH 80/85] cargo fmt --- precompiles/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 9c7ec8a4fa..a980106ef3 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -286,10 +286,7 @@ where ) } a if a == hash(LimitOrdersPrecompile::::INDEX) => { - LimitOrdersPrecompile::::try_execute::( - handle, - PrecompileEnum::LimitOrders, - ) + LimitOrdersPrecompile::::try_execute::(handle, PrecompileEnum::LimitOrders) } _ => None, } From e82cfd64d1fd985a7d72fbc347adc6403c0d69b4 Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 16:41:25 +0200 Subject: [PATCH 81/85] change the pallet to default false, but enable it on genesis so that tests dont break --- pallets/limit-orders/src/lib.rs | 35 ++++++++----- pallets/limit-orders/src/tests/mock.rs | 3 +- runtime/tests/limit_orders.rs | 70 +++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f9903b383e..a510b20a6b 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -4,10 +4,13 @@ pub use pallet::*; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; +mod migrations; #[cfg(test)] mod tests; pub mod weights; +type MigrationKeyMaxLen = frame_support::traits::ConstU32<128>; + use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use frame_support::{BoundedVec, traits::ConstU32}; use scale_info::TypeInfo; @@ -247,9 +250,16 @@ pub mod pallet { #[pallet::storage] pub type Orders = StorageMap<_, Blake2_128Concat, H256, OrderStatus, OptionQuery>; - /// Switch to enable/disable the pallet. true by default + /// Switch to enable/disable the pallet. + /// Defaults to `false` so bare node deployments are safe; genesis sets it to `true`. + #[pallet::storage] + pub type LimitOrdersEnabled = StorageValue<_, bool, ValueQuery, ConstBool>; + + /// Tracks which named migrations have already been applied. + /// Keyed by a short migration name; value is always `true`. #[pallet::storage] - pub type LimitOrdersEnabled = StorageValue<_, bool, ValueQuery, ConstBool>; + pub type HasMigrationRun = + StorageMap<_, Identity, BoundedVec, bool, ValueQuery>; // ── Events ──────────────────────────────────────────────────────────────── @@ -361,6 +371,10 @@ pub mod pallet { &Pallet::::pallet_account(), &T::PalletHotkey::get(), ); + // Enable the pallet on all networks that start from this genesis. + // The storage default is `false` (safe for bare upgrades); genesis + // explicitly opts new chains in. + LimitOrdersEnabled::::set(true); } } @@ -368,16 +382,13 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { - fn on_runtime_upgrade() -> Weight { - LimitOrdersEnabled::::set(false); - let pallet_acct = Self::pallet_account(); - let pallet_hotkey = T::PalletHotkey::get(); - if T::SwapInterface::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey) { - return T::DbWeight::get().reads_writes(1, 1); - } - let _ = T::SwapInterface::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); - // 1 read (already-registered check) + 1 write (LimitOrdersEnabled) + 3 writes (Owner, OwnedHotkeys, StakingHotkeys) - T::DbWeight::get().reads_writes(1, 4) + fn on_runtime_upgrade() -> frame_support::weights::Weight { + let mut weight = frame_support::weights::Weight::from_parts(0, 0); + + weight = weight + .saturating_add(migrations::migrate_register_pallet_hotkey::()); + + weight } } diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index eef35a2cb4..fd7b8a9940 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -657,9 +657,10 @@ pub fn new_test_ext() -> sp_io::TestExternalities { ext.execute_with(|| { System::set_block_number(1); MockSwap::clear_log(); - // Simulate genesis_build: claim pallet hotkey ownership so set_pallet_status(true) succeeds. + // Simulate genesis_build: register the pallet hotkey and enable the pallet. let pallet_acct: AccountId = LimitOrdersPalletId::get().into_account_truncating(); let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &PalletHotkeyAccount::get()); + LimitOrdersEnabled::set(true); }); ext } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 71463bdfb2..87b3d590d4 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -5,12 +5,16 @@ )] use codec::Encode; -use frame_support::{BoundedVec, assert_noop, assert_ok, traits::ConstU32}; +use frame_support::{BoundedVec, PalletId, assert_noop, assert_ok, traits::{ConstU32, Hooks}}; use node_subtensor_runtime::{ BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, System, pallet_subtensor, }; -use pallet_limit_orders::{Order, OrderStatus, OrderType, Orders, SignedOrder, VersionedOrder}; +use pallet_limit_orders::{ + HasMigrationRun, LimitOrdersEnabled, Order, OrderStatus, OrderType, Orders, SignedOrder, + VersionedOrder, +}; +use sp_runtime::traits::AccountIdConversion; use pallet_subtensor::{SubnetAlphaIn, SubnetMechanism, SubnetTAO}; use sp_core::{Get, H256, Pair}; use sp_keyring::Sr25519Keyring; @@ -2086,3 +2090,65 @@ fn execute_orders_sell_tight_slippage_partial_fill_skipped() { ); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Migration integration tests +// ───────────────────────────────────────────────────────────────────────────── + +fn migration_key() -> BoundedVec> { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +fn pallet_acct() -> AccountId { + PalletId(*b"bt/limit").into_account_truncating() +} + +fn pallet_hotkey() -> AccountId { + PalletId(*b"bt/lmhky").into_account_truncating() +} + +/// `on_runtime_upgrade` registers the pallet hotkey and marks the migration as run. +/// +/// Starting from the default genesis (which already registers the hotkey and +/// enables the pallet via `GenesisConfig::build`), the upgrade hook must: +/// - set `HasMigrationRun[migration_key]` to `true` +/// - leave `LimitOrdersEnabled` untouched (still `true`) +/// - leave the hotkey registration intact +#[test] +fn on_runtime_upgrade_marks_migration_run_without_touching_pallet_status() { + new_test_ext().execute_with(|| { + assert!(LimitOrdersEnabled::::get()); + assert!(!HasMigrationRun::::get(migration_key())); + assert!(SubtensorModule::coldkey_owns_hotkey(&pallet_acct(), &pallet_hotkey())); + + >::on_runtime_upgrade(); + + assert!( + HasMigrationRun::::get(migration_key()), + "migration must be marked as run" + ); + assert!( + LimitOrdersEnabled::::get(), + "upgrade must not change LimitOrdersEnabled" + ); + assert!(SubtensorModule::coldkey_owns_hotkey(&pallet_acct(), &pallet_hotkey())); + }); +} + +/// Running `on_runtime_upgrade` twice is a no-op on the second call. +#[test] +fn on_runtime_upgrade_is_idempotent() { + new_test_ext().execute_with(|| { + >::on_runtime_upgrade(); + assert!(HasMigrationRun::::get(migration_key())); + + // Second run must not change any state. + LimitOrdersEnabled::::set(false); + >::on_runtime_upgrade(); + + assert!( + !LimitOrdersEnabled::::get(), + "second upgrade must not touch LimitOrdersEnabled" + ); + }); +} From fbaedabcea2f69abd8d543efa3c8b60e79e8774c Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 16:41:44 +0200 Subject: [PATCH 82/85] add migration fail so that this does not run twice --- .../migrate_register_pallet_hotkey.rs | 158 ++++++++++++++++++ pallets/limit-orders/src/migrations/mod.rs | 2 + 2 files changed, 160 insertions(+) create mode 100644 pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs create mode 100644 pallets/limit-orders/src/migrations/mod.rs diff --git a/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs new file mode 100644 index 0000000000..29bd0857fc --- /dev/null +++ b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs @@ -0,0 +1,158 @@ +use alloc::string::String; +use frame_support::{BoundedVec, traits::Get, weights::Weight}; + +use crate::*; + +fn migration_key() -> BoundedVec { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +/// One-shot migration that disables the limit-orders pallet on first upgrade and +/// registers the pallet intermediary hotkey if it has not been registered yet. +/// +/// Guarded by `HasMigrationRun` so it is safe to include in every runtime upgrade: +/// subsequent calls return immediately after a single storage read. +pub fn migrate_register_pallet_hotkey() -> Weight { + let migration_name = migration_key(); + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{:?}' has already run. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + + log::info!( + "Running migration '{}'", + String::from_utf8_lossy(&migration_name) + ); + + // Register the pallet intermediary hotkey if it has not been registered yet. + let pallet_acct = Pallet::::pallet_account(); + let pallet_hotkey = T::PalletHotkey::get(); + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + + if !T::SwapInterface::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey) { + let _ = T::SwapInterface::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + // register_pallet_hotkey writes Owner, OwnedHotkeys, StakingHotkeys + weight = weight.saturating_add(T::DbWeight::get().writes(3)); + } + + HasMigrationRun::::insert(&migration_name, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{:?}' completed successfully.", + String::from_utf8_lossy(&migration_name) + ); + + weight +} + +#[cfg(test)] +mod tests { + use frame_support::traits::{Get, Hooks}; + use sp_runtime::traits::AccountIdConversion; + + use super::*; + use crate::tests::mock::{ + LimitOrdersPalletId, MockSwap, PalletHotkeyAccount, System, Test, + }; + + /// Minimal externalities: system genesis only, no pallet hotkey pre-registered, + /// `LimitOrdersEnabled` at its storage default (`false`). + fn migration_ext() -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| System::set_block_number(1)); + ext + } + + #[test] + fn migration_registers_hotkey_and_marks_run_on_first_call() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + assert!(!MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(!HasMigrationRun::::get(migration_key())); + + migrate_register_pallet_hotkey::(); + + assert!( + MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey), + "hotkey must be registered after migration" + ); + assert!( + HasMigrationRun::::get(migration_key()), + "migration must be marked as run" + ); + // Migration no longer touches LimitOrdersEnabled — value is unchanged. + assert!(!LimitOrdersEnabled::::get()); + }); + } + + #[test] + fn migration_does_not_touch_limit_orders_enabled() { + migration_ext().execute_with(|| { + // Enable the pallet before running the migration (simulates a chain + // that already had it enabled via genesis or admin action). + LimitOrdersEnabled::::set(true); + + migrate_register_pallet_hotkey::(); + + assert!( + LimitOrdersEnabled::::get(), + "migration must not change LimitOrdersEnabled" + ); + }); + } + + #[test] + fn migration_skips_hotkey_registration_when_already_registered() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + + // Must not panic on duplicate registration. + migrate_register_pallet_hotkey::(); + + assert!(HasMigrationRun::::get(migration_key())); + }); + } + + #[test] + fn migration_is_idempotent() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + + // Second run must be a no-op — hotkey stays registered, flag stays set. + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(HasMigrationRun::::get(migration_key())); + }); + } + + #[test] + fn on_runtime_upgrade_delegates_to_migration() { + migration_ext().execute_with(|| { + assert!(!HasMigrationRun::::get(migration_key())); + + as Hooks>::on_runtime_upgrade(); + + assert!(HasMigrationRun::::get(migration_key())); + }); + } +} diff --git a/pallets/limit-orders/src/migrations/mod.rs b/pallets/limit-orders/src/migrations/mod.rs new file mode 100644 index 0000000000..391730d481 --- /dev/null +++ b/pallets/limit-orders/src/migrations/mod.rs @@ -0,0 +1,2 @@ +mod migrate_register_pallet_hotkey; +pub use migrate_register_pallet_hotkey::*; From b4abf464d9ff214f3bd67496125326d25b4bbcdb Mon Sep 17 00:00:00 2001 From: girazoki Date: Mon, 25 May 2026 17:57:50 +0200 Subject: [PATCH 83/85] changes related to conviction --- Cargo.lock | 1 + pallets/limit-orders/Cargo.toml | 2 + pallets/limit-orders/src/lib.rs | 4 +- .../migrate_register_pallet_hotkey.rs | 6 +- pallets/limit-orders/src/tests/mock.rs | 4 +- pallets/subtensor/src/staking/order_swap.rs | 22 +-- runtime/tests/limit_orders.rs | 159 +++++++++++++++++- 7 files changed, 165 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfd6ffcd20..ae208c8153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9993,6 +9993,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", + "log", "parity-scale-codec", "scale-info", "sp-core", diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 57dacdc879..48ffc61dcb 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -14,6 +14,7 @@ scale-info.workspace = true sp-core.workspace = true sp-runtime.workspace = true sp-std.workspace = true +log.workspace = true substrate-fixed.workspace = true subtensor-runtime-common.workspace = true subtensor-macros.workspace = true @@ -41,6 +42,7 @@ std = [ "sp-keystore/std", "sp-runtime/std", "sp-std/std", + "log/std", "substrate-fixed/std", "subtensor-runtime-common/std", "subtensor-swap-interface/std", diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index a510b20a6b..20f6db6f55 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + pub use pallet::*; #[cfg(feature = "runtime-benchmarks")] @@ -561,7 +563,7 @@ pub mod pallet { } /// Account derived from the pallet's `PalletId`. - fn pallet_account() -> T::AccountId { + pub(crate) fn pallet_account() -> T::AccountId { T::PalletId::get().into_account_truncating() } diff --git a/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs index 29bd0857fc..539c689e01 100644 --- a/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs +++ b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs @@ -53,8 +53,8 @@ pub fn migrate_register_pallet_hotkey() -> Weight { #[cfg(test)] mod tests { - use frame_support::traits::{Get, Hooks}; - use sp_runtime::traits::AccountIdConversion; + use frame_support::traits::Hooks; + use sp_runtime::{BuildStorage, traits::AccountIdConversion}; use super::*; use crate::tests::mock::{ @@ -150,7 +150,7 @@ mod tests { migration_ext().execute_with(|| { assert!(!HasMigrationRun::::get(migration_key())); - as Hooks>::on_runtime_upgrade(); + as Hooks>::on_runtime_upgrade(); assert!(HasMigrationRun::::get(migration_key())); }); diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index fd7b8a9940..03d5559c91 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -23,7 +23,7 @@ use substrate_fixed::types::U96F32; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; -use crate as pallet_limit_orders; +use crate::{self as pallet_limit_orders, LimitOrdersEnabled}; // ── Runtime ────────────────────────────────────────────────────────────────── @@ -660,7 +660,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities { // Simulate genesis_build: register the pallet hotkey and enable the pallet. let pallet_acct: AccountId = LimitOrdersPalletId::get().into_account_truncating(); let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &PalletHotkeyAccount::get()); - LimitOrdersEnabled::set(true); + LimitOrdersEnabled::::set(true); }); ext } diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 49f4b0f531..2ede95b34d 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -66,26 +66,7 @@ impl OrderSwapInterface for Pallet { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; if validate { - ensure!( - Self::hotkey_account_exists(hotkey), - Error::::HotKeyAccountNotExists - ); - - ensure!(!alpha_amount.is_zero(), Error::::AmountTooLow); - let tao_equiv = T::SwapInterface::current_alpha_price(netuid) - .saturating_mul(U96F32::saturating_from_num(alpha_amount.to_u64())) - .saturating_to_num::(); - ensure!( - TaoBalance::from(tao_equiv) >= DefaultMinStake::::get(), - Error::::AmountTooLow - ); - let available = - Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); - ensure!( - available >= alpha_amount, - Error::::NotEnoughStakeToWithdraw - ); - Self::ensure_stake_operation_limit_not_exceeded(hotkey, coldkey, netuid)?; + Self::validate_remove_stake(coldkey, hotkey, netuid, alpha_amount, alpha_amount, false)?; } // `limit_price` is already in ×10⁹ scale (same as the `current_alpha_price` RPC // endpoint), which is also the scale the AMM uses for its price_limit argument. @@ -148,6 +129,7 @@ impl OrderSwapInterface for Pallet { Error::::AmountTooLow ); Self::ensure_stake_operation_limit_not_exceeded(from_hotkey, from_coldkey, netuid)?; + Self::ensure_available_to_unstake(from_coldkey, netuid, amount)?; } let available = diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 87b3d590d4..76bc663520 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -37,6 +37,10 @@ fn new_test_ext() -> sp_io::TestExternalities { /// fixed 1 TAO : 1 alpha rate without requiring pre-seeded AMM liquidity. fn setup_subnet(netuid: NetUid) { SubtensorModule::init_new_network(netuid, 0); + // Genesis forces netuid 1 to dynamic (mechanism_id = 1); override to stable + // (mechanism_id = 0) so that swaps are 1:1 with no AMM fees, matching the + // intent of every test that calls this helper. + pallet_subtensor::SubnetMechanism::::insert(netuid, 0u16); pallet_subtensor::SubtokenEnabled::::insert(netuid, true); } @@ -1700,12 +1704,19 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { // Same limit_price — trigger still met. max_slippage = None → floor = 0 // → AMM limit = 0 → no floor constraint → pool executes the sell. + // + // Sell 5× min_default_stake: the dynamic AMM deducts a small fee (~0.05%) + // from the alpha input before swapping, so the TAO output is slightly below + // the sell amount. The `validate_remove_stake` sim-swap check verifies that + // the TAO equivalent is ≥ DefaultMinStake — selling 5× ensures the fee cannot + // drag the output below that floor even on a lightly-loaded pool. + let sell_amount = min_default_stake().to_u64() * 5; let signed = make_signed_order_with_slippage_rt( alice, bob_id.clone(), netuid, OrderType::StopLoss, - min_default_stake().into(), + sell_amount, 2_000_000_000, u64::MAX, Perbill::zero(), @@ -1728,13 +1739,13 @@ fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { "order should be fulfilled when no slippage floor is set" ); - // Alice's staked alpha must have decreased by exactly min_default_stake. + // Alice's staked alpha must have decreased by the sold amount (5× min_default_stake). let remaining = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); assert_eq!( remaining, - AlphaBalance::from(min_default_stake().to_u64() * 9u64), - "alice's staked alpha should decrease by min_default_stake after StopLoss executes" + AlphaBalance::from(min_default_stake().to_u64() * 5u64), + "alice's staked alpha should decrease by 5×min_default_stake after StopLoss executes" ); }); } @@ -2121,7 +2132,7 @@ fn on_runtime_upgrade_marks_migration_run_without_touching_pallet_status() { assert!(!HasMigrationRun::::get(migration_key())); assert!(SubtensorModule::coldkey_owns_hotkey(&pallet_acct(), &pallet_hotkey())); - >::on_runtime_upgrade(); + >::on_runtime_upgrade(); assert!( HasMigrationRun::::get(migration_key()), @@ -2139,12 +2150,12 @@ fn on_runtime_upgrade_marks_migration_run_without_touching_pallet_status() { #[test] fn on_runtime_upgrade_is_idempotent() { new_test_ext().execute_with(|| { - >::on_runtime_upgrade(); + >::on_runtime_upgrade(); assert!(HasMigrationRun::::get(migration_key())); // Second run must not change any state. LimitOrdersEnabled::::set(false); - >::on_runtime_upgrade(); + >::on_runtime_upgrade(); assert!( !LimitOrdersEnabled::::get(), @@ -2152,3 +2163,137 @@ fn on_runtime_upgrade_is_idempotent() { ); }); } + +// ── Conviction-lock protection ──────────────────────────────────────────────── + +/// A sell order whose alpha is fully conviction-locked is silently skipped by +/// `execute_orders` (best-effort path): the extrinsic returns `Ok`, the order +/// is never written to `Orders` storage, and the seller's staked alpha is +/// unchanged. +#[test] +fn individual_sell_order_skipped_when_alpha_is_conviction_locked() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Give alice staked alpha through bob. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 3u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // Lock ALL of alice's alpha with conviction — nothing is available to sell. + assert_ok!(SubtensorModule::do_lock_stake( + &alice_id, + netuid, + &bob_id, + initial_alpha, + )); + + let sell_amount = min_default_stake().to_u64(); + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + sell_amount, + 0, // price floor — always satisfied + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + // Best-effort: the locked order is silently skipped, extrinsic still returns Ok. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + make_order_batch(vec![signed]), + )); + + // Order must NOT be in storage — it was skipped, not fulfilled. + assert_eq!( + Orders::::get(id), + None, + "order should be skipped when alpha is conviction-locked" + ); + + // Alice's staked alpha must be completely unchanged. + let remaining = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + ); + assert_eq!( + remaining, + initial_alpha, + "conviction-locked alpha must not be moved by a skipped sell order" + ); + }); +} + +/// A batched sell order whose alpha is fully conviction-locked causes the +/// entire `execute_batched_orders` call to fail atomically with +/// `StakeUnavailable` — no state is committed. +#[test] +fn batched_sell_order_fails_when_alpha_is_conviction_locked() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Give alice staked alpha through bob. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 3u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // Lock ALL of alice's alpha with conviction — nothing is available to sell. + assert_ok!(SubtensorModule::do_lock_stake( + &alice_id, + netuid, + &bob_id, + initial_alpha, + )); + + let sell = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().to_u64(), + 0, // price floor — always satisfied + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + // Atomic path: the lock violation must revert the entire batch. + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + make_order_batch(vec![sell]), + ), + pallet_subtensor::Error::::StakeUnavailable + ); + }); +} From 358d7184de718c731d6129e201f52b91d34ca26e Mon Sep 17 00:00:00 2001 From: girazoki Date: Tue, 26 May 2026 08:58:17 +0200 Subject: [PATCH 84/85] reorg tests and clippy --- pallets/limit-orders/src/lib.rs | 2 +- .../migrate_register_pallet_hotkey.rs | 106 ----------------- pallets/limit-orders/src/tests/migration.rs | 111 ++++++++++++++++++ pallets/limit-orders/src/tests/mod.rs | 1 + 4 files changed, 113 insertions(+), 107 deletions(-) create mode 100644 pallets/limit-orders/src/tests/migration.rs diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index 20f6db6f55..ef737e890d 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -6,7 +6,7 @@ pub use pallet::*; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; -mod migrations; +pub(crate) mod migrations; #[cfg(test)] mod tests; pub mod weights; diff --git a/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs index 539c689e01..f3d6b4ef3f 100644 --- a/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs +++ b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs @@ -50,109 +50,3 @@ pub fn migrate_register_pallet_hotkey() -> Weight { weight } - -#[cfg(test)] -mod tests { - use frame_support::traits::Hooks; - use sp_runtime::{BuildStorage, traits::AccountIdConversion}; - - use super::*; - use crate::tests::mock::{ - LimitOrdersPalletId, MockSwap, PalletHotkeyAccount, System, Test, - }; - - /// Minimal externalities: system genesis only, no pallet hotkey pre-registered, - /// `LimitOrdersEnabled` at its storage default (`false`). - fn migration_ext() -> sp_io::TestExternalities { - let storage = frame_system::GenesisConfig::::default() - .build_storage() - .unwrap(); - let mut ext = sp_io::TestExternalities::new(storage); - ext.execute_with(|| System::set_block_number(1)); - ext - } - - #[test] - fn migration_registers_hotkey_and_marks_run_on_first_call() { - migration_ext().execute_with(|| { - let pallet_acct: crate::tests::mock::AccountId = - LimitOrdersPalletId::get().into_account_truncating(); - let pallet_hotkey = PalletHotkeyAccount::get(); - - assert!(!MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); - assert!(!HasMigrationRun::::get(migration_key())); - - migrate_register_pallet_hotkey::(); - - assert!( - MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey), - "hotkey must be registered after migration" - ); - assert!( - HasMigrationRun::::get(migration_key()), - "migration must be marked as run" - ); - // Migration no longer touches LimitOrdersEnabled — value is unchanged. - assert!(!LimitOrdersEnabled::::get()); - }); - } - - #[test] - fn migration_does_not_touch_limit_orders_enabled() { - migration_ext().execute_with(|| { - // Enable the pallet before running the migration (simulates a chain - // that already had it enabled via genesis or admin action). - LimitOrdersEnabled::::set(true); - - migrate_register_pallet_hotkey::(); - - assert!( - LimitOrdersEnabled::::get(), - "migration must not change LimitOrdersEnabled" - ); - }); - } - - #[test] - fn migration_skips_hotkey_registration_when_already_registered() { - migration_ext().execute_with(|| { - let pallet_acct: crate::tests::mock::AccountId = - LimitOrdersPalletId::get().into_account_truncating(); - let pallet_hotkey = PalletHotkeyAccount::get(); - let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); - - // Must not panic on duplicate registration. - migrate_register_pallet_hotkey::(); - - assert!(HasMigrationRun::::get(migration_key())); - }); - } - - #[test] - fn migration_is_idempotent() { - migration_ext().execute_with(|| { - let pallet_acct: crate::tests::mock::AccountId = - LimitOrdersPalletId::get().into_account_truncating(); - let pallet_hotkey = PalletHotkeyAccount::get(); - - migrate_register_pallet_hotkey::(); - assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); - - // Second run must be a no-op — hotkey stays registered, flag stays set. - migrate_register_pallet_hotkey::(); - assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); - assert!(HasMigrationRun::::get(migration_key())); - }); - } - - #[test] - fn on_runtime_upgrade_delegates_to_migration() { - migration_ext().execute_with(|| { - assert!(!HasMigrationRun::::get(migration_key())); - - as Hooks>::on_runtime_upgrade(); - - assert!(HasMigrationRun::::get(migration_key())); - }); - } -} diff --git a/pallets/limit-orders/src/tests/migration.rs b/pallets/limit-orders/src/tests/migration.rs new file mode 100644 index 0000000000..a04e240847 --- /dev/null +++ b/pallets/limit-orders/src/tests/migration.rs @@ -0,0 +1,111 @@ +#![allow(clippy::unwrap_used)] +//! Tests for the `migrate_register_pallet_hotkey` migration. + +use frame_support::{BoundedVec, traits::Hooks}; +use sp_runtime::{BuildStorage, traits::AccountIdConversion}; +use subtensor_swap_interface::OrderSwapInterface as _; + +use crate::{ + HasMigrationRun, LimitOrdersEnabled, MigrationKeyMaxLen, + migrations::migrate_register_pallet_hotkey, + tests::mock::{LimitOrdersPalletId, MockSwap, PalletHotkeyAccount, System, Test}, +}; + +fn migration_key() -> BoundedVec { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +/// Minimal externalities: system genesis only, no pallet hotkey pre-registered, +/// `LimitOrdersEnabled` at its storage default (`false`). +fn migration_ext() -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +#[test] +fn migration_registers_hotkey_and_marks_run_on_first_call() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + assert!(!MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(!HasMigrationRun::::get(migration_key())); + + migrate_register_pallet_hotkey::(); + + assert!( + MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey), + "hotkey must be registered after migration" + ); + assert!( + HasMigrationRun::::get(migration_key()), + "migration must be marked as run" + ); + // Migration no longer touches LimitOrdersEnabled — value is unchanged. + assert!(!LimitOrdersEnabled::::get()); + }); +} + +#[test] +fn migration_does_not_touch_limit_orders_enabled() { + migration_ext().execute_with(|| { + // Enable the pallet before running the migration (simulates a chain + // that already had it enabled via genesis or admin action). + LimitOrdersEnabled::::set(true); + + migrate_register_pallet_hotkey::(); + + assert!( + LimitOrdersEnabled::::get(), + "migration must not change LimitOrdersEnabled" + ); + }); +} + +#[test] +fn migration_skips_hotkey_registration_when_already_registered() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + + // Must not panic on duplicate registration. + migrate_register_pallet_hotkey::(); + + assert!(HasMigrationRun::::get(migration_key())); + }); +} + +#[test] +fn migration_is_idempotent() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + + // Second run must be a no-op — hotkey stays registered, flag stays set. + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(HasMigrationRun::::get(migration_key())); + }); +} + +#[test] +fn on_runtime_upgrade_delegates_to_migration() { + migration_ext().execute_with(|| { + assert!(!HasMigrationRun::::get(migration_key())); + + as Hooks>::on_runtime_upgrade(); + + assert!(HasMigrationRun::::get(migration_key())); + }); +} diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs index 9cc3736c43..95e0875b26 100644 --- a/pallets/limit-orders/src/tests/mod.rs +++ b/pallets/limit-orders/src/tests/mod.rs @@ -1,3 +1,4 @@ pub mod auxiliary; pub mod extrinsics; +pub mod migration; pub mod mock; From 18baf7c31da7536f3d658623a3e158c5fd2b0e06 Mon Sep 17 00:00:00 2001 From: open-junius Date: Tue, 26 May 2026 22:42:35 +0800 Subject: [PATCH 85/85] more e2e tests --- contract-tests/package-lock.json | 2 +- contract-tests/package.json | 2 +- contract-tests/src/contracts/limitOrders.ts | 7 + contract-tests/src/limit-orders.ts | 247 +++++++ .../test/limitOrders.precompile.test.ts | 183 ++++- contract-tests/yarn.lock | 648 +++++++++++------- precompiles/src/limit_orders.rs | 19 +- 7 files changed, 847 insertions(+), 261 deletions(-) create mode 100644 contract-tests/src/limit-orders.ts diff --git a/contract-tests/package-lock.json b/contract-tests/package-lock.json index 06c722a6de..935b787749 100644 --- a/contract-tests/package-lock.json +++ b/contract-tests/package-lock.json @@ -35,7 +35,7 @@ }, ".papi/descriptors": { "name": "@polkadot-api/descriptors", - "version": "0.1.0-autogenerated.5063582544821983772", + "version": "0.1.0-autogenerated.10455080799430942741", "peerDependencies": { "polkadot-api": ">=1.21.0" } diff --git a/contract-tests/package.json b/contract-tests/package.json index 3acf069c1d..61583a63be 100644 --- a/contract-tests/package.json +++ b/contract-tests/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --retries 3 --file src/setup.ts --require ts-node/register --extension ts \"test/**/*.ts\"" + "test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --file src/setup.ts --require ts-node/register --extension ts \"test/limitOrders.precompile.test.ts\"" }, "keywords": [], "author": "", diff --git a/contract-tests/src/contracts/limitOrders.ts b/contract-tests/src/contracts/limitOrders.ts index b1bf0b9d1c..f18686d6f3 100644 --- a/contract-tests/src/contracts/limitOrders.ts +++ b/contract-tests/src/contracts/limitOrders.ts @@ -219,6 +219,13 @@ export const ILimitOrdersABI = [ }, ] as const; +export type SignedOrderInput = { + order: OrderInput; + signature: string; + has_partial_fill: boolean; + partial_fill: bigint; +}; + export type OrderInput = { signer: string; hotkey: string; diff --git a/contract-tests/src/limit-orders.ts b/contract-tests/src/limit-orders.ts new file mode 100644 index 0000000000..96f76fd328 --- /dev/null +++ b/contract-tests/src/limit-orders.ts @@ -0,0 +1,247 @@ +import { devnet } from "@polkadot-api/descriptors"; +import { KeyPair } from "@polkadot-labs/hdkd-helpers"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { Binary, getTypedCodecs, TypedApi } from "polkadot-api"; +import type { SS58String } from "polkadot-api/ss58"; + +import { convertPublicKeyToSs58 } from "./address-utils"; +import { + buildOrderInput, + FAR_FUTURE, + OrderInput, + SignedOrderInput, +} from "./contracts/limitOrders"; +import { + getAliceSigner, + getCharlieSigner, + getSignerFromKeypair, + waitForTransactionWithRetry, +} from "./substrate"; +import { forceSetBalanceToSs58Address, setSubtokenEnable } from "./subtensor"; + +export type OrderType = "LimitBuy" | "TakeProfit" | "StopLoss"; + +export interface SubstrateOrder { + signer: string; + hotkey: string; + netuid: number; + order_type: OrderType; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; + relayer: string[] | null; + max_slippage: number | null; + chain_id: bigint; + partial_fills_enabled: boolean; +} + +export interface SubstrateVersionedOrder { + V1: SubstrateOrder; +} + +export interface SubstrateSignedOrder { + order: SubstrateVersionedOrder; + signature: { Sr25519: `0x${string}` }; + partial_fill: number | null; +} + +type VersionedOrderCodec = Awaited< + ReturnType> +>["tx"]["LimitOrders"]["execute_orders"]["inner"]["orders"]["inner"]["inner"]["order"]; + +let versionedOrderCodec: VersionedOrderCodec | undefined; + +async function getVersionedOrderCodec(): Promise { + if (versionedOrderCodec === undefined) { + const codec = await getTypedCodecs(devnet); + versionedOrderCodec = + codec.tx.LimitOrders.execute_orders.inner.orders.inner.inner.order; + } + return versionedOrderCodec; +} + +function toPapiVersionedOrder(order: SubstrateVersionedOrder) { + const inner = order.V1; + return { + type: "V1" as const, + value: { + signer: inner.signer as SS58String, + hotkey: inner.hotkey as SS58String, + netuid: inner.netuid, + order_type: { type: inner.order_type, value: undefined }, + amount: inner.amount, + limit_price: inner.limit_price, + expiry: inner.expiry, + fee_rate: inner.fee_rate, + fee_recipient: inner.fee_recipient as SS58String, + relayer: inner.relayer?.map((account) => account as SS58String), + max_slippage: inner.max_slippage ?? undefined, + chain_id: inner.chain_id, + partial_fills_enabled: inner.partial_fills_enabled, + }, + }; +} + +function toPapiSignedOrder(order: SubstrateSignedOrder) { + return { + order: toPapiVersionedOrder(order.order), + signature: { + type: "Sr25519" as const, + value: Binary.fromHex(order.signature.Sr25519), + }, + partial_fill: order.partial_fill ?? undefined, + }; +} + +export async function fetchChainId( + api: TypedApi, +): Promise { + return await api.query.EVMChainId.ChainId.getValue(); +} + +export async function ensureLimitOrdersEnabled( + api: TypedApi, +): Promise { + const enabled = await api.query.LimitOrders.LimitOrdersEnabled.getValue(); + if (enabled) { + return; + } + + const alice = getAliceSigner(); + const tx = api.tx.Sudo.sudo({ + call: api.tx.LimitOrders.set_pallet_status({ enabled: true }).decodedCall, + }); + await waitForTransactionWithRetry(api, tx, alice); +} + +export async function setupLimitOrderSubnet( + api: TypedApi, + netuid: number, +): Promise { + await setSubtokenEnable(api, netuid, true); +} + +export async function buildSubstrateSignedOrder( + api: TypedApi, + params: { + signer: KeyPair; + hotkey: string; + netuid: number; + orderType: OrderType; + amount: bigint; + limitPrice: bigint; + expiry: bigint; + feeRate: number; + feeRecipient: string; + chainId: bigint; + relayer?: string[] | null; + maxSlippage?: number | null; + partialFillsEnabled?: boolean; + }, +): Promise { + void api; + const inner: SubstrateOrder = { + signer: convertPublicKeyToSs58(params.signer.publicKey), + hotkey: params.hotkey, + netuid: params.netuid, + order_type: params.orderType, + amount: params.amount, + limit_price: params.limitPrice, + expiry: params.expiry, + fee_rate: params.feeRate, + fee_recipient: params.feeRecipient, + relayer: params.relayer ?? null, + max_slippage: params.maxSlippage ?? null, + chain_id: params.chainId, + partial_fills_enabled: params.partialFillsEnabled ?? false, + }; + + const versionedOrder: SubstrateVersionedOrder = { V1: inner }; + const orderCodec = await getVersionedOrderCodec(); + const encoded = orderCodec.enc(toPapiVersionedOrder(versionedOrder)); + const sig = params.signer.sign(encoded); + + return { + order: versionedOrder, + signature: { + Sr25519: (`0x${Buffer.from(sig).toString("hex")}`) as `0x${string}`, + }, + partial_fill: null, + }; +} + +export async function orderIdFromVersionedOrder( + api: TypedApi, + order: SubstrateVersionedOrder, +): Promise<`0x${string}`> { + void api; + const orderCodec = await getVersionedOrderCodec(); + const encoded = orderCodec.enc(toPapiVersionedOrder(order)); + return blake2AsHex(encoded, 256) as `0x${string}`; +} + +export function toPrecompileSignedOrderInput( + order: OrderInput, + signatureHex: string, + partialFill?: bigint, +): SignedOrderInput { + const normalized = signatureHex.startsWith("0x") + ? signatureHex + : `0x${signatureHex}`; + + return { + order, + signature: normalized, + has_partial_fill: partialFill !== undefined, + partial_fill: partialFill ?? BigInt(0), + }; +} + +export function buildInvalidSignedOrderInput( + signerAddress: string, + hotkeyAddress: string, + chainId: bigint, +): SignedOrderInput { + const order = buildOrderInput(signerAddress, hotkeyAddress, { chain_id: chainId }); + // sr25519 signatures are 64 bytes (128 hex chars). + return toPrecompileSignedOrderInput(order, `0x${"00".repeat(64)}`); +} + +export async function associateHotkey( + api: TypedApi, + coldkey: KeyPair, + hotkeySs58: string, +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.try_associate_hotkey({ hotkey: hotkeySs58 }); + await waitForTransactionWithRetry(api, tx, signer); +} + +export async function prepareBuyerForLimitBuy( + api: TypedApi, + buyer: KeyPair, + netuid: number, + hotkeySs58: string, +): Promise { + await setupLimitOrderSubnet(api, netuid); + await forceSetBalanceToSs58Address( + api, + convertPublicKeyToSs58(buyer.publicKey), + ); + await associateHotkey(api, buyer, hotkeySs58); +} + +export async function executeSignedOrdersViaSubstrate( + api: TypedApi, + orders: SubstrateSignedOrder[], +): Promise { + const charlie = getCharlieSigner(); + const tx = api.tx.LimitOrders.execute_orders({ + orders: orders.map(toPapiSignedOrder), + }); + await waitForTransactionWithRetry(api, tx, charlie); +} + +export { FAR_FUTURE }; diff --git a/contract-tests/test/limitOrders.precompile.test.ts b/contract-tests/test/limitOrders.precompile.test.ts index ecb44a98db..ae91b99338 100644 --- a/contract-tests/test/limitOrders.precompile.test.ts +++ b/contract-tests/test/limitOrders.precompile.test.ts @@ -1,28 +1,60 @@ import * as assert from "assert"; +import { devnet } from "@polkadot-api/descriptors"; import { ethers } from "ethers"; import { TypedApi } from "polkadot-api"; -import { devnet } from "@polkadot-api/descriptors"; + +import { convertH160ToSS58, convertPublicKeyToSs58 } from "../src/address-utils"; import { buildOrderInput, ILIMITORDERS_ADDRESS, ILimitOrdersABI, } from "../src/contracts/limitOrders"; -import { generateRandomEthersWallet } from "../src/utils"; -import { getDevnetApi } from "../src/substrate"; +import { + buildInvalidSignedOrderInput, + buildSubstrateSignedOrder, + ensureLimitOrdersEnabled, + executeSignedOrdersViaSubstrate, + fetchChainId, + orderIdFromVersionedOrder, + prepareBuyerForLimitBuy, +} from "../src/limit-orders"; +import { + getAlice, + getCharlie, + getDevnetApi, +} from "../src/substrate"; import { forceSetBalanceToEthAddress } from "../src/subtensor"; +import { generateRandomEthersWallet } from "../src/utils"; + +const NETUID = 1; +const BUY_AMOUNT = BigInt(1_000_000_000); + +async function readOrderStatus( + contract: ethers.Contract, + orderId: string, +): Promise { + return Number(await contract.getOrderStatus(orderId)); +} describe("Limit orders precompile E2E smoke", () => { let api: TypedApi; + let chainId: bigint; let wallet1: ethers.Wallet; let wallet2: ethers.Wallet; + let wallet3: ethers.Wallet; let limitOrdersContract: ethers.Contract; - beforeEach(async () => { + before(async () => { api = await getDevnetApi(); + await ensureLimitOrdersEnabled(api); + chainId = await fetchChainId(api); + }); + beforeEach(async () => { wallet1 = generateRandomEthersWallet(); wallet2 = generateRandomEthersWallet(); + wallet3 = generateRandomEthersWallet(); limitOrdersContract = new ethers.Contract( ILIMITORDERS_ADDRESS, ILimitOrdersABI, @@ -31,22 +63,155 @@ describe("Limit orders precompile E2E smoke", () => { await forceSetBalanceToEthAddress(api, wallet1.address); await forceSetBalanceToEthAddress(api, wallet2.address); + await forceSetBalanceToEthAddress(api, wallet3.address); }); - it("reads pallet status through the precompile", async () => { + it("reads pallet status through getLimitOrdersEnabled", async () => { const enabled = await limitOrdersContract.getLimitOrdersEnabled(); assert.strictEqual(enabled, true); }); - it("derives order ids and cancels orders through the precompile", async () => { - const order = buildOrderInput(wallet1.address, wallet2.address); + it("returns zero status for unknown orders via getOrderStatus", async () => { + const unknownId = ethers.id("unknown-limit-order"); + assert.strictEqual( + await readOrderStatus(limitOrdersContract, unknownId), + 0, + ); + }); + + it("derives stable order ids via deriveOrderId", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); + + const first = await limitOrdersContract.deriveOrderId(order); + const second = await limitOrdersContract.deriveOrderId(order); + assert.strictEqual(first, second); + assert.strictEqual(await readOrderStatus(limitOrdersContract, first), 0); + }); + it("matches deriveOrderId with substrate encoding for mapped EVM accounts", async () => { + const orderInput = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + fee_recipient: wallet3.address, + relayer: [wallet3.address], + has_max_slippage: true, + max_slippage: 10_000_000, + }); + + const substrateOrder = { + V1: { + signer: convertH160ToSS58(wallet1.address), + hotkey: convertH160ToSS58(wallet2.address), + netuid: NETUID, + order_type: "LimitBuy" as const, + amount: orderInput.amount, + limit_price: orderInput.limit_price, + expiry: orderInput.expiry, + fee_rate: orderInput.fee_rate, + fee_recipient: convertH160ToSS58(wallet3.address), + relayer: [convertH160ToSS58(wallet3.address)], + max_slippage: orderInput.max_slippage, + chain_id: chainId, + partial_fills_enabled: false, + }, + }; + + const precompileId = await limitOrdersContract.deriveOrderId(orderInput); + const substrateId = await orderIdFromVersionedOrder(api, substrateOrder); + assert.strictEqual(precompileId, substrateId); + }); + + it("registers cancellations through cancelOrder", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); const orderId = await limitOrdersContract.deriveOrderId(order); - assert.strictEqual(await limitOrdersContract.getOrderStatus(orderId), 0); const tx = await limitOrdersContract.cancelOrder(order); await tx.wait(); - assert.strictEqual(await limitOrdersContract.getOrderStatus(orderId), 3); + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 3); + }); + + it("rejects cancelOrder from a non-signer", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); + const otherWalletContract = new ethers.Contract( + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, + wallet2, + ); + + await assert.rejects( + otherWalletContract.cancelOrder(order), + /revert|execution reverted/i, + ); + }); + + it("accepts empty batches through executeOrders", async () => { + const tx = await limitOrdersContract.executeOrders([]); + await tx.wait(); + }); + + it("accepts empty batches through executeBatchedOrders", async () => { + const tx = await limitOrdersContract.executeBatchedOrders(NETUID, []); + await tx.wait(); + }); + + it("dispatches executeOrders without fulfilling invalid signatures", async () => { + const invalidOrder = buildInvalidSignedOrderInput( + wallet1.address, + wallet2.address, + chainId, + ); + const orderId = await limitOrdersContract.deriveOrderId(invalidOrder.order); + + const tx = await limitOrdersContract.executeOrders([invalidOrder]); + await tx.wait(); + + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 0); + }); + + it("reverts executeBatchedOrders on invalid signatures", async () => { + const invalidOrder = buildInvalidSignedOrderInput( + wallet1.address, + wallet2.address, + chainId, + ); + + await assert.rejects( + limitOrdersContract.executeBatchedOrders(NETUID, [invalidOrder], { + gasLimit: 10_000_000, + }), + /revert|execution reverted/i, + ); + }); + + it("reports fulfilled orders via getOrderStatus after substrate execution", async () => { + const alice = getAlice(); + const charlie = getCharlie(); + const aliceSs58 = convertPublicKeyToSs58(alice.publicKey); + + await prepareBuyerForLimitBuy(api, alice, NETUID, aliceSs58); + + const signed = await buildSubstrateSignedOrder(api, { + signer: alice, + hotkey: aliceSs58, + netuid: NETUID, + orderType: "LimitBuy", + amount: BUY_AMOUNT, + limitPrice: BigInt("18446744073709551615"), + expiry: BigInt("18446744073709551615"), + feeRate: 0, + feeRecipient: convertPublicKeyToSs58(charlie.publicKey), + chainId, + }); + const orderId = await orderIdFromVersionedOrder(api, signed.order); + + await executeSignedOrdersViaSubstrate(api, [signed]); + + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 1); }); }); diff --git a/contract-tests/yarn.lock b/contract-tests/yarn.lock index 080ecb1325..9a83674a7f 100644 --- a/contract-tests/yarn.lock +++ b/contract-tests/yarn.lock @@ -2,16 +2,16 @@ # yarn lockfile v1 -"@adraffy/ens-normalize@^1.10.1", "@adraffy/ens-normalize@^1.11.0": - version "1.11.1" - resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz" - integrity sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ== - "@adraffy/ens-normalize@1.10.1": version "1.10.1" resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz" integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw== +"@adraffy/ens-normalize@^1.10.1", "@adraffy/ens-normalize@^1.11.0": + version "1.11.1" + resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz" + integrity sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ== + "@babel/code-frame@^7.26.2": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" @@ -38,11 +38,136 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + "@esbuild/darwin-arm64@0.25.12": version "0.25.12" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz" integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + "@ethereumjs/rlp@^10.0.0": version "10.1.0" resolved "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.0.tgz" @@ -50,7 +175,7 @@ "@isaacs/cliui@^8.0.2": version "8.0.2" - resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== dependencies: string-width "^5.1.2" @@ -78,14 +203,6 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24": - version "0.3.31" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -94,46 +211,19 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@noble/ciphers@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz" integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== -"@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@~1.9.0", "@noble/curves@~1.9.2": - version "1.9.7" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" - integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== - dependencies: - "@noble/hashes" "1.8.0" - -"@noble/curves@^2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - -"@noble/curves@^2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - -"@noble/curves@~1.8.1": - version "1.8.2" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz" - integrity sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g== - dependencies: - "@noble/hashes" "1.7.2" - -"@noble/curves@~2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - "@noble/curves@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz" @@ -155,54 +245,55 @@ dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@^1.3.1": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.3.3", "@noble/hashes@~1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.5.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.8.0", "@noble/hashes@1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@~1.9.0", "@noble/curves@~1.9.2": + version "1.9.7" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" -"@noble/hashes@^2.0.0", "@noble/hashes@^2.0.1", "@noble/hashes@~2.0.0", "@noble/hashes@2.0.1": +"@noble/curves@^2.0.0", "@noble/curves@^2.0.1", "@noble/curves@~2.0.0": version "2.0.1" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" - integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== - -"@noble/hashes@~1.7.1", "@noble/hashes@1.7.1": - version "1.7.1" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz" - integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" + integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== + dependencies: + "@noble/hashes" "2.0.1" -"@noble/hashes@~1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/curves@~1.8.1": + version "1.8.2" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz" + integrity sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g== + dependencies: + "@noble/hashes" "1.7.2" "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/hashes@1.7.1", "@noble/hashes@~1.7.1": + version "1.7.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz" + integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + "@noble/hashes@1.7.2": version "1.7.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz" integrity sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ== +"@noble/hashes@1.8.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.3", "@noble/hashes@^1.5.0", "@noble/hashes@^1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.0", "@noble/hashes@^2.0.1", "@noble/hashes@~2.0.0": + version "2.0.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" - resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@polkadot-api/cli@0.16.3": @@ -255,10 +346,9 @@ integrity sha512-cgA9fh8dfBai9b46XaaQmj9vwzyHStQjc/xrAvQksgF6SqvZ0yAfxVqLvGrsz/Xi3dsAdKLg09PybC7MUAMv9w== "@polkadot-api/descriptors@file:.papi/descriptors": - version "0.1.0-autogenerated.5063582544821983772" - resolved "file:.papi/descriptors" + version "0.1.0-autogenerated.10455080799430942741" -"@polkadot-api/ink-contracts@^0.4.1", "@polkadot-api/ink-contracts@>=0.4.0", "@polkadot-api/ink-contracts@0.4.3": +"@polkadot-api/ink-contracts@0.4.3", "@polkadot-api/ink-contracts@^0.4.1": version "0.4.3" resolved "https://registry.npmjs.org/@polkadot-api/ink-contracts/-/ink-contracts-0.4.3.tgz" integrity sha512-Wl+4Dxjt0GAl+rADZEgrrqEesqX/xygTpX18TmzmspcKhb9QIZf9FJI8A5Sgtq0TKAOwsd1d/hbHVX3LgbXFXg== @@ -267,17 +357,17 @@ "@polkadot-api/substrate-bindings" "0.16.5" "@polkadot-api/utils" "0.2.0" -"@polkadot-api/json-rpc-provider-proxy@^0.1.0": - version "0.1.0" - resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.1.0.tgz" - integrity sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg== - "@polkadot-api/json-rpc-provider-proxy@0.2.7": version "0.2.7" resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.2.7.tgz" integrity sha512-+HM4JQXzO2GPUD2++4GOLsmFL6LO8RoLvig0HgCLuypDgfdZMlwd8KnyGHjRnVEHA5X+kvXbk84TDcAXVxTazQ== -"@polkadot-api/json-rpc-provider@^0.0.1", "@polkadot-api/json-rpc-provider@0.0.1": +"@polkadot-api/json-rpc-provider-proxy@^0.1.0": + version "0.1.0" + resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.1.0.tgz" + integrity sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg== + +"@polkadot-api/json-rpc-provider@0.0.1", "@polkadot-api/json-rpc-provider@^0.0.1": version "0.0.1" resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.0.1.tgz" integrity sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA== @@ -342,15 +432,6 @@ "@polkadot-api/metadata-builders" "0.13.7" "@polkadot-api/substrate-bindings" "0.16.5" -"@polkadot-api/observable-client@^0.3.0": - version "0.3.2" - resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.3.2.tgz" - integrity sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug== - dependencies: - "@polkadot-api/metadata-builders" "0.3.2" - "@polkadot-api/substrate-bindings" "0.6.0" - "@polkadot-api/utils" "0.1.0" - "@polkadot-api/observable-client@0.17.0": version "0.17.0" resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.17.0.tgz" @@ -361,6 +442,15 @@ "@polkadot-api/substrate-client" "0.4.7" "@polkadot-api/utils" "0.2.0" +"@polkadot-api/observable-client@^0.3.0": + version "0.3.2" + resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.3.2.tgz" + integrity sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug== + dependencies: + "@polkadot-api/metadata-builders" "0.3.2" + "@polkadot-api/substrate-bindings" "0.6.0" + "@polkadot-api/utils" "0.1.0" + "@polkadot-api/pjs-signer@0.6.17": version "0.6.17" resolved "https://registry.npmjs.org/@polkadot-api/pjs-signer/-/pjs-signer-0.6.17.tgz" @@ -432,7 +522,7 @@ "@polkadot-api/json-rpc-provider" "0.0.4" "@polkadot-api/json-rpc-provider-proxy" "0.2.7" -"@polkadot-api/smoldot@>=0.3", "@polkadot-api/smoldot@0.3.14": +"@polkadot-api/smoldot@0.3.14": version "0.3.14" resolved "https://registry.npmjs.org/@polkadot-api/smoldot/-/smoldot-0.3.14.tgz" integrity sha512-eWqO0xFQaKzqY5mRYxYuZcj1IiaLcQP+J38UQyuJgEorm+9yHVEQ/XBWoM83P+Y8TwE5IWTICp1LCVeiFQTGPQ== @@ -440,7 +530,7 @@ "@types/node" "^24.5.2" smoldot "2.0.39" -"@polkadot-api/substrate-bindings@^0.16.3", "@polkadot-api/substrate-bindings@0.16.5": +"@polkadot-api/substrate-bindings@0.16.5", "@polkadot-api/substrate-bindings@^0.16.3": version "0.16.5" resolved "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.16.5.tgz" integrity sha512-QFgNlBmtLtiUGTCTurxcE6UZrbI2DaQ5/gyIiC2FYfEhStL8tl20b09FRYHcSjY+lxN42Rcf9HVX+MCFWLYlpQ== @@ -460,14 +550,6 @@ "@scure/base" "^1.1.1" scale-ts "^1.6.0" -"@polkadot-api/substrate-client@^0.1.2", "@polkadot-api/substrate-client@0.1.4": - version "0.1.4" - resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.1.4.tgz" - integrity sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A== - dependencies: - "@polkadot-api/json-rpc-provider" "0.0.1" - "@polkadot-api/utils" "0.1.0" - "@polkadot-api/substrate-client@0.4.7": version "0.4.7" resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.4.7.tgz" @@ -477,6 +559,14 @@ "@polkadot-api/raw-client" "0.1.1" "@polkadot-api/utils" "0.2.0" +"@polkadot-api/substrate-client@^0.1.2": + version "0.1.4" + resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.1.4.tgz" + integrity sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A== + dependencies: + "@polkadot-api/json-rpc-provider" "0.0.1" + "@polkadot-api/utils" "0.1.0" + "@polkadot-api/utils@0.1.0": version "0.1.0" resolved "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.1.0.tgz" @@ -571,7 +661,7 @@ rxjs "^7.8.1" tslib "^2.8.1" -"@polkadot/api@^16.4.6", "@polkadot/api@16.5.3": +"@polkadot/api@16.5.3", "@polkadot/api@^16.4.6": version "16.5.3" resolved "https://registry.npmjs.org/@polkadot/api/-/api-16.5.3.tgz" integrity sha512-Ptwo0f5Qonmus7KIklsbFcGTdHtNjbTAwl5GGI8Mp0dmBc7Y/ISJpIJX49UrG6FhW6COMa0ItsU87XIWMRwI/Q== @@ -603,7 +693,7 @@ "@polkadot/util-crypto" "13.5.9" tslib "^2.8.0" -"@polkadot/networks@^13.5.9", "@polkadot/networks@13.5.9": +"@polkadot/networks@13.5.9", "@polkadot/networks@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/networks/-/networks-13.5.9.tgz" integrity sha512-nmKUKJjiLgcih0MkdlJNMnhEYdwEml2rv/h59ll2+rAvpsVWMTLCb6Cq6q7UC44+8kiWK2UUJMkFU+3PFFxndA== @@ -726,7 +816,7 @@ rxjs "^7.8.1" tslib "^2.8.1" -"@polkadot/util-crypto@^13.5.9": +"@polkadot/util-crypto@13.5.9", "@polkadot/util-crypto@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-13.5.9.tgz" integrity sha512-foUesMhxkTk8CZ0/XEcfvHk6I0O+aICqqVJllhOpyp/ZVnrTBKBf59T6RpsXx2pCtBlMsLRvg/6Mw7RND1HqDg== @@ -759,23 +849,7 @@ "@scure/sr25519" "^0.2.0" tslib "^2.8.0" -"@polkadot/util-crypto@13.5.9": - version "13.5.9" - resolved "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-13.5.9.tgz" - integrity sha512-foUesMhxkTk8CZ0/XEcfvHk6I0O+aICqqVJllhOpyp/ZVnrTBKBf59T6RpsXx2pCtBlMsLRvg/6Mw7RND1HqDg== - dependencies: - "@noble/curves" "^1.3.0" - "@noble/hashes" "^1.3.3" - "@polkadot/networks" "13.5.9" - "@polkadot/util" "13.5.9" - "@polkadot/wasm-crypto" "^7.5.3" - "@polkadot/wasm-util" "^7.5.3" - "@polkadot/x-bigint" "13.5.9" - "@polkadot/x-randomvalues" "13.5.9" - "@scure/base" "^1.1.7" - tslib "^2.8.0" - -"@polkadot/util@*", "@polkadot/util@^13.5.9", "@polkadot/util@13.5.9": +"@polkadot/util@13.5.9", "@polkadot/util@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/util/-/util-13.5.9.tgz" integrity sha512-pIK3XYXo7DKeFRkEBNYhf3GbCHg6dKQisSvdzZwuyzA6m7YxQq4DFw4IE464ve4Z7WsJFt3a6C9uII36hl9EWw== @@ -847,14 +921,14 @@ "@polkadot/wasm-util" "7.5.3" tslib "^2.7.0" -"@polkadot/wasm-util@*", "@polkadot/wasm-util@^7.5.3", "@polkadot/wasm-util@7.5.3": +"@polkadot/wasm-util@7.5.3", "@polkadot/wasm-util@^7.5.3": version "7.5.3" resolved "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.3.tgz" integrity sha512-hBr9bbjS+Yr7DrDUSkIIuvlTSoAlI8WXuo9YEB4C76j130u/cl+zyq6Iy/WnaTE6QH+8i9DhM8QTety6TqYnUQ== dependencies: tslib "^2.7.0" -"@polkadot/x-bigint@^13.5.9", "@polkadot/x-bigint@13.5.9": +"@polkadot/x-bigint@13.5.9", "@polkadot/x-bigint@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-13.5.9.tgz" integrity sha512-JVW6vw3e8fkcRyN9eoc6JIl63MRxNQCP/tuLdHWZts1tcAYao0hpWUzteqJY93AgvmQ91KPsC1Kf3iuuZCi74g== @@ -879,7 +953,7 @@ node-fetch "^3.3.2" tslib "^2.8.0" -"@polkadot/x-global@^13.5.9", "@polkadot/x-global@13.5.9": +"@polkadot/x-global@13.5.9", "@polkadot/x-global@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-global/-/x-global-13.5.9.tgz" integrity sha512-zSRWvELHd3Q+bFkkI1h2cWIqLo1ETm+MxkNXLec3lB56iyq/MjWBxfXnAFFYFayvlEVneo7CLHcp+YTFd9aVSA== @@ -893,7 +967,7 @@ dependencies: tslib "^2.8.0" -"@polkadot/x-randomvalues@*", "@polkadot/x-randomvalues@13.5.9": +"@polkadot/x-randomvalues@13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-13.5.9.tgz" integrity sha512-Uuuz3oubf1JCCK97fsnVUnHvk4BGp/W91mQWJlgl5TIOUSSTIRr+lb5GurCfl4kgnQq53Zi5fJV+qR9YumbnZw== @@ -950,11 +1024,116 @@ tslib "^2.8.0" ws "^8.18.0" +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + "@rollup/rollup-darwin-arm64@4.53.3": version "4.53.3" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz" integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + "@rx-state/core@^0.1.4": version "0.1.4" resolved "https://registry.npmjs.org/@rx-state/core/-/core-0.1.4.tgz" @@ -970,15 +1149,6 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== -"@scure/bip32@^1.5.0", "@scure/bip32@^1.7.0", "@scure/bip32@1.7.0": - version "1.7.0" - resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz" - integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== - dependencies: - "@noble/curves" "~1.9.0" - "@noble/hashes" "~1.8.0" - "@scure/base" "~1.2.5" - "@scure/bip32@1.6.2": version "1.6.2" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz" @@ -988,11 +1158,12 @@ "@noble/hashes" "~1.7.1" "@scure/base" "~1.2.2" -"@scure/bip39@^1.4.0", "@scure/bip39@^1.6.0", "@scure/bip39@1.6.0": - version "1.6.0" - resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz" - integrity sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A== +"@scure/bip32@1.7.0", "@scure/bip32@^1.5.0", "@scure/bip32@^1.7.0": + version "1.7.0" + resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== dependencies: + "@noble/curves" "~1.9.0" "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" @@ -1004,6 +1175,14 @@ "@noble/hashes" "~1.7.1" "@scure/base" "~1.2.4" +"@scure/bip39@1.6.0", "@scure/bip39@^1.4.0", "@scure/bip39@^1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz" + integrity sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A== + dependencies: + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + "@scure/sr25519@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@scure/sr25519/-/sr25519-0.2.0.tgz" @@ -1125,27 +1304,20 @@ dependencies: undici-types "~6.21.0" -"@types/node@^24.10.1": - version "24.10.1" - resolved "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz" - integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== +"@types/node@22.7.5": + version "22.7.5" + resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" + integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== dependencies: - undici-types "~7.16.0" + undici-types "~6.19.2" -"@types/node@^24.5.2": +"@types/node@^24.10.1", "@types/node@^24.5.2": version "24.10.1" resolved "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz" integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== dependencies: undici-types "~7.16.0" -"@types/node@22.7.5": - version "22.7.5" - resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" - integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== - dependencies: - undici-types "~6.19.2" - "@types/normalize-package-data@^2.4.3", "@types/normalize-package-data@^2.4.4": version "2.4.4" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz" @@ -1158,11 +1330,6 @@ dependencies: "@types/node" "*" -abitype@^1.0.6, abitype@^1.0.9, abitype@^1.1.1: - version "1.2.0" - resolved "https://registry.npmjs.org/abitype/-/abitype-1.2.0.tgz" - integrity sha512-fD3ROjckUrWsybaSor2AdWxzA0e/DSyV2dA4aYd7bd8orHsoJjl09fOgKfUkTDfk0BsDGBf4NBgu/c7JoS2Npw== - abitype@1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz" @@ -1173,6 +1340,11 @@ abitype@1.1.0: resolved "https://registry.npmjs.org/abitype/-/abitype-1.1.0.tgz" integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== +abitype@^1.0.6, abitype@^1.0.9, abitype@^1.1.1: + version "1.2.0" + resolved "https://registry.npmjs.org/abitype/-/abitype-1.2.0.tgz" + integrity sha512-fD3ROjckUrWsybaSor2AdWxzA0e/DSyV2dA4aYd7bd8orHsoJjl09fOgKfUkTDfk0BsDGBf4NBgu/c7JoS2Npw== + acorn-walk@^8.1.1: version "8.3.4" resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" @@ -1195,9 +1367,9 @@ ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: +ansi-regex@^6.0.1, ansi-regex@^6.2.2: version "6.2.2" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^4.0.0, ansi-styles@^4.1.0: @@ -1209,7 +1381,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: ansi-styles@^6.1.0: version "6.2.3" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== any-promise@^1.0.0: @@ -1260,10 +1432,10 @@ bn.js@^5.2.1: resolved "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz" integrity sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw== -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== +brace-expansion@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.1.tgz#c68b1c4111c76aae3a6fba55d496cee10c39dad8" + integrity sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA== dependencies: balanced-match "^1.0.0" @@ -1335,7 +1507,7 @@ chalk@^5.6.2: chokidar@^4.0.1, chokidar@^4.0.3: version "4.0.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== dependencies: readdirp "^4.0.1" @@ -1354,7 +1526,7 @@ cli-spinners@^3.2.0: cliui@^8.0.1: version "8.0.1" - resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== dependencies: string-width "^4.2.0" @@ -1373,7 +1545,7 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -commander@^14.0.2, commander@~14.0.0: +commander@^14.0.2: version "14.0.2" resolved "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz" integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== @@ -1459,7 +1631,7 @@ diff@^4.0.1: diff@^7.0.0: version "7.0.0" - resolved "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== dotenv@17.2.1: @@ -1478,7 +1650,7 @@ dunder-proto@^1.0.1: eastasianwidth@^0.2.0: version "0.2.0" - resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== emoji-regex@^8.0.0: @@ -1488,7 +1660,7 @@ emoji-regex@^8.0.0: emoji-regex@^9.2.2: version "9.2.2" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== es-define-property@^1.0.0, es-define-property@^1.0.1: @@ -1508,7 +1680,7 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" -esbuild@^0.25.0, esbuild@>=0.18: +esbuild@^0.25.0: version "0.25.12" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz" integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== @@ -1563,7 +1735,7 @@ ethers@^6.13.5: tslib "2.7.0" ws "8.17.1" -eventemitter3@^5.0.1, eventemitter3@5.0.1: +eventemitter3@5.0.1, eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== @@ -1637,7 +1809,7 @@ for-each@^0.3.5: foreground-child@^3.1.0: version "3.3.1" - resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: cross-spawn "^7.0.6" @@ -1714,7 +1886,7 @@ get-stream@^9.0.0: glob@^10.4.5: version "10.5.0" - resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" @@ -1796,7 +1968,7 @@ index-to-position@^1.1.0: inherits@^2.0.3: version "2.0.4" - resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== is-arguments@^1.0.4: @@ -1843,7 +2015,7 @@ is-nan@^1.3.2: is-path-inside@^3.0.3: version "3.0.3" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== is-plain-obj@^2.1.0: @@ -1905,7 +2077,7 @@ isows@1.0.7: jackspeak@^3.1.2: version "3.4.3" - resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== dependencies: "@isaacs/cliui" "^8.0.2" @@ -1979,7 +2151,7 @@ log-symbols@^7.0.1: lru-cache@^10.0.1, lru-cache@^10.2.0: version "10.4.3" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== lru-cache@^11.1.0: @@ -2010,16 +2182,16 @@ mimic-function@^5.0.0: integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== minimatch@^9.0.4, minimatch@^9.0.5: - version "9.0.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^2.0.2" "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== mlly@^1.7.4: version "1.8.0" @@ -2032,9 +2204,9 @@ mlly@^1.7.4: ufo "^1.6.1" mocha@^11.1.0: - version "11.7.5" - resolved "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz" - integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + version "11.7.6" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.6.tgz#ebbe22989d04cbb9424a36307320476624c41a33" + integrity sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA== dependencies: browser-stdout "^1.3.1" chokidar "^4.0.1" @@ -2221,7 +2393,7 @@ p-locate@^5.0.0: package-json-from-dist@^1.0.0: version "1.0.1" - resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== parse-json@^8.0.0, parse-json@^8.3.0: @@ -2255,7 +2427,7 @@ path-key@^4.0.0: path-scurry@^1.11.1: version "1.11.1" - resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== dependencies: lru-cache "^10.2.0" @@ -2271,7 +2443,7 @@ picocolors@^1.1.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -"picomatch@^3 || ^4", picomatch@^4.0.3: +picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -2290,7 +2462,7 @@ pkg-types@^1.3.1: mlly "^1.7.4" pathe "^2.0.1" -polkadot-api@^1.22.0, polkadot-api@^1.8.1, polkadot-api@>=1.19.0, polkadot-api@>=1.21.0: +polkadot-api@^1.22.0: version "1.22.0" resolved "https://registry.npmjs.org/polkadot-api/-/polkadot-api-1.22.0.tgz" integrity sha512-uREBLroPbnJxBBQ+qSkKLF493qukX4PAg32iThlELrZdxfNNgro6nvWRdVmBv73tFHvf+nyWWHKTx1c57nbixg== @@ -2432,7 +2604,7 @@ rollup@^4.34.8: "@rollup/rollup-win32-x64-msvc" "4.53.3" fsevents "~2.3.2" -rxjs@^7.8.1, rxjs@^7.8.2, rxjs@>=7, rxjs@>=7.8.0, rxjs@>=7.8.1: +rxjs@^7.8.1, rxjs@^7.8.2: version "7.8.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== @@ -2499,7 +2671,7 @@ signal-exit@^4.0.1, signal-exit@^4.1.0: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -smoldot@2.0.26, smoldot@2.x: +smoldot@2.0.26: version "2.0.26" resolved "https://registry.npmjs.org/smoldot/-/smoldot-2.0.26.tgz" integrity sha512-F+qYmH4z2s2FK+CxGj8moYcd1ekSIKH8ywkdqlOz88Dat35iB1DIYL11aILN46YSGMzQW/lbJNS307zBSDN5Ig== @@ -2560,7 +2732,7 @@ stdin-discarder@^0.2.2: "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -2578,7 +2750,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== dependencies: eastasianwidth "^0.2.0" @@ -2595,7 +2767,7 @@ string-width@^8.1.0: "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" @@ -2608,11 +2780,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: ansi-regex "^5.0.1" strip-ansi@^7.0.1: - version "7.1.2" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: - ansi-regex "^6.0.1" + ansi-regex "^6.2.2" strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" @@ -2731,16 +2903,16 @@ tsc-prog@^2.3.0: resolved "https://registry.npmjs.org/tsc-prog/-/tsc-prog-2.3.0.tgz" integrity sha512-ycET2d75EgcX7y8EmG4KiZkLAwUzbY4xRhA6NU0uVbHkY4ZjrAAuzTMxXI85kOwATqPnBI5C/7y7rlpY0xdqHA== -tslib@^2.1.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: - version "2.8.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== +tslib@^2.1.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsup@8.5.0: version "8.5.0" resolved "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz" @@ -2776,7 +2948,7 @@ type-fest@^5.2.0: dependencies: tagged-tag "^1.0.0" -typescript@^5.7.2, typescript@^5.9.3, typescript@>=2.7, typescript@>=4, typescript@>=4.5.0, typescript@>=5.0.4, typescript@>=5.4.0: +typescript@^5.7.2, typescript@^5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -2835,20 +3007,6 @@ validate-npm-package-license@^3.0.4: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -viem@^2.37.9: - version "2.41.2" - resolved "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz" - integrity sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g== - dependencies: - "@noble/curves" "1.9.1" - "@noble/hashes" "1.8.0" - "@scure/bip32" "1.7.0" - "@scure/bip39" "1.6.0" - abitype "1.1.0" - isows "1.0.7" - ox "0.9.6" - ws "8.18.3" - viem@2.23.4: version "2.23.4" resolved "https://registry.npmjs.org/viem/-/viem-2.23.4.tgz" @@ -2863,6 +3021,20 @@ viem@2.23.4: ox "0.6.7" ws "8.18.0" +viem@^2.37.9: + version "2.41.2" + resolved "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz" + integrity sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.1.0" + isows "1.0.7" + ox "0.9.6" + ws "8.18.3" + web-streams-polyfill@^3.0.3: version "3.3.3" resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz" @@ -2904,12 +3076,12 @@ which@^2.0.1: workerpool@^9.2.0: version "9.3.4" - resolved "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0" @@ -2927,7 +3099,7 @@ wrap-ansi@^7.0.0: wrap-ansi@^8.1.0: version "8.1.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== dependencies: ansi-styles "^6.1.0" @@ -2963,11 +3135,6 @@ write-package@^7.2.0: type-fest "^4.23.0" write-json-file "^6.0.0" -ws@*, ws@^8.18.0, ws@^8.18.2, ws@^8.18.3, ws@^8.8.1, ws@8.18.3: - version "8.18.3" - resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" - integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== - ws@8.17.1: version "8.17.1" resolved "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz" @@ -2978,6 +3145,11 @@ ws@8.18.0: resolved "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +ws@8.18.3, ws@^8.18.0, ws@^8.18.2, ws@^8.18.3, ws@^8.8.1: + version "8.18.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" @@ -2985,7 +3157,7 @@ y18n@^5.0.5: yargs-parser@^21.1.1: version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== yargs-unparser@^2.0.0: @@ -3000,7 +3172,7 @@ yargs-unparser@^2.0.0: yargs@^17.7.2: version "17.7.2" - resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== dependencies: cliui "^8.0.1" diff --git a/precompiles/src/limit_orders.rs b/precompiles/src/limit_orders.rs index c25f3e3ed6..15dc5b25a1 100644 --- a/precompiles/src/limit_orders.rs +++ b/precompiles/src/limit_orders.rs @@ -8,7 +8,7 @@ use frame_support::{BoundedVec, traits::IsSubType}; use frame_system::RawOrigin; use pallet_evm::{AddressMapping, PrecompileHandle}; use pallet_limit_orders::{Order, OrderStatus, OrderType, SignedOrder, VersionedOrder}; -use precompile_utils::prelude::Address; +use precompile_utils::prelude::{Address, UnboundedBytes}; use precompile_utils::{EvmResult, solidity::Codec}; use sp_core::{ByteArray, H256, sr25519}; use sp_runtime::{MultiSignature, Perbill, traits::AsSystemOriginSigner, traits::Dispatchable}; @@ -167,7 +167,7 @@ pub struct OrderInput { #[derive(Codec)] pub struct SignedOrderInput { order: OrderInput, - signature: alloc::vec::Vec, + signature: UnboundedBytes, has_partial_fill: bool, partial_fill: u64, } @@ -192,15 +192,10 @@ fn order_type_from_u8(order_type: u8) -> Result { } } -fn signature_from_bytes( - signature: alloc::vec::Vec, -) -> Result { - let sig: [u8; 64] = signature - .as_slice() - .try_into() - .map_err(|_| PrecompileFailure::Error { - exit_status: ExitError::Other("sr25519 signature must be 64 bytes".into()), - })?; +fn signature_from_bytes(signature: &[u8]) -> Result { + let sig: [u8; 64] = signature.try_into().map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("sr25519 signature must be 64 bytes".into()), + })?; Ok(MultiSignature::Sr25519(sr25519::Signature::from_raw(sig))) } @@ -276,7 +271,7 @@ where { Ok(SignedOrder { order: versioned_order_from_input::(input.order)?, - signature: signature_from_bytes(input.signature)?, + signature: signature_from_bytes(input.signature.as_bytes())?, partial_fill: if input.has_partial_fill { Some(input.partial_fill) } else {