diff --git a/Cargo.lock b/Cargo.lock index ef764199aa..ae0bfc75fa 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", @@ -9965,6 +9967,25 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-limit-orders" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-keyring", + "sp-runtime", + "sp-std", + "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 2a76ef639d..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 } @@ -70,7 +71,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/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml new file mode 100644 index 0000000000..302ab1fac6 --- /dev/null +++ b/pallets/limit-orders/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "pallet-limit-orders" +version = "0.1.0" +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 +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 + +[dev-dependencies] +sp-io.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/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/README.md b/pallets/limit-orders/README.md new file mode 100644 index 0000000000..24de94106e --- /dev/null +++ b/pallets/limit-orders/README.md @@ -0,0 +1,232 @@ +# 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 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 +``` + +--- + +## 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 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` + +| Variant | Action | Triggers when | Use case | +|--------------|---------------|-------------------------|----------| +| `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. | + +### `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 + +### `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. | +| `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. | + +--- + +## 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:** 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 +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** — 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 + `(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 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** — 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. + +--- + +### `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`. + +--- + +## 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 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. | + +--- + +## 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`. 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. | + +--- + +## 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 | 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 = fee_rate * amount` (using `Perbill` multiplication, which +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. 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 new file mode 100644 index 0000000000..d73a6cf0c5 --- /dev/null +++ b/pallets/limit-orders/src/lib.rs @@ -0,0 +1,921 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod tests; + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::H256; +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; + +// ── 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, +)] +pub enum OrderSide { + Buy, + 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 | +/// |--------------|--------|---------------------| +/// | `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 { + LimitBuy, + TakeProfit, + StopLoss, +} + +impl OrderType { + /// `true` if this order results in buying alpha (staking into subnet). + pub fn is_buy(&self) -> bool { + matches!(self, OrderType::LimitBuy) + } +} + +/// 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, + /// Order type (LimitBuy, TakeProfit, or StopLoss). + 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`). + /// 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, + /// 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 +/// 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. 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 signature: MultiSignature, +} + +#[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, +} + +/// Classified, fee-adjusted entry produced by `validate_and_classify`. +/// Used in every in-memory batch pipeline step; never stored on-chain. +#[derive(Debug, PartialEq)] +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). + /// For buys: `gross - fee_rate * gross`. For sells: equals `gross` (fee on TAO output). + pub(crate) net: u64, + /// Per-order fee rate. + pub(crate) fee_rate: Perbill, + /// Per-order fee recipient. + pub(crate) fee_recipient: AccountId, +} + +// ── Pallet ─────────────────────────────────────────────────────────────────── + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::{ + PalletId, + pallet_prelude::*, + traits::{Get, UnixTime}, + }; + use frame_system::pallet_prelude::*; + use sp_runtime::traits::AccountIdConversion; + use sp_std::vec::Vec; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Full swap + balance execution interface (see [`OrderSwapInterface`]). + type SwapInterface: OrderSwapInterface; + + /// Time provider for expiry checks. + type TimeProvider: UnixTime; + + /// Maximum number of orders in a single `execute_orders` call. + /// 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 ─────────────────────────────────────────────────────────────── + + /// 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>; + + /// Switch to enable/disable the pallet. true by default + #[pallet::storage] + pub type LimitOrdersEnabled = StorageValue<_, bool, ValueQuery, ConstBool>; + + // ── 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, + 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). + 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 }, + /// A user registered a cancellation intent for their order. + OrderCancelled { + order_id: H256, + signer: T::AccountId, + }, + /// 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, + }, + /// Root has either enabled(true) or disabled(false) the pallet + LimitOrdersPalletStatusChanged { enabled: bool }, + } + + // ── 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 order signer (required for cancellation). + 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, + /// Limit orders are disabled + LimitOrdersDisabled, + } + + // ── 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 { + ensure_signed(origin)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); + + for signed_order in orders { + // Best-effort: individual order failures do not revert the batch. + 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(1)] + #[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)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); + + 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 + /// 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)))] + 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 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 ────────────────────────────────────────────────────── + + 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())) + } + + /// Account derived from the pallet's `PalletId`. + fn pallet_account() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// 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. + /// 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, + now_ms: u64, + 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 + .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 + /// 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 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). + 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. + 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( + &order.signer, + &order.hotkey, + order.netuid, + tao_after_fee, + TaoBalance::from(order.limit_price), + true, + )?; + + // 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(); + } + (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), + true, + )?; + + // 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(); + } + (order.amount, tao_out.saturating_sub(fee_tao).to_u64()) + }; + + // 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, + order_type: order.order_type.clone(), + amount_in, + amount_out, + }); + + Ok(()) + } + + /// Thin orchestrator for `execute_batched_orders`. + fn do_execute_batched_orders( + 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); + + // 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)?; + + 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(|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(); + + // 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 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, + &pallet_acct, + netuid, + )?; + + // 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, + 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)`. + /// + /// 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, + ) -> Result< + ( + BoundedVec, T::MaxOrdersPerBatch>, + BoundedVec, T::MaxOrdersPerBatch>, + ), + DispatchError, + > { + let mut buys = BoundedVec::new(); + let mut sells = BoundedVec::new(); + + for signed_order in orders.iter() { + let order = &signed_order.order; + let order_id = Self::derive_order_id(order); + + // Hard-fail if the order targets a different subnet than the batch netuid. + 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)?; + + 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); + } + } + + Ok((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, T::MaxOrdersPerBatch>, + sells: &BoundedVec, T::MaxOrdersPerBatch>, + pallet_acct: &T::AccountId, + pallet_hotkey: &T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + for e in buys.iter() { + T::SwapInterface::transfer_tao(&e.signer, pallet_acct, TaoBalance::from(e.gross))?; + } + for e in sells.iter() { + T::SwapInterface::transfer_staked_alpha( + &e.signer, + &e.hotkey, + pallet_acct, + 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(()) + } + + /// 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, + ) -> 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 { + let out = T::SwapInterface::buy_alpha( + pallet_acct, + pallet_hotkey, + 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); + out + } else { + 0u128 + }; + Ok((OrderSide::Buy, actual_alpha)) + } else { + 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( + pallet_acct, + pallet_hotkey, + netuid, + AlphaBalance::from(net_alpha), + TaoBalance::ZERO, + false, + )? + .to_u64() as u128; + ensure!(out > 0, Error::::SwapReturnedZero); + out + } else { + 0u128 + }; + Ok((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`. + pub(crate) fn distribute_alpha_pro_rata( + buys: &BoundedVec, 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 => Self::tao_to_alpha(total_buy_net, current_price), + }; + + 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 + } else { + 0 + }; + if share > 0 { + T::SwapInterface::transfer_staked_alpha( + pallet_acct, + pallet_hotkey, + &e.signer, + &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); + Self::deposit_event(Event::OrderExecuted { + order_id: e.order_id, + signer: e.signer.clone(), + netuid, + order_type: e.side.clone(), + amount_in: e.gross, + amount_out: share, + }); + } + 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. + pub(crate) fn distribute_tao_pro_rata( + sells: &BoundedVec, T::MaxOrdersPerBatch>, + actual_out: u128, + total_buy_net: u128, + total_sell_tao_equiv: u128, + net_side: &OrderSide, + current_price: U96F32, + pallet_acct: &T::AccountId, + netuid: NetUid, + ) -> Result, DispatchError> { + let total_tao: u128 = match net_side { + OrderSide::Sell => actual_out.saturating_add(total_buy_net), + OrderSide::Buy => total_sell_tao_equiv, + }; + + // 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); + 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 = e.fee_rate * gross_share; + let net_share = gross_share.saturating_sub(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, + &e.signer, + TaoBalance::from(net_share), + )?; + Orders::::insert(e.order_id, OrderStatus::Fulfilled); + Self::deposit_event(Event::OrderExecuted { + order_id: e.order_id, + signer: e.signer.clone(), + netuid, + order_type: e.side.clone(), + amount_in: e.gross, + amount_out: net_share, + }); + } + Ok(sell_fees) + } + + /// Forward accumulated fees to their respective recipients. + /// + /// 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_fees: Vec<(T::AccountId, u64)>, + pallet_acct: &T::AccountId, + ) { + // 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)); + } + } + } + + // 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. + // 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. + pub(crate) 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 = 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::() + } + } +} diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs new file mode 100644 index 0000000000..abecea347c --- /dev/null +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -0,0 +1,1267 @@ +//! 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, 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::NetUid; + +use sp_runtime::Perbill; + +use crate::pallet::Pallet as LimitOrders; +use crate::{OrderEntry, OrderSide, OrderStatus, OrderType, Orders}; + +use super::mock::*; + +// ───────────────────────────────────────────────────────────────────────────── +// 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); + + let buy_order = make_signed_order( + AccountKeyring::Alice, + 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 + Perbill::zero(), + fee_recipient(), + ); + let sell_order = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid(), + OrderType::TakeProfit, + 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]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid(), + &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"); + + // 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_rate, Perbill::zero()); + + // 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); + }); +} + +#[test] +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); + + let wrong_netuid_order = make_signed_order( + AccountKeyring::Alice, + bob(), + NetUid::from(99u16), // different netuid + OrderType::LimitBuy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + ); + + let orders = bounded(vec![wrong_netuid_order]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), // batch is for netuid 1 + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderNetUidMismatch + ); + }); +} + +#[test] +fn validate_and_classify_fails_for_expired_order() { + new_test_ext().execute_with(|| { + // now_ms = 2_000_001, expiry = 2_000_000 → expired → hard failure. + MockTime::set(2_000_001); + MockSwap::set_price(1.0); + + let expired = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, // expiry already past + Perbill::zero(), + fee_recipient(), + ); + + let orders = bounded(vec![expired]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 2_000_001u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderExpired + ); + }); +} + +#[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. + MockTime::set(1_000_000); + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + 2u64, // limit_price = 2 TAO/alpha + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + ); + + let orders = bounded(vec![order]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(3u32), // current price = 3 > limit 2 → fails + ), + crate::Error::::PriceConditionNotMet + ); + }); +} + +#[test] +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, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + 2_000_000u64, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + ); + + // Pre-mark as fulfilled on-chain. + let oid = LimitOrders::::derive_order_id(&order.order); + Orders::::insert(oid, OrderStatus::Fulfilled); + + let orders = bounded(vec![order]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + ), + crate::Error::::OrderAlreadyProcessed + ); + }); +} + +#[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 + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 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]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + ) + .expect("validate_and_classify should succeed"); + + assert_eq!(buys.len(), 1); + let entry = &buys[0]; + assert_eq!(entry.gross, 1_000_000_000u64); + assert_eq!(entry.fee_rate, Perbill::from_parts(1_000_000)); + assert_eq!(entry.net, 999_000_000u64); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// distribute_alpha_pro_rata +// ───────────────────────────────────────────────────────────────────────────── +// +// 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) +// 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 +// Bob: 1000 * 200 / 1000 = 200 alpha +// Charlie: 1000 * 500 / 1000 = 500 alpha +// +// 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, + signer: AccountId, + hotkey: AccountId, + gross: u64, + net: u64, + fee_rate: Perbill, + fee_recipient: AccountId, +) -> OrderEntry { + OrderEntry { + order_id, + signer, + hotkey, + side: OrderType::LimitBuy, + gross, + net, + fee_rate, + fee_recipient, + } +} + +fn bounded_buy_entries( + v: Vec>, +) -> BoundedVec, ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +fn bounded_sell_entries( + v: Vec>, +) -> BoundedVec, ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +#[test] +fn distribute_alpha_pro_rata_buy_dominant_scenario_a() { + new_test_ext().execute_with(|| { + // 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, + 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(); + + 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(), + ) + .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_scenario_b() { + new_test_ext().execute_with(|| { + // 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, + 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(); + + 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(), + ) + .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"); + }); +} + +#[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, + 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(); + + 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(), + ) + .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(), 10); + + let entries = bounded_buy_entries(vec![ + 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( + &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(), + ) + .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()); + 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 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 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 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_scenario_a() { + new_test_ext().execute_with(|| { + // 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, + 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_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), + &pallet_acct, + netuid(), + ) + .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_fees, + vec![] as Vec<(AccountId, u64)>, + "No fees at 0 ppb" + ); + }); +} + +#[test] +fn distribute_tao_pro_rata_sell_dominant_with_fee_scenario_b() { + new_test_ext().execute_with(|| { + // 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, + 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_fees = LimitOrders::::distribute_tao_pro_rata( + &entries, + 1_200u128, + 800u128, + 2_000u128, + &OrderSide::Sell, + U96F32::from_num(2u32), + &pallet_acct, + netuid(), + ) + .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_fees, + vec![(fee_recipient(), 20u64)], + "total sell fee = 8 + 12" + ); + }); +} + +#[test] +fn distribute_tao_pro_rata_buy_dominant_scenario_c() { + new_test_ext().execute_with(|| { + // 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, + 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_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), + &pallet_acct, + netuid(), + ) + .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_fees, vec![] as Vec<(AccountId, u64)>); + }); +} + +#[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, + 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_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), + &pallet_acct, + netuid(), + ) + .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_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. + let pallet_remaining = MockSwap::tao_balance(&pallet_acct); + assert_eq!( + pallet_remaining, 1u64, + "1 TAO dust stays in pallet account, not burnt" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(|| { + 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, + Perbill::from_parts(50_000_000), // 5% of 1000 = 50 + fee_recipient(), + ), + make_buy_entry( + H256::repeat_byte(21), + bob(), + hotkey.clone(), + 1_500, + 1_350, + Perbill::from_parts(100_000_000), // 10% of 1500 = 150 + fee_recipient(), + ), + ]); + let pallet_acct = PalletHotkeyAccount::get(); + + 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 fee_recipient"); + let (from, to, amount) = &tao_transfers[0]; + assert_eq!(from, &pallet_acct, "fee comes from pallet account"); + assert_eq!(to, &fee_recipient(), "fee goes to fee_recipient"); + assert_eq!(*amount, 280u64, "total fee = 200 (buy) + 80 (sell)"); + }); +} + +#[test] +fn collect_fees_no_transfer_when_zero_fees() { + new_test_ext().execute_with(|| { + // 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, + Perbill::zero(), + fee_recipient(), + )]); + let pallet_acct = PalletHotkeyAccount::get(); + + 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"); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// is_order_valid +// ───────────────────────────────────────────────────────────────────────────── + +use crate::Error; +use codec::Encode; +use sp_core::Pair; +use sp_runtime::MultiSignature; +use subtensor_swap_interface::OrderSwapInterface; + +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 + ); + }); +} diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs new file mode 100644 index 0000000000..bb38d8b218 --- /dev/null +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -0,0 +1,1541 @@ +//! 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}; +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}; + +type LimitOrders = crate::pallet::Pallet; + +use super::mock::*; + +/// 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:?}", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cancel_order +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn cancel_order_signer_can_cancel() { + new_test_ext().execute_with(|| { + let order = 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(), + }; + 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(), + order_type: OrderType::LimitBuy, + 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!( + 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(), + order_type: OrderType::LimitBuy, + 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); + + 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(), + order_type: OrderType::LimitBuy, + 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); + + 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(), + order_type: OrderType::LimitBuy, + 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), + 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(), + OrderType::LimitBuy, + 1_000, + 2_000_000_000, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + 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::LimitBuy, + amount_in: 1_000, + amount_out: 0, + }); + }); +} + +#[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(), + OrderType::TakeProfit, + 500, + 1, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + 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::TakeProfit, + amount_in: 500, + amount_out: 0, + }); + }); +} + +#[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, + Perbill::zero(), + fee_recipient(), + ); + 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::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, + Perbill::zero(), + fee_recipient(), + ); + 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(|| { + 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()); + }); +} + +#[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(), + OrderType::LimitBuy, + 1_000, + 2, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + 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(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + 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(), + 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); + + 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); + + // fee_rate = 1% (10_000_000 parts-per-billion), recipient = fee_recipient(). + 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(), + ); + 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 fee_recipient via transfer_tao. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 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 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); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), + ); + 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"); + + // 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); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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 +// ───────────────────────────────────────────────────────────────────────────── + +#[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_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, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + 1_000_000, + Perbill::zero(), + fee_recipient(), + ); + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![expired]), + ), + Error::::OrderExpired + ); + }); +} + +#[test] +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); + + let wrong_net = make_signed_order( + AccountKeyring::Alice, + bob(), + NetUid::from(99u16), // wrong netuid + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), // batch targets netuid 1 + bounded(vec![wrong_net]), + ), + Error::::OrderNetUidMismatch + ); + }); +} + +#[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(), + OrderType::LimitBuy, + 600, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 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); + + 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(), + OrderType::TakeProfit, + 300, + 0, + FAR_FUTURE, // limit=0 → accept any price + Perbill::zero(), + fee_recipient(), + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + 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(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 600, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + 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(), + OrderType::LimitBuy, + 200, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let bob_sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 300, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + 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 fee_recipient. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + + 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% + fee_recipient(), + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_buy]), + )); + + // Fee recipient received the buy-side fee. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 10); + }); +} + +#[test] +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); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + let id = order_id(&signed.order); + Orders::::insert(id, OrderStatus::Cancelled); + + 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)); + }); +} + +#[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.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); + + 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% + fee_recipient(), + ); + let bob_sell = make_signed_order( + AccountKeyring::Bob, + 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(charlie()), + netuid(), + 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); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + 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(), + OrderType::TakeProfit, + 1_000, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + 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(), + OrderType::TakeProfit, + 1_000, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("pool error") + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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"); + }); +} + +/// 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" + ); + }); +} + +/// 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" + ); + }); +} + +/// 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/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs new file mode 100644 index 0000000000..1a5e727b2a --- /dev/null +++ b/pallets/limit-orders/src/tests/mock.rs @@ -0,0 +1,483 @@ +//! 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 std::collections::HashMap; + +use codec::Encode; +use frame_support::{ + BoundedVec, PalletId, construct_runtime, derive_impl, parameter_types, + traits::{ConstU32, Everything}, +}; +use frame_system as system; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +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); + /// 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()); + /// 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; + +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 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()); + 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) { + 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()) + } + 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, + _apply_limits: bool, + ) -> 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, + }) + }); + Ok(AlphaBalance::from(alpha_out)) + } + + fn sell_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + 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( + "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, + }) + }); + Ok(TaoBalance::from(tao_out)) + } + + 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 { + 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: amt, + }) + }); + Ok(()) + } + + fn transfer_staked_alpha( + from_coldkey: &AccountId, + from_hotkey: &AccountId, + to_coldkey: &AccountId, + 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(); + 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); + }); + 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(), + from_hotkey: from_hotkey.clone(), + to_coldkey: to_coldkey.clone(), + to_hotkey: to_hotkey.clone(), + netuid, + amount: amt, + }) + }); + 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 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 MaxOrdersPerBatch = ConstU32<64>; + type PalletId = LimitOrdersPalletId; + 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, + order_type: crate::OrderType, + 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 { + signer, + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + }; + 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 { + 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..9cc3736c43 --- /dev/null +++ b/pallets/limit-orders/src/tests/mod.rs @@ -0,0 +1,3 @@ +pub mod auxiliary; +pub mod extrinsics; +pub mod mock; diff --git a/pallets/subtensor/src/staking/mod.rs b/pallets/subtensor/src/staking/mod.rs index ad2b66189f..8a4585db30 100644 --- a/pallets/subtensor/src/staking/mod.rs +++ b/pallets/subtensor/src/staking/mod.rs @@ -6,6 +6,7 @@ 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; diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs new file mode 100644 index 0000000000..06f422abbb --- /dev/null +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -0,0 +1,156 @@ +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}; + +impl OrderSwapInterface for Pallet { + fn buy_alpha( + coldkey: &T::AccountId, + hotkey: &T::AccountId, + netuid: NetUid, + tao_amount: TaoBalance, + limit_price: TaoBalance, + validate: bool, + ) -> Result { + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + Self::ensure_subtoken_enabled(netuid)?; + if validate { + ensure!( + Self::coldkey_owns_hotkey(coldkey, 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)?; + let alpha_out = Self::stake_into_subnet( + hotkey, + coldkey, + netuid, + actual_tao, + limit_price, + false, + false, + )?; + if validate { + Self::set_stake_operation_limit(hotkey, coldkey, netuid); + } + Ok(alpha_out) + } + + fn sell_alpha( + coldkey: &T::AccountId, + hotkey: &T::AccountId, + netuid: NetUid, + alpha_amount: AlphaBalance, + limit_price: TaoBalance, + validate: bool, + ) -> Result { + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + Self::ensure_subtoken_enabled(netuid)?; + if validate { + 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())) + .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 + // (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 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, + validate_sender: 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())) + .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); + 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(), + ); + 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/pallets/swap-interface/src/lib.rs b/pallets/swap-interface/src/lib.rs deleted file mode 100644 index 1a1cd0156e..0000000000 --- a/pallets/swap-interface/src/lib.rs +++ /dev/null @@ -1,98 +0,0 @@ -#![cfg_attr(not(feature = "std"), no_std)] -use core::ops::Neg; - -use frame_support::pallet_prelude::*; -use substrate_fixed::types::U96F32; -use subtensor_macros::freeze_struct; -use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; - -pub use order::*; - -mod order; - -pub trait SwapEngine: DefaultPriceLimit { - fn swap( - netuid: NetUid, - order: O, - price_limit: TaoBalance, - drop_fees: bool, - should_rollback: bool, - ) -> Result, DispatchError>; -} - -pub trait SwapHandler { - fn swap( - netuid: NetUid, - order: O, - price_limit: TaoBalance, - drop_fees: bool, - should_rollback: bool, - ) -> Result, DispatchError> - where - Self: SwapEngine; - fn sim_swap( - netuid: NetUid, - order: O, - ) -> Result, DispatchError> - where - Self: SwapEngine; - - fn approx_fee_amount(netuid: NetUid, amount: T) -> T; - fn current_alpha_price(netuid: NetUid) -> U96F32; - fn get_protocol_tao(netuid: NetUid) -> TaoBalance; - fn max_price() -> C; - fn min_price() -> C; - fn adjust_protocol_liquidity(netuid: NetUid, tao_delta: TaoBalance, alpha_delta: AlphaBalance); - fn is_user_liquidity_enabled(netuid: NetUid) -> bool; - fn dissolve_all_liquidity_providers(netuid: NetUid) -> DispatchResult; - fn toggle_user_liquidity(netuid: NetUid, enabled: bool); - fn clear_protocol_liquidity(netuid: NetUid) -> DispatchResult; - fn get_alpha_amount_for_tao(netuid: NetUid, tao_amount: TaoBalance) -> AlphaBalance; -} - -pub trait DefaultPriceLimit -where - PaidIn: Token, - PaidOut: Token, -{ - fn default_price_limit() -> C; -} - -/// Externally used swap result (for RPC) -#[freeze_struct("6a03533fc53ccfb8")] -#[derive(Decode, Encode, PartialEq, Eq, Clone, Debug, TypeInfo)] -pub struct SwapResult -where - PaidIn: Token, - PaidOut: Token, -{ - pub amount_paid_in: PaidIn, - pub amount_paid_out: PaidOut, - pub fee_paid: PaidIn, - pub fee_to_block_author: PaidIn, -} - -impl SwapResult -where - PaidIn: Token, - PaidOut: Token, -{ - pub fn paid_in_reserve_delta(&self) -> i128 { - self.amount_paid_in.to_u64() as i128 - } - - pub fn paid_in_reserve_delta_i64(&self) -> i64 { - self.paid_in_reserve_delta() - .clamp(i64::MIN as i128, i64::MAX as i128) as i64 - } - - pub fn paid_out_reserve_delta(&self) -> i128 { - (self.amount_paid_out.to_u64() as i128).neg() - } - - pub fn paid_out_reserve_delta_i64(&self) -> i64 { - (self.amount_paid_out.to_u64() as i128) - .neg() - .clamp(i64::MIN as i128, i64::MAX as i128) as i64 - } -} 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/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs new file mode 100644 index 0000000000..40b40a39e9 --- /dev/null +++ b/primitives/swap-interface/src/lib.rs @@ -0,0 +1,188 @@ +#![cfg_attr(not(feature = "std"), no_std)] +use core::ops::Neg; + +use frame_support::pallet_prelude::*; +use substrate_fixed::types::U96F32; +use subtensor_macros::freeze_struct; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; + +pub use order::*; + +mod order; + +pub trait SwapEngine: DefaultPriceLimit { + fn swap( + netuid: NetUid, + order: O, + price_limit: TaoBalance, + drop_fees: bool, + should_rollback: bool, + ) -> Result, DispatchError>; +} + +pub trait SwapHandler { + fn swap( + netuid: NetUid, + order: O, + price_limit: TaoBalance, + drop_fees: bool, + should_rollback: bool, + ) -> Result, DispatchError> + where + Self: SwapEngine; + fn sim_swap( + netuid: NetUid, + order: O, + ) -> Result, DispatchError> + where + Self: SwapEngine; + + fn approx_fee_amount(netuid: NetUid, amount: T) -> T; + fn current_alpha_price(netuid: NetUid) -> U96F32; + fn get_protocol_tao(netuid: NetUid) -> TaoBalance; + fn max_price() -> C; + fn min_price() -> C; + fn adjust_protocol_liquidity(netuid: NetUid, tao_delta: TaoBalance, alpha_delta: AlphaBalance); + fn is_user_liquidity_enabled(netuid: NetUid) -> bool; + fn dissolve_all_liquidity_providers(netuid: NetUid) -> DispatchResult; + fn toggle_user_liquidity(netuid: NetUid, enabled: bool); + fn clear_protocol_liquidity(netuid: NetUid) -> DispatchResult; + 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`. + /// + /// 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 + /// 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, + 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 `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 + /// block). Pass `false` for internal pallet-intermediary swaps. + fn sell_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + netuid: NetUid, + alpha_amount: AlphaBalance, + limit_price: TaoBalance, + validate: bool, + ) -> Result; + + /// 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. + /// + /// 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 `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, 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, validate_receiver: true` — skips checking + /// the intermediary (which would fail) and rate-limits the buyer. + fn transfer_staked_alpha( + from_coldkey: &AccountId, + from_hotkey: &AccountId, + to_coldkey: &AccountId, + to_hotkey: &AccountId, + netuid: NetUid, + amount: AlphaBalance, + validate_sender: bool, + validate_receiver: bool, + ) -> DispatchResult; +} + +pub trait DefaultPriceLimit +where + PaidIn: Token, + PaidOut: Token, +{ + fn default_price_limit() -> C; +} + +/// Externally used swap result (for RPC) +#[freeze_struct("6a03533fc53ccfb8")] +#[derive(Decode, Encode, PartialEq, Eq, Clone, Debug, TypeInfo)] +pub struct SwapResult +where + PaidIn: Token, + PaidOut: Token, +{ + pub amount_paid_in: PaidIn, + pub amount_paid_out: PaidOut, + pub fee_paid: PaidIn, + pub fee_to_block_author: PaidIn, +} + +impl SwapResult +where + PaidIn: Token, + PaidOut: Token, +{ + pub fn paid_in_reserve_delta(&self) -> i128 { + self.amount_paid_in.to_u64() as i128 + } + + pub fn paid_in_reserve_delta_i64(&self) -> i64 { + self.paid_in_reserve_delta() + .clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } + + pub fn paid_out_reserve_delta(&self) -> i128 { + (self.amount_paid_out.to_u64() as i128).neg() + } + + pub fn paid_out_reserve_delta_i64(&self) -> i64 { + (self.amount_paid_out.to_u64() as i128) + .neg() + .clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } +} 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 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..2d65583369 --- /dev/null +++ b/runtime/tests/limit_orders.rs @@ -0,0 +1,1491 @@ +#![allow(clippy::unwrap_used)] + +use codec::Encode; +use frame_support::{BoundedVec, assert_noop, 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. + // 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::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:?})" + ); + }); +} + +/// 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 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!( + 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:?})" + ); + }); +} + +// ── 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. + // 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::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_eq!( + bob_tao, + TaoBalance::from(min_default_stake().to_u64()), + "bob should hold exactly min_default_stake TAO after buy-dominant batch" + ); + }); +} + +/// Sell side (min_default_stake()*2 alpha ≈ min_default_stake()*2 TAO at 1:1) exceeds buy side (min_default_stake() TAO). +/// +/// 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 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(|| { + 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. + // 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_eq!( + alice_alpha, + AlphaBalance::from(min_default_stake().to_u64()), + "alice should hold exactly min_default_stake alpha after sell-dominant batch" + ); + + // 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::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:?})" + ); + }); +} + +#[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 + ); + }); +} + +/// `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 + ); + }); +} + +// ── 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()); + }); +} + +// ── 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:?})" + ); + }); +}