diff --git a/Cargo.lock b/Cargo.lock index 32d4c7655d..653bb91244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8416,6 +8416,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", @@ -8459,6 +8460,7 @@ dependencies = [ "sp-genesis-builder", "sp-inherents", "sp-io", + "sp-keyring", "sp-npos-elections", "sp-offchain", "sp-runtime", @@ -9984,6 +9986,28 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-limit-orders" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-keyring", + "sp-keystore", + "sp-runtime", + "sp-std", + "substrate-fixed", + "subtensor-macros", + "subtensor-runtime-common", + "subtensor-swap-interface", +] + [[package]] name = "pallet-lottery" version = "41.0.0" @@ -18264,6 +18288,7 @@ dependencies = [ "pallet-evm-precompile-modexp", "pallet-evm-precompile-sha3fips", "pallet-evm-precompile-simple", + "pallet-limit-orders", "pallet-preimage", "pallet-scheduler", "pallet-shield", diff --git a/Cargo.toml b/Cargo.toml index 14ded6a4f9..70441ab056 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ useless_conversion = "allow" # until polkadot is patched pallet-alpha-assets = { path = "pallets/alpha-assets", default-features = false } 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 } @@ -72,7 +73,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 = "7cc54bf2d50ae3921d718736dfeb0de9468539c7", default-features = false } diff --git a/chain-extensions/Cargo.toml b/chain-extensions/Cargo.toml index ecc30878b5..74fa71e78a 100644 --- a/chain-extensions/Cargo.toml +++ b/chain-extensions/Cargo.toml @@ -84,5 +84,6 @@ runtime-benchmarks = [ "pallet-subtensor-proxy/runtime-benchmarks", "pallet-subtensor-utility/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", - "subtensor-runtime-common/runtime-benchmarks" + "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks" ] diff --git a/contract-tests/package-lock.json b/contract-tests/package-lock.json index 06c722a6de..935b787749 100644 --- a/contract-tests/package-lock.json +++ b/contract-tests/package-lock.json @@ -35,7 +35,7 @@ }, ".papi/descriptors": { "name": "@polkadot-api/descriptors", - "version": "0.1.0-autogenerated.5063582544821983772", + "version": "0.1.0-autogenerated.10455080799430942741", "peerDependencies": { "polkadot-api": ">=1.21.0" } diff --git a/contract-tests/package.json b/contract-tests/package.json index 3acf069c1d..61583a63be 100644 --- a/contract-tests/package.json +++ b/contract-tests/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --retries 3 --file src/setup.ts --require ts-node/register --extension ts \"test/**/*.ts\"" + "test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --file src/setup.ts --require ts-node/register --extension ts \"test/limitOrders.precompile.test.ts\"" }, "keywords": [], "author": "", diff --git a/contract-tests/src/contracts/limitOrders.ts b/contract-tests/src/contracts/limitOrders.ts new file mode 100644 index 0000000000..f18686d6f3 --- /dev/null +++ b/contract-tests/src/contracts/limitOrders.ts @@ -0,0 +1,270 @@ +export const ILIMITORDERS_ADDRESS = + "0x000000000000000000000000000000000000080e"; + +export const ILimitOrdersABI = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { internalType: "uint64", name: "limit_price", type: "uint64" }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { internalType: "address", name: "fee_recipient", type: "address" }, + { internalType: "address[]", name: "relayer", type: "address[]" }, + { internalType: "bool", name: "has_max_slippage", type: "bool" }, + { internalType: "uint32", name: "max_slippage", type: "uint32" }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + ], + name: "cancelOrder", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { internalType: "uint64", name: "limit_price", type: "uint64" }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { internalType: "address", name: "fee_recipient", type: "address" }, + { internalType: "address[]", name: "relayer", type: "address[]" }, + { internalType: "bool", name: "has_max_slippage", type: "bool" }, + { internalType: "uint32", name: "max_slippage", type: "uint32" }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + ], + name: "deriveOrderId", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint16", name: "netuid", type: "uint16" }, + { + components: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { + internalType: "uint64", + name: "limit_price", + type: "uint64", + }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { + internalType: "address", + name: "fee_recipient", + type: "address", + }, + { + internalType: "address[]", + name: "relayer", + type: "address[]", + }, + { + internalType: "bool", + name: "has_max_slippage", + type: "bool", + }, + { + internalType: "uint32", + name: "max_slippage", + type: "uint32", + }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { + internalType: "bool", + name: "has_partial_fill", + type: "bool", + }, + { internalType: "uint64", name: "partial_fill", type: "uint64" }, + ], + internalType: "struct SignedOrderInput[]", + name: "orders", + type: "tuple[]", + }, + ], + name: "executeBatchedOrders", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { internalType: "address", name: "signer", type: "address" }, + { internalType: "address", name: "hotkey", type: "address" }, + { internalType: "uint16", name: "netuid", type: "uint16" }, + { internalType: "uint8", name: "order_type", type: "uint8" }, + { internalType: "uint64", name: "amount", type: "uint64" }, + { + internalType: "uint64", + name: "limit_price", + type: "uint64", + }, + { internalType: "uint64", name: "expiry", type: "uint64" }, + { internalType: "uint32", name: "fee_rate", type: "uint32" }, + { + internalType: "address", + name: "fee_recipient", + type: "address", + }, + { + internalType: "address[]", + name: "relayer", + type: "address[]", + }, + { + internalType: "bool", + name: "has_max_slippage", + type: "bool", + }, + { + internalType: "uint32", + name: "max_slippage", + type: "uint32", + }, + { internalType: "uint64", name: "chain_id", type: "uint64" }, + { + internalType: "bool", + name: "partial_fills_enabled", + type: "bool", + }, + ], + internalType: "struct OrderInput", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { + internalType: "bool", + name: "has_partial_fill", + type: "bool", + }, + { internalType: "uint64", name: "partial_fill", type: "uint64" }, + ], + internalType: "struct SignedOrderInput[]", + name: "orders", + type: "tuple[]", + }, + ], + name: "executeOrders", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "getLimitOrdersEnabled", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "bytes32", name: "orderId", type: "bytes32" }], + name: "getOrderStatus", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export type SignedOrderInput = { + order: OrderInput; + signature: string; + has_partial_fill: boolean; + partial_fill: bigint; +}; + +export type OrderInput = { + signer: string; + hotkey: string; + netuid: number; + order_type: number; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; + relayer: string[]; + has_max_slippage: boolean; + max_slippage: number; + chain_id: bigint; + partial_fills_enabled: boolean; +}; + +export const FAR_FUTURE = BigInt("18446744073709551615"); + +export function buildOrderInput( + signer: string, + hotkey: string, + overrides: Partial = {}, +): OrderInput { + return { + signer, + hotkey, + netuid: 1, + order_type: 0, + amount: BigInt(1_000), + limit_price: BigInt(1_000_000_000), + expiry: FAR_FUTURE, + fee_rate: 0, + fee_recipient: signer, + relayer: [], + has_max_slippage: false, + max_slippage: 0, + chain_id: BigInt(42), + partial_fills_enabled: false, + ...overrides, + }; +} diff --git a/contract-tests/src/limit-orders.ts b/contract-tests/src/limit-orders.ts new file mode 100644 index 0000000000..96f76fd328 --- /dev/null +++ b/contract-tests/src/limit-orders.ts @@ -0,0 +1,247 @@ +import { devnet } from "@polkadot-api/descriptors"; +import { KeyPair } from "@polkadot-labs/hdkd-helpers"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { Binary, getTypedCodecs, TypedApi } from "polkadot-api"; +import type { SS58String } from "polkadot-api/ss58"; + +import { convertPublicKeyToSs58 } from "./address-utils"; +import { + buildOrderInput, + FAR_FUTURE, + OrderInput, + SignedOrderInput, +} from "./contracts/limitOrders"; +import { + getAliceSigner, + getCharlieSigner, + getSignerFromKeypair, + waitForTransactionWithRetry, +} from "./substrate"; +import { forceSetBalanceToSs58Address, setSubtokenEnable } from "./subtensor"; + +export type OrderType = "LimitBuy" | "TakeProfit" | "StopLoss"; + +export interface SubstrateOrder { + signer: string; + hotkey: string; + netuid: number; + order_type: OrderType; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; + relayer: string[] | null; + max_slippage: number | null; + chain_id: bigint; + partial_fills_enabled: boolean; +} + +export interface SubstrateVersionedOrder { + V1: SubstrateOrder; +} + +export interface SubstrateSignedOrder { + order: SubstrateVersionedOrder; + signature: { Sr25519: `0x${string}` }; + partial_fill: number | null; +} + +type VersionedOrderCodec = Awaited< + ReturnType> +>["tx"]["LimitOrders"]["execute_orders"]["inner"]["orders"]["inner"]["inner"]["order"]; + +let versionedOrderCodec: VersionedOrderCodec | undefined; + +async function getVersionedOrderCodec(): Promise { + if (versionedOrderCodec === undefined) { + const codec = await getTypedCodecs(devnet); + versionedOrderCodec = + codec.tx.LimitOrders.execute_orders.inner.orders.inner.inner.order; + } + return versionedOrderCodec; +} + +function toPapiVersionedOrder(order: SubstrateVersionedOrder) { + const inner = order.V1; + return { + type: "V1" as const, + value: { + signer: inner.signer as SS58String, + hotkey: inner.hotkey as SS58String, + netuid: inner.netuid, + order_type: { type: inner.order_type, value: undefined }, + amount: inner.amount, + limit_price: inner.limit_price, + expiry: inner.expiry, + fee_rate: inner.fee_rate, + fee_recipient: inner.fee_recipient as SS58String, + relayer: inner.relayer?.map((account) => account as SS58String), + max_slippage: inner.max_slippage ?? undefined, + chain_id: inner.chain_id, + partial_fills_enabled: inner.partial_fills_enabled, + }, + }; +} + +function toPapiSignedOrder(order: SubstrateSignedOrder) { + return { + order: toPapiVersionedOrder(order.order), + signature: { + type: "Sr25519" as const, + value: Binary.fromHex(order.signature.Sr25519), + }, + partial_fill: order.partial_fill ?? undefined, + }; +} + +export async function fetchChainId( + api: TypedApi, +): Promise { + return await api.query.EVMChainId.ChainId.getValue(); +} + +export async function ensureLimitOrdersEnabled( + api: TypedApi, +): Promise { + const enabled = await api.query.LimitOrders.LimitOrdersEnabled.getValue(); + if (enabled) { + return; + } + + const alice = getAliceSigner(); + const tx = api.tx.Sudo.sudo({ + call: api.tx.LimitOrders.set_pallet_status({ enabled: true }).decodedCall, + }); + await waitForTransactionWithRetry(api, tx, alice); +} + +export async function setupLimitOrderSubnet( + api: TypedApi, + netuid: number, +): Promise { + await setSubtokenEnable(api, netuid, true); +} + +export async function buildSubstrateSignedOrder( + api: TypedApi, + params: { + signer: KeyPair; + hotkey: string; + netuid: number; + orderType: OrderType; + amount: bigint; + limitPrice: bigint; + expiry: bigint; + feeRate: number; + feeRecipient: string; + chainId: bigint; + relayer?: string[] | null; + maxSlippage?: number | null; + partialFillsEnabled?: boolean; + }, +): Promise { + void api; + const inner: SubstrateOrder = { + signer: convertPublicKeyToSs58(params.signer.publicKey), + hotkey: params.hotkey, + netuid: params.netuid, + order_type: params.orderType, + amount: params.amount, + limit_price: params.limitPrice, + expiry: params.expiry, + fee_rate: params.feeRate, + fee_recipient: params.feeRecipient, + relayer: params.relayer ?? null, + max_slippage: params.maxSlippage ?? null, + chain_id: params.chainId, + partial_fills_enabled: params.partialFillsEnabled ?? false, + }; + + const versionedOrder: SubstrateVersionedOrder = { V1: inner }; + const orderCodec = await getVersionedOrderCodec(); + const encoded = orderCodec.enc(toPapiVersionedOrder(versionedOrder)); + const sig = params.signer.sign(encoded); + + return { + order: versionedOrder, + signature: { + Sr25519: (`0x${Buffer.from(sig).toString("hex")}`) as `0x${string}`, + }, + partial_fill: null, + }; +} + +export async function orderIdFromVersionedOrder( + api: TypedApi, + order: SubstrateVersionedOrder, +): Promise<`0x${string}`> { + void api; + const orderCodec = await getVersionedOrderCodec(); + const encoded = orderCodec.enc(toPapiVersionedOrder(order)); + return blake2AsHex(encoded, 256) as `0x${string}`; +} + +export function toPrecompileSignedOrderInput( + order: OrderInput, + signatureHex: string, + partialFill?: bigint, +): SignedOrderInput { + const normalized = signatureHex.startsWith("0x") + ? signatureHex + : `0x${signatureHex}`; + + return { + order, + signature: normalized, + has_partial_fill: partialFill !== undefined, + partial_fill: partialFill ?? BigInt(0), + }; +} + +export function buildInvalidSignedOrderInput( + signerAddress: string, + hotkeyAddress: string, + chainId: bigint, +): SignedOrderInput { + const order = buildOrderInput(signerAddress, hotkeyAddress, { chain_id: chainId }); + // sr25519 signatures are 64 bytes (128 hex chars). + return toPrecompileSignedOrderInput(order, `0x${"00".repeat(64)}`); +} + +export async function associateHotkey( + api: TypedApi, + coldkey: KeyPair, + hotkeySs58: string, +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.try_associate_hotkey({ hotkey: hotkeySs58 }); + await waitForTransactionWithRetry(api, tx, signer); +} + +export async function prepareBuyerForLimitBuy( + api: TypedApi, + buyer: KeyPair, + netuid: number, + hotkeySs58: string, +): Promise { + await setupLimitOrderSubnet(api, netuid); + await forceSetBalanceToSs58Address( + api, + convertPublicKeyToSs58(buyer.publicKey), + ); + await associateHotkey(api, buyer, hotkeySs58); +} + +export async function executeSignedOrdersViaSubstrate( + api: TypedApi, + orders: SubstrateSignedOrder[], +): Promise { + const charlie = getCharlieSigner(); + const tx = api.tx.LimitOrders.execute_orders({ + orders: orders.map(toPapiSignedOrder), + }); + await waitForTransactionWithRetry(api, tx, charlie); +} + +export { FAR_FUTURE }; diff --git a/contract-tests/test/limitOrders.precompile.test.ts b/contract-tests/test/limitOrders.precompile.test.ts new file mode 100644 index 0000000000..ae91b99338 --- /dev/null +++ b/contract-tests/test/limitOrders.precompile.test.ts @@ -0,0 +1,217 @@ +import * as assert from "assert"; + +import { devnet } from "@polkadot-api/descriptors"; +import { ethers } from "ethers"; +import { TypedApi } from "polkadot-api"; + +import { convertH160ToSS58, convertPublicKeyToSs58 } from "../src/address-utils"; +import { + buildOrderInput, + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, +} from "../src/contracts/limitOrders"; +import { + buildInvalidSignedOrderInput, + buildSubstrateSignedOrder, + ensureLimitOrdersEnabled, + executeSignedOrdersViaSubstrate, + fetchChainId, + orderIdFromVersionedOrder, + prepareBuyerForLimitBuy, +} from "../src/limit-orders"; +import { + getAlice, + getCharlie, + getDevnetApi, +} from "../src/substrate"; +import { forceSetBalanceToEthAddress } from "../src/subtensor"; +import { generateRandomEthersWallet } from "../src/utils"; + +const NETUID = 1; +const BUY_AMOUNT = BigInt(1_000_000_000); + +async function readOrderStatus( + contract: ethers.Contract, + orderId: string, +): Promise { + return Number(await contract.getOrderStatus(orderId)); +} + +describe("Limit orders precompile E2E smoke", () => { + let api: TypedApi; + let chainId: bigint; + let wallet1: ethers.Wallet; + let wallet2: ethers.Wallet; + let wallet3: ethers.Wallet; + let limitOrdersContract: ethers.Contract; + + before(async () => { + api = await getDevnetApi(); + await ensureLimitOrdersEnabled(api); + chainId = await fetchChainId(api); + }); + + beforeEach(async () => { + wallet1 = generateRandomEthersWallet(); + wallet2 = generateRandomEthersWallet(); + wallet3 = generateRandomEthersWallet(); + limitOrdersContract = new ethers.Contract( + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, + wallet1, + ); + + await forceSetBalanceToEthAddress(api, wallet1.address); + await forceSetBalanceToEthAddress(api, wallet2.address); + await forceSetBalanceToEthAddress(api, wallet3.address); + }); + + it("reads pallet status through getLimitOrdersEnabled", async () => { + const enabled = await limitOrdersContract.getLimitOrdersEnabled(); + assert.strictEqual(enabled, true); + }); + + it("returns zero status for unknown orders via getOrderStatus", async () => { + const unknownId = ethers.id("unknown-limit-order"); + assert.strictEqual( + await readOrderStatus(limitOrdersContract, unknownId), + 0, + ); + }); + + it("derives stable order ids via deriveOrderId", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); + + const first = await limitOrdersContract.deriveOrderId(order); + const second = await limitOrdersContract.deriveOrderId(order); + assert.strictEqual(first, second); + assert.strictEqual(await readOrderStatus(limitOrdersContract, first), 0); + }); + + it("matches deriveOrderId with substrate encoding for mapped EVM accounts", async () => { + const orderInput = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + fee_recipient: wallet3.address, + relayer: [wallet3.address], + has_max_slippage: true, + max_slippage: 10_000_000, + }); + + const substrateOrder = { + V1: { + signer: convertH160ToSS58(wallet1.address), + hotkey: convertH160ToSS58(wallet2.address), + netuid: NETUID, + order_type: "LimitBuy" as const, + amount: orderInput.amount, + limit_price: orderInput.limit_price, + expiry: orderInput.expiry, + fee_rate: orderInput.fee_rate, + fee_recipient: convertH160ToSS58(wallet3.address), + relayer: [convertH160ToSS58(wallet3.address)], + max_slippage: orderInput.max_slippage, + chain_id: chainId, + partial_fills_enabled: false, + }, + }; + + const precompileId = await limitOrdersContract.deriveOrderId(orderInput); + const substrateId = await orderIdFromVersionedOrder(api, substrateOrder); + assert.strictEqual(precompileId, substrateId); + }); + + it("registers cancellations through cancelOrder", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); + const orderId = await limitOrdersContract.deriveOrderId(order); + + const tx = await limitOrdersContract.cancelOrder(order); + await tx.wait(); + + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 3); + }); + + it("rejects cancelOrder from a non-signer", async () => { + const order = buildOrderInput(wallet1.address, wallet2.address, { + chain_id: chainId, + }); + const otherWalletContract = new ethers.Contract( + ILIMITORDERS_ADDRESS, + ILimitOrdersABI, + wallet2, + ); + + await assert.rejects( + otherWalletContract.cancelOrder(order), + /revert|execution reverted/i, + ); + }); + + it("accepts empty batches through executeOrders", async () => { + const tx = await limitOrdersContract.executeOrders([]); + await tx.wait(); + }); + + it("accepts empty batches through executeBatchedOrders", async () => { + const tx = await limitOrdersContract.executeBatchedOrders(NETUID, []); + await tx.wait(); + }); + + it("dispatches executeOrders without fulfilling invalid signatures", async () => { + const invalidOrder = buildInvalidSignedOrderInput( + wallet1.address, + wallet2.address, + chainId, + ); + const orderId = await limitOrdersContract.deriveOrderId(invalidOrder.order); + + const tx = await limitOrdersContract.executeOrders([invalidOrder]); + await tx.wait(); + + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 0); + }); + + it("reverts executeBatchedOrders on invalid signatures", async () => { + const invalidOrder = buildInvalidSignedOrderInput( + wallet1.address, + wallet2.address, + chainId, + ); + + await assert.rejects( + limitOrdersContract.executeBatchedOrders(NETUID, [invalidOrder], { + gasLimit: 10_000_000, + }), + /revert|execution reverted/i, + ); + }); + + it("reports fulfilled orders via getOrderStatus after substrate execution", async () => { + const alice = getAlice(); + const charlie = getCharlie(); + const aliceSs58 = convertPublicKeyToSs58(alice.publicKey); + + await prepareBuyerForLimitBuy(api, alice, NETUID, aliceSs58); + + const signed = await buildSubstrateSignedOrder(api, { + signer: alice, + hotkey: aliceSs58, + netuid: NETUID, + orderType: "LimitBuy", + amount: BUY_AMOUNT, + limitPrice: BigInt("18446744073709551615"), + expiry: BigInt("18446744073709551615"), + feeRate: 0, + feeRecipient: convertPublicKeyToSs58(charlie.publicKey), + chainId, + }); + const orderId = await orderIdFromVersionedOrder(api, signed.order); + + await executeSignedOrdersViaSubstrate(api, [signed]); + + assert.strictEqual(await readOrderStatus(limitOrdersContract, orderId), 1); + }); +}); diff --git a/contract-tests/yarn.lock b/contract-tests/yarn.lock index 080ecb1325..9a83674a7f 100644 --- a/contract-tests/yarn.lock +++ b/contract-tests/yarn.lock @@ -2,16 +2,16 @@ # yarn lockfile v1 -"@adraffy/ens-normalize@^1.10.1", "@adraffy/ens-normalize@^1.11.0": - version "1.11.1" - resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz" - integrity sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ== - "@adraffy/ens-normalize@1.10.1": version "1.10.1" resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz" integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw== +"@adraffy/ens-normalize@^1.10.1", "@adraffy/ens-normalize@^1.11.0": + version "1.11.1" + resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz" + integrity sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ== + "@babel/code-frame@^7.26.2": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" @@ -38,11 +38,136 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + "@esbuild/darwin-arm64@0.25.12": version "0.25.12" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz" integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + "@ethereumjs/rlp@^10.0.0": version "10.1.0" resolved "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.0.tgz" @@ -50,7 +175,7 @@ "@isaacs/cliui@^8.0.2": version "8.0.2" - resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== dependencies: string-width "^5.1.2" @@ -78,14 +203,6 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24": - version "0.3.31" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -94,46 +211,19 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@noble/ciphers@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz" integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== -"@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@~1.9.0", "@noble/curves@~1.9.2": - version "1.9.7" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" - integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== - dependencies: - "@noble/hashes" "1.8.0" - -"@noble/curves@^2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - -"@noble/curves@^2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - -"@noble/curves@~1.8.1": - version "1.8.2" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz" - integrity sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g== - dependencies: - "@noble/hashes" "1.7.2" - -"@noble/curves@~2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" - integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== - dependencies: - "@noble/hashes" "2.0.1" - "@noble/curves@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz" @@ -155,54 +245,55 @@ dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@^1.3.1": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.3.3", "@noble/hashes@~1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.5.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@^1.8.0", "@noble/hashes@1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@~1.9.0", "@noble/curves@~1.9.2": + version "1.9.7" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" -"@noble/hashes@^2.0.0", "@noble/hashes@^2.0.1", "@noble/hashes@~2.0.0", "@noble/hashes@2.0.1": +"@noble/curves@^2.0.0", "@noble/curves@^2.0.1", "@noble/curves@~2.0.0": version "2.0.1" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" - integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== - -"@noble/hashes@~1.7.1", "@noble/hashes@1.7.1": - version "1.7.1" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz" - integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz" + integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== + dependencies: + "@noble/hashes" "2.0.1" -"@noble/hashes@~1.8.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/curves@~1.8.1": + version "1.8.2" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz" + integrity sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g== + dependencies: + "@noble/hashes" "1.7.2" "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/hashes@1.7.1", "@noble/hashes@~1.7.1": + version "1.7.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz" + integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + "@noble/hashes@1.7.2": version "1.7.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz" integrity sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ== +"@noble/hashes@1.8.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.3", "@noble/hashes@^1.5.0", "@noble/hashes@^1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.0", "@noble/hashes@^2.0.1", "@noble/hashes@~2.0.0": + version "2.0.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" - resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@polkadot-api/cli@0.16.3": @@ -255,10 +346,9 @@ integrity sha512-cgA9fh8dfBai9b46XaaQmj9vwzyHStQjc/xrAvQksgF6SqvZ0yAfxVqLvGrsz/Xi3dsAdKLg09PybC7MUAMv9w== "@polkadot-api/descriptors@file:.papi/descriptors": - version "0.1.0-autogenerated.5063582544821983772" - resolved "file:.papi/descriptors" + version "0.1.0-autogenerated.10455080799430942741" -"@polkadot-api/ink-contracts@^0.4.1", "@polkadot-api/ink-contracts@>=0.4.0", "@polkadot-api/ink-contracts@0.4.3": +"@polkadot-api/ink-contracts@0.4.3", "@polkadot-api/ink-contracts@^0.4.1": version "0.4.3" resolved "https://registry.npmjs.org/@polkadot-api/ink-contracts/-/ink-contracts-0.4.3.tgz" integrity sha512-Wl+4Dxjt0GAl+rADZEgrrqEesqX/xygTpX18TmzmspcKhb9QIZf9FJI8A5Sgtq0TKAOwsd1d/hbHVX3LgbXFXg== @@ -267,17 +357,17 @@ "@polkadot-api/substrate-bindings" "0.16.5" "@polkadot-api/utils" "0.2.0" -"@polkadot-api/json-rpc-provider-proxy@^0.1.0": - version "0.1.0" - resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.1.0.tgz" - integrity sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg== - "@polkadot-api/json-rpc-provider-proxy@0.2.7": version "0.2.7" resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.2.7.tgz" integrity sha512-+HM4JQXzO2GPUD2++4GOLsmFL6LO8RoLvig0HgCLuypDgfdZMlwd8KnyGHjRnVEHA5X+kvXbk84TDcAXVxTazQ== -"@polkadot-api/json-rpc-provider@^0.0.1", "@polkadot-api/json-rpc-provider@0.0.1": +"@polkadot-api/json-rpc-provider-proxy@^0.1.0": + version "0.1.0" + resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.1.0.tgz" + integrity sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg== + +"@polkadot-api/json-rpc-provider@0.0.1", "@polkadot-api/json-rpc-provider@^0.0.1": version "0.0.1" resolved "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.0.1.tgz" integrity sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA== @@ -342,15 +432,6 @@ "@polkadot-api/metadata-builders" "0.13.7" "@polkadot-api/substrate-bindings" "0.16.5" -"@polkadot-api/observable-client@^0.3.0": - version "0.3.2" - resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.3.2.tgz" - integrity sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug== - dependencies: - "@polkadot-api/metadata-builders" "0.3.2" - "@polkadot-api/substrate-bindings" "0.6.0" - "@polkadot-api/utils" "0.1.0" - "@polkadot-api/observable-client@0.17.0": version "0.17.0" resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.17.0.tgz" @@ -361,6 +442,15 @@ "@polkadot-api/substrate-client" "0.4.7" "@polkadot-api/utils" "0.2.0" +"@polkadot-api/observable-client@^0.3.0": + version "0.3.2" + resolved "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.3.2.tgz" + integrity sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug== + dependencies: + "@polkadot-api/metadata-builders" "0.3.2" + "@polkadot-api/substrate-bindings" "0.6.0" + "@polkadot-api/utils" "0.1.0" + "@polkadot-api/pjs-signer@0.6.17": version "0.6.17" resolved "https://registry.npmjs.org/@polkadot-api/pjs-signer/-/pjs-signer-0.6.17.tgz" @@ -432,7 +522,7 @@ "@polkadot-api/json-rpc-provider" "0.0.4" "@polkadot-api/json-rpc-provider-proxy" "0.2.7" -"@polkadot-api/smoldot@>=0.3", "@polkadot-api/smoldot@0.3.14": +"@polkadot-api/smoldot@0.3.14": version "0.3.14" resolved "https://registry.npmjs.org/@polkadot-api/smoldot/-/smoldot-0.3.14.tgz" integrity sha512-eWqO0xFQaKzqY5mRYxYuZcj1IiaLcQP+J38UQyuJgEorm+9yHVEQ/XBWoM83P+Y8TwE5IWTICp1LCVeiFQTGPQ== @@ -440,7 +530,7 @@ "@types/node" "^24.5.2" smoldot "2.0.39" -"@polkadot-api/substrate-bindings@^0.16.3", "@polkadot-api/substrate-bindings@0.16.5": +"@polkadot-api/substrate-bindings@0.16.5", "@polkadot-api/substrate-bindings@^0.16.3": version "0.16.5" resolved "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.16.5.tgz" integrity sha512-QFgNlBmtLtiUGTCTurxcE6UZrbI2DaQ5/gyIiC2FYfEhStL8tl20b09FRYHcSjY+lxN42Rcf9HVX+MCFWLYlpQ== @@ -460,14 +550,6 @@ "@scure/base" "^1.1.1" scale-ts "^1.6.0" -"@polkadot-api/substrate-client@^0.1.2", "@polkadot-api/substrate-client@0.1.4": - version "0.1.4" - resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.1.4.tgz" - integrity sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A== - dependencies: - "@polkadot-api/json-rpc-provider" "0.0.1" - "@polkadot-api/utils" "0.1.0" - "@polkadot-api/substrate-client@0.4.7": version "0.4.7" resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.4.7.tgz" @@ -477,6 +559,14 @@ "@polkadot-api/raw-client" "0.1.1" "@polkadot-api/utils" "0.2.0" +"@polkadot-api/substrate-client@^0.1.2": + version "0.1.4" + resolved "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.1.4.tgz" + integrity sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A== + dependencies: + "@polkadot-api/json-rpc-provider" "0.0.1" + "@polkadot-api/utils" "0.1.0" + "@polkadot-api/utils@0.1.0": version "0.1.0" resolved "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.1.0.tgz" @@ -571,7 +661,7 @@ rxjs "^7.8.1" tslib "^2.8.1" -"@polkadot/api@^16.4.6", "@polkadot/api@16.5.3": +"@polkadot/api@16.5.3", "@polkadot/api@^16.4.6": version "16.5.3" resolved "https://registry.npmjs.org/@polkadot/api/-/api-16.5.3.tgz" integrity sha512-Ptwo0f5Qonmus7KIklsbFcGTdHtNjbTAwl5GGI8Mp0dmBc7Y/ISJpIJX49UrG6FhW6COMa0ItsU87XIWMRwI/Q== @@ -603,7 +693,7 @@ "@polkadot/util-crypto" "13.5.9" tslib "^2.8.0" -"@polkadot/networks@^13.5.9", "@polkadot/networks@13.5.9": +"@polkadot/networks@13.5.9", "@polkadot/networks@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/networks/-/networks-13.5.9.tgz" integrity sha512-nmKUKJjiLgcih0MkdlJNMnhEYdwEml2rv/h59ll2+rAvpsVWMTLCb6Cq6q7UC44+8kiWK2UUJMkFU+3PFFxndA== @@ -726,7 +816,7 @@ rxjs "^7.8.1" tslib "^2.8.1" -"@polkadot/util-crypto@^13.5.9": +"@polkadot/util-crypto@13.5.9", "@polkadot/util-crypto@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-13.5.9.tgz" integrity sha512-foUesMhxkTk8CZ0/XEcfvHk6I0O+aICqqVJllhOpyp/ZVnrTBKBf59T6RpsXx2pCtBlMsLRvg/6Mw7RND1HqDg== @@ -759,23 +849,7 @@ "@scure/sr25519" "^0.2.0" tslib "^2.8.0" -"@polkadot/util-crypto@13.5.9": - version "13.5.9" - resolved "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-13.5.9.tgz" - integrity sha512-foUesMhxkTk8CZ0/XEcfvHk6I0O+aICqqVJllhOpyp/ZVnrTBKBf59T6RpsXx2pCtBlMsLRvg/6Mw7RND1HqDg== - dependencies: - "@noble/curves" "^1.3.0" - "@noble/hashes" "^1.3.3" - "@polkadot/networks" "13.5.9" - "@polkadot/util" "13.5.9" - "@polkadot/wasm-crypto" "^7.5.3" - "@polkadot/wasm-util" "^7.5.3" - "@polkadot/x-bigint" "13.5.9" - "@polkadot/x-randomvalues" "13.5.9" - "@scure/base" "^1.1.7" - tslib "^2.8.0" - -"@polkadot/util@*", "@polkadot/util@^13.5.9", "@polkadot/util@13.5.9": +"@polkadot/util@13.5.9", "@polkadot/util@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/util/-/util-13.5.9.tgz" integrity sha512-pIK3XYXo7DKeFRkEBNYhf3GbCHg6dKQisSvdzZwuyzA6m7YxQq4DFw4IE464ve4Z7WsJFt3a6C9uII36hl9EWw== @@ -847,14 +921,14 @@ "@polkadot/wasm-util" "7.5.3" tslib "^2.7.0" -"@polkadot/wasm-util@*", "@polkadot/wasm-util@^7.5.3", "@polkadot/wasm-util@7.5.3": +"@polkadot/wasm-util@7.5.3", "@polkadot/wasm-util@^7.5.3": version "7.5.3" resolved "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.3.tgz" integrity sha512-hBr9bbjS+Yr7DrDUSkIIuvlTSoAlI8WXuo9YEB4C76j130u/cl+zyq6Iy/WnaTE6QH+8i9DhM8QTety6TqYnUQ== dependencies: tslib "^2.7.0" -"@polkadot/x-bigint@^13.5.9", "@polkadot/x-bigint@13.5.9": +"@polkadot/x-bigint@13.5.9", "@polkadot/x-bigint@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-13.5.9.tgz" integrity sha512-JVW6vw3e8fkcRyN9eoc6JIl63MRxNQCP/tuLdHWZts1tcAYao0hpWUzteqJY93AgvmQ91KPsC1Kf3iuuZCi74g== @@ -879,7 +953,7 @@ node-fetch "^3.3.2" tslib "^2.8.0" -"@polkadot/x-global@^13.5.9", "@polkadot/x-global@13.5.9": +"@polkadot/x-global@13.5.9", "@polkadot/x-global@^13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-global/-/x-global-13.5.9.tgz" integrity sha512-zSRWvELHd3Q+bFkkI1h2cWIqLo1ETm+MxkNXLec3lB56iyq/MjWBxfXnAFFYFayvlEVneo7CLHcp+YTFd9aVSA== @@ -893,7 +967,7 @@ dependencies: tslib "^2.8.0" -"@polkadot/x-randomvalues@*", "@polkadot/x-randomvalues@13.5.9": +"@polkadot/x-randomvalues@13.5.9": version "13.5.9" resolved "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-13.5.9.tgz" integrity sha512-Uuuz3oubf1JCCK97fsnVUnHvk4BGp/W91mQWJlgl5TIOUSSTIRr+lb5GurCfl4kgnQq53Zi5fJV+qR9YumbnZw== @@ -950,11 +1024,116 @@ tslib "^2.8.0" ws "^8.18.0" +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + "@rollup/rollup-darwin-arm64@4.53.3": version "4.53.3" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz" integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + "@rx-state/core@^0.1.4": version "0.1.4" resolved "https://registry.npmjs.org/@rx-state/core/-/core-0.1.4.tgz" @@ -970,15 +1149,6 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== -"@scure/bip32@^1.5.0", "@scure/bip32@^1.7.0", "@scure/bip32@1.7.0": - version "1.7.0" - resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz" - integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== - dependencies: - "@noble/curves" "~1.9.0" - "@noble/hashes" "~1.8.0" - "@scure/base" "~1.2.5" - "@scure/bip32@1.6.2": version "1.6.2" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz" @@ -988,11 +1158,12 @@ "@noble/hashes" "~1.7.1" "@scure/base" "~1.2.2" -"@scure/bip39@^1.4.0", "@scure/bip39@^1.6.0", "@scure/bip39@1.6.0": - version "1.6.0" - resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz" - integrity sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A== +"@scure/bip32@1.7.0", "@scure/bip32@^1.5.0", "@scure/bip32@^1.7.0": + version "1.7.0" + resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== dependencies: + "@noble/curves" "~1.9.0" "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" @@ -1004,6 +1175,14 @@ "@noble/hashes" "~1.7.1" "@scure/base" "~1.2.4" +"@scure/bip39@1.6.0", "@scure/bip39@^1.4.0", "@scure/bip39@^1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz" + integrity sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A== + dependencies: + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + "@scure/sr25519@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@scure/sr25519/-/sr25519-0.2.0.tgz" @@ -1125,27 +1304,20 @@ dependencies: undici-types "~6.21.0" -"@types/node@^24.10.1": - version "24.10.1" - resolved "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz" - integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== +"@types/node@22.7.5": + version "22.7.5" + resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" + integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== dependencies: - undici-types "~7.16.0" + undici-types "~6.19.2" -"@types/node@^24.5.2": +"@types/node@^24.10.1", "@types/node@^24.5.2": version "24.10.1" resolved "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz" integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== dependencies: undici-types "~7.16.0" -"@types/node@22.7.5": - version "22.7.5" - resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" - integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== - dependencies: - undici-types "~6.19.2" - "@types/normalize-package-data@^2.4.3", "@types/normalize-package-data@^2.4.4": version "2.4.4" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz" @@ -1158,11 +1330,6 @@ dependencies: "@types/node" "*" -abitype@^1.0.6, abitype@^1.0.9, abitype@^1.1.1: - version "1.2.0" - resolved "https://registry.npmjs.org/abitype/-/abitype-1.2.0.tgz" - integrity sha512-fD3ROjckUrWsybaSor2AdWxzA0e/DSyV2dA4aYd7bd8orHsoJjl09fOgKfUkTDfk0BsDGBf4NBgu/c7JoS2Npw== - abitype@1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz" @@ -1173,6 +1340,11 @@ abitype@1.1.0: resolved "https://registry.npmjs.org/abitype/-/abitype-1.1.0.tgz" integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== +abitype@^1.0.6, abitype@^1.0.9, abitype@^1.1.1: + version "1.2.0" + resolved "https://registry.npmjs.org/abitype/-/abitype-1.2.0.tgz" + integrity sha512-fD3ROjckUrWsybaSor2AdWxzA0e/DSyV2dA4aYd7bd8orHsoJjl09fOgKfUkTDfk0BsDGBf4NBgu/c7JoS2Npw== + acorn-walk@^8.1.1: version "8.3.4" resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" @@ -1195,9 +1367,9 @@ ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: +ansi-regex@^6.0.1, ansi-regex@^6.2.2: version "6.2.2" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^4.0.0, ansi-styles@^4.1.0: @@ -1209,7 +1381,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: ansi-styles@^6.1.0: version "6.2.3" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== any-promise@^1.0.0: @@ -1260,10 +1432,10 @@ bn.js@^5.2.1: resolved "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz" integrity sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw== -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== +brace-expansion@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.1.tgz#c68b1c4111c76aae3a6fba55d496cee10c39dad8" + integrity sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA== dependencies: balanced-match "^1.0.0" @@ -1335,7 +1507,7 @@ chalk@^5.6.2: chokidar@^4.0.1, chokidar@^4.0.3: version "4.0.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== dependencies: readdirp "^4.0.1" @@ -1354,7 +1526,7 @@ cli-spinners@^3.2.0: cliui@^8.0.1: version "8.0.1" - resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== dependencies: string-width "^4.2.0" @@ -1373,7 +1545,7 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -commander@^14.0.2, commander@~14.0.0: +commander@^14.0.2: version "14.0.2" resolved "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz" integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== @@ -1459,7 +1631,7 @@ diff@^4.0.1: diff@^7.0.0: version "7.0.0" - resolved "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== dotenv@17.2.1: @@ -1478,7 +1650,7 @@ dunder-proto@^1.0.1: eastasianwidth@^0.2.0: version "0.2.0" - resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== emoji-regex@^8.0.0: @@ -1488,7 +1660,7 @@ emoji-regex@^8.0.0: emoji-regex@^9.2.2: version "9.2.2" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== es-define-property@^1.0.0, es-define-property@^1.0.1: @@ -1508,7 +1680,7 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" -esbuild@^0.25.0, esbuild@>=0.18: +esbuild@^0.25.0: version "0.25.12" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz" integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== @@ -1563,7 +1735,7 @@ ethers@^6.13.5: tslib "2.7.0" ws "8.17.1" -eventemitter3@^5.0.1, eventemitter3@5.0.1: +eventemitter3@5.0.1, eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== @@ -1637,7 +1809,7 @@ for-each@^0.3.5: foreground-child@^3.1.0: version "3.3.1" - resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: cross-spawn "^7.0.6" @@ -1714,7 +1886,7 @@ get-stream@^9.0.0: glob@^10.4.5: version "10.5.0" - resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" @@ -1796,7 +1968,7 @@ index-to-position@^1.1.0: inherits@^2.0.3: version "2.0.4" - resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== is-arguments@^1.0.4: @@ -1843,7 +2015,7 @@ is-nan@^1.3.2: is-path-inside@^3.0.3: version "3.0.3" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== is-plain-obj@^2.1.0: @@ -1905,7 +2077,7 @@ isows@1.0.7: jackspeak@^3.1.2: version "3.4.3" - resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== dependencies: "@isaacs/cliui" "^8.0.2" @@ -1979,7 +2151,7 @@ log-symbols@^7.0.1: lru-cache@^10.0.1, lru-cache@^10.2.0: version "10.4.3" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== lru-cache@^11.1.0: @@ -2010,16 +2182,16 @@ mimic-function@^5.0.0: integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== minimatch@^9.0.4, minimatch@^9.0.5: - version "9.0.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^2.0.2" "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== mlly@^1.7.4: version "1.8.0" @@ -2032,9 +2204,9 @@ mlly@^1.7.4: ufo "^1.6.1" mocha@^11.1.0: - version "11.7.5" - resolved "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz" - integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + version "11.7.6" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.6.tgz#ebbe22989d04cbb9424a36307320476624c41a33" + integrity sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA== dependencies: browser-stdout "^1.3.1" chokidar "^4.0.1" @@ -2221,7 +2393,7 @@ p-locate@^5.0.0: package-json-from-dist@^1.0.0: version "1.0.1" - resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== parse-json@^8.0.0, parse-json@^8.3.0: @@ -2255,7 +2427,7 @@ path-key@^4.0.0: path-scurry@^1.11.1: version "1.11.1" - resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== dependencies: lru-cache "^10.2.0" @@ -2271,7 +2443,7 @@ picocolors@^1.1.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -"picomatch@^3 || ^4", picomatch@^4.0.3: +picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -2290,7 +2462,7 @@ pkg-types@^1.3.1: mlly "^1.7.4" pathe "^2.0.1" -polkadot-api@^1.22.0, polkadot-api@^1.8.1, polkadot-api@>=1.19.0, polkadot-api@>=1.21.0: +polkadot-api@^1.22.0: version "1.22.0" resolved "https://registry.npmjs.org/polkadot-api/-/polkadot-api-1.22.0.tgz" integrity sha512-uREBLroPbnJxBBQ+qSkKLF493qukX4PAg32iThlELrZdxfNNgro6nvWRdVmBv73tFHvf+nyWWHKTx1c57nbixg== @@ -2432,7 +2604,7 @@ rollup@^4.34.8: "@rollup/rollup-win32-x64-msvc" "4.53.3" fsevents "~2.3.2" -rxjs@^7.8.1, rxjs@^7.8.2, rxjs@>=7, rxjs@>=7.8.0, rxjs@>=7.8.1: +rxjs@^7.8.1, rxjs@^7.8.2: version "7.8.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== @@ -2499,7 +2671,7 @@ signal-exit@^4.0.1, signal-exit@^4.1.0: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -smoldot@2.0.26, smoldot@2.x: +smoldot@2.0.26: version "2.0.26" resolved "https://registry.npmjs.org/smoldot/-/smoldot-2.0.26.tgz" integrity sha512-F+qYmH4z2s2FK+CxGj8moYcd1ekSIKH8ywkdqlOz88Dat35iB1DIYL11aILN46YSGMzQW/lbJNS307zBSDN5Ig== @@ -2560,7 +2732,7 @@ stdin-discarder@^0.2.2: "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -2578,7 +2750,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== dependencies: eastasianwidth "^0.2.0" @@ -2595,7 +2767,7 @@ string-width@^8.1.0: "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" @@ -2608,11 +2780,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: ansi-regex "^5.0.1" strip-ansi@^7.0.1: - version "7.1.2" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: - ansi-regex "^6.0.1" + ansi-regex "^6.2.2" strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" @@ -2731,16 +2903,16 @@ tsc-prog@^2.3.0: resolved "https://registry.npmjs.org/tsc-prog/-/tsc-prog-2.3.0.tgz" integrity sha512-ycET2d75EgcX7y8EmG4KiZkLAwUzbY4xRhA6NU0uVbHkY4ZjrAAuzTMxXI85kOwATqPnBI5C/7y7rlpY0xdqHA== -tslib@^2.1.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: - version "2.8.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== +tslib@^2.1.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsup@8.5.0: version "8.5.0" resolved "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz" @@ -2776,7 +2948,7 @@ type-fest@^5.2.0: dependencies: tagged-tag "^1.0.0" -typescript@^5.7.2, typescript@^5.9.3, typescript@>=2.7, typescript@>=4, typescript@>=4.5.0, typescript@>=5.0.4, typescript@>=5.4.0: +typescript@^5.7.2, typescript@^5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -2835,20 +3007,6 @@ validate-npm-package-license@^3.0.4: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -viem@^2.37.9: - version "2.41.2" - resolved "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz" - integrity sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g== - dependencies: - "@noble/curves" "1.9.1" - "@noble/hashes" "1.8.0" - "@scure/bip32" "1.7.0" - "@scure/bip39" "1.6.0" - abitype "1.1.0" - isows "1.0.7" - ox "0.9.6" - ws "8.18.3" - viem@2.23.4: version "2.23.4" resolved "https://registry.npmjs.org/viem/-/viem-2.23.4.tgz" @@ -2863,6 +3021,20 @@ viem@2.23.4: ox "0.6.7" ws "8.18.0" +viem@^2.37.9: + version "2.41.2" + resolved "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz" + integrity sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.1.0" + isows "1.0.7" + ox "0.9.6" + ws "8.18.3" + web-streams-polyfill@^3.0.3: version "3.3.3" resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz" @@ -2904,12 +3076,12 @@ which@^2.0.1: workerpool@^9.2.0: version "9.3.4" - resolved "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0" @@ -2927,7 +3099,7 @@ wrap-ansi@^7.0.0: wrap-ansi@^8.1.0: version "8.1.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== dependencies: ansi-styles "^6.1.0" @@ -2963,11 +3135,6 @@ write-package@^7.2.0: type-fest "^4.23.0" write-json-file "^6.0.0" -ws@*, ws@^8.18.0, ws@^8.18.2, ws@^8.18.3, ws@^8.8.1, ws@8.18.3: - version "8.18.3" - resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" - integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== - ws@8.17.1: version "8.17.1" resolved "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz" @@ -2978,6 +3145,11 @@ ws@8.18.0: resolved "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +ws@8.18.3, ws@^8.18.0, ws@^8.18.2, ws@^8.18.3, ws@^8.8.1: + version "8.18.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" @@ -2985,7 +3157,7 @@ y18n@^5.0.5: yargs-parser@^21.1.1: version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== yargs-unparser@^2.0.0: @@ -3000,7 +3172,7 @@ yargs-unparser@^2.0.0: yargs@^17.7.2: version "17.7.2" - resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== dependencies: cliui "^8.0.1" diff --git a/eco-tests/Cargo.toml b/eco-tests/Cargo.toml index f93c81386a..b00e2ced42 100644 --- a/eco-tests/Cargo.toml +++ b/eco-tests/Cargo.toml @@ -38,7 +38,7 @@ pallet-subtensor-proxy = { path = "../pallets/proxy", default-features = false, pallet-subtensor-utility = { path = "../pallets/utility", default-features = false, features = ["std"] } pallet-shield = { path = "../pallets/shield", default-features = false, features = ["std"] } subtensor-runtime-common = { path = "../common", default-features = false, features = ["std"] } -subtensor-swap-interface = { path = "../pallets/swap-interface", default-features = false, features = ["std"] } +subtensor-swap-interface = { path = "../primitives/swap-interface", default-features = false, features = ["std"] } share-pool = { path = "../primitives/share-pool", default-features = false, features = ["std"] } safe-math = { path = "../primitives/safe-math", default-features = false, features = ["std"] } log = { version = "0.4.21", default-features = false, features = ["std"] } diff --git a/node/src/dev_keystore.rs b/node/src/dev_keystore.rs new file mode 100644 index 0000000000..6011021bff --- /dev/null +++ b/node/src/dev_keystore.rs @@ -0,0 +1,56 @@ +use stc_shield::MemoryShieldKeystore; +use stp_shield::{Result as TraitResult, ShieldKeystore}; + +/// A fixed (non-rotating) shield keystore for single-validator dev/manual-seal nodes. +/// +/// Uses the same ML-KEM-768 keypair for both `next_enc_key()` and `current_dec_key()`, +/// bypassing the multi-validator key-rotation timing assumption. In a real multi-validator +/// AURA chain, each validator builds every Kth block (K≥3), so the keystore rolls at the +/// same cadence as the on-chain PendingKey pipeline (2-block delay). In single-validator +/// manual-seal mode the keystore would roll on every block, drifting 2 pairs ahead of +/// PendingKey. This keystore avoids that by keeping both keys from the same generated pair. +/// +/// Construction: capture `next_enc_key()` from a fresh `MemoryShieldKeystore`, roll once +/// so that key becomes current, then freeze. `current_dec_key()` delegates to the inner +/// store (which now holds the matching pair), and `roll_for_next_slot()` is a no-op. +pub struct DevShieldKeystore { + enc_key_bytes: Vec, + inner: MemoryShieldKeystore, +} + +impl DevShieldKeystore { + #[allow(clippy::expect_used)] + pub fn new() -> Self { + let inner = MemoryShieldKeystore::new(); + let enc_key_bytes = inner + .next_enc_key() + .expect("MemoryShieldKeystore always has a next key"); + inner + .roll_for_next_slot() + .expect("initial roll should not fail"); + Self { + enc_key_bytes, + inner, + } + } +} + +impl Default for DevShieldKeystore { + fn default() -> Self { + Self::new() + } +} + +impl ShieldKeystore for DevShieldKeystore { + fn roll_for_next_slot(&self) -> TraitResult<()> { + Ok(()) + } + + fn next_enc_key(&self) -> TraitResult> { + Ok(self.enc_key_bytes.clone()) + } + + fn current_dec_key(&self) -> TraitResult> { + self.inner.current_dec_key() + } +} diff --git a/node/src/lib.rs b/node/src/lib.rs index 4740155f5e..d269fe583d 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -4,6 +4,7 @@ pub mod client; pub mod clone_spec; pub mod conditional_evm_block_import; pub mod consensus; +pub mod dev_keystore; pub mod ethereum; pub mod rpc; pub mod service; diff --git a/node/src/main.rs b/node/src/main.rs index 2766b93054..a6aa15038f 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -10,6 +10,7 @@ mod clone_spec; mod command; mod conditional_evm_block_import; mod consensus; +mod dev_keystore; mod ethereum; mod rpc; mod service; diff --git a/node/src/service.rs b/node/src/service.rs index d07671f81f..624f63b968 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -544,10 +544,14 @@ where .await; if role.is_authority() { - let shield_keystore = Arc::new(MemoryShieldKeystore::new()); - - // manual-seal authorship + // manual-seal authorship — use a fixed keystore so the single-validator dev + // node doesn't drift: MemoryShieldKeystore rolls on every own-block import + // (every block in single-validator mode), advancing current_dec_key() 2 pairs + // ahead of PendingKey on-chain. DevShieldKeystore avoids this by keeping the + // same keypair for both next_enc_key() and current_dec_key(). if let Some(sealing) = sealing { + let dev_shield_keystore: stp_shield::ShieldKeystorePtr = + Arc::new(crate::dev_keystore::DevShieldKeystore::new()); run_manual_seal_authorship( sealing, client, @@ -558,12 +562,14 @@ where prometheus_registry.as_ref(), telemetry.as_ref(), commands_stream, - shield_keystore.clone(), + dev_shield_keystore, )?; log::info!("Manual Seal Ready"); return Ok(task_manager); } + let shield_keystore = Arc::new(MemoryShieldKeystore::new()); + stc_shield::spawn_key_rotation_on_own_import( &task_manager.spawn_handle(), client.clone(), @@ -749,7 +755,7 @@ fn run_manual_seal_authorship( transaction_pool.clone(), prometheus_registry, telemetry.as_ref().map(|x| x.handle()), - shield_keystore, + shield_keystore.clone(), ); thread_local!(static TIMESTAMP: RefCell = const { RefCell::new(0) }); @@ -781,8 +787,15 @@ fn run_manual_seal_authorship( } } - let create_inherent_data_providers = - move |_, ()| async move { Ok(MockTimestampInherentDataProvider) }; + let create_inherent_data_providers = move |_, ()| { + let keystore = shield_keystore.clone(); + async move { + Ok(( + MockTimestampInherentDataProvider, + stc_shield::InherentDataProvider::new(keystore), + )) + } + }; let aura_data_provider = sc_consensus_manual_seal::consensus::aura::AuraConsensusDataProvider::new(client.clone()); diff --git a/pallets/admin-utils/Cargo.toml b/pallets/admin-utils/Cargo.toml index 85236a425a..ae374b4938 100644 --- a/pallets/admin-utils/Cargo.toml +++ b/pallets/admin-utils/Cargo.toml @@ -92,6 +92,7 @@ runtime-benchmarks = [ "pallet-subtensor-swap/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] try-runtime = [ "frame-support/try-runtime", diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index ccf047b2b3..4cef7cce73 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -181,6 +181,8 @@ pub mod pallet { AddressMapping, /// Voting power precompile VotingPower, + /// Limit orders precompile + LimitOrders, } #[pallet::type_value] diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml new file mode 100644 index 0000000000..48ffc61dcb --- /dev/null +++ b/pallets/limit-orders/Cargo.toml @@ -0,0 +1,59 @@ +[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-io = { 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 +log.workspace = true +substrate-fixed.workspace = true +subtensor-runtime-common.workspace = true +subtensor-macros.workspace = true +subtensor-swap-interface.workspace = true + +[dev-dependencies] +sp-io.workspace = true +sp-keyring.workspace = true +sp-keystore.workspace = true + +[lints] +workspace = true + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "scale-info/std", + "sp-core/std", + "sp-io?/std", + "sp-keyring?/std", + "sp-keystore/std", + "sp-runtime/std", + "sp-std/std", + "log/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-io", + "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/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..669980739a --- /dev/null +++ b/pallets/limit-orders/README.md @@ -0,0 +1,273 @@ +# 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 VersionedOrder::V1(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 → + │ skipped, emits OrderSkipped │ entire batch fails (DispatchError) + │ with DispatchError reason │ + │ │ + └─ 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 + +### `VersionedOrder` + +Versioned wrapper around an order payload. Currently has one variant: + +| Variant | Description | +|---------|-------------| +| `V1(Order)` | First version of the order schema. | + +Versioning lets the pallet accept orders signed against different schemas +simultaneously. When a new variant is added (`V2`, etc.), old `V1` signed orders +remain valid because the `OrderId` and signature both cover the full +`VersionedOrder` encoding (including the version discriminant byte). + +### `Order` + +The payload that a user signs off-chain, wrapped inside `VersionedOrder`. Never +stored in full on-chain — only the `blake2_256` hash of the `VersionedOrder` +encoding (`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. | +| `relayer` | `Option` | If `Some`, restricts execution to a single designated relayer account. Any attempt by a different account to execute this order is rejected with `RelayerMissMatch`. `None` = any relayer may execute. | +| `max_slippage` | `Option` | Maximum acceptable slippage in parts per billion applied to `limit_price` at swap time. `None` = no slippage protection (execute at market). When `Some(p)`: Buy ceiling = `limit_price + limit_price * p`; Sell floor = `limit_price - limit_price * p`. Both saturate at `u64` bounds. | + +### `OrderType` + +| 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 `VersionedOrder` payload plus the user's +sr25519 signature over the SCALE encoding of the `VersionedOrder` (including the +version discriminant). Only sr25519 signatures are accepted. Signature +verification uses the inner `order.signer` as the expected public key. + +### `OrderStatus` + +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 `VersionedOrder`) to its +terminal `OrderStatus`. Absence means the order has never been seen and is still +executable (provided it is valid). Presence means it is permanently closed — +neither `Fulfilled` nor `Cancelled` orders can be re-executed. + +--- + +## Config + +| Item | Type | Description | +|-----------------------|---------------------------------------------------|-------------| +| `SwapInterface` | `OrderSwapInterface` | Full swap + balance execution interface. Implemented by `pallet_subtensor::Pallet`. Provides `buy_alpha`, `sell_alpha`, `transfer_tao`, `transfer_staked_alpha`, and `current_alpha_price`. | +| `TimeProvider` | `UnixTime` | Current wall-clock time for expiry checks. | +| `MaxOrdersPerBatch` | `Get` (constant) | Maximum number of orders accepted in a single `execute_orders` or `execute_batched_orders` call. Should equal `floor(max_block_weight / per_order_weight)`. | +| `PalletId` | `Get` (constant) | Used to derive the pallet intermediary account (`PalletId::into_account_truncating`). This account temporarily holds pooled TAO and staked alpha during `execute_batched_orders`. | +| `PalletHotkey` | `Get` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated hotkey registered on every subnet the pallet may operate on. Operators should register it as a non-validator neuron. | +| `WeightInfo` | `weights::WeightInfo` | Benchmarked weight functions for each extrinsic. Use `weights::SubstrateWeight` in production and `()` in tests. | + +--- + +## 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 1 + +**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. Each order's `effective_swap_limit` (derived from `limit_price` and + `max_slippage`) is computed and stored for use in the pool swap. + +2. **Collect assets** — gross TAO is pulled from each buyer's free balance into + the pallet intermediary account. Gross alpha stake is moved from each seller's + `(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. Price ceiling = `min(effective_swap_limit)` across all buy orders. + - Sell-dominant: residual alpha is sent to the pool; pool returns TAO. Price floor = `max(effective_swap_limit)` across all sell orders. + - Perfectly offset: no pool interaction. + +4. **Distribute alpha pro-rata** — every buyer receives their share of the total + 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 2 + +**Origin:** the order's `signer` (coldkey). + +Registers a cancellation intent by writing the `OrderId` into `Orders` as +`Cancelled`. Once cancelled an order can never be executed. The full +`VersionedOrder` 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`, `reason` | An order was skipped by `execute_orders` (bad signature, expired, wrong netuid, already processed, price condition not met, or root netuid). `reason` is the `DispatchError` that caused the skip. Not emitted by `execute_batched_orders` — invalid orders there cause the whole call to fail. | +| `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. | +| `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. | + +--- + +## 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. | +| `RelayerMissMatch` | The caller is not the relayer designated in the order's `relayer` field. Only raised when the field is `Some`. | + +--- + +## 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. + +--- + +## Known limitations + +### `max_slippage` is semantically inverted for `StopLoss` orders + +`StopLoss` sells are triggered when the spot price *falls* to `limit_price`. +`max_slippage` derives a sell floor as `limit_price - limit_price * slippage`, +which is computed from the (higher) trigger threshold. By the time the order +fires, the actual market price will typically be **below** `limit_price`, so +the derived floor will almost always exceed the real fill price, causing the +swap to be rejected. + +**Consequence:** Applying `max_slippage` to a `StopLoss` order will usually +prevent it from executing. In `execute_orders` the order is silently skipped; +in `execute_batched_orders` the entire batch fails. + +**Recommendation:** Relayers should set `max_slippage: None` on `StopLoss` +orders. If slippage protection is desired, apply it at the relayer layer by +choosing a conservative `limit_price` rather than relying on `max_slippage`. diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs new file mode 100644 index 0000000000..d360c2f9d5 --- /dev/null +++ b/pallets/limit-orders/src/benchmarking.rs @@ -0,0 +1,184 @@ +//! Benchmarks for Limit Orders Pallet +#![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::{Get, H256}; +use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::AccountIdConversion}; +extern crate alloc; +use crate::{Call, Config, Pallet}; +use codec::Encode; + +/// Sign a versioned order using the runtime keystore (no `full_crypto` required). +/// +/// The key identified by `public` must already be registered in the keystore +/// (e.g. via `sp_io::crypto::sr25519_generate`) before calling this. +fn sign_order( + public: sp_core::sr25519::Public, + order: &crate::VersionedOrder, +) -> crate::SignedOrder { + let sig = sp_io::crypto::sr25519_sign( + sp_core::crypto::key_types::ACCOUNT, + &public, + &order.encode(), + ) + .unwrap(); + crate::SignedOrder { + order: order.clone(), + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +/// Generate a deterministic sr25519 key for benchmark index `i` and return its +/// public key. The key is inserted into the runtime keystore so it can sign. +fn benchmark_key(i: u32) -> (sp_core::sr25519::Public, AccountId32) { + let seed = alloc::format!("//BenchSigner{}", i).into_bytes(); + let public = sp_io::crypto::sr25519_generate(sp_core::crypto::key_types::ACCOUNT, Some(seed)); + let account = AccountId32::from(public); + (public, account) +} + +pub fn order_id(order: &crate::VersionedOrder) -> H256 { + crate::pallet::Pallet::::derive_order_id(order) +} + +/// Build `n` signed benchmark orders for `netuid`, one per distinct signer. +/// +/// For each index `i` in `0..n` the function: +/// - derives a deterministic sr25519 key via `benchmark_key(i)`, +/// - calls `T::SwapInterface::set_up_acc_for_benchmark` so the account has +/// sufficient balance / stake, +/// - constructs a worst-case `LimitBuy` order (amount = 1 TAO, price = u64::MAX, +/// expiry = u64::MAX, fee 1 %, distinct fee recipient), and +/// - signs it with the generated key. +fn make_benchmark_orders( + n: u32, + netuid: NetUid, +) -> alloc::vec::Vec> { + use subtensor_swap_interface::OrderSwapInterface; + + let mut orders = alloc::vec::Vec::new(); + + for i in 0..n { + let (public, account_id) = benchmark_key(i); + let account: T::AccountId = account_id.into(); + let fee_recipient: T::AccountId = frame_benchmarking::account("fee_recipient", i, 0); + + T::SwapInterface::set_up_acc_for_benchmark(&account, &account); + + let order = crate::VersionedOrder::V1(crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000_000_000u64, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::from_percent(1), + fee_recipient, + relayer: None, + max_slippage: None, + chain_id: T::ChainId::get(), + partial_fills_enabled: false, + }); + orders.push(sign_order::(public, &order)); + } + + orders +} + +#[benchmarks] +mod benchmarks { + use super::*; + use frame_support::traits::Get; + use subtensor_swap_interface::OrderSwapInterface; + + #[benchmark] + fn cancel_order() { + let (public, account_id) = benchmark_key(0); + let account: T::AccountId = account_id.into(); + + let order = crate::VersionedOrder::V1(crate::Order { + signer: account.clone(), + hotkey: account.clone(), + netuid: NetUid::from(1u16), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: 2_000_000_000, + expiry: 1_000_000_000, + fee_rate: Perbill::zero(), + fee_recipient: account.clone(), + relayer: None, + max_slippage: None, + chain_id: T::ChainId::get(), + partial_fills_enabled: false, + }); + let signed = sign_order::(public, &order); + + #[extrinsic_call] + _(RawOrigin::Signed(account.clone()), 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); + } + + /// Worst case: `n` orders each with a distinct signer (coldkey/hotkey) and a + /// distinct fee recipient, maximising per-order storage reads and fee transfers. + #[benchmark] + fn execute_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { + let netuid = NetUid::from(1u16); + T::SwapInterface::set_up_netuid_for_benchmark(netuid); + + let orders = make_benchmark_orders::(n, netuid); + + let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = + frame_support::BoundedVec::try_from(orders).unwrap(); + let caller: T::AccountId = frame_benchmarking::account("caller", 0, 0); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), bounded_orders); + } + + /// Worst case: `n` buy orders each with a distinct signer and fee recipient, + /// maximising asset-collection reads, pro-rata distribution writes, and the + /// number of unique fee-transfer recipients in `collect_fees`. + #[benchmark] + fn execute_batched_orders(n: Linear<1, { T::MaxOrdersPerBatch::get() }>) { + let netuid = NetUid::from(1u16); + T::SwapInterface::set_up_netuid_for_benchmark(netuid); + + // Set up the pallet intermediary so the net pool swap and alpha + // distribution transfers succeed. + let pallet_acct: T::AccountId = T::PalletId::get().into_account_truncating(); + let pallet_hotkey: T::AccountId = T::PalletHotkey::get(); + T::SwapInterface::set_up_acc_for_benchmark(&pallet_hotkey, &pallet_acct); + + let orders = make_benchmark_orders::(n, netuid); + + let bounded_orders: frame_support::BoundedVec<_, T::MaxOrdersPerBatch> = + frame_support::BoundedVec::try_from(orders).unwrap(); + let caller: T::AccountId = frame_benchmarking::account("caller", 0, 0); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), netuid, bounded_orders); + } + + impl_benchmark_test_suite!( + Pallet, + crate::tests::mock::new_test_ext(), + 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..ef737e890d --- /dev/null +++ b/pallets/limit-orders/src/lib.rs @@ -0,0 +1,1233 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub use pallet::*; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +pub(crate) mod migrations; +#[cfg(test)] +mod tests; +pub mod weights; + +type MigrationKeyMaxLen = frame_support::traits::ConstU32<128>; + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{BoundedVec, traits::ConstU32}; +use scale_info::TypeInfo; +use sp_core::H256; +use sp_runtime::{ + AccountId32, MultiSignature, Perbill, + traits::{ConstBool, Verify}, +}; +use substrate_fixed::types::U96F32; +use subtensor_macros::freeze_struct; +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). +#[allow(clippy::multiple_bound_locations)] // bounds on AccountId required by FRAME derives +#[freeze_struct("27c7eedb92261456")] +#[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 ×10⁹ scale (same as the `current_alpha_price` RPC endpoint). + /// A value of `1_000_000_000` represents a price of 1.0 TAO/alpha. + /// Sub-unity prices (e.g. 0.5 TAO/alpha) are expressed as `500_000_000`. + /// Buy: maximum acceptable price. Sell: minimum acceptable price. + /// `u64::MAX` means no ceiling (buy at any price); `0` means no floor (sell at any price). + pub limit_price: u64, + /// Unix timestamp in milliseconds after which this order must not be executed. + pub expiry: u64, + /// 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, + /// Accounts authorized to relay this order. When set, only an account present + /// in this list may submit the execution transaction. Supports up to 10 relayers. + pub relayer: Option>>, + /// Maximum slippage tolerance in parts per billion applied to `limit_price` + /// at execution time. `None` = no protection (execute at market). + /// - Buy: effective price ceiling = `limit_price + limit_price * max_slippage` + /// - Sell: effective price floor = `limit_price - limit_price * max_slippage` + pub max_slippage: Option, + /// EVM-compatible chain ID that this order is bound to. + /// Prevents replay of testnet-signed orders on mainnet and vice versa. + pub chain_id: u64, + /// Wether partial fills are enabled + pub partial_fills_enabled: bool, +} + +/// Versioned wrapper around an order payload. +/// +/// Adding a new variant in the future (e.g. `V2`) lets the pallet accept orders +/// signed against either schema simultaneously, preventing old signed orders from +/// being invalidated by a schema upgrade. +#[derive( + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, +)] +pub enum VersionedOrder { + V1(Order), +} + +impl VersionedOrder { + /// Returns a reference to the inner order regardless of version. + pub fn inner(&self) -> &Order { + match self { + VersionedOrder::V1(order) => order, + } + } +} + +/// The envelope the admin submits on-chain: the versioned order payload plus +/// the user's signature over the SCALE-encoded `VersionedOrder`. +/// +/// Signature verification is performed against `order.inner().signer` (the AccountId) +/// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants +/// of `MultiSignature` are rejected at validation time. +#[freeze_struct("9dd5a8ac812dc504")] +#[derive( + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, +)] +pub struct SignedOrder { + pub order: VersionedOrder, + /// Sr25519 signature over `SCALE_ENCODE(VersionedOrder)`. + pub signature: MultiSignature, + /// Whether we want a partial fill for this order + pub partial_fill: Option, +} + +#[derive( + Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, +)] +pub enum OrderStatus { + /// The order was successfully executed. + Fulfilled, + /// The order was partially filled, with the amount already fulfilled in the enum + PartiallyFilled(u64), + /// 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, + /// Actual input amount being processed this execution (partial or full, before fee). + pub(crate) gross: u64, + /// Full order amount as signed by the user. Used to determine terminal status. + pub(crate) order_amount: u64, + /// Net input amount (after fee). + /// For buys: `gross - fee_rate * gross`. For sells: equals `gross` (fee on TAO output). + pub(crate) net: u64, + /// Per-order fee rate. + pub(crate) fee_rate: Perbill, + /// Per-order fee recipient. + pub(crate) fee_recipient: AccountId, + /// Effective price limit passed to the pool swap. + /// For buys: ceiling (max TAO per alpha the pool may charge). + /// For sells: floor (min TAO per alpha the pool must return). + /// Derived from `limit_price` and `max_slippage` during classification. + pub(crate) effective_swap_limit: u64, + /// Present when this execution covers only part of the order. + pub(crate) partial_fill: Option, +} + +// ── Pallet ─────────────────────────────────────────────────────────────────── + +#[frame_support::pallet] +#[allow(clippy::expect_used)] +pub mod pallet { + use super::*; + use crate::weights::WeightInfo as _; + 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; + + /// Weight information for the pallet's extrinsics. + type WeightInfo: crate::weights::WeightInfo; + + /// EVM-compatible chain ID used to bind orders to a specific chain. + /// Wire to `pallet_evm_chain_id` in the runtime via `ConfigurableChainId`. + type ChainId: Get; + } + + // ── Storage ─────────────────────────────────────────────────────────────── + + /// 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. + /// Defaults to `false` so bare node deployments are safe; genesis sets it to `true`. + #[pallet::storage] + pub type LimitOrdersEnabled = StorageValue<_, bool, ValueQuery, ConstBool>; + + /// Tracks which named migrations have already been applied. + /// Keyed by a short migration name; value is always `true`. + #[pallet::storage] + pub type HasMigrationRun = + StorageMap<_, Identity, BoundedVec, bool, ValueQuery>; + + // ── 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 execution. + OrderSkipped { + order_id: H256, + reason: sp_runtime::DispatchError, + }, + /// 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, + }, + /// A fee transfer to a recipient failed. The fee remains with the + /// original sender. Emitted best-effort — does not revert the order. + FeeTransferFailed { + recipient: T::AccountId, + amount: u64, + reason: sp_runtime::DispatchError, + }, + /// Root has either enabled(true) or disabled(false) the pallet + LimitOrdersPalletStatusChanged { enabled: bool }, + } + + // ── 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, + /// Order has been cancelled + OrderCancelled, + /// The order's expiry timestamp is in the past. + OrderExpired, + /// The current market price does not satisfy the order's limit price. + 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, + /// Relayer not the same as specified in the order + RelayerMissMatch, + /// Partial fills not enabled for this order + PartialFillsNotEnabled, + /// Incorrect partial fill amount provided + IncorrectPartialFillAmount, + /// A relayer must be set on the order when using partial fills + RelayerRequiredForPartialFill, + /// The order's chain_id does not match the current chain. + ChainIdMismatch, + /// The pallet hotkey has not been registered to the pallet account. + /// Call on_runtime_upgrade or wait for genesis to complete registration + /// before enabling the pallet. + PalletHotkeyNotRegistered, + } + + // ── Hooks ───────────────────────────────────────────────────────────────── + + // ── Genesis ─────────────────────────────────────────────────────────────── + + #[pallet::genesis_config] + #[derive(frame_support::DefaultNoBound)] + pub struct GenesisConfig { + #[serde(skip)] + pub _phantom: core::marker::PhantomData, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + let _ = T::SwapInterface::register_pallet_hotkey( + &Pallet::::pallet_account(), + &T::PalletHotkey::get(), + ); + // Enable the pallet on all networks that start from this genesis. + // The storage default is `false` (safe for bare upgrades); genesis + // explicitly opts new chains in. + LimitOrdersEnabled::::set(true); + } + } + + // ── Hooks ───────────────────────────────────────────────────────────────── + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_runtime_upgrade() -> frame_support::weights::Weight { + let mut weight = frame_support::weights::Weight::from_parts(0, 0); + + weight = weight + .saturating_add(migrations::migrate_register_pallet_hotkey::()); + + weight + } + } + + // ── 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(T::WeightInfo::execute_orders(orders.len() as u32))] + pub fn execute_orders( + origin: OriginFor, + orders: BoundedVec, T::MaxOrdersPerBatch>, + ) -> DispatchResult { + let relayer = ensure_signed(origin)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); + + for signed_order in orders { + // Best-effort: individual order failures do not revert the batch. + let order_id = Self::derive_order_id(&signed_order.order); + if let Err(reason) = Self::try_execute_order(signed_order, order_id, &relayer) { + Self::deposit_event(Event::OrderSkipped { order_id, reason }); + } + } + + 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(T::WeightInfo::execute_batched_orders(orders.len() as u32))] + pub fn execute_batched_orders( + origin: OriginFor, + netuid: NetUid, + orders: BoundedVec, T::MaxOrdersPerBatch>, + ) -> DispatchResult { + let relayer = ensure_signed(origin)?; + ensure!( + LimitOrdersEnabled::::get(), + Error::::LimitOrdersDisabled + ); + + Self::do_execute_batched_orders(netuid, orders, relayer) + } + + /// 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(T::WeightInfo::cancel_order())] + pub fn cancel_order( + origin: OriginFor, + order: VersionedOrder, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(order.inner().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(T::WeightInfo::set_pallet_status())] + pub fn set_pallet_status(origin: OriginFor, enabled: bool) -> DispatchResult { + ensure_root(origin)?; + + if enabled { + ensure!( + T::SwapInterface::pallet_hotkey_registered( + &Self::pallet_account(), + &T::PalletHotkey::get(), + ), + Error::::PalletHotkeyNotRegistered + ); + } + + LimitOrdersEnabled::::set(enabled); + + Self::deposit_event(Event::LimitOrdersPalletStatusChanged { enabled }); + + Ok(()) + } + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + impl Pallet { + /// Compute the effective price limit passed to the pool swap. + /// + /// - `None` slippage → no constraint: `u64::MAX` for buys (no ceiling), + /// `0` for sells (no floor). + /// - `Some(p)` → widens `limit_price` by the slippage fraction: + /// - Buy: ceiling = `limit_price + limit_price * p` (saturating) + /// - Sell: floor = `limit_price - limit_price * p` (saturating) + pub(crate) fn compute_effective_swap_limit( + is_buy: bool, + limit_price: u64, + max_slippage: Option, + ) -> u64 { + match max_slippage { + None => { + if is_buy { + u64::MAX + } else { + 0 + } + } + Some(slippage) => { + let delta = slippage.mul_floor(limit_price); + if is_buy { + limit_price.saturating_add(delta) + } else { + limit_price.saturating_sub(delta) + } + } + } + } + + /// Derive the on-chain `OrderId` as blake2_256 over the SCALE-encoded order. + pub fn derive_order_id(order: &VersionedOrder) -> H256 { + H256(sp_core::hashing::blake2_256(&order.encode())) + } + + /// Account derived from the pallet's `PalletId`. + pub(crate) fn pallet_account() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Transfer `fee_tao` from `signer` to `recipient`, emitting + /// `FeeTransferFailed` best-effort on failure without reverting the + /// surrounding operation. Does nothing when `fee_tao` is zero. + fn forward_fee(signer: &T::AccountId, recipient: &T::AccountId, fee_tao: TaoBalance) { + if fee_tao.is_zero() { + return; + } + if let Err(reason) = T::SwapInterface::transfer_tao(signer, recipient, fee_tao) { + Self::deposit_event(Event::FeeTransferFailed { + recipient: recipient.clone(), + amount: fee_tao.to_u64(), + reason, + }); + } + } + + /// Validates all execution preconditions for a signed order. + /// Checks that the order's netuid is not root (0), that the signature is valid, + /// the order has not been processed, is not expired, and the price condition is met. + /// 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, + relayer: &T::AccountId, + ) -> DispatchResult { + let order = signed_order.order.inner(); + ensure!(!order.netuid.is_root(), Error::::RootNetUidNotAllowed); + ensure!( + order.chain_id == T::ChainId::get(), + Error::::ChainIdMismatch + ); + ensure!( + matches!(signed_order.signature, MultiSignature::Sr25519(_)) + && signed_order + .signature + .verify(signed_order.order.encode().as_slice(), &order.signer), + Error::::InvalidSignature + ); + let order_status = Orders::::get(order_id); + ensure!( + order_status != Some(OrderStatus::Fulfilled), + Error::::OrderAlreadyProcessed + ); + ensure!( + order_status != Some(OrderStatus::Cancelled), + Error::::OrderCancelled + ); + ensure!(now_ms <= order.expiry, Error::::OrderExpired); + // Scale current_price to ×10⁹ to match the limit_price field, which is + // expressed in the same ×10⁹ scale as the `current_alpha_price` RPC endpoint. + // This allows sub-unity prices (e.g. 0.5 TAO/alpha = 500_000_000) to be + // represented and compared correctly. + let scaled_price = current_price + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::(); + ensure!( + match order.order_type { + OrderType::TakeProfit => scaled_price >= order.limit_price, + OrderType::StopLoss | OrderType::LimitBuy => scaled_price <= order.limit_price, + }, + Error::::PriceConditionNotMet + ); + if let Some(forced_relayers) = order.relayer.as_ref() { + ensure!( + forced_relayers.contains(relayer), + Error::::RelayerMissMatch + ); + } + if let Some(partial_fill) = signed_order.partial_fill { + ensure!( + order.relayer.is_some(), + Error::::RelayerRequiredForPartialFill + ); + ensure!( + order.partial_fills_enabled, + Error::::PartialFillsNotEnabled + ); + let max_fill = + if let Some(OrderStatus::PartiallyFilled(already_filled)) = order_status { + order.amount.saturating_sub(already_filled) + } else { + order.amount + }; + ensure!( + partial_fill > 0 && partial_fill <= max_fill, + Error::::IncorrectPartialFillAmount + ); + } + Ok(()) + } + + /// Compute the new `OrderStatus` to write after filling `fill_amount` of an order. + /// + /// Reads the current on-chain status to find any already-filled amount, adds + /// `fill_amount`, and returns `Fulfilled` when the total reaches `order_amount`. + /// Pass `None` for `fill_amount` when the order is being fully executed in one shot. + pub(crate) fn compute_order_status( + order_id: H256, + fill_amount: Option, + order_amount: u64, + ) -> OrderStatus { + let Some(fill) = fill_amount else { + return OrderStatus::Fulfilled; + }; + let already_filled = + if let Some(OrderStatus::PartiallyFilled(n)) = Orders::::get(order_id) { + n + } else { + 0 + }; + let new_total = already_filled.saturating_add(fill); + if new_total >= order_amount { + OrderStatus::Fulfilled + } else { + OrderStatus::PartiallyFilled(new_total) + } + } + + /// Attempt to execute one signed order. Returns an error on any + /// validation or execution failure without panicking. + fn try_execute_order( + signed_order: SignedOrder, + order_id: H256, + relayer: &T::AccountId, + ) -> DispatchResult { + let order = signed_order.order.inner(); + let now_ms = T::TimeProvider::now().as_millis() as u64; + let current_price = T::SwapInterface::current_alpha_price(order.netuid); + + Self::is_order_valid(&signed_order, order_id, now_ms, current_price, relayer)?; + + let effective_swap_limit = Self::compute_effective_swap_limit( + order.order_type.is_buy(), + order.limit_price, + order.max_slippage, + ); + + // Execute the swap, taking the order's fee from the input (buys) or output (sells). + // `effective_swap_limit` enforces slippage protection: for buys it caps the price + // ceiling; for sells it sets a minimum floor. When `max_slippage` is None the + // limit is u64::MAX (buys) or 0 (sells), matching previous market-order behaviour. + let (amount_in, amount_out) = if order.order_type.is_buy() { + // partial fill validations have passed, it is safe here to do this + let tao_in = TaoBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); + // Deduct fee from TAO input before swapping. + let fee_tao = TaoBalance::from(order.fee_rate.mul_floor(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(effective_swap_limit), + true, + )?; + + // Forward the fee TAO to the order's fee recipient. + Self::forward_fee(&order.signer, &order.fee_recipient, fee_tao); + (tao_after_fee.to_u64(), alpha_out.to_u64()) + } else { + // partial fill validations have passed, it is safe here to do this + let alpha_in = + AlphaBalance::from(signed_order.partial_fill.unwrap_or(order.amount)); + + // Sell the full alpha amount; fee is taken from the TAO output. + let tao_out = T::SwapInterface::sell_alpha( + &order.signer, + &order.hotkey, + order.netuid, + alpha_in, + TaoBalance::from(effective_swap_limit), + true, + )?; + + // Deduct fee from TAO output and forward to the order's fee recipient. + let fee_tao = TaoBalance::from(order.fee_rate.mul_floor(tao_out.to_u64())); + Self::forward_fee(&order.signer, &order.fee_recipient, fee_tao); + (alpha_in.to_u64(), tao_out.saturating_sub(fee_tao).to_u64()) + }; + + // Mark as fulfilled or partially filled and emit event. + let status = + Self::compute_order_status(order_id, signed_order.partial_fill, order.amount); + Orders::::insert(order_id, status); + Self::deposit_event(Event::OrderExecuted { + order_id, + signer: order.signer.clone(), + 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>, + relayer: T::AccountId, + ) -> 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, relayer)?; + + let executed_count = valid_buys.len().saturating_add(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, + )?; + + // Derive the tightest slippage constraint from the dominant side: + // buy-dominant → min of all buy ceilings; sell-dominant → max of all sell floors. + let pool_price_limit = if total_buy_net >= total_sell_tao_equiv { + valid_buys + .iter() + .map(|e| e.effective_swap_limit) + .min() + .unwrap_or(u64::MAX) + } else { + valid_sells + .iter() + .map(|e| e.effective_swap_limit) + .max() + .unwrap_or(0) + }; + + // Execute a single pool swap for the residual (buy TAO minus sell TAO-equiv, or vice versa). + let (net_side, actual_out) = Self::net_pool_swap( + total_buy_net, + total_sell_net, + total_sell_tao_equiv, + current_price, + &pallet_acct, + &pallet_hotkey, + netuid, + pool_price_limit, + )?; + + // 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, + relayer: T::AccountId, + ) -> 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_id = Self::derive_order_id(&signed_order.order); + let order = signed_order.order.inner(); + + // Hard-fail if the order targets a different subnet than the batch netuid. + ensure!(order.netuid == netuid, Error::::OrderNetUidMismatch); + + // Hard-fail on any per-order validation error (signature, expiry, price, root). + Self::is_order_valid(signed_order, order_id, now_ms, current_price, &relayer)?; + + let amount_in = signed_order.partial_fill.unwrap_or(order.amount); + let net = if order.order_type.is_buy() { + // Buy: fee on TAO input — net is the amount that reaches the pool. + amount_in.saturating_sub(order.fee_rate.mul_floor(amount_in)) + } else { + // Sell: fee on TAO output — full alpha enters the pool; the fee is + // deducted from the TAO payout later in `distribute_tao_pro_rata`. + amount_in + }; + + let effective_swap_limit = Self::compute_effective_swap_limit( + order.order_type.is_buy(), + order.limit_price, + order.max_slippage, + ); + + let entry = OrderEntry { + order_id, + signer: order.signer.clone(), + hotkey: order.hotkey.clone(), + side: order.order_type.clone(), + gross: amount_in, + order_amount: order.amount, + net, + fee_rate: order.fee_rate, + fee_recipient: order.fee_recipient.clone(), + effective_swap_limit, + partial_fill: signed_order.partial_fill, + }; + + // try_push cannot fail: both vecs share the same bound as `orders`. + 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). + /// + /// `price_limit` encodes the tightest slippage constraint across all dominant-side + /// orders: a ceiling for buy-dominant swaps, a floor for sell-dominant swaps. + #[allow(clippy::too_many_arguments)] + fn net_pool_swap( + total_buy_net: u128, + total_sell_net: u128, + total_sell_tao_equiv: u128, + current_price: U96F32, + pallet_acct: &T::AccountId, + pallet_hotkey: &T::AccountId, + netuid: NetUid, + price_limit: u64, + ) -> Result<(OrderSide, u128), DispatchError> { + if total_buy_net >= total_sell_tao_equiv { + let net_tao = (total_buy_net.saturating_sub(total_sell_tao_equiv)) as u64; + let actual_alpha = if net_tao > 0 { + let out = T::SwapInterface::buy_alpha( + pallet_acct, + pallet_hotkey, + netuid, + TaoBalance::from(net_tao), + TaoBalance::from(price_limit), + 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::from(price_limit), + 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`. + #[allow(clippy::too_many_arguments)] + 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) + .checked_div(total_buy_net) + .unwrap_or(0) 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 + )?; + } + let status = Self::compute_order_status(e.order_id, e.partial_fill, e.order_amount); + Orders::::insert(e.order_id, status); + Self::deposit_event(Event::OrderExecuted { + order_id: e.order_id, + signer: e.signer.clone(), + 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. + #[allow(clippy::too_many_arguments)] + 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) + .checked_div(total_sell_tao_equiv) + .unwrap_or(0) as u64 + } else { + 0u64 + }; + let fee = e.fee_rate.mul_floor(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), + )?; + let status = Self::compute_order_status(e.order_id, e.partial_fill, e.order_amount); + Orders::::insert(e.order_id, status); + Self::deposit_event(Event::OrderExecuted { + order_id: e.order_id, + signer: e.signer.clone(), + 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 { + Self::forward_fee(pallet_acct, &recipient, TaoBalance::from(amount)); + } + + // 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. + #[allow(clippy::arithmetic_side_effects)] + 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/migrations/migrate_register_pallet_hotkey.rs b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs new file mode 100644 index 0000000000..f3d6b4ef3f --- /dev/null +++ b/pallets/limit-orders/src/migrations/migrate_register_pallet_hotkey.rs @@ -0,0 +1,52 @@ +use alloc::string::String; +use frame_support::{BoundedVec, traits::Get, weights::Weight}; + +use crate::*; + +fn migration_key() -> BoundedVec { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +/// One-shot migration that disables the limit-orders pallet on first upgrade and +/// registers the pallet intermediary hotkey if it has not been registered yet. +/// +/// Guarded by `HasMigrationRun` so it is safe to include in every runtime upgrade: +/// subsequent calls return immediately after a single storage read. +pub fn migrate_register_pallet_hotkey() -> Weight { + let migration_name = migration_key(); + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{:?}' has already run. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + + log::info!( + "Running migration '{}'", + String::from_utf8_lossy(&migration_name) + ); + + // Register the pallet intermediary hotkey if it has not been registered yet. + let pallet_acct = Pallet::::pallet_account(); + let pallet_hotkey = T::PalletHotkey::get(); + weight = weight.saturating_add(T::DbWeight::get().reads(1)); + + if !T::SwapInterface::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey) { + let _ = T::SwapInterface::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + // register_pallet_hotkey writes Owner, OwnedHotkeys, StakingHotkeys + weight = weight.saturating_add(T::DbWeight::get().writes(3)); + } + + HasMigrationRun::::insert(&migration_name, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{:?}' completed successfully.", + String::from_utf8_lossy(&migration_name) + ); + + weight +} diff --git a/pallets/limit-orders/src/migrations/mod.rs b/pallets/limit-orders/src/migrations/mod.rs new file mode 100644 index 0000000000..391730d481 --- /dev/null +++ b/pallets/limit-orders/src/migrations/mod.rs @@ -0,0 +1,2 @@ +mod migrate_register_pallet_hotkey; +pub use migrate_register_pallet_hotkey::*; diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs new file mode 100644 index 0000000000..ef6594e08f --- /dev/null +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -0,0 +1,1639 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +//! Unit tests for the auxiliary helper functions in `pallet-limit-orders`. +//! +//! Extrinsics are NOT tested here. Each section focuses on one helper. + +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_000_000u64, // limit_price: willing to pay up to 2 TAO/alpha in ×10⁹ scale (scaled=1_000_000_000 ≤ 2_000_000_000 ✓) + 2_000_000u64, // expiry ms + Perbill::zero(), + fee_recipient(), + None, + ); + let sell_order = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid(), + OrderType::TakeProfit, + 500u64, // amount in alpha + 1_000_000_000u64, // limit_price: sell if price >= 1 TAO/alpha in ×10⁹ scale (scaled=1_000_000_000 >= 1_000_000_000 ✓) + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + + let orders = bounded(vec![buy_order, sell_order]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob(), + ) + .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_000_000u64, // 2.0 in ×10⁹ scale + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + + 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), + bob() + ), + 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_000_000u64, // 2.0 in ×10⁹ scale + 2_000_000u64, // expiry already past + Perbill::zero(), + fee_recipient(), + None, + ); + + let orders = bounded(vec![expired]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 2_000_001u64, + U96F32::from_num(1u32), + bob() + ), + 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, scaled = 3_000_000_000, buyer's limit = 2_000_000_000 (2.0 in ×10⁹) → scaled > limit → hard failure. + MockTime::set(1_000_000); + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + + 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 + bob() + ), + 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_000_000u64, // 2.0 in ×10⁹ scale + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + + // 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), + bob() + ), + 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(), + None, + ); + + let orders = bounded(vec![order]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob(), + ) + .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); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// compute_effective_swap_limit +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compute_effective_swap_limit_buy_no_slippage() { + new_test_ext().execute_with(|| { + // No slippage → u64::MAX (no ceiling). + let limit = LimitOrders::::compute_effective_swap_limit(true, 1_000, None); + assert_eq!(limit, u64::MAX); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_no_slippage() { + new_test_ext().execute_with(|| { + // No slippage → 0 (no floor). + let limit = LimitOrders::::compute_effective_swap_limit(false, 1_000, None); + assert_eq!(limit, 0); + }); +} + +#[test] +fn compute_effective_swap_limit_buy_one_percent() { + new_test_ext().execute_with(|| { + // 1% slippage on a buy with limit_price=1000 → ceiling = 1010. + let limit = LimitOrders::::compute_effective_swap_limit( + true, + 1_000, + Some(Perbill::from_percent(1)), + ); + assert_eq!(limit, 1_010); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_one_percent() { + new_test_ext().execute_with(|| { + // 1% slippage on a sell with limit_price=1000 → floor = 990. + let limit = LimitOrders::::compute_effective_swap_limit( + false, + 1_000, + Some(Perbill::from_percent(1)), + ); + assert_eq!(limit, 990); + }); +} + +#[test] +fn compute_effective_swap_limit_sell_saturates_at_zero() { + new_test_ext().execute_with(|| { + // 100% slippage on a sell with limit_price=500 → floor saturates at 0. + let limit = LimitOrders::::compute_effective_swap_limit( + false, + 500, + Some(Perbill::from_percent(100)), + ); + assert_eq!(limit, 0); + }); +} + +#[test] +fn compute_effective_swap_limit_buy_saturates_at_u64_max() { + new_test_ext().execute_with(|| { + // 100% slippage on a buy with limit_price=u64::MAX → ceiling saturates at u64::MAX. + let limit = LimitOrders::::compute_effective_swap_limit( + true, + u64::MAX, + Some(Perbill::from_percent(100)), + ); + assert_eq!(limit, u64::MAX); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// validate_and_classify — effective_swap_limit propagation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn validate_and_classify_stores_effective_swap_limit_for_buy() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // 1% slippage on limit_price=2_000_000_000 (2.0 in ×10⁹) → ceiling = 2_020_000_000. + // price=1.0, scaled=1_000_000_000 <= 2_000_000_000 ✓. + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 500u64, + 2_000_000_000u64, // 2.0 in ×10⁹ scale + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + None, + ); + // Override max_slippage on the inner order after signing — we need to rebuild + // the signed order so the signature covers the updated payload. + let new_inner = { + let mut o = order.order.inner().clone(); + o.max_slippage = Some(Perbill::from_percent(1)); + o + }; + let versioned = crate::VersionedOrder::V1(new_inner.clone()); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed_with_slippage = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + let orders = bounded(vec![signed_with_slippage]); + let (buys, _) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob(), + ) + .expect("should succeed"); + + assert_eq!(buys[0].effective_swap_limit, 2_020_000_000); + }); +} + +#[test] +fn validate_and_classify_stores_effective_swap_limit_for_sell() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price must be >= limit_price (in ×10⁹ scale) for TakeProfit to trigger. + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → floor = 990_000_000. + let new_inner = crate::Order { + signer: AccountKeyring::Alice.to_account_id(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::TakeProfit, + amount: 500u64, + limit_price: 1_000_000_000u64, // 1.0 in ×10⁹ scale + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: Some(Perbill::from_percent(1)), + chain_id: 945, + partial_fills_enabled: false, + }; + let versioned = crate::VersionedOrder::V1(new_inner); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + let orders = bounded(vec![signed]); + let (_, sells) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(2u32), // current_price=2.0, scaled=2_000_000_000 >= limit_price=1_000_000_000 ✓ + bob(), + ) + .expect("should succeed"); + + assert_eq!(sells[0].effective_swap_limit, 990_000_000); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// validate_and_classify — relayer enforcement +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn validate_and_classify_fails_for_wrong_relayer() { + new_test_ext().execute_with(|| { + // Order explicitly locks execution to charlie(); submitting as bob() must fail. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + u64::MAX, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order + ); + + let orders = bounded(vec![order]); + assert_noop!( + LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + bob() // wrong relayer + ), + crate::Error::::RelayerMissMatch + ); + }); +} + +#[test] +fn validate_and_classify_succeeds_for_correct_relayer() { + new_test_ext().execute_with(|| { + // Same setup as above but now the correct relayer (charlie) is used. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000u64, + u64::MAX, + 2_000_000u64, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::try_from(vec![charlie()]).unwrap()), // only charlie may relay this order + ); + + let orders = bounded(vec![order]); + let (buys, sells) = LimitOrders::::validate_and_classify( + netuid(), + &orders, + 1_000_000u64, + U96F32::from_num(1u32), + charlie(), // correct relayer + ) + .expect("validate_and_classify should succeed"); + + assert_eq!(buys.len(), 1, "expected 1 valid buy"); + assert_eq!(sells.len(), 0); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// distribute_alpha_pro_rata +// ───────────────────────────────────────────────────────────────────────────── +// +// 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, + order_amount: gross, + net, + fee_rate, + fee_recipient, + effective_swap_limit: u64::MAX, // no slippage constraint + partial_fill: None, + } +} + +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::VersionedOrder::V1(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(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + (signed, id) +} + +#[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, + &bob() + )); + }); +} + +#[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, &bob()), + 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, &bob()), + 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, &bob()), + 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::VersionedOrder::V1(crate::Order { + expiry: 500_000, + ..signed.order.inner().clone() + }); + let id2 = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed2 = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed2, id2, 1_000_000, price, &bob()), + 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, scaled = 5_000_000_000 > limit_price 2_000_000_000 (2.0 in ×10⁹) → LimitBuy condition (scaled ≤ limit) not met. + MockSwap::set_price(5.0); + let keyring = AccountKeyring::Alice; + let order = crate::VersionedOrder::V1(crate::Order { + signer: keyring.to_account_id(), + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: 2_000_000_000, // 2.0 in ×10⁹ scale + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::PriceConditionNotMet + ); + }); +} + +#[test] +fn is_order_valid_wrong_chain_id_returns_error() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + let keyring = AccountKeyring::Alice; + // Build an order with a chain_id that doesn't match the mock config (945). + let order = crate::VersionedOrder::V1(crate::Order { + chain_id: 9999, + ..make_valid_signed_order().0.order.inner().clone() + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::ChainIdMismatch + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// compute_order_status +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compute_order_status_no_partial_fill_returns_fulfilled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(1); + // No existing state, no partial fill → Fulfilled immediately. + let status = LimitOrders::::compute_order_status(id, None, 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_partial_fill_below_total_returns_partially_filled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(2); + // First partial fill of 400 on a 1000-unit order → PartiallyFilled(400). + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::PartiallyFilled(400)); + }); +} + +#[test] +fn compute_order_status_partial_fill_exact_total_returns_fulfilled() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(3); + // Single partial fill that equals the full order amount → Fulfilled. + let status = LimitOrders::::compute_order_status(id, Some(1_000), 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_accumulates_previous_partial_fill() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(4); + // Pre-seed storage as if a prior partial fill of 300 already happened. + Orders::::insert(id, OrderStatus::PartiallyFilled(300)); + + // Second fill of 400 → 300 + 400 = 700, still below 1000. + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::PartiallyFilled(700)); + }); +} + +#[test] +fn compute_order_status_completes_order_when_accumulated_total_reaches_amount() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(5); + Orders::::insert(id, OrderStatus::PartiallyFilled(600)); + + // Fill the remaining 400 → 600 + 400 = 1000 = order_amount → Fulfilled. + let status = LimitOrders::::compute_order_status(id, Some(400), 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} + +#[test] +fn compute_order_status_ignores_fulfilled_storage_when_no_partial_fill() { + new_test_ext().execute_with(|| { + let id = H256::repeat_byte(6); + // If somehow called with no partial_fill regardless of what's in storage + // (should not happen in practice) it still returns Fulfilled. + Orders::::insert(id, OrderStatus::PartiallyFilled(500)); + let status = LimitOrders::::compute_order_status(id, None, 1_000); + assert_eq!(status, OrderStatus::Fulfilled); + }); +} diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs new file mode 100644 index 0000000000..bb92a1744e --- /dev/null +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -0,0 +1,2946 @@ +#![allow(clippy::indexing_slicing)] +//! 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 codec::Encode; +use frame_support::{BoundedVec, assert_noop, assert_ok}; +use sp_core::Pair; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{DispatchError, Perbill}; +use subtensor_runtime_common::NetUid; + +use crate::{ + Error, Order, OrderSide, OrderStatus, OrderType, Orders, VersionedOrder, 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 = VersionedOrder::V1(Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + 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 = VersionedOrder::V1(Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + // 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 = VersionedOrder::V1(Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + 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 = VersionedOrder::V1(Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + 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 = VersionedOrder::V1(Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + 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(), + None, + ); + 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, scaled = 2_000_000_000 ≥ limit = 1_000_000_000 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_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, scaled = 500_000_000 ≤ limit = 1_000_000_000 → condition met. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::StopLoss, + 500, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_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, scaled=2_000_000_000 > limit 1_000_000_000 → stop loss condition not met + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::StopLoss, + 500, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); + }); +} + +#[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(), + None, + ); + 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()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderExpired.into(), + }); + }); +} + +#[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, scaled=5_000_000_000 > limit 2_000_000_000 → buy condition not met + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 2_000_000_000, // 2.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); + }); +} + +// Regression tests: with the ×10⁹ scale fix, sub-unity prices can be meaningfully +// expressed as limit_price values. A price of 0.5 TAO/alpha is represented as +// 500_000_000 in ×10⁹ scale, enabling fine-grained TakeProfit thresholds below 1.0. +#[test] +fn take_profit_sub_unity_price_executes_when_limit_met() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Market price = 0.5 TAO/alpha → scaled = 500_000_000. + MockSwap::set_price(0.5); + + // limit_price = 400_000_000 (0.4 in ×10⁹ scale). + // TakeProfit condition: scaled_price (500_000_000) >= limit_price (400_000_000) ✓ + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 400_000_000, // 0.4 in ×10⁹ scale — below current price of 0.5 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Executes: 500_000_000 >= 400_000_000 → condition met. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn take_profit_sub_unity_price_skipped_when_limit_not_met() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Market price = 0.5 TAO/alpha → scaled = 500_000_000. + MockSwap::set_price(0.5); + + // limit_price = 600_000_000 (0.6 in ×10⁹ scale). + // TakeProfit condition: scaled_price (500_000_000) >= limit_price (600_000_000) → FALSE. + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 600_000_000, // 0.6 in ×10⁹ scale — above current price of 0.5 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Skipped: 500_000_000 >= 600_000_000 is false. + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); + }); +} + +#[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(), + None, + ); + 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)); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderAlreadyProcessed.into(), + }); + }); +} + +#[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(), + None, + ); + let expired = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid(), + OrderType::LimitBuy, + 500, + u64::MAX, + 500_000, // already expired + Perbill::zero(), + fee_recipient(), + None, + ); + 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]), + )); + + assert_eq!(Orders::::get(valid_id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderSkipped { + order_id: expired_id, + reason: Error::::OrderExpired.into(), + }); + }); +} + +#[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(), + None, + ); + 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(), + None, + ); + 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); + }); +} + +#[test] +fn execute_orders_empty_batch_returns_ok() { + new_test_ext().execute_with(|| { + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![]) + )); + }); +} + +#[test] +fn execute_orders_fee_transfer_failure_emits_event() { + new_test_ext().execute_with(|| { + // Order executes successfully, but the fee transfer to the recipient fails. + // The order should still be marked Fulfilled and FeeTransferFailed emitted. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 10_000); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::from_parts(10_000_000), // 1% + fee_recipient(), + None, + ); + + FAIL_FEE_TRANSFER.with(|f| *f.borrow_mut() = true); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed.clone()]) + )); + FAIL_FEE_TRANSFER.with(|f| *f.borrow_mut() = false); + + // Order was executed despite the failed fee transfer. + let id = crate::tests::mock::order_id(&signed.order); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // FeeTransferFailed was emitted with the correct recipient and error. + assert_event(Event::FeeTransferFailed { + recipient: fee_recipient(), + amount: 10, // 1% of 1_000 + reason: DispatchError::CannotLookup, + }); + + // fee_recipient received nothing. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 0); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute_orders — silent-skip behaviour +// ───────────────────────────────────────────────────────────────────────────── + +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(), + None, + ); + 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()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::OrderExpired.into(), + }); + }); + } + + /// 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(), + None, + ); + 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()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::PriceConditionNotMet.into(), + }); + }); + } + + /// 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(), + None, + ); + let expired = make_signed_order( + AccountKeyring::Bob, + alice(), + netuid(), + OrderType::LimitBuy, + 500, + u64::MAX, + 500_000, // already expired + Perbill::zero(), + fee_recipient(), + None, + ); + 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()); + assert_event(Event::OrderSkipped { + order_id: expired_id, + reason: Error::::OrderExpired.into(), + }); + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(), + None, + ); + 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(), + None, + ); + + 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_price_condition_not_met_fails_entire_batch() { + new_test_ext().execute_with(|| { + // Price condition not met is a hard-fail in execute_batched_orders — + // unlike execute_orders where it silently skips the order. + MockTime::set(1_000_000); + MockSwap::set_price(100.0); // current price = 100, scaled = 100_000_000_000 + + // LimitBuy requires scaled_price <= limit_price; with limit_price=1_000_000_000 (1.0) this fails. + let order = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale, far below scaled price of 100_000_000_000 + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]) + ), + Error::::PriceConditionNotMet + ); + }); +} + +#[test] +fn execute_batched_orders_buy_only_fulfills_orders_and_distributes_alpha() { + new_test_ext().execute_with(|| { + // 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(), + None, + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 400, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + 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(), + None, + ); + let bob_order = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + 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(), + None, + ); + let bob_buy = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 600, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + 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(), + None, + ); + let bob_sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 300, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + let charlie_sell = make_signed_order( + AccountKeyring::Charlie, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + 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(), + None, + ); + + 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(), + None, + ); + 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::::OrderCancelled + ); + + // 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 = mul_floor(1%, 999) = floor(9.99) = 9; Bob nets 990 TAO. + // fee_recipient total = buy_fee(10) + sell_fee(9) = 19 TAO. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_sell_tao_return(9); + 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(), + None, + ); + 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(), + None, + ); + + 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 (9) = 19. + assert_eq!(MockSwap::tao_balance(&fee_recipient()), 19); + // Bob receives 990 TAO after sell-side fee (999 gross - 9 fee). + assert_eq!(MockSwap::tao_balance(&bob()), 990); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(), + None, + ); + + 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(), + None, + ); + + 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(), + None, + ); + + 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(), + None, + ); + 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(), + None, + ); + + 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(), + None, + ); + 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(), + None, + ); + + 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 = mul_floor(1%, 999) = floor(9.99) = 9 TAO each +/// +/// Expected: +/// ferdie receives 10 (Alice) + 10 (Bob) = 20 TAO (1 transfer) +/// fee_recipient() receives 9 (Charlie) + 9 (Eve) = 18 TAO (1 transfer) +#[test] +fn execute_batched_orders_four_orders_two_fee_recipients() { + new_test_ext().execute_with(|| { + 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(), + None, + ); + 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(), + None, + ); + 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(), + None, + ); + 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(), + None, + ); + + 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, 18, + "fee_recipient receives 18 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(), + None, + ); + let sell = make_signed_order( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 500, + 0, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + // 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(), + None, + ); + + // 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 + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — execute_orders passes effective_swap_limit to pool +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a signed order with a specific `max_slippage` value. +#[allow(clippy::too_many_arguments)] +fn make_signed_order_with_slippage( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: subtensor_runtime_common::NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: sp_runtime::Perbill, + fee_recipient: AccountId, + max_slippage: Option, +) -> crate::SignedOrder { + let order = crate::VersionedOrder::V1(crate::Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage, + chain_id: 945, + partial_fills_enabled: false, + }); + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +#[test] +fn execute_orders_buy_no_slippage_passes_u64_max_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // no slippage → u64::MAX ceiling + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + // Pool must have been called with u64::MAX as price ceiling. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![u64::MAX]); + }); +} + +#[test] +fn execute_orders_sell_no_slippage_passes_zero_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(2.0); + + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=2.0 (scaled=2_000_000_000) >= 1_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // no slippage → 0 floor + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![0]); + }); +} + +#[test] +fn execute_orders_buy_one_percent_slippage_passes_ceiling_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → ceiling = 1_010_000_000. + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=1.0 (scaled=1_000_000_000) <= 1_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); + }); +} + +#[test] +fn execute_orders_sell_one_percent_slippage_passes_floor_to_pool() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + // Price must be >= limit_price for TakeProfit to trigger. + MockSwap::set_price(2_000.0); + + // limit_price=1_000_000_000 (1.0 in ×10⁹), 1% slippage → floor = 990_000_000. + let signed = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 500, + 1_000_000_000, // 1.0 in ×10⁹ scale; price=2000.0 (scaled=2T) >= 1_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]) + )); + + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — execute_batched_orders aggregates tightest constraint +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_buy_dominant_uses_min_ceiling() { + new_test_ext().execute_with(|| { + // 3 buy orders with different slippage constraints. + // Alice: limit=1_000_000_000, 2% → ceiling=1_020_000_000 + // Bob: limit=1_000_000_000, 1% → ceiling=1_010_000_000 ← tightest + // Charlie (as signer, not relayer): limit=1_000_000_000, 3% → ceiling=1_030_000_000 + // Expected pool price_limit = min(1_020_000_000, 1_010_000_000, 1_030_000_000) = 1_010_000_000. + // price=1.0, scaled=1_000_000_000 <= 1_000_000_000 ✓ for all LimitBuy orders. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 600); + MockSwap::set_tao_balance(bob(), 200); + MockSwap::set_tao_balance(dave(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::LimitBuy, + 600, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), // ceiling = 1_020_000_000 + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::LimitBuy, + 200, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // ceiling = 1_010_000_000 ← tightest + ); + let dave_order = make_signed_order_with_slippage( + AccountKeyring::Dave, + dave(), + netuid(), + OrderType::LimitBuy, + 200, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(3)), // ceiling = 1_030_000_000 + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_order]), + )); + + // Net pool swap must have been called with the tightest ceiling = 1_010_000_000. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); + }); +} + +#[test] +fn execute_batched_orders_sell_dominant_uses_max_floor() { + new_test_ext().execute_with(|| { + // 3 sell orders with different slippage constraints. + // Alice: limit=1_000_000_000, 3% → floor=970_000_000 + // Bob: limit=1_000_000_000, 1% → floor=990_000_000 ← tightest (highest floor) + // Dave: limit=1_000_000_000, 2% → floor=980_000_000 + // Expected pool price_limit = max(970_000_000, 990_000_000, 980_000_000) = 990_000_000. + // Price must be >= limit_price=1_000_000_000 (1.0 in ×10⁹) for TakeProfit to trigger. + // price=2000.0, scaled=2_000_000_000_000 >= 1_000_000_000 ✓. + MockTime::set(1_000_000); + MockSwap::set_price(2_000.0); + MockSwap::set_sell_tao_return(500); + MockSwap::set_alpha_balance(alice(), dave(), netuid(), 600); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); + MockSwap::set_alpha_balance(dave(), dave(), netuid(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::TakeProfit, + 600, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(3)), // floor = 970_000_000 + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // floor = 990_000_000 ← tightest + ); + let dave_order = make_signed_order_with_slippage( + AccountKeyring::Dave, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), // floor = 980_000_000 + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_order]), + )); + + // Net pool swap must have been called with the tightest floor = 990_000_000. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); + }); +} + +#[test] +fn execute_batched_orders_no_slippage_uses_unconstrained_limits() { + new_test_ext().execute_with(|| { + // Orders without max_slippage should pass u64::MAX (buy) or 0 (sell). + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(500); + MockSwap::set_tao_balance(alice(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, + ); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + )); + + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![u64::MAX]); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// max_slippage — mixed order type coexistence +// ───────────────────────────────────────────────────────────────────────────── + +/// Sell-dominant batch: TakeProfit orders (with slippage) + StopLoss (no slippage). +/// +/// TakeProfit orders set meaningful floors; StopLoss contributes 0 (no constraint). +/// pool_price_limit = max(take_floors..., 0s) = max(take_floors). +/// All three orders are fulfilled. +#[test] +fn execute_batched_orders_takeprofit_and_stoploss_coexist_sell_dominant() { + new_test_ext().execute_with(|| { + // Price = 2000 — scaled = 2_000_000_000_000. + // TakeProfit triggers when scaled_price >= limit_price (2T >= 1_000_000_000 ✓). + // StopLoss triggers when scaled_price <= limit_price (2T <= 5_000_000_000_000 ✓). + MockTime::set(1_000_000); + MockSwap::set_price(2_000.0); + MockSwap::set_sell_tao_return(500); + + // Alice TakeProfit: limit=1_000_000_000 (1.0), 3% → floor=970_000_000. + // Bob TakeProfit: limit=1_000_000_000 (1.0), 1% → floor=990_000_000. ← tightest + // Dave StopLoss: limit=5_000_000_000_000 (5000.0), None → floor=0. + MockSwap::set_alpha_balance(alice(), dave(), netuid(), 600); + MockSwap::set_alpha_balance(bob(), dave(), netuid(), 200); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + dave(), + netuid(), + OrderType::TakeProfit, + 600, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(3)), + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + dave(), + netuid(), + OrderType::TakeProfit, + 200, + 1_000_000_000, // 1.0 in ×10⁹ scale + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + let dave_stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 5_000_000_000_000, // 5000.0 in ×10⁹ scale; scaled_price 2T <= 5T ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // StopLoss: no slippage → floor=0, does not constrain pool + ); + + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_stoploss]), + )); + + // All three fulfilled. + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + + // Pool called once with the tightest TakeProfit floor (990_000_000), not 0 from StopLoss. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![990_000_000]); + }); +} + +/// Buy-dominant batch: LimitBuy orders (with slippage) dominant + StopLoss (no slippage) on offset side. +/// +/// The offset StopLoss is settled internally at spot price; it does not contribute +/// to the pool's price ceiling (which comes only from the dominant buy side). +/// pool_price_limit = min(buy_ceilings) = 1_010_000_000. +#[test] +fn execute_batched_orders_limitbuy_and_stoploss_offset_coexist_buy_dominant() { + new_test_ext().execute_with(|| { + // Price = 1.0, scaled = 1_000_000_000. + // LimitBuy triggers (scaled <= limit ✓). StopLoss triggers (scaled <= limit ✓). + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(900); + + // Alice LimitBuy: limit=1_000_000_000 (1.0), 2% → ceiling=1_020_000_000. + // Bob LimitBuy: limit=1_000_000_000 (1.0), 1% → ceiling=1_010_000_000. ← tightest + // Dave StopLoss: limit=2_000_000_000 (2.0), None → floor=0 (offset side, not used for pool limit). + MockSwap::set_tao_balance(alice(), 600); + MockSwap::set_tao_balance(bob(), 400); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 100); + + let alice_order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 600, + 1_000_000_000, // 1.0 in ×10⁹ scale; scaled=1_000_000_000 <= 1_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(2)), + ); + let bob_order = make_signed_order_with_slippage( + AccountKeyring::Bob, + bob(), + netuid(), + OrderType::LimitBuy, + 400, + 1_000_000_000, // 1.0 in ×10⁹ scale; scaled=1_000_000_000 <= 1_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), + ); + let dave_stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 100, + 2_000_000_000, // 2.0 in ×10⁹ scale; scaled=1_000_000_000 <= 2_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + None, // StopLoss: no slippage; settled at spot, never constrains pool ceiling + ); + + let alice_id = order_id(&alice_order.order); + let bob_id = order_id(&bob_order.order); + let dave_id = order_id(&dave_stoploss.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![alice_order, bob_order, dave_stoploss]), + )); + + // All three fulfilled. + assert_eq!(Orders::::get(alice_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(bob_id), Some(OrderStatus::Fulfilled)); + assert_eq!(Orders::::get(dave_id), Some(OrderStatus::Fulfilled)); + + // Pool buy called with min(1_020_000_000, 1_010_000_000) = 1_010_000_000. StopLoss's floor (0) is ignored on buy side. + assert_eq!(MockSwap::buy_alpha_limit_prices(), vec![1_010_000_000]); + }); +} + +/// StopLoss with a narrow slippage sets an effective floor above the current market price, +/// making the pool swap impossible and failing the entire batch. +/// +/// This demonstrates Issue 1 from the design: relayers should not apply max_slippage to +/// StopLoss orders. StopLoss triggers when price has already fallen; a floor derived from +/// the (higher) trigger threshold will almost always exceed the actual market price. +#[test] +fn execute_batched_orders_stoploss_narrow_slippage_breaks_batch() { + new_test_ext().execute_with(|| { + // StopLoss: limit=100_000_000_000 (100.0 in ×10⁹), triggers at price=50 (scaled=50_000_000_000 ≤ 100_000_000_000 ✓). + // 1% slippage → floor=99_000_000_000. Market is at 50 → pool cannot deliver ≥99_000_000_000. + MockTime::set(1_000_000); + MockSwap::set_price(50.0); + MockSwap::set_sell_tao_return(100); // non-zero so SwapReturnedZero is not the cause + MockSwap::set_enforce_price_limit(true); + MockSwap::set_alpha_balance(dave(), alice(), netuid(), 200); + + let stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 100_000_000_000, // 100.0 in ×10⁹ scale; scaled=50_000_000_000 <= 100_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // floor=99_000_000_000, but market=50 → pool rejects + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![stoploss]), + ), + DispatchError::Other("price limit exceeded") + ); + }); +} + +/// Same StopLoss scenario through execute_orders (best-effort): the order is silently +/// skipped rather than failing the whole call. +/// +/// Note: `DispatchError::Other` has `#[codec(skip)]` on its string field, so the reason +/// string is lost when stored in the event log. We verify the skip via storage absence +/// and by asserting the floor (99_000_000_000 = 100_000_000_000 - 1%) was actually passed +/// to the pool — which is what caused the rejection. The `execute_batched_orders` variant +/// below uses `assert_noop!` (checks the return value directly, no storage round-trip) and +/// can verify the string. +#[test] +fn execute_orders_stoploss_narrow_slippage_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(50.0); + MockSwap::set_sell_tao_return(100); + MockSwap::set_enforce_price_limit(true); + + let stoploss = make_signed_order_with_slippage( + AccountKeyring::Dave, + alice(), + netuid(), + OrderType::StopLoss, + 200, + 100_000_000_000, // 100.0 in ×10⁹ scale; scaled=50_000_000_000 <= 100_000_000_000 ✓ + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_percent(1)), // floor=99_000_000_000, but market=50 → pool rejects + ); + let id = order_id(&stoploss.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![stoploss]), + )); + + // Order not stored — pool rejected the floor. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + + // The sell was attempted with the correct floor (99_000_000_000 = 100_000_000_000 - 1%). + // This is the value that exceeded the market price and caused the rejection. + assert_eq!(MockSwap::sell_alpha_limit_prices(), vec![99_000_000_000]); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// relayer enforcement +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_orders_wrong_relayer_skipped() { + new_test_ext().execute_with(|| { + // Order locks execution to charlie(); submitting as bob() must be silently skipped. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::truncate_from(vec![charlie()])), // only charlie may relay this order + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(bob()), // wrong relayer + bounded(vec![signed]) + )); + + // Order not stored — it was skipped. + assert!(Orders::::get(id).is_none()); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::RelayerMissMatch.into(), + }); + }); +} + +#[test] +fn execute_orders_correct_relayer_executed() { + new_test_ext().execute_with(|| { + // Same order submitted by the designated relayer (charlie) — must succeed. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::truncate_from(vec![charlie()])), // charlie is the designated relayer + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), // correct relayer + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + assert_event(Event::OrderExecuted { + order_id: id, + signer: alice(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount_in: 1_000, + amount_out: 0, + }); + }); +} + +#[test] +fn execute_batched_orders_wrong_relayer_fails_entire_batch() { + new_test_ext().execute_with(|| { + // In execute_batched_orders a relayer mismatch is a hard failure — the + // whole call is reverted, unlike the best-effort skip in execute_orders. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::truncate_from(vec![charlie()])), // only charlie may relay this order + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(bob()), // wrong relayer + netuid(), + bounded(vec![signed]) + ), + Error::::RelayerMissMatch + ); + }); +} + +#[test] +fn execute_batched_orders_correct_relayer_succeeds() { + new_test_ext().execute_with(|| { + // Same order submitted by the designated relayer — must execute and + // distribute alpha to the buyer. + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(1_000); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed = make_signed_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(BoundedVec::truncate_from(vec![charlie()])), // charlie is the designated relayer + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), // correct relayer + netuid(), + bounded(vec![signed]) + )); + + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Partial fills — execute_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_orders_partial_fill_sets_partially_filled_status() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Order for 1000 TAO; relayer is charlie (required for partial fills). + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 400, // fill 400 out of 1000 + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]), + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(400)) + ); + }); +} + +#[test] +fn execute_orders_second_partial_fill_completes_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed_first = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 600, + ); + let id = order_id(&signed_first.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed_first.clone()]), + )); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(600)) + ); + + // Re-submit the same signed order payload with a different partial_fill amount. + let mut signed_second = signed_first.clone(); + signed_second.partial_fill = Some(400); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed_second]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +#[test] +fn execute_orders_partial_fill_without_relayer_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Build an order with partial_fills_enabled but no relayer set. + let inner = crate::Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: FAR_FUTURE, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, // <-- no relayer + max_slippage: None, + chain_id: 945, + partial_fills_enabled: true, + }; + let versioned = VersionedOrder::V1(inner); + let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let signed = crate::SignedOrder { + order: versioned, + signature: sp_runtime::MultiSignature::Sr25519(sig), + partial_fill: Some(400), + }; + let id = order_id(&signed.order); + + // The order is skipped (best-effort), not reverting the batch. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed]), + )); + + // Nothing written to storage. + assert_eq!(Orders::::get(id), None); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::RelayerRequiredForPartialFill.into(), + }); + }); +} + +#[test] +fn execute_orders_partial_fill_exceeding_remaining_is_skipped() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_tao_balance(alice(), 1_000); + + // Pre-fill 700 of 1000. + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 700, + ); + let id = order_id(&signed.order); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![signed.clone()]), + )); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(700)) + ); + + // Try to fill 500 more, but only 300 remain → should be skipped. + let mut over_fill = signed.clone(); + over_fill.partial_fill = Some(500); + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![over_fill]), + )); + + // Status unchanged. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(700)) + ); + assert_event(Event::OrderSkipped { + order_id: id, + reason: Error::::IncorrectPartialFillAmount.into(), + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Partial fills — execute_batched_orders +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn execute_batched_orders_partial_fill_sets_partially_filled_status() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(400); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 400, + ); + let id = order_id(&signed.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed]), + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(400)) + ); + }); +} + +#[test] +fn execute_batched_orders_second_partial_fill_completes_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_buy_alpha_return(600); + MockSwap::set_tao_balance(alice(), 1_000); + + let signed_first = make_partial_fill_order( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, + FAR_FUTURE, + charlie(), + 600, + ); + let id = order_id(&signed_first.order); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed_first.clone()]), + )); + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(600)) + ); + + let mut signed_second = signed_first.clone(); + signed_second.partial_fill = Some(400); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![signed_second]), + )); + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + }); +} + +/// Non-root origin cannot disable the pallet +#[test] +fn non_root_cannot_disable_the_pallet() { + new_test_ext().execute_with(|| { + // Try disabling the pallet with charlie + assert_noop!( + LimitOrders::set_pallet_status(RuntimeOrigin::signed(charlie()), false), + DispatchError::BadOrigin + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// MOCK_SIMULATE_PARTIAL_FILL — sim-swap detects partial fill before funds move +// ───────────────────────────────────────────────────────────────────────────── + +/// `execute_batched_orders` hard-fails the whole batch when the sim-swap for a +/// `LimitBuy` order detects a partial fill (price limit would stop the AMM +/// before consuming the full input). +#[test] +fn execute_batched_orders_buy_partial_fill_fails_batch() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, // limit_price always passes for a buy + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("slippage too high") + ); + }); +} + +/// `execute_orders` silently skips a `LimitBuy` order when the sim-swap detects +/// a partial fill: the order must not appear in storage and an `OrderSkipped` +/// event must be emitted. +#[test] +fn execute_orders_buy_partial_fill_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::LimitBuy, + 1_000, + u64::MAX, // limit_price always passes for a buy + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + let id = order_id(&order.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![order]), + )); + + // Order must not be stored — it was skipped, not fulfilled. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + }); +} + +/// `execute_batched_orders` hard-fails the whole batch when the sim-swap for a +/// `TakeProfit` (sell) order detects a partial fill. +#[test] +fn execute_batched_orders_sell_partial_fill_fails_batch() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + // Seed alpha so the order passes the balance check before reaching the swap. + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, // limit_price = 0 → floor always passes for a TakeProfit + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie()), + netuid(), + bounded(vec![order]), + ), + DispatchError::Other("slippage too high") + ); + }); +} + +/// `execute_orders` silently skips a `TakeProfit` order when the sim-swap +/// detects a partial fill: the order must not appear in storage and an +/// `OrderSkipped` event must be emitted. +#[test] +fn execute_orders_sell_partial_fill_skips_order() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + MockSwap::set_simulate_partial_fill(true); + // Seed alpha so the order passes the balance check before reaching the swap. + MockSwap::set_alpha_balance(alice(), bob(), netuid(), 1_000); + + let order = make_signed_order_with_slippage( + AccountKeyring::Alice, + bob(), + netuid(), + OrderType::TakeProfit, + 1_000, + 0, // limit_price = 0 → floor always passes for a TakeProfit + FAR_FUTURE, + Perbill::zero(), + fee_recipient(), + Some(Perbill::from_parts(1)), // slippage field set; mock ignores value + ); + let id = order_id(&order.order); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie()), + bounded(vec![order]), + )); + + // Order must not be stored — it was skipped, not fulfilled. + assert!(Orders::::get(id).is_none()); + + // An OrderSkipped event must have been emitted for this order. + assert!( + System::events().iter().any(|r| matches!( + &r.event, + RuntimeEvent::LimitOrders(Event::OrderSkipped { order_id, .. }) + if *order_id == id + )), + "expected OrderSkipped event for this order" + ); + }); +} diff --git a/pallets/limit-orders/src/tests/migration.rs b/pallets/limit-orders/src/tests/migration.rs new file mode 100644 index 0000000000..a04e240847 --- /dev/null +++ b/pallets/limit-orders/src/tests/migration.rs @@ -0,0 +1,111 @@ +#![allow(clippy::unwrap_used)] +//! Tests for the `migrate_register_pallet_hotkey` migration. + +use frame_support::{BoundedVec, traits::Hooks}; +use sp_runtime::{BuildStorage, traits::AccountIdConversion}; +use subtensor_swap_interface::OrderSwapInterface as _; + +use crate::{ + HasMigrationRun, LimitOrdersEnabled, MigrationKeyMaxLen, + migrations::migrate_register_pallet_hotkey, + tests::mock::{LimitOrdersPalletId, MockSwap, PalletHotkeyAccount, System, Test}, +}; + +fn migration_key() -> BoundedVec { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +/// Minimal externalities: system genesis only, no pallet hotkey pre-registered, +/// `LimitOrdersEnabled` at its storage default (`false`). +fn migration_ext() -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +#[test] +fn migration_registers_hotkey_and_marks_run_on_first_call() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + assert!(!MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(!HasMigrationRun::::get(migration_key())); + + migrate_register_pallet_hotkey::(); + + assert!( + MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey), + "hotkey must be registered after migration" + ); + assert!( + HasMigrationRun::::get(migration_key()), + "migration must be marked as run" + ); + // Migration no longer touches LimitOrdersEnabled — value is unchanged. + assert!(!LimitOrdersEnabled::::get()); + }); +} + +#[test] +fn migration_does_not_touch_limit_orders_enabled() { + migration_ext().execute_with(|| { + // Enable the pallet before running the migration (simulates a chain + // that already had it enabled via genesis or admin action). + LimitOrdersEnabled::::set(true); + + migrate_register_pallet_hotkey::(); + + assert!( + LimitOrdersEnabled::::get(), + "migration must not change LimitOrdersEnabled" + ); + }); +} + +#[test] +fn migration_skips_hotkey_registration_when_already_registered() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &pallet_hotkey); + + // Must not panic on duplicate registration. + migrate_register_pallet_hotkey::(); + + assert!(HasMigrationRun::::get(migration_key())); + }); +} + +#[test] +fn migration_is_idempotent() { + migration_ext().execute_with(|| { + let pallet_acct: crate::tests::mock::AccountId = + LimitOrdersPalletId::get().into_account_truncating(); + let pallet_hotkey = PalletHotkeyAccount::get(); + + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + + // Second run must be a no-op — hotkey stays registered, flag stays set. + migrate_register_pallet_hotkey::(); + assert!(MockSwap::pallet_hotkey_registered(&pallet_acct, &pallet_hotkey)); + assert!(HasMigrationRun::::get(migration_key())); + }); +} + +#[test] +fn on_runtime_upgrade_delegates_to_migration() { + migration_ext().execute_with(|| { + assert!(!HasMigrationRun::::get(migration_key())); + + as Hooks>::on_runtime_upgrade(); + + assert!(HasMigrationRun::::get(migration_key())); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs new file mode 100644 index 0000000000..03d5559c91 --- /dev/null +++ b/pallets/limit-orders/src/tests/mock.rs @@ -0,0 +1,666 @@ +#![allow(clippy::unwrap_used)] +//! 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, ConstU64, Everything}, +}; +use frame_system as system; +use sp_core::{H256, Pair}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{ + AccountId32, BuildStorage, MultiSignature, + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, +}; +use substrate_fixed::types::U96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_swap_interface::OrderSwapInterface; + +use crate::{self as pallet_limit_orders, LimitOrdersEnabled}; + +// ── 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, + limit_price: u64, + }, + SellAlpha { + coldkey: AccountId, + hotkey: AccountId, + netuid: NetUid, + alpha: u64, + limit_price: 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> = const { RefCell::new(Vec::new()) }; + /// Fixed price returned by `current_alpha_price` (default 1.0). + pub static MOCK_PRICE: RefCell = RefCell::new(U96F32::from_num(1u32)); + /// Fixed alpha returned by `buy_alpha` (default 0 — tests override as needed). + pub static MOCK_BUY_ALPHA_RETURN: RefCell = const { RefCell::new(0u64) }; + /// Fixed TAO returned by `sell_alpha` (default 0 — tests override as needed). + pub static MOCK_SELL_TAO_RETURN: RefCell = const { RefCell::new(0u64) }; + /// In-memory staked alpha ledger: (coldkey, hotkey, netuid) → balance. + /// `transfer_staked_alpha` debits/credits this map so tests can assert + /// on residual balances after distribution. + 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 set to `true`, `transfer_tao` returns `Err(CannotTransfer)` so + /// tests can exercise the `FeeTransferFailed` event path. + pub static FAIL_FEE_TRANSFER: RefCell = const { RefCell::new(false) }; + /// When `true`, `buy_alpha` and `sell_alpha` return `DispatchError::Other("pool error")`. + pub static MOCK_SWAP_FAIL: RefCell = const { RefCell::new(false) }; + /// When `true`, swap calls enforce their `limit_price` argument against `MOCK_PRICE`: + /// `buy_alpha` fails if `market_price > limit_price` (ceiling exceeded); + /// `sell_alpha` fails if `market_price < limit_price` (floor not met). + pub static MOCK_ENFORCE_PRICE_LIMIT: RefCell = const { RefCell::new(false) }; + /// When `true`, `buy_alpha` and `sell_alpha` return a slippage error to simulate + /// the case where the AMM price limit stops the swap before the full amount is consumed. + pub static MOCK_SIMULATE_PARTIAL_FILL: RefCell = const { RefCell::new(false) }; + /// Rate-limit flags set by `transfer_staked_alpha` when `set_receiver_limit` is true. + /// Key: (hotkey, coldkey, netuid) — mirrors `StakingOperationRateLimiter` in subtensor. + pub static RATE_LIMITS: RefCell> = + RefCell::new(std::collections::HashSet::new()); + /// Registered (coldkey, hotkey) ownership pairs — mirrors `Owner` storage in subtensor. + pub static HOTKEY_REGISTRATIONS: RefCell> = + RefCell::new(std::collections::HashSet::new()); +} + +pub struct MockSwap; + +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 set_enforce_price_limit(enforce: bool) { + MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = enforce); + } + pub fn set_simulate_partial_fill(val: bool) { + MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow_mut() = val); + } + pub fn clear_log() { + SWAP_LOG.with(|l| l.borrow_mut().clear()); + ALPHA_BALANCES.with(|b| b.borrow_mut().clear()); + TAO_BALANCES.with(|b| b.borrow_mut().clear()); + RATE_LIMITS.with(|r| r.borrow_mut().clear()); + HOTKEY_REGISTRATIONS.with(|r| r.borrow_mut().clear()); + MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow_mut() = false); + MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow_mut() = false); + } + pub fn is_rate_limited(hotkey: &AccountId, coldkey: &AccountId, netuid: NetUid) -> bool { + RATE_LIMITS.with(|r| { + 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()) + } + /// Returns the `limit_price` argument from every `buy_alpha` call, in order. + pub fn buy_alpha_limit_prices() -> Vec { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::BuyAlpha { limit_price, .. } = c { + Some(limit_price) + } else { + None + } + }) + .collect() + } + /// Returns the `limit_price` argument from every `sell_alpha` call, in order. + pub fn sell_alpha_limit_prices() -> Vec { + Self::log() + .into_iter() + .filter_map(|c| { + if let SwapCall::SellAlpha { limit_price, .. } = c { + Some(limit_price) + } else { + None + } + }) + .collect() + } + + pub fn tao_transfers() -> Vec<(AccountId, AccountId, u64)> { + Self::log() + .into_iter() + .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", + )); + } + if MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "slippage too high", + )); + } + let tao = tao_amount.to_u64(); + // Record the call (including rejected ones) so tests can verify the limit was passed. + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::BuyAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + tao, + limit_price: limit_price.to_u64(), + }) + }); + if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) { + let price = MOCK_PRICE.with(|p| { + p.borrow() + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::() + }); + if price > limit_price.to_u64() { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "price limit exceeded", + )); + } + } + 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); + }); + 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", + )); + } + if MOCK_SIMULATE_PARTIAL_FILL.with(|v| *v.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "slippage too high", + )); + } + let alpha = alpha_amount.to_u64(); + // Record the call (including rejected ones) so tests can verify the limit was passed. + SWAP_LOG.with(|l| { + l.borrow_mut().push(SwapCall::SellAlpha { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + alpha, + limit_price: limit_price.to_u64(), + }) + }); + // Only enforce if a non-zero floor was requested (0 means no constraint). + if MOCK_ENFORCE_PRICE_LIMIT.with(|v| *v.borrow()) && limit_price.to_u64() > 0 { + let price = MOCK_PRICE.with(|p| { + p.borrow() + .saturating_mul(U96F32::from_num(1_000_000_000u64)) + .saturating_to_num::() + }); + if price < limit_price.to_u64() { + return Err(frame_support::pallet_prelude::DispatchError::Other( + "price limit exceeded", + )); + } + } + 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); + }); + 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 { + if FAIL_FEE_TRANSFER.with(|f| *f.borrow()) { + return Err(frame_support::pallet_prelude::DispatchError::CannotLookup); + } + let amt = amount.to_u64(); + TAO_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + 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(()) + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(_netuid: NetUid) { + // Mock price is already set; no subnet state to initialise. + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(hotkey: &AccountId, coldkey: &AccountId) { + // Provide non-zero swap returns so batched-order benchmarks don't hit + // `SwapReturnedZero`. Also seed TAO and alpha balances so transfers + // succeed in the mock ledgers. + MockSwap::set_buy_alpha_return(1_000_000); + MockSwap::set_sell_tao_return(1_000_000); + MockSwap::set_tao_balance(coldkey.clone(), u64::MAX / 2); + MockSwap::set_alpha_balance( + coldkey.clone(), + hotkey.clone(), + NetUid::from(1u16), + u64::MAX / 2, + ); + } + + fn register_pallet_hotkey( + coldkey: &AccountId, + hotkey: &AccountId, + ) -> frame_support::pallet_prelude::DispatchResult { + HOTKEY_REGISTRATIONS.with(|r| { + r.borrow_mut().insert((coldkey.clone(), hotkey.clone())); + }); + Ok(()) + } + + fn pallet_hotkey_registered(coldkey: &AccountId, hotkey: &AccountId) -> bool { + HOTKEY_REGISTRATIONS.with(|r| r.borrow().contains(&(coldkey.clone(), hotkey.clone()))) + } + + fn transfer_staked_alpha( + from_coldkey: &AccountId, + from_hotkey: &AccountId, + 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 = const { 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; + type WeightInfo = (); + type ChainId = ConstU64<945>; +} + +// ── 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; + +#[allow(clippy::too_many_arguments)] +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, + relayer: Option>>, +) -> crate::SignedOrder { + let signer = keyring.to_account_id(); + let order = crate::VersionedOrder::V1(crate::Order { + signer, + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +/// Build a signed order with partial fills enabled and a relayer set. +/// `partial_fill` is the fill amount to inject into the `SignedOrder` envelope. +#[allow(clippy::too_many_arguments)] +pub fn make_partial_fill_order( + keyring: AccountKeyring, + hotkey: AccountId, + netuid: NetUid, + order_type: crate::OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + relayer: AccountId, + partial_fill: u64, +) -> crate::SignedOrder { + let signer = keyring.to_account_id(); + let order = crate::VersionedOrder::V1(crate::Order { + signer, + hotkey, + netuid, + order_type, + amount, + limit_price, + expiry, + fee_rate: sp_runtime::Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: Some(BoundedVec::try_from(vec![relayer]).unwrap()), + max_slippage: None, + chain_id: 945, + partial_fills_enabled: true, + }); + let sig = keyring.pair().sign(&order.encode()); + crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: Some(partial_fill), + } +} + +pub fn bounded( + v: Vec>, +) -> BoundedVec, ConstU32<64>> { + BoundedVec::try_from(v).unwrap() +} + +pub fn order_id(order: &crate::VersionedOrder) -> 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); + // Register a keystore so `sp_io::crypto` functions work in benchmark tests. + let keystore = sp_keystore::testing::MemoryKeystore::new(); + ext.register_extension(sp_keystore::KeystoreExt::new(keystore)); + ext.execute_with(|| { + System::set_block_number(1); + MockSwap::clear_log(); + // Simulate genesis_build: register the pallet hotkey and enable the pallet. + let pallet_acct: AccountId = LimitOrdersPalletId::get().into_account_truncating(); + let _ = MockSwap::register_pallet_hotkey(&pallet_acct, &PalletHotkeyAccount::get()); + LimitOrdersEnabled::::set(true); + }); + ext +} diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs new file mode 100644 index 0000000000..95e0875b26 --- /dev/null +++ b/pallets/limit-orders/src/tests/mod.rs @@ -0,0 +1,4 @@ +pub mod auxiliary; +pub mod extrinsics; +pub mod migration; +pub mod mock; diff --git a/pallets/limit-orders/src/weights.rs b/pallets/limit-orders/src/weights.rs new file mode 100644 index 0000000000..78e859e93b --- /dev/null +++ b/pallets/limit-orders/src/weights.rs @@ -0,0 +1,260 @@ + +//! Autogenerated weights for `pallet_limit_orders` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-04-06, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `girazoki-XPS-15-9530`, CPU: `13th Gen Intel(R) Core(TM) i9-13900H` +//! WASM-EXECUTION: `Compiled`, CHAIN: `Some("local")`, DB CACHE: 1024 + +// Executed Command: +// ./target/release/node-subtensor +// benchmark +// pallet +// --pallet +// pallet_limit_orders +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --chain +// local +// --output +// pallets/limit-orders/src/weights.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::{traits::Get, weights::Weight}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_limit_orders`. +pub trait WeightInfo { + fn execute_orders(n: u32) -> Weight; + fn execute_batched_orders(n: u32) -> Weight; + fn cancel_order() -> Weight; + fn set_pallet_status() -> Weight; +} + +impl WeightInfo for () { + fn execute_orders(_n: u32) -> Weight { + Weight::zero() + } + fn execute_batched_orders(_n: u32) -> Weight { + Weight::zero() + } + fn cancel_order() -> Weight { + Weight::zero() + } + fn set_pallet_status() -> Weight { + Weight::zero() + } +} + +/// Benchmarked weight functions for `pallet_limit_orders`. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `LimitOrders::Orders` (r:1 w:1) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + fn cancel_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `42` + // Estimated: `3514` + // Minimum execution time: 12_568_000 picoseconds. + Weight::from_parts(13_219_000, 0) + .saturating_add(Weight::from_parts(0, 3514)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:0 w:1) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + fn set_pallet_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 5_899_000 picoseconds. + Weight::from_parts(6_212_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:1 w:0) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapV3Initialized` (r:1 w:1) + /// Proof: `Swap::SwapV3Initialized` (`max_values`: None, `max_size`: Some(11), added: 2486, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTaoProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaInProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetAlphaInProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `LimitOrders::Orders` (r:100 w:100) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubtokenEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::SubtokenEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Owner` (r:100 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:200 w:200) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Swap::Positions` (r:1 w:1) + /// Proof: `Swap::Positions` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + /// Storage: `Swap::Ticks` (r:2 w:2) + /// Proof: `Swap::Ticks` (`max_values`: None, `max_size`: Some(78), added: 2553, mode: `MaxEncodedLen`) + /// Storage: `Swap::TickIndexBitmapWords` (r:5 w:5) + /// Proof: `Swap::TickIndexBitmapWords` (`max_values`: None, `max_size`: Some(47), added: 2522, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalTao` (r:1 w:0) + /// Proof: `Swap::FeeGlobalTao` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalAlpha` (r:1 w:0) + /// Proof: `Swap::FeeGlobalAlpha` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentLiquidity` (r:1 w:1) + /// Proof: `Swap::CurrentLiquidity` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::LastPositionId` (r:1 w:1) + /// Proof: `Swap::LastPositionId` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeRate` (r:1 w:0) + /// Proof: `Swap::FeeRate` (`max_values`: None, `max_size`: Some(12), added: 2487, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaOut` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaOut` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetVolume` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetVolume` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:100 w:100) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyShares` (r:100 w:0) + /// Proof: `SubtensorModule::TotalHotkeyShares` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeySharesV2` (r:100 w:100) + /// Proof: `SubtensorModule::TotalHotkeySharesV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingHotkeys` (r:100 w:0) + /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Alpha` (r:100 w:0) + /// Proof: `SubtensorModule::Alpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AlphaV2` (r:100 w:100) + /// Proof: `SubtensorModule::AlphaV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoFlow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTaoFlow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingOperationRateLimiter` (r:0 w:100) + /// Proof: `SubtensorModule::StakingOperationRateLimiter` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (r:0 w:100) + /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::AlphaSqrtPrice` (r:0 w:1) + /// Proof: `Swap::AlphaSqrtPrice` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentTick` (r:0 w:1) + /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 100]`. + fn execute_orders(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1428 + n * (285 ±0)` + // Estimated: `13600 + n * (5158 ±0)` + // Minimum execution time: 425_473_000 picoseconds. + Weight::from_parts(278_641_419, 0) + .saturating_add(Weight::from_parts(0, 13600)) + // Standard Error: 327_930 + .saturating_add(Weight::from_parts(241_272_484, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(28)) + .saturating_add(T::DbWeight::get().reads((10_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(20)) + .saturating_add(T::DbWeight::get().writes((8_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5158).saturating_mul(n.into())) + } + /// Storage: `LimitOrders::LimitOrdersEnabled` (r:1 w:0) + /// Proof: `LimitOrders::LimitOrdersEnabled` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapV3Initialized` (r:1 w:1) + /// Proof: `Swap::SwapV3Initialized` (`max_values`: None, `max_size`: Some(11), added: 2486, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTaoProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetAlphaInProvided` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetAlphaInProvided` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `LimitOrders::Orders` (r:100 w:100) + /// Proof: `LimitOrders::Orders` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:201 w:201) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubtokenEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::SubtokenEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::Positions` (r:1 w:1) + /// Proof: `Swap::Positions` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + /// Storage: `Swap::Ticks` (r:2 w:2) + /// Proof: `Swap::Ticks` (`max_values`: None, `max_size`: Some(78), added: 2553, mode: `MaxEncodedLen`) + /// Storage: `Swap::TickIndexBitmapWords` (r:5 w:5) + /// Proof: `Swap::TickIndexBitmapWords` (`max_values`: None, `max_size`: Some(47), added: 2522, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalTao` (r:1 w:0) + /// Proof: `Swap::FeeGlobalTao` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeGlobalAlpha` (r:1 w:0) + /// Proof: `Swap::FeeGlobalAlpha` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentLiquidity` (r:1 w:1) + /// Proof: `Swap::CurrentLiquidity` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::LastPositionId` (r:1 w:1) + /// Proof: `Swap::LastPositionId` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Swap::FeeRate` (r:1 w:0) + /// Proof: `Swap::FeeRate` (`max_values`: None, `max_size`: Some(12), added: 2487, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaOut` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetAlphaOut` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetVolume` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetVolume` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:101 w:101) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyShares` (r:101 w:0) + /// Proof: `SubtensorModule::TotalHotkeyShares` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeySharesV2` (r:101 w:101) + /// Proof: `SubtensorModule::TotalHotkeySharesV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingHotkeys` (r:101 w:0) + /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Alpha` (r:101 w:0) + /// Proof: `SubtensorModule::Alpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AlphaV2` (r:101 w:101) + /// Proof: `SubtensorModule::AlphaV2` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTaoFlow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetTaoFlow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Owner` (r:100 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::StakingOperationRateLimiter` (r:0 w:100) + /// Proof: `SubtensorModule::StakingOperationRateLimiter` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (r:0 w:101) + /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::AlphaSqrtPrice` (r:0 w:1) + /// Proof: `Swap::AlphaSqrtPrice` (`max_values`: None, `max_size`: Some(26), added: 2501, mode: `MaxEncodedLen`) + /// Storage: `Swap::CurrentTick` (r:0 w:1) + /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 100]`. + fn execute_batched_orders(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1622 + n * (285 ±0)` + // Estimated: `13600 + n * (5158 ±0)` + // Minimum execution time: 581_441_000 picoseconds. + Weight::from_parts(542_245_728, 0) + .saturating_add(Weight::from_parts(0, 13600)) + // Standard Error: 146_067 + .saturating_add(Weight::from_parts(228_266_487, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(35)) + .saturating_add(T::DbWeight::get().reads((10_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(25)) + .saturating_add(T::DbWeight::get().writes((8_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5158).saturating_mul(n.into())) + } +} diff --git a/pallets/subtensor/Cargo.toml b/pallets/subtensor/Cargo.toml index 99ba71629f..0433975acd 100644 --- a/pallets/subtensor/Cargo.toml +++ b/pallets/subtensor/Cargo.toml @@ -160,6 +160,7 @@ runtime-benchmarks = [ "pallet-subtensor-utility/runtime-benchmarks", "pallet-shield/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] pow-faucet = [] fast-runtime = ["subtensor-runtime-common/fast-runtime"] diff --git a/pallets/subtensor/src/staking/mod.rs b/pallets/subtensor/src/staking/mod.rs index a10908eca3..cf8ac006ac 100644 --- a/pallets/subtensor/src/staking/mod.rs +++ b/pallets/subtensor/src/staking/mod.rs @@ -7,6 +7,7 @@ pub mod helpers; pub mod increase_take; pub mod lock; 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..2ede95b34d --- /dev/null +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -0,0 +1,187 @@ +use super::*; +use frame_support::traits::fungible::Mutate; +use frame_support::traits::tokens::Preservation; +use frame_support::transactional; +use substrate_fixed::types::U96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; +use subtensor_swap_interface::{Order, OrderSwapInterface, SwapHandler, SwapResult}; + +impl OrderSwapInterface for Pallet { + #[transactional] + 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::hotkey_account_exists(hotkey), + Error::::HotKeyAccountNotExists + ); + ensure!( + tao_amount >= DefaultMinStake::::get(), + Error::::AmountTooLow + ); + ensure!( + Self::can_remove_balance_from_coldkey_account(coldkey, tao_amount), + Error::::NotEnoughBalanceToStake + ); + } + // `limit_price` is already in ×10⁹ scale (same as the `current_alpha_price` RPC + // endpoint), which is also the scale the AMM uses for its price_limit argument. + // Pass it directly without any scaling. u64::MAX means "no ceiling". + let amm_limit = limit_price; + // Stable subnets (mechanism_id != 1) are always 1:1 and never stop early. + if SubnetMechanism::::get(netuid) == 1 { + let sim_order = GetAlphaForTao::::with_amount(tao_amount); + let sim: SwapResult = + T::SwapInterface::swap(netuid.into(), sim_order, amm_limit, false, true)?; + ensure!( + sim.amount_paid_in.saturating_add(sim.fee_paid) >= tao_amount, + Error::::SlippageTooHigh + ); + } + let alpha_out = + Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, amm_limit, false, false)?; + if validate { + Self::set_stake_operation_limit(hotkey, coldkey, netuid); + } + Ok(alpha_out) + } + + #[transactional] + 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 { + Self::validate_remove_stake(coldkey, hotkey, netuid, alpha_amount, alpha_amount, false)?; + } + // `limit_price` is already in ×10⁹ scale (same as the `current_alpha_price` RPC + // endpoint), which is also the scale the AMM uses for its price_limit argument. + // Pass it directly without any scaling. 0 means "no floor". + let amm_limit = limit_price; + // Stable subnets (mechanism_id != 1) are always 1:1 and never stop early. + if SubnetMechanism::::get(netuid) == 1 { + let sim_order = GetTaoForAlpha::::with_amount(alpha_amount); + let sim: SwapResult = + T::SwapInterface::swap(netuid.into(), sim_order, amm_limit, false, true)?; + ensure!( + sim.amount_paid_in.saturating_add(sim.fee_paid) >= alpha_amount, + Error::::SlippageTooHigh + ); + } + let tao_out = Self::unstake_from_subnet( + hotkey, + coldkey, + coldkey, + netuid, + alpha_amount, + amm_limit, + false, + )?; + 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::hotkey_account_exists(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)?; + Self::ensure_available_to_unstake(from_coldkey, netuid, amount)?; + } + + 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::hotkey_account_exists(to_hotkey), + Error::::HotKeyAccountNotExists + ); + Self::set_stake_operation_limit(to_hotkey, to_coldkey, netuid); + } + Ok(()) + } + + fn register_pallet_hotkey(coldkey: &T::AccountId, hotkey: &T::AccountId) -> DispatchResult { + Self::create_account_if_non_existent(coldkey, hotkey) + } + + fn pallet_hotkey_registered(coldkey: &T::AccountId, hotkey: &T::AccountId) -> bool { + Self::coldkey_owns_hotkey(coldkey, hotkey) + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(netuid: NetUid) { + if !Self::if_subnet_exist(netuid) { + Self::init_new_network(netuid, 100); + } + SubtokenEnabled::::insert(netuid, true); + // Seed pool reserves so the AMM price is well-defined and swaps return non-zero. + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(hotkey: &T::AccountId, coldkey: &T::AccountId) { + let _ = Self::create_account_if_non_existent(coldkey, hotkey); + let credit = Self::mint_tao(TaoBalance::from(1_000_000_000_000_u64)); + let _ = Self::spend_tao(coldkey, credit, TaoBalance::from(1_000_000_000_000_u64)); + } +} diff --git a/pallets/swap-interface/src/lib.rs b/pallets/swap-interface/src/lib.rs deleted file mode 100644 index a37e9e49ad..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) -> DispatchResultWithPostInfo; - 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/Cargo.toml b/pallets/swap/Cargo.toml index c50d1d4f78..2b54449388 100644 --- a/pallets/swap/Cargo.toml +++ b/pallets/swap/Cargo.toml @@ -61,4 +61,5 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] diff --git a/pallets/transaction-fee/Cargo.toml b/pallets/transaction-fee/Cargo.toml index 9e19bf2758..22f89b5f4c 100644 --- a/pallets/transaction-fee/Cargo.toml +++ b/pallets/transaction-fee/Cargo.toml @@ -91,4 +91,5 @@ runtime-benchmarks = [ "pallet-subtensor/runtime-benchmarks", "pallet-subtensor-swap/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] diff --git a/precompiles/Cargo.toml b/precompiles/Cargo.toml index c896ecb731..7f124f843d 100644 --- a/precompiles/Cargo.toml +++ b/precompiles/Cargo.toml @@ -39,6 +39,7 @@ pallet-subtensor-swap.workspace = true pallet-admin-utils.workspace = true subtensor-swap-interface.workspace = true pallet-crowdloan.workspace = true +pallet-limit-orders.workspace = true pallet-shield.workspace = true [lints] @@ -57,6 +58,7 @@ std = [ "pallet-alpha-assets/std", "pallet-balances/std", "pallet-crowdloan/std", + "pallet-limit-orders/std", "pallet-drand/std", "pallet-evm-precompile-bn128/std", "pallet-evm-chain-id/std", @@ -88,6 +90,7 @@ runtime-benchmarks = [ "pallet-admin-utils/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-crowdloan/runtime-benchmarks", + "pallet-limit-orders/runtime-benchmarks", "pallet-drand/runtime-benchmarks", "pallet-evm/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", @@ -99,6 +102,7 @@ runtime-benchmarks = [ "pallet-timestamp/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks" ] [dev-dependencies] diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 39815a6946..a980106ef3 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -34,6 +34,7 @@ pub use crowdloan::CrowdloanPrecompile; pub use ed25519::Ed25519Verify; pub use extensions::PrecompileExt; pub use leasing::LeasingPrecompile; +pub use limit_orders::LimitOrdersPrecompile; pub use metagraph::MetagraphPrecompile; pub use neuron::NeuronPrecompile; pub use proxy::ProxyPrecompile; @@ -51,6 +52,7 @@ mod crowdloan; mod ed25519; mod extensions; mod leasing; +mod limit_orders; mod metagraph; mod neuron; mod proxy; @@ -76,6 +78,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -88,6 +91,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -113,6 +117,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -125,6 +130,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -139,7 +145,7 @@ where Self(Default::default()) } - pub fn used_addresses() -> [H160; 27] { + pub fn used_addresses() -> [H160; 28] { [ hash(1), hash(2), @@ -168,6 +174,7 @@ where hash(VotingPowerPrecompile::::INDEX), hash(ProxyPrecompile::::INDEX), hash(AddressMappingPrecompile::::INDEX), + hash(LimitOrdersPrecompile::::INDEX), ] } } @@ -181,6 +188,7 @@ where + pallet_subtensor_swap::Config + pallet_proxy::Config + pallet_crowdloan::Config + + pallet_limit_orders::Config + pallet_shield::Config + pallet_subtensor_proxy::Config + Send @@ -193,6 +201,7 @@ where + From> + From> + From> + + From> + GetDispatchInfo + Dispatchable + IsSubType> @@ -276,6 +285,9 @@ where PrecompileEnum::AddressMapping, ) } + a if a == hash(LimitOrdersPrecompile::::INDEX) => { + LimitOrdersPrecompile::::try_execute::(handle, PrecompileEnum::LimitOrders) + } _ => None, } } diff --git a/precompiles/src/limit_orders.rs b/precompiles/src/limit_orders.rs new file mode 100644 index 0000000000..15dc5b25a1 --- /dev/null +++ b/precompiles/src/limit_orders.rs @@ -0,0 +1,312 @@ +use core::marker::PhantomData; + +use alloc::string::String; +use fp_evm::{ExitError, PrecompileFailure}; +use frame_support::dispatch::{DispatchInfo, GetDispatchInfo, PostDispatchInfo}; +use frame_support::traits::ConstU32; +use frame_support::{BoundedVec, traits::IsSubType}; +use frame_system::RawOrigin; +use pallet_evm::{AddressMapping, PrecompileHandle}; +use pallet_limit_orders::{Order, OrderStatus, OrderType, SignedOrder, VersionedOrder}; +use precompile_utils::prelude::{Address, UnboundedBytes}; +use precompile_utils::{EvmResult, solidity::Codec}; +use sp_core::{ByteArray, H256, sr25519}; +use sp_runtime::{MultiSignature, Perbill, traits::AsSystemOriginSigner, traits::Dispatchable}; +use subtensor_runtime_common::NetUid; + +use crate::{PrecompileExt, PrecompileHandleExt}; + +pub struct LimitOrdersPrecompile(PhantomData); + +impl PrecompileExt for LimitOrdersPrecompile +where + R: frame_system::Config + + pallet_balances::Config + + pallet_evm::Config + + pallet_limit_orders::Config + + pallet_subtensor::Config + + pallet_shield::Config + + pallet_subtensor_proxy::Config + + Send + + Sync + + scale_info::TypeInfo, + R::AccountId: From<[u8; 32]> + ByteArray, + ::RuntimeOrigin: AsSystemOriginSigner + Clone, + ::RuntimeCall: From> + + GetDispatchInfo + + Dispatchable + + IsSubType> + + IsSubType> + + IsSubType> + + IsSubType>, + ::AddressMapping: AddressMapping, +{ + const INDEX: u64 = 2062; +} + +#[precompile_utils::precompile] +impl LimitOrdersPrecompile +where + R: frame_system::Config + + pallet_balances::Config + + pallet_evm::Config + + pallet_limit_orders::Config + + pallet_subtensor::Config + + pallet_shield::Config + + pallet_subtensor_proxy::Config + + Send + + Sync + + scale_info::TypeInfo, + R::AccountId: From<[u8; 32]> + ByteArray, + ::RuntimeOrigin: AsSystemOriginSigner + Clone, + ::RuntimeCall: From> + + GetDispatchInfo + + Dispatchable + + IsSubType> + + IsSubType> + + IsSubType> + + IsSubType>, + ::AddressMapping: AddressMapping, +{ + #[precompile::public("getLimitOrdersEnabled()")] + #[precompile::view] + fn get_limit_orders_enabled(_handle: &mut impl PrecompileHandle) -> EvmResult { + Ok(pallet_limit_orders::LimitOrdersEnabled::::get()) + } + + #[precompile::public("getOrderStatus(bytes32)")] + #[precompile::view] + fn get_order_status(_handle: &mut impl PrecompileHandle, order_id: H256) -> EvmResult { + Ok(order_status_to_u8(pallet_limit_orders::Orders::::get( + order_id, + ))) + } + + #[precompile::public( + "deriveOrderId((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool))" + )] + #[precompile::view] + fn derive_order_id(_handle: &mut impl PrecompileHandle, order: OrderInput) -> EvmResult { + let versioned = versioned_order_from_input::(order)?; + Ok(pallet_limit_orders::Pallet::::derive_order_id( + &versioned, + )) + } + + #[precompile::public( + "executeOrders(((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool),bytes,bool,uint64)[])" + )] + #[precompile::payable] + fn execute_orders( + handle: &mut impl PrecompileHandle, + orders: alloc::vec::Vec, + ) -> EvmResult<()> { + let batch = signed_orders_batch::(orders)?; + let call = pallet_limit_orders::Call::::execute_orders { orders: batch }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } + + #[precompile::public( + "executeBatchedOrders(uint16,((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool),bytes,bool,uint64)[])" + )] + #[precompile::payable] + fn execute_batched_orders( + handle: &mut impl PrecompileHandle, + netuid: u16, + orders: alloc::vec::Vec, + ) -> EvmResult<()> { + let batch = signed_orders_batch::(orders)?; + let call = pallet_limit_orders::Call::::execute_batched_orders { + netuid: netuid.into(), + orders: batch, + }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } + + #[precompile::public( + "cancelOrder((address,address,uint16,uint8,uint64,uint64,uint64,uint32,address,address[],bool,uint32,uint64,bool))" + )] + #[precompile::payable] + fn cancel_order(handle: &mut impl PrecompileHandle, order: OrderInput) -> EvmResult<()> { + let versioned = versioned_order_from_input::(order)?; + let call = pallet_limit_orders::Call::::cancel_order { order: versioned }; + + handle.try_dispatch_runtime_call::( + call, + RawOrigin::Signed(handle.caller_account_id::()), + ) + } +} + +#[derive(Codec)] +pub struct OrderInput { + signer: Address, + hotkey: Address, + netuid: u16, + order_type: u8, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: u32, + fee_recipient: Address, + relayer: alloc::vec::Vec
, + has_max_slippage: bool, + max_slippage: u32, + chain_id: u64, + partial_fills_enabled: bool, +} + +#[derive(Codec)] +pub struct SignedOrderInput { + order: OrderInput, + signature: UnboundedBytes, + has_partial_fill: bool, + partial_fill: u64, +} + +fn account_from_address(address: Address) -> R::AccountId +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + ::AddressMapping::into_account_id(address.0) +} + +fn order_type_from_u8(order_type: u8) -> Result { + match order_type { + 0 => Ok(OrderType::LimitBuy), + 1 => Ok(OrderType::TakeProfit), + 2 => Ok(OrderType::StopLoss), + _ => Err(PrecompileFailure::Error { + exit_status: ExitError::Other("invalid order type".into()), + }), + } +} + +fn signature_from_bytes(signature: &[u8]) -> Result { + let sig: [u8; 64] = signature.try_into().map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("sr25519 signature must be 64 bytes".into()), + })?; + Ok(MultiSignature::Sr25519(sr25519::Signature::from_raw(sig))) +} + +fn relayer_from_input( + relayer: alloc::vec::Vec
, +) -> Result>>, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + if relayer.is_empty() { + return Ok(None); + } + + let accounts = relayer + .into_iter() + .map(account_from_address::) + .collect::>(); + + Ok(Some(BoundedVec::try_from(accounts).map_err(|_| { + PrecompileFailure::Error { + exit_status: ExitError::Other("relayer list exceeds maximum of 10".into()), + } + })?)) +} + +fn order_from_input(order: OrderInput) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(Order { + signer: account_from_address::(order.signer), + hotkey: account_from_address::(order.hotkey), + netuid: NetUid::from(order.netuid), + order_type: order_type_from_u8(order.order_type)?, + amount: order.amount, + limit_price: order.limit_price, + expiry: order.expiry, + fee_rate: Perbill::from_parts(order.fee_rate), + fee_recipient: account_from_address::(order.fee_recipient), + relayer: relayer_from_input::(order.relayer)?, + max_slippage: if order.has_max_slippage { + Some(Perbill::from_parts(order.max_slippage)) + } else { + None + }, + chain_id: order.chain_id, + partial_fills_enabled: order.partial_fills_enabled, + }) +} + +fn versioned_order_from_input( + order: OrderInput, +) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(VersionedOrder::V1(order_from_input::(order)?)) +} + +fn signed_order_from_input( + input: SignedOrderInput, +) -> Result, PrecompileFailure> +where + R: frame_system::Config + pallet_evm::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + Ok(SignedOrder { + order: versioned_order_from_input::(input.order)?, + signature: signature_from_bytes(input.signature.as_bytes())?, + partial_fill: if input.has_partial_fill { + Some(input.partial_fill) + } else { + None + }, + }) +} + +fn signed_orders_batch( + orders: alloc::vec::Vec, +) -> Result< + BoundedVec, ::MaxOrdersPerBatch>, + PrecompileFailure, +> +where + R: frame_system::Config + pallet_evm::Config + pallet_limit_orders::Config, + R::AccountId: ByteArray, + ::AddressMapping: AddressMapping, +{ + orders + .into_iter() + .map(signed_order_from_input::) + .collect::, _>>() + .and_then(|converted| { + BoundedVec::try_from(converted).map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("orders batch exceeds maximum size".into()), + }) + }) +} + +fn order_status_to_u8(status: Option) -> u8 { + match status { + None => 0, + Some(OrderStatus::Fulfilled) => 1, + Some(OrderStatus::PartiallyFilled(_)) => 2, + Some(OrderStatus::Cancelled) => 3, + } +} diff --git a/precompiles/src/solidity/limitOrders.abi b/precompiles/src/solidity/limitOrders.abi new file mode 100644 index 0000000000..7b6802df48 --- /dev/null +++ b/precompiles/src/solidity/limitOrders.abi @@ -0,0 +1,429 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + } + ], + "name": "deriveOrderId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "has_partial_fill", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "partial_fill", + "type": "uint64" + } + ], + "internalType": "struct SignedOrderInput[]", + "name": "orders", + "type": "tuple[]" + } + ], + "name": "executeBatchedOrders", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "hotkey", + "type": "address" + }, + { + "internalType": "uint16", + "name": "netuid", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "order_type", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "limit_price", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "expiry", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "fee_rate", + "type": "uint32" + }, + { + "internalType": "address", + "name": "fee_recipient", + "type": "address" + }, + { + "internalType": "address[]", + "name": "relayer", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "has_max_slippage", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "max_slippage", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "chain_id", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "partial_fills_enabled", + "type": "bool" + } + ], + "internalType": "struct OrderInput", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + }, + { + "internalType": "bool", + "name": "has_partial_fill", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "partial_fill", + "type": "uint64" + } + ], + "internalType": "struct SignedOrderInput[]", + "name": "orders", + "type": "tuple[]" + } + ], + "name": "executeOrders", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "getLimitOrdersEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "getOrderStatus", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/precompiles/src/solidity/limitOrders.sol b/precompiles/src/solidity/limitOrders.sol new file mode 100644 index 0000000000..67ac2c3b6a --- /dev/null +++ b/precompiles/src/solidity/limitOrders.sol @@ -0,0 +1,66 @@ +pragma solidity ^0.8.0; + +address constant ILIMITORDERS_ADDRESS = 0x000000000000000000000000000000000000080e; + +struct OrderInput { + address signer; + address hotkey; + uint16 netuid; + uint8 order_type; + uint64 amount; + uint64 limit_price; + uint64 expiry; + uint32 fee_rate; + address fee_recipient; + address[] relayer; + bool has_max_slippage; + uint32 max_slippage; + uint64 chain_id; + bool partial_fills_enabled; +} + +struct SignedOrderInput { + OrderInput order; + bytes signature; + bool has_partial_fill; + uint64 partial_fill; +} + +interface ILimitOrders { + /** + * @dev Returns whether the limit orders pallet is enabled. + */ + function getLimitOrdersEnabled() external view returns (bool); + + /** + * @dev Returns the on-chain status for an order id. + * 0 = none, 1 = fulfilled, 2 = partially filled, 3 = cancelled. + */ + function getOrderStatus(bytes32 orderId) external view returns (uint8); + + /** + * @dev Derives the order id from an order payload. + */ + function deriveOrderId(OrderInput calldata order) external view returns (bytes32); + + /** + * @dev Executes a batch of signed limit orders. + * The EVM caller is treated as the relayer. + */ + function executeOrders(SignedOrderInput[] calldata orders) external payable; + + /** + * @dev Executes signed limit orders for a single subnet. + * The EVM caller is treated as the relayer. + */ + function executeBatchedOrders( + uint16 netuid, + SignedOrderInput[] calldata orders + ) external payable; + + /** + * @dev Registers a cancellation intent for an order. + * The EVM caller must match the order signer. + */ + function cancelOrder(OrderInput calldata order) external payable; +} diff --git a/pallets/swap-interface/Cargo.toml b/primitives/swap-interface/Cargo.toml similarity index 81% rename from pallets/swap-interface/Cargo.toml rename to primitives/swap-interface/Cargo.toml index e4392c6d67..5d4020edc2 100644 --- a/pallets/swap-interface/Cargo.toml +++ b/primitives/swap-interface/Cargo.toml @@ -16,6 +16,10 @@ workspace = true [features] default = ["std"] +runtime-benchmarks = [ + "frame-support/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", +] std = [ "codec/std", "frame-support/std", diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs new file mode 100644 index 0000000000..3267e0f205 --- /dev/null +++ b/primitives/swap-interface/src/lib.rs @@ -0,0 +1,233 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(clippy::too_many_arguments)] +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) -> DispatchResultWithPostInfo; + 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. + /// Buy alpha with TAO: debit `tao_amount` from `coldkey`'s free balance, + /// credit resulting alpha as stake at `hotkey` on `netuid`. + /// + /// **Implementations MUST be transactional** (wrap in + /// `frame_support::storage::with_transaction` or annotate with + /// `#[frame_support::transactional]`). The implementation debits the + /// caller's balance before the pool swap; if the swap fails the debit + /// must be rolled back to leave the caller's state unchanged. + fn buy_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + 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. + /// Sell alpha for TAO: remove `alpha_amount` from `coldkey`'s stake at + /// `hotkey` on `netuid`, credit resulting TAO to `coldkey`'s free balance. + /// + /// **Implementations MUST be transactional** (wrap in + /// `frame_support::storage::with_transaction` or annotate with + /// `#[frame_support::transactional]`). The implementation decrements the + /// caller's stake before the pool swap; if the swap fails the decrement + /// must be rolled back to leave the caller's state unchanged. + fn sell_alpha( + coldkey: &AccountId, + hotkey: &AccountId, + 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; + + /// Set up a subnet for benchmark execution. + /// + /// Called once per benchmark before any orders are built. Implementations + /// should initialise the subnet (registers it, enables the subtoken, seeds + /// pool reserves) so that price queries and swaps succeed. + /// The default is a no-op; override in runtime implementations. + #[cfg(feature = "runtime-benchmarks")] + fn set_up_netuid_for_benchmark(_netuid: NetUid) {} + + /// Register `hotkey` as owned by `coldkey`. + /// + /// Called during `on_genesis` and `on_runtime_upgrade` to claim ownership of + /// the pallet's hotkey before any external actor can register it. Safe to call + /// multiple times — is a no-op if the hotkey account already exists. + fn register_pallet_hotkey(coldkey: &AccountId, hotkey: &AccountId) -> DispatchResult; + + /// Returns `true` if `coldkey` is the registered owner of `hotkey`. + fn pallet_hotkey_registered(coldkey: &AccountId, hotkey: &AccountId) -> bool; + + /// Set up accounts for benchmark execution. + /// + /// Called once per order before the benchmarked extrinsic runs. Implementations + /// should fund `coldkey` with sufficient TAO (and alpha for sell orders) and + /// register `hotkey` on the relevant subnet so that swap operations succeed. + /// The default is a no-op; override in runtime implementations. + #[cfg(feature = "runtime-benchmarks")] + fn set_up_acc_for_benchmark(_hotkey: &AccountId, _coldkey: &AccountId) {} +} + +pub trait DefaultPriceLimit +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 48269f5eb5..bff2b3d935 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -151,6 +151,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 @@ -161,6 +164,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] @@ -220,6 +224,7 @@ std = [ "subtensor-transaction-fee/std", "serde_json/std", "sp-io/std", + "sp-keyring/std", "sp-tracing/std", "log/std", "safe-math/std", @@ -227,6 +232,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", @@ -330,7 +336,9 @@ runtime-benchmarks = [ "pallet-shield/runtime-benchmarks", "subtensor-runtime-common/runtime-benchmarks", - "subtensor-chain-extensions/runtime-benchmarks" + "subtensor-chain-extensions/runtime-benchmarks", + "pallet-limit-orders/runtime-benchmarks", + "subtensor-swap-interface/runtime-benchmarks", ] try-runtime = [ "frame-try-runtime/try-runtime", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 735ebd03d2..972d46864f 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::{ @@ -1573,6 +1574,29 @@ 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; + type WeightInfo = pallet_limit_orders::weights::SubstrateWeight; + type ChainId = ConfigurableChainId; +} + fn contracts_schedule() -> pallet_contracts::Schedule { pallet_contracts::Schedule { limits: pallet_contracts::Limits { @@ -1695,6 +1719,7 @@ construct_runtime!( Contracts: pallet_contracts = 29, MevShield: pallet_shield = 30, AlphaAssets: pallet_alpha_assets = 31, + LimitOrders: pallet_limit_orders = 32, } ); @@ -1780,6 +1805,7 @@ mod benches { [pallet_shield, MevShield] [pallet_subtensor_proxy, Proxy] [pallet_subtensor_utility, Utility] + [pallet_limit_orders, LimitOrders] ); } diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs new file mode 100644 index 0000000000..76bc663520 --- /dev/null +++ b/runtime/tests/limit_orders.rs @@ -0,0 +1,2299 @@ +#![allow( + clippy::unwrap_used, + clippy::arithmetic_side_effects, + clippy::too_many_arguments +)] + +use codec::Encode; +use frame_support::{BoundedVec, PalletId, assert_noop, assert_ok, traits::{ConstU32, Hooks}}; +use node_subtensor_runtime::{ + BuildStorage, LimitOrders, Runtime, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, + System, pallet_subtensor, +}; +use pallet_limit_orders::{ + HasMigrationRun, LimitOrdersEnabled, Order, OrderStatus, OrderType, Orders, SignedOrder, + VersionedOrder, +}; +use sp_runtime::traits::AccountIdConversion; +use pallet_subtensor::{SubnetAlphaIn, SubnetMechanism, SubnetTAO}; +use sp_core::{Get, H256, Pair}; +use sp_keyring::Sr25519Keyring; +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); + // Genesis forces netuid 1 to dynamic (mechanism_id = 1); override to stable + // (mechanism_id = 0) so that swaps are 1:1 with no AMM fees, matching the + // intent of every test that calls this helper. + pallet_subtensor::SubnetMechanism::::insert(netuid, 0u16); + pallet_subtensor::SubtokenEnabled::::insert(netuid, true); +} + +fn min_default_stake() -> TaoBalance { + pallet_subtensor::DefaultMinStake::::get() +} + +fn add_balance_to_coldkey_account(coldkey: &AccountId, tao: TaoBalance) { + let credit = SubtensorModule::mint_tao(tao); + let _ = SubtensorModule::spend_tao(coldkey, credit, tao); +} + +fn seed_subnet_tao(netuid: NetUid, amount: TaoBalance) { + let subnet_account = SubtensorModule::get_subnet_account_id(netuid).unwrap(); + add_balance_to_coldkey_account(&subnet_account, amount); +} + +fn fund_account(id: &AccountId) { + add_balance_to_coldkey_account(id, min_default_stake() * 10u64.into()); +} + +fn order_id(order: &VersionedOrder) -> H256 { + H256(sp_io::hashing::blake2_256(&order.encode())) +} + +fn make_order_batch( + orders: Vec>, +) -> BoundedVec, ::MaxOrdersPerBatch> +{ + orders.try_into().unwrap() +} + +fn setup_buyer_seller( + netuid: NetUid, + alice_id: &AccountId, + charlie_id: &AccountId, + bob_id: &AccountId, + dave_id: &AccountId, +) { + fund_account(alice_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + dave_id, + bob_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + let _ = SubtensorModule::create_account_if_non_existent(alice_id, charlie_id); + let _ = SubtensorModule::create_account_if_non_existent(bob_id, dave_id); +} + +struct OrderParams { + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, + relayer: Option>>, + max_slippage: Option, + partial_fills_enabled: bool, +} + +/// Shared implementation: constructs and signs a `VersionedOrder::V1` from an +/// `OrderParams` and returns a `SignedOrder` with `partial_fill = None`. +/// All three public factory functions delegate here so that adding a new field +/// to `Order` requires updating only this function. +fn make_signed_order_inner( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + params: OrderParams, +) -> SignedOrder { + let order = VersionedOrder::V1(Order { + signer: keyring.to_account_id(), + hotkey, + netuid, + order_type: params.order_type, + amount: params.amount, + limit_price: params.limit_price, + expiry: params.expiry, + fee_rate: params.fee_rate, + fee_recipient: params.fee_recipient, + relayer: params.relayer, + max_slippage: params.max_slippage, + partial_fills_enabled: params.partial_fills_enabled, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, + }); + let sig = keyring.pair().sign(&order.encode()); + SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +fn make_signed_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, +) -> SignedOrder { + make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + }, + ) +} + +/// Set up a dynamic-mechanism (Uniswap v3-style) subnet with equal TAO and +/// alpha reserves, giving an initial pool price of exactly 1.0 TAO/alpha. +/// +/// The stable mechanism (mechanism_id = 0) ignores the `price_limit` parameter +/// entirely and always executes at 1:1, so slippage enforcement can only be +/// tested against a dynamic subnet. +fn setup_dynamic_subnet(netuid: NetUid) { + SubtensorModule::init_new_network(netuid, 0); + // Override the mechanism to 1 (dynamic / Uniswap v3). + SubnetMechanism::::insert(netuid, 1u16); + pallet_subtensor::SubtokenEnabled::::insert(netuid, true); + // Equal reserves → price = tao_reserve / alpha_reserve = 1.0 + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_000_u64)); + seed_subnet_tao(netuid, TaoBalance::from(1_000_000_000_000_u64)); +} + +/// Build a signed order with an explicit `max_slippage` value. +fn make_signed_order_with_slippage_rt( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_rate: Perbill, + fee_recipient: AccountId, + max_slippage: Option, +) -> SignedOrder { + make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate, + fee_recipient, + relayer: None, + max_slippage, + partial_fills_enabled: false, + }, + ) +} + +/// Build a `SignedOrder` with `partial_fills_enabled = true` and the relayer set +/// to `relayer`. The `partial_fill` field on the envelope is supplied separately +/// by each test so that the *same* `VersionedOrder` payload (and therefore the +/// same order-id) can be re-used across multiple submissions. +fn make_partial_fill_order( + keyring: Sr25519Keyring, + hotkey: AccountId, + netuid: NetUid, + order_type: OrderType, + amount: u64, + limit_price: u64, + expiry: u64, + fee_recipient: AccountId, + relayer: AccountId, + partial_fill: Option, +) -> SignedOrder { + let mut signed = make_signed_order_inner( + keyring, + hotkey, + netuid, + OrderParams { + order_type, + amount, + limit_price, + expiry, + fee_rate: Perbill::zero(), + fee_recipient, + relayer: Some(BoundedVec::try_from(vec![relayer]).unwrap()), + max_slippage: None, + partial_fills_enabled: true, + }, + ); + signed.partial_fill = partial_fill; + signed +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// Signing and cancelling an order writes the order id to storage as Cancelled +/// 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 = VersionedOrder::V1(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, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, + }); + let id = order_id(&order); + + 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 = VersionedOrder::V1(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, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, + }); + let id = order_id(&order); + + // 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), + partial_fill: None, + }; + + let orders = make_order_batch(vec![signed]); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(alice_id), + orders, + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// An order carrying a wrong chain_id is silently skipped by `execute_orders` +/// (the per-order error path) and must not appear in the Orders storage map. +#[test] +fn execute_orders_chain_id_mismatch_rejected() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + setup_subnet(netuid); + + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let fee_recipient = Sr25519Keyring::Charlie.to_account_id(); + fund_account(&alice_id); + + // Build an order with a chain_id that doesn't match the runtime (0). + let order = VersionedOrder::V1(Order { + signer: alice_id.clone(), + hotkey: bob_id, + netuid, + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient, + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + chain_id: 9999, // wrong chain — should be rejected + }); + let id = order_id(&order); + let sig = alice.pair().sign(&order.encode()); + let signed = SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(alice_id), + make_order_batch(vec![signed]), + )); + + // Order was silently skipped — nothing written to storage. + assert!(Orders::::get(id).is_none()); + }); +} + +/// A LimitBuy order whose price condition is satisfied executes against the pool, +/// marks the order as Fulfilled, and credits staked alpha to the buyer. +#[test] +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. + fund_account(&alice_id); + + // Create the hot-key association. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // limit_price = u64::MAX → current_price (1.0) ≤ MAX → condition always met. + let signed = make_signed_order( + 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 = make_order_batch(vec![signed]); + + 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. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Seed Alice with staked alpha through Bob so she has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // 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 = make_order_batch(vec![signed]); + + 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. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Seed Alice with staked alpha through Bob so she has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // limit_price = 1_000_000_000 (1.0 × 10⁹) → scaled_price (1_000_000_000) ≤ 1_000_000_000 + // → StopLoss condition always met. Stable mechanism ignores the AMM floor, so any + // value ≥ 1_000_000_000 works here. + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + min_default_stake().into(), // sell min_default_stake alpha units + 1_000_000_000, // price ceiling in ×10⁹ scale (1.0) — always met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + 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); + + setup_buyer_seller(netuid, &alice_id, &charlie_id, &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 = make_order_batch(vec![buy, sell]); + + 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); + + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); + + 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 = make_order_batch(vec![buy, sell]); + + 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); + + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); + + 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 = make_order_batch(vec![buy, sell]); + + 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. + fund_account(&alice_id); + + // Seed Bob with staked alph so he has something to sell. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &dave_id, + &bob_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + 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 = make_order_batch(vec![buy, sell]); + + 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`. + fund_account(&alice_id); + + 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 = make_order_batch(vec![buy]); + + 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. + fund_account(&alice_id); + + 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 = make_order_batch(vec![buy]); + + 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 = make_order_batch(vec![signed]); + + 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 = make_order_batch(vec![signed]); + + 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. + fund_account(&alice_id); + + 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 = make_order_batch(vec![buy]); + + 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 = make_order_batch(vec![signed]); + + // 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. + fund_account(&alice_id); + + // Create the hotkey association for Alice so buy_alpha succeeds. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Timestamp at 100_000 ms — Bob's order (expiry 50_000) will be expired. + pallet_timestamp::Now::::put(100_000u64); + + // 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 = make_order_batch(vec![valid, expired]); + + 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. + fund_account(&alice_id); + + // Deliberately do NOT call create_account_if_non_existent — Alice has no + // hotkey association, so the order should be silently skipped. + + 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 = make_order_batch(vec![signed]); + + // 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. + fund_account(&alice_id); + + // Create the hotkey association so that is not the reason for skipping. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // amount = 1 is well below min_default_stake(), triggering AmountTooLow. + let signed = make_signed_order( + 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 = make_order_batch(vec![signed]); + + // 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. + fund_account(&alice_id); + + // Create the hotkey association so that is not the reason for skipping. + let _ = 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 = make_order_batch(vec![signed]); + + // 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. + fund_account(&alice_id); + + // Create the hotkey association Alice → Bob. + let _ = 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 = make_order_batch(vec![signed]); + + 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); + + setup_buyer_seller(netuid, &alice_id, &charlie_id, &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 = make_order_batch(vec![buy, sell]); + + 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); + + setup_buyer_seller(netuid, &alice_id, &charlie_id, &bob_id, &dave_id); + + // Charlie and Dave start with zero free balance (they are hotkeys; no initial funding). + assert_eq!( + 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 = make_order_batch(vec![buy, sell]); + + 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:?})" + ); + }); +} + +// ── max_slippage enforcement against the real dynamic-mechanism AMM ─────────── + +/// A StopLoss order whose price condition is met (`current_price ≤ limit_price`) +/// but whose `max_slippage`-derived floor exceeds the pool's actual price is +/// silently skipped by `execute_orders`. +/// +/// Setup: +/// Dynamic subnet, equal reserves → pool price = 1.0 (raw ratio, i.e. 1 rao/alpha). +/// limit_price = 2_000_000_000 (2.0 × 10⁹) → StopLoss trigger: 1.0 ≤ 2.0 ✓ +/// max_slippage = 10% → effective AMM floor = 2_000_000_000 − 10% × 2_000_000_000 = 1_800_000_000. +/// Pool price = 1_000_000_000 (1.0 × 10⁹) < 1_800_000_000 → PriceLimitExceeded. +/// `execute_orders` catches the error and skips the order (no storage write). +/// Because `sell_alpha` is `#[transactional]`, the stake decrement is rolled back. +#[test] +fn execute_orders_stoploss_max_slippage_exceeds_pool_price_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs staked alpha so the sell can debit her position. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 2_000_000_000 (2.0 × 10⁹): StopLoss triggers when price ≤ 2.0; pool is at 1.0 → met. + // max_slippage = 10% → effective AMM floor = 1_800_000_000. + // Pool price = 1_000_000_000 < 1_800_000_000 → PriceLimitExceeded → order skipped. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + min_default_stake().into(), + 2_000_000_000, // trigger at price 2.0 × 10⁹; pool is at 1.0 — condition met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_percent(10)), + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // execute_orders is best-effort: the call succeeds even though the order + // is rejected by the AMM. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // `try_execute_order` is #[transactional]: the stake decrement inside + // `unstake_from_subnet` is rolled back when the AMM rejects the swap, + // so alice's alpha is unchanged. + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, initial_alpha, + "alice's staked alpha should be unchanged when the order is rolled back" + ); + }); +} + +/// Contrasting test: the same StopLoss order without `max_slippage` executes +/// successfully against the dynamic-mechanism pool. +/// +/// This confirms that the price condition alone is not the blocker and that +/// the previous test's skip is genuinely caused by the slippage floor. +#[test] +fn execute_orders_stoploss_no_slippage_executes_on_dynamic_subnet() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // Same limit_price — trigger still met. max_slippage = None → floor = 0 + // → AMM limit = 0 → no floor constraint → pool executes the sell. + // + // Sell 5× min_default_stake: the dynamic AMM deducts a small fee (~0.05%) + // from the alpha input before swapping, so the TAO output is slightly below + // the sell amount. The `validate_remove_stake` sim-swap check verifies that + // the TAO equivalent is ≥ DefaultMinStake — selling 5× ensures the fee cannot + // drag the output below that floor even on a lightly-loaded pool. + let sell_amount = min_default_stake().to_u64() * 5; + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::StopLoss, + sell_amount, + 2_000_000_000, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + None, + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must be marked as fulfilled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be fulfilled when no slippage floor is set" + ); + + // Alice's staked alpha must have decreased by the sold amount (5× min_default_stake). + let remaining = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining, + AlphaBalance::from(min_default_stake().to_u64() * 5u64), + "alice's staked alpha should decrease by 5×min_default_stake after StopLoss executes" + ); + }); +} + +// ── Partial fill tests ──────────────────────────────────────────────────────── + +/// A LimitBuy order with `partial_fills_enabled` is partially filled on the +/// first `execute_orders` call, then fully filled (Fulfilled) on a second call +/// carrying the remaining amount. +/// +/// The signed payload (`VersionedOrder`) is identical in both submissions so +/// both calls share the same order-id. Only `SignedOrder::partial_fill` changes. +#[test] +fn execute_orders_partial_fill_then_complete() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + // Alice funds two fills: partial_amount + remaining_amount = order amount. + let order_amount = min_default_stake().to_u64() * 4u64; + let partial_amount = min_default_stake().to_u64() * 3u64; + let remaining_amount = order_amount - partial_amount; + + add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); + + // Create the hotkey association Alice → Bob. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Build the base signed order — this exact payload is re-used for both submissions. + let first_signed = make_partial_fill_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + order_amount, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + charlie_id.clone(), + charlie_id.clone(), // relayer = caller + Some(partial_amount), + ); + let id = order_id(&first_signed.order); + + // ── First submission: partial fill ──────────────────────────────────── + let orders = make_order_batch(vec![first_signed.clone()]); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id.clone()), + orders, + )); + + // After the first execution the order must be partially filled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(partial_amount)), + "order should be PartiallyFilled({partial_amount}) after first execution" + ); + + // ── Second submission: fill the remainder ───────────────────────────── + // Clone the order payload from the first signed order (same VersionedOrder, + // same order-id) but set partial_fill to the remaining amount. + let second_signed = SignedOrder { + order: first_signed.order.clone(), + signature: first_signed.signature.clone(), + partial_fill: Some(remaining_amount), + }; + + let orders2 = make_order_batch(vec![second_signed]); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id.clone()), + orders2, + )); + + // After the second execution the order must be fulfilled. + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be Fulfilled after the remaining amount is filled" + ); + }); +} + +/// Same partial-fill-then-complete scenario exercised through +/// `execute_batched_orders`. +/// +/// The buy order is the only order in the batch both times, so the batch is +/// buy-dominant and routes all TAO through the pool. The signed payload is +/// identical between submissions; only `SignedOrder::partial_fill` changes. +#[test] +fn execute_batched_orders_partial_fill_then_complete() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + + let order_amount = min_default_stake().to_u64() * 4u64; + let partial_amount = min_default_stake().to_u64() * 3u64; + let remaining_amount = order_amount - partial_amount; + + add_balance_to_coldkey_account(&alice_id, TaoBalance::from(order_amount * 2u64)); + + // Create the hotkey association Alice → Bob. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Build the base signed order — identical payload reused in both batches. + let first_signed = make_partial_fill_order( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + order_amount, + u64::MAX, // price ceiling — always satisfied + u64::MAX, // no expiry + charlie_id.clone(), + charlie_id.clone(), // relayer = caller + Some(partial_amount), + ); + let id = order_id(&first_signed.order); + + // ── First batch: partial fill ───────────────────────────────────────── + let orders = make_order_batch(vec![first_signed.clone()]); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders, + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::PartiallyFilled(partial_amount)), + "order should be PartiallyFilled({partial_amount}) after first batch" + ); + + // ── Second batch: fill the remainder ────────────────────────────────── + let second_signed = SignedOrder { + order: first_signed.order.clone(), + signature: first_signed.signature.clone(), + partial_fill: Some(remaining_amount), + }; + + let orders2 = make_order_batch(vec![second_signed]); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id.clone()), + netuid, + orders2, + )); + + assert_eq!( + Orders::::get(id), + Some(OrderStatus::Fulfilled), + "order should be Fulfilled after the remaining amount is filled in the second batch" + ); + }); +} + +// ── sim-swap partial-fill guard ─────────────────────────────────────────────── + +/// A LimitBuy order with 1 ppb max_slippage is silently skipped by +/// `execute_orders` because the sim-swap detects that the AMM would only +/// consume a microscopic fraction of the input before the price ceiling is +/// breached (partial fill). +/// +/// Setup: dynamic subnet, equal 1T TAO / 1T alpha reserves → pool price = 1.0. +/// limit_price = 1_000_000_000 (1.0 × 10⁹): LimitBuy triggers when price ≤ 1.0 — met. +/// max_slippage = 1 ppb → ceiling = 1_000_000_001, barely above pool price. +/// Sending any real TAO amount immediately pushes the price above the ceiling, +/// so sim.amount_paid_in + sim.fee_paid < input_amount → SlippageTooHigh. +/// `execute_orders` is best-effort: it catches the error and skips the order. +#[test] +fn execute_orders_buy_tight_slippage_partial_fill_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs a hotkey association for the buy to validate. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + // Alice needs TAO to fund the buy. + fund_account(&alice_id); + + let initial_balance = SubtensorModule::get_coldkey_balance(&alice_id); + + // limit_price = 1_000_000_000 (= 1.0 × 10⁹): LimitBuy trigger (spot ≤ 1.0) met. + // max_slippage = 1 ppb → price ceiling = 1_000_000_001, just above pool price. + // Any real TAO amount pushes the price above the ceiling → partial fill detected. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + 1_000_000_000, // price ceiling at exactly 1.0 × 10⁹ — trigger met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), // 1 ppb — ceiling barely above spot + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // execute_orders is best-effort: the call succeeds even though the guard + // rejects the order due to partial fill. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // No funds should have been debited from Alice — the rollback guard + // prevents any state change when partial fill is detected. + let final_balance = SubtensorModule::get_coldkey_balance(&alice_id); + assert_eq!( + final_balance, initial_balance, + "alice's TAO balance should be unchanged when the order is rolled back" + ); + }); +} + +/// Same setup as `execute_orders_buy_tight_slippage_partial_fill_skipped` but +/// submitted via `execute_batched_orders`. The batch hard-fails with +/// `SlippageTooHigh` because batched execution is not best-effort. +#[test] +fn execute_batched_orders_buy_tight_slippage_partial_fill_fails() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + fund_account(&alice_id); + + // Identical order to the execute_orders variant above. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::LimitBuy, + min_default_stake().into(), + 1_000_000_000, + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), + ); + + let orders = make_order_batch(vec![signed]); + + // Batched execution hard-fails: the partial-fill guard surfaces the error + // directly to the caller instead of silently skipping. + assert_noop!( + LimitOrders::execute_batched_orders(RuntimeOrigin::signed(charlie_id), netuid, orders,), + pallet_subtensor::Error::::SlippageTooHigh + ); + }); +} + +/// A TakeProfit order with 1 ppb max_slippage is silently skipped by +/// `execute_orders` because the sim-swap detects that selling any real alpha +/// amount immediately pushes the pool price below the 1 ppb floor. +/// +/// Setup: dynamic subnet, equal 1T TAO / 1T alpha reserves → pool price = 1.0. +/// limit_price = 1_000_000_000 (1.0 × 10⁹): TakeProfit triggers when price ≥ 1.0 — met. +/// max_slippage = 1 ppb → floor = 999_999_999, barely below pool price. +/// Selling any real alpha amount moves the price below the floor, +/// so sim.amount_paid_in + sim.fee_paid < input_amount → SlippageTooHigh. +/// `execute_orders` is best-effort: it catches the error and skips the order. +#[test] +fn execute_orders_sell_tight_slippage_partial_fill_skipped() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_dynamic_subnet(netuid); + + // Alice needs a hotkey association and staked alpha for the sell. + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 10u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + + // limit_price = 1_000_000_000 (= 1.0 × 10⁹): TakeProfit trigger (spot ≥ 1.0) met. + // max_slippage = 1 ppb → price floor = 999_999_999, just below pool price. + // Any real alpha sale pushes the price below the floor → partial fill detected. + let signed = make_signed_order_with_slippage_rt( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().into(), + 1_000_000_000, // price floor at exactly 1.0 × 10⁹ — trigger met + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + Some(Perbill::from_parts(1)), // 1 ppb — floor barely below spot + ); + let id = order_id(&signed.order); + + let orders = make_order_batch(vec![signed]); + + // execute_orders is best-effort: the call succeeds even though the guard + // rejects the order due to partial fill. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + )); + + // Order must NOT have been written to storage — it was silently skipped. + assert!( + Orders::::get(id).is_none(), + "order should have been skipped, not stored" + ); + + // Alice's staked alpha must be unchanged — the rollback guard prevents + // any state change when partial fill is detected. + let remaining_alpha = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + assert_eq!( + remaining_alpha, initial_alpha, + "alice's staked alpha should be unchanged when the order is rolled back" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Migration integration tests +// ───────────────────────────────────────────────────────────────────────────── + +fn migration_key() -> BoundedVec> { + BoundedVec::truncate_from(b"migrate_register_pallet_hotkey".to_vec()) +} + +fn pallet_acct() -> AccountId { + PalletId(*b"bt/limit").into_account_truncating() +} + +fn pallet_hotkey() -> AccountId { + PalletId(*b"bt/lmhky").into_account_truncating() +} + +/// `on_runtime_upgrade` registers the pallet hotkey and marks the migration as run. +/// +/// Starting from the default genesis (which already registers the hotkey and +/// enables the pallet via `GenesisConfig::build`), the upgrade hook must: +/// - set `HasMigrationRun[migration_key]` to `true` +/// - leave `LimitOrdersEnabled` untouched (still `true`) +/// - leave the hotkey registration intact +#[test] +fn on_runtime_upgrade_marks_migration_run_without_touching_pallet_status() { + new_test_ext().execute_with(|| { + assert!(LimitOrdersEnabled::::get()); + assert!(!HasMigrationRun::::get(migration_key())); + assert!(SubtensorModule::coldkey_owns_hotkey(&pallet_acct(), &pallet_hotkey())); + + >::on_runtime_upgrade(); + + assert!( + HasMigrationRun::::get(migration_key()), + "migration must be marked as run" + ); + assert!( + LimitOrdersEnabled::::get(), + "upgrade must not change LimitOrdersEnabled" + ); + assert!(SubtensorModule::coldkey_owns_hotkey(&pallet_acct(), &pallet_hotkey())); + }); +} + +/// Running `on_runtime_upgrade` twice is a no-op on the second call. +#[test] +fn on_runtime_upgrade_is_idempotent() { + new_test_ext().execute_with(|| { + >::on_runtime_upgrade(); + assert!(HasMigrationRun::::get(migration_key())); + + // Second run must not change any state. + LimitOrdersEnabled::::set(false); + >::on_runtime_upgrade(); + + assert!( + !LimitOrdersEnabled::::get(), + "second upgrade must not touch LimitOrdersEnabled" + ); + }); +} + +// ── Conviction-lock protection ──────────────────────────────────────────────── + +/// A sell order whose alpha is fully conviction-locked is silently skipped by +/// `execute_orders` (best-effort path): the extrinsic returns `Ok`, the order +/// is never written to `Orders` storage, and the seller's staked alpha is +/// unchanged. +#[test] +fn individual_sell_order_skipped_when_alpha_is_conviction_locked() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Give alice staked alpha through bob. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 3u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // Lock ALL of alice's alpha with conviction — nothing is available to sell. + assert_ok!(SubtensorModule::do_lock_stake( + &alice_id, + netuid, + &bob_id, + initial_alpha, + )); + + let sell_amount = min_default_stake().to_u64(); + let signed = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + sell_amount, + 0, // price floor — always satisfied + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + let id = order_id(&signed.order); + + // Best-effort: the locked order is silently skipped, extrinsic still returns Ok. + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + make_order_batch(vec![signed]), + )); + + // Order must NOT be in storage — it was skipped, not fulfilled. + assert_eq!( + Orders::::get(id), + None, + "order should be skipped when alpha is conviction-locked" + ); + + // Alice's staked alpha must be completely unchanged. + let remaining = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + ); + assert_eq!( + remaining, + initial_alpha, + "conviction-locked alpha must not be moved by a skipped sell order" + ); + }); +} + +/// A batched sell order whose alpha is fully conviction-locked causes the +/// entire `execute_batched_orders` call to fail atomically with +/// `StakeUnavailable` — no state is committed. +#[test] +fn batched_sell_order_fails_when_alpha_is_conviction_locked() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Give alice staked alpha through bob. + let initial_alpha: AlphaBalance = (min_default_stake().to_u64() * 3u64).into(); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, + &alice_id, + netuid, + initial_alpha, + ); + seed_subnet_tao(netuid, TaoBalance::from(initial_alpha.to_u64())); + + // Lock ALL of alice's alpha with conviction — nothing is available to sell. + assert_ok!(SubtensorModule::do_lock_stake( + &alice_id, + netuid, + &bob_id, + initial_alpha, + )); + + let sell = make_signed_order( + alice, + bob_id.clone(), + netuid, + OrderType::TakeProfit, + min_default_stake().to_u64(), + 0, // price floor — always satisfied + u64::MAX, + Perbill::zero(), + charlie_id.clone(), + ); + + // Atomic path: the lock violation must revert the entire batch. + assert_noop!( + LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + make_order_batch(vec![sell]), + ), + pallet_subtensor::Error::::StakeUnavailable + ); + }); +} diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts new file mode 100644 index 0000000000..1d2bf9b4a8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-buys.ts @@ -0,0 +1,99 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; + +// execute_batched_orders — all-buy batch. Own subnet, own file. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_BUY", + title: "execute_batched_orders — all-buy batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "all buyers receive alpha and GroupExecutionSummary is emitted", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobStakeBefore = await devGetAlphaStake(polkadotJs, bobHotKey.address, bob.address, netuid); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(50), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(50), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); + + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobStakeAfter = await devGetAlphaStake(polkadotJs, bobHotKey.address, bob.address, netuid); + expect(bobStakeAfter).toBeGreaterThan(bobStakeBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts new file mode 100644 index 0000000000..9ce3fa0c2e --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-all-sells.ts @@ -0,0 +1,105 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_SELL", + title: "execute_batched_orders — all-sell batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Stake alpha for both sellers + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(200)); + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(200)); + }); + + it({ + id: "T01", + title: "all sellers receive TAO and GroupExecutionSummary is emitted", + test: async () => { + const aliceTaoBefore = ( + (await polkadotJs.query.system.account(alice.address)) as any + ).data.free.toBigInt(); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(50), + limitPrice: 1_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(50), + limitPrice: 1_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + expect(filterEvents(events, "GroupExecutionSummary").length).toBe(1); + + const aliceTaoAfter = ( + (await polkadotJs.query.system.account(alice.address)) as any + ).data.free.toBigInt(); + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + + expect(aliceTaoAfter).toBeGreaterThan(aliceTaoBefore); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts new file mode 100644 index 0000000000..48be9461c4 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-fees.ts @@ -0,0 +1,168 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Batched buy orders with fee recipients — own file, hits pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_FEES", + title: "execute_batched_orders — fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "unique fee recipients each receive their own fee", + test: async () => { + const feeRecipient1 = generateKeyringPair(); + const feeRecipient2 = generateKeyringPair(); + + const r1Before = ( + (await polkadotJs.query.system.account(feeRecipient1.address)) as any + ).data.free.toBigInt(); + const r2Before = ( + (await polkadotJs.query.system.account(feeRecipient2.address)) as any + ).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient1.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient2.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const r1After = ( + (await polkadotJs.query.system.account(feeRecipient1.address)) as any + ).data.free.toBigInt(); + const r2After = ( + (await polkadotJs.query.system.account(feeRecipient2.address)) as any + ).data.free.toBigInt(); + + // Both recipients must have received some fee + expect(r1After).toBeGreaterThan(r1Before); + expect(r2After).toBeGreaterThan(r2Before); + }, + }); + + it({ + id: "T02", + title: "shared fee recipient receives aggregated fee", + test: async () => { + const sharedRecipient = generateKeyringPair(); + + const recipientBefore = ( + (await polkadotJs.query.system.account(sharedRecipient.address)) as any + ).data.free.toBigInt(); + + const orderAlice = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: sharedRecipient.address, + }); + + const orderBob = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: sharedRecipient.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [orderAlice, orderBob]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const recipientAfter = ( + (await polkadotJs.query.system.account(sharedRecipient.address)) as any + ).data.free.toBigInt(); + + // Should have received fees from both orders in a single transfer + const expectedFee = tao(100) / 100n + tao(100) / 100n; // 1% * 2 + expect(recipientAfter - recipientBefore).toBe(expectedFee); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts new file mode 100644 index 0000000000..f36f845efe --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-hardfail.ts @@ -0,0 +1,156 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { buildSignedOrder, FAR_FUTURE, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; + +// Hard-fail cases for execute_batched_orders — no pool interaction needed, +// all batches fail before reaching the swap step. Single subnet is fine. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_HARDFAIL", + title: "execute_batched_orders — hard-fail conditions", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "batch fails entirely when one order has an invalid signature", + test: async () => { + const valid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const badSig = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + // Tamper after signing — signature now covers different bytes + const tampered = { + ...badSig, + order: { V1: { ...badSig.order.V1, amount: tao(999) } }, + }; + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [valid, tampered]).signAsync(alice), + ]); + + // The whole extrinsic should fail — hard-fail on invalid signature + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("InvalidSignature"); + }, + }); + + it({ + id: "T02", + title: "batch fails when one order targets a different netuid", + test: async () => { + const correct = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const wrongNetuid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: netuid + 1, // different subnet + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [correct, wrongNetuid]) + .signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("OrderNetUidMismatch"); + }, + }); + + it({ + id: "T03", + title: "root netuid (0) as batch parameter fails immediately", + test: async () => { + const order = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: 0, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(0, [order]).signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("RootNetUidNotAllowed"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts new file mode 100644 index 0000000000..ed846b0b07 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-buy-dominant.ts @@ -0,0 +1,124 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + computeNetAmount, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Buy-dominant mixed batch — net buy hits the pool. Own file. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_MIX_BUY", + title: "execute_batched_orders — buy-dominant mixed batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Bob sells, needs alpha + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(200)); + }); + + it({ + id: "T01", + title: "buy side dominates: both orders fulfilled, net buy hits pool", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + + // Alice buys 200 TAO worth, Bob sells 10 alpha (~10 TAO equiv) + // → net buy ~190 TAO hits the pool + const buyOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(200), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const sellOrder = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(10), + limitPrice: 1_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + // Read price before the swap — pallet uses pre-swap price for netting + const expectedNetAmount = await computeNetAmount(polkadotJs, netuid, tao(200), tao(10), "Buy"); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [buyOrder, sellOrder]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const summary = filterEvents(events, "GroupExecutionSummary"); + expect(summary.length).toBe(1); + const summaryData = summary[0].event.data; + // net_side should be Buy (residual TAO sent to pool) + expect(summaryData[1].type).toBe("Buy"); + // net_amount matches buy_tao - alpha_to_tao(sell_alpha, price) + const netAmountDiff = summaryData[2].toBigInt() - expectedNetAmount; + expect(netAmountDiff < 0n ? -netAmountDiff : netAmountDiff).toBeLessThanOrEqual(10n); + // actual_out > 0 proves the pool returned alpha + expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); + + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts new file mode 100644 index 0000000000..b4eb8b19d2 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-mixed-sell-dominant.ts @@ -0,0 +1,122 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + computeNetAmount, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_MIX_SELL", + title: "execute_batched_orders — sell-dominant mixed batch", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Bob sells a large amount, needs alpha + await devAddStake(polkadotJs, context, bob, bobHotKey.address, netuid, tao(500)); + }); + + it({ + id: "T01", + title: "sell side dominates: both orders fulfilled, net sell hits pool", + test: async () => { + const aliceStakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const bobTaoBefore = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + + // Alice buys 10 TAO, Bob sells 200 alpha (~200 TAO equiv) + // → net sell ~190 alpha hits the pool + const buyOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(10), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const sellOrder = buildSignedOrder(polkadotJs, { + signer: bob, + hotkey: bobHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(200), + limitPrice: 1_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: bob.address, + }); + + // Read price before the swap — pallet uses pre-swap price for netting + const expectedNetAmount = await computeNetAmount(polkadotJs, netuid, tao(10), tao(200), "Sell"); + + await context.createBlock([ + await polkadotJs.tx.limitOrders + .executeBatchedOrders(netuid, [buyOrder, sellOrder]) + .signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(2); + + const summary = filterEvents(events, "GroupExecutionSummary"); + expect(summary.length).toBe(1); + const summaryData = summary[0].event.data; + // net_side should be Sell (residual alpha sent to pool) + expect(summaryData[1].type).toBe("Sell"); + // net_amount matches sell_alpha - tao_to_alpha(buy_tao, price) + const netAmountDiff = summaryData[2].toBigInt() - expectedNetAmount; + expect(netAmountDiff < 0n ? -netAmountDiff : netAmountDiff).toBeLessThanOrEqual(10n); + // actual_out > 0 proves the pool returned TAO + expect(summaryData[3].toBigInt()).toBeGreaterThan(0n); + + const aliceStakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(aliceStakeAfter).toBeGreaterThan(aliceStakeBefore); + + const bobTaoAfter = ((await polkadotJs.query.system.account(bob.address)) as any).data.free.toBigInt(); + expect(bobTaoAfter).toBeGreaterThan(bobTaoBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts new file mode 100644 index 0000000000..6d1a4637e9 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-batched-partial-fill.ts @@ -0,0 +1,142 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + getPartiallyFilledAmount, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests for partial fill via execute_batched_orders. +// Same semantics as the execute_orders variant: the signed VersionedOrder +// payload is reused unchanged; only partial_fill on the envelope changes. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BATCH_PARTIAL_FILL", + title: "execute_batched_orders — partial fill", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "first batched partial fill sets status to PartiallyFilled", + test: async () => { + const orderAmount = tao(100); + const firstFill = Number(tao(50)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [alice.address], + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // Submit first partial fill (50 out of 100 TAO) via execute_batched_orders. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [firstEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + const filled = await getPartiallyFilledAmount(polkadotJs, id); + expect(filled).toBe(BigInt(firstFill)); + + // Alpha stake should have increased from the partial buy. + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(stakeAfter).toBeGreaterThan(0n); + }, + }); + + it({ + id: "T02", + title: "second batched partial fill completing the order sets status to Fulfilled", + test: async () => { + const orderAmount = tao(200); + const firstFill = Number(tao(100)); + const secondFill = Number(tao(100)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [alice.address], + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // First fill: 100 / 200. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [firstEnvelope]).signAsync(alice), + ]); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + expect(await getPartiallyFilledAmount(polkadotJs, id)).toBe(BigInt(firstFill)); + + // Second fill: the remaining 100 — completes the order. + const secondEnvelope = { ...signed, partial_fill: secondFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [secondEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts new file mode 100644 index 0000000000..11c72eaf12 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-cancel-order.ts @@ -0,0 +1,145 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao } from "../../../../utils"; +import { devForceSetBalance, devExecuteOrders } from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_CANCEL", + title: "cancel_order", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + bob = context.keyring.bob; + + registerLimitOrderTypes(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(1_000)); + }); + + it({ + id: "T01", + title: "signer can cancel their own order", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx.signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderCancelled").length).toBe(1); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Cancelled"); + }, + }); + + it({ + id: "T02", + title: "non-signer cannot cancel another account's order", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Bob tries to cancel Alice's order + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + const { + result: [attempt], + } = await context.createBlock([await tx.signAsync(bob)]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("Unauthorized"); + }, + }); + + it({ + id: "T03", + title: "cancelling an already-cancelled order fails", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(3), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const tx = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx.signAsync(alice)]); + + // Second cancel must fail + const tx2 = polkadotJs.tx.limitOrders.cancelOrder(signed.order); + await context.createBlock([await tx2.signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + const cancelled = filterEvents(events, "OrderCancelled"); + expect(cancelled.length).toBe(0); + }, + }); + + it({ + id: "T04", + title: "executing a cancelled order emits OrderSkipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid, + orderType: "LimitBuy", + amount: tao(4), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Cancel first + await context.createBlock([await polkadotJs.tx.limitOrders.cancelOrder(signed.order).signAsync(alice)]); + + // Now try to execute + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts new file mode 100644 index 0000000000..2945ecb535 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-fees.ts @@ -0,0 +1,124 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Each test hits the pool so each gets its own file. +// This file covers fee collection for a buy order only. +// Sell-order fee is covered in 07. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_FEE_BUY", + title: "execute_orders — buy order fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let feeRecipient: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair(); + bob = context.keyring.bob; + feeRecipient = generateKeyringPair(); + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "fee recipient receives TAO for a buy order with 1% fee", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const orderAmount = tao(100); + const expectedFee = orderAmount / 100n; // 1% + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + expect(recipientAfter - recipientBefore).toBe(expectedFee); + }, + }); + + it({ + id: "T02", + title: "zero fee rate — fee recipient balance unchanged", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(10), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + expect(recipientAfter).toBe(recipientBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts new file mode 100644 index 0000000000..c1d43601ae --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-limit-buy.ts @@ -0,0 +1,105 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + fetchChainId, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// One subnet per file — this test submits a real buy order that hits the pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_BUY", + title: "execute_orders — LimitBuy execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + let chainId: bigint; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + chainId = await fetchChainId(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy executes when price condition is met", + test: async () => { + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + + // TODO: why here far future? + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + chainId, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + const executed = filterEvents(events, "OrderExecuted"); + expect(executed.length).toBe(1); + + // OrderId should be stored as Fulfilled + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + // Alpha stake should have increased + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(stakeAfter).toBeGreaterThan(stakeBefore); + + // TAO balance should have decreased + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + expect(taoBalanceAfter).toBeLessThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts new file mode 100644 index 0000000000..2b2d8d295f --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-partial-fill.ts @@ -0,0 +1,146 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + getPartiallyFilledAmount, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests for partial fill via execute_orders. +// The relayer (alice) submits the same signed payload twice with different +// partial_fill values on the envelope. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_PARTIAL_FILL", + title: "execute_orders — partial fill", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "first partial fill sets status to PartiallyFilled", + test: async () => { + const orderAmount = tao(100); + const firstFill = Number(tao(60)); + + // Build a partial-fills-enabled order with alice as relayer. + // The signed VersionedOrder payload is the same for both fills. + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [alice.address], + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // Submit first partial fill (60 out of 100 TAO). + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([firstEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + const filled = await getPartiallyFilledAmount(polkadotJs, id); + expect(filled).toBe(BigInt(firstFill)); + + // Alpha stake should have increased (partial buy occurred). + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(stakeAfter).toBeGreaterThan(0n); + }, + }); + + it({ + id: "T02", + title: "second partial fill completing the order sets status to Fulfilled", + test: async () => { + const orderAmount = tao(200); + const firstFill = Number(tao(120)); + const secondFill = Number(tao(80)); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: orderAmount, + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [alice.address], + partialFillsEnabled: true, + }); + + const id = orderId(polkadotJs, signed.order); + + // First fill: 120 / 200. + const firstEnvelope = { ...signed, partial_fill: firstFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([firstEnvelope]).signAsync(alice), + ]); + + expect(await getOrderStatus(polkadotJs, id)).toBe("PartiallyFilled"); + expect(await getPartiallyFilledAmount(polkadotJs, id)).toBe(BigInt(firstFill)); + + // Second fill: the remaining 80 — completes the order. + // The signed VersionedOrder payload is identical; only partial_fill on the + // envelope changes, per the Rust design. + const secondEnvelope = { ...signed, partial_fill: secondFill }; + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([secondEnvelope]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts new file mode 100644 index 0000000000..761af62de8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -0,0 +1,95 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { generateKeyringPair, tao } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + PERBILL_ONE_PERCENT, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Sell order with fee — separate file, hits pool. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_FEE_SELL", + title: "execute_orders — sell order fee collection", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let feeRecipient: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair(); + bob = context.keyring.bob; + feeRecipient = generateKeyringPair(); + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "fee recipient receives TAO from sell order output with 1% fee", + test: async () => { + const recipientBefore = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(100), + limitPrice: 1_000_000_000n, // always met when price >= 1 TAO/alpha (×10⁹ scale) + expiry: FAR_FUTURE, + feeRate: PERBILL_ONE_PERCENT, + feeRecipient: feeRecipient.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const recipientAfter = ( + await polkadotJs.query.system.account(feeRecipient.address) + ).data.free.toBigInt(); + + // Fee recipient must have received something > 0 + expect(recipientAfter).toBeGreaterThan(recipientBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts new file mode 100644 index 0000000000..0d5de67b24 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-skip-conditions.ts @@ -0,0 +1,256 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + EXPIRED, + FAR_FUTURE, + filterEvents, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Tests in this file cover skip conditions: price-not-met, expired, bad-sig, +// root-netuid, already-processed. Pool price after devEnableSubtoken is ~1 TAO/alpha, +// so LimitBuy with limitPrice=1n is always skipped and TakeProfit with limitPrice=FAR_FUTURE too. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_SKIP", + title: "execute_orders — skip conditions", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy skipped when limit_price below current price", + test: async () => { + // limit_price = 0: current_price (1.0 TAO/alpha) > 0 → condition never met + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: 0n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T02", + title: "TakeProfit skipped when price below limit_price", + test: async () => { + // limit_price = u64::MAX — price can never reach this + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T03", + title: "expired order is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: EXPIRED, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T04", + title: "order with invalid signature is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // Tamper: change the amount inside the V1 inner order after signing. + // The signature now covers different bytes — validation must reject it. + const tampered = { + ...signed, + order: { V1: { ...signed.order.V1, amount: tao(999) } }, + }; + + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([tampered]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T05", + title: "order targeting root netuid (0) is skipped", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid: 0, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + }, + }); + + it({ + id: "T06", + title: "already-fulfilled order is skipped on second execution attempt", + test: async () => { + // Use a price condition that is always met (limitPrice = u64::MAX for buy) + // so the first call succeeds and fulfils the order. + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + // First execution — should succeed. + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + // Second attempt — order already Fulfilled, must be skipped. + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderSkipped").length).toBe(1); + expect(filterEvents(events, "OrderExecuted").length).toBe(0); + }, + }); + + it({ + id: "T07", + title: "mixed batch: valid orders execute, invalid ones are skipped", + test: async () => { + const valid = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(4), // distinct from T06 to get a different OrderId + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const expired = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(2), + limitPrice: FAR_FUTURE, + expiry: EXPIRED, + feeRate: 0, + feeRecipient: alice.address, + }); + + const priceNotMet = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(3), + limitPrice: 0n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([valid, expired, priceNotMet]).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(2); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts new file mode 100644 index 0000000000..6f32bbb17b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-stop-loss.ts @@ -0,0 +1,105 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; + +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Separate file — StopLoss sell changes pool price. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_SL", + title: "execute_orders — StopLoss execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "StopLoss executes when price <= limit_price", + test: async () => { + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + + // limit_price = 100_000_000_000 (100.0 TAO/alpha in ×10⁹ scale) — safely above the + // actual pool price on the freshly registered dynamic subnet after devAddStake(tao(1000)). + // max_slippage is unset (None) so the effective AMM floor is 0; the limit_price here + // only controls the StopLoss trigger condition, not the swap execution price. + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "StopLoss", + amount: tao(100), + limitPrice: 100_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(stakeAfter).toBeLessThan(stakeBefore); + + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts new file mode 100644 index 0000000000..338bc075eb --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-take-profit.ts @@ -0,0 +1,104 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devAddStake, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// Separate file because a TakeProfit sell changes pool price. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_TP", + title: "execute_orders — TakeProfit execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let bob: KeyringPair; + let bobHotKey: KeyringPair; + let netuid: number; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + bob = context.keyring.bob; + bobHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, bob.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // ENable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // associate hotkeys + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, bob, bobHotKey.address); + + // Give Alice some alpha stake to sell + await devAddStake(polkadotJs, context, alice, aliceHotKey.address, netuid, tao(1000)); + }); + + it({ + id: "T01", + title: "TakeProfit executes when price >= limit_price", + test: async () => { + const stakeBefore = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + + // limit_price = 1_000_000_000 (1.0 TAO/alpha in ×10⁹ scale) — current price after + // devAddStake(tao(1000)) is above 1.0 TAO/alpha, so this condition is always met + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "TakeProfit", + amount: tao(100), + limitPrice: 1_000_000_000n, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + expect(filterEvents(events, "OrderSkipped").length).toBe(0); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + // Alpha stake should have decreased + const stakeAfter = await devGetAlphaStake(polkadotJs, aliceHotKey.address, alice.address, netuid); + expect(stakeAfter).toBeLessThan(stakeBefore); + + // TAO balance should have increased + const taoBalanceAfter = (await polkadotJs.query.system.account(alice.address)).data.free.toBigInt(); + expect(taoBalanceAfter).toBeGreaterThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts new file mode 100644 index 0000000000..3e51be83a8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts @@ -0,0 +1,189 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildSignedOrder, + FAR_FUTURE, + fetchChainId, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; +import { encryptTransaction } from "../../../../utils/shield_helpers.js"; +import { u8aToHex } from "@polkadot/util"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_MEVSHIELD", + title: "execute_orders via MEVShield submit_encrypted", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let netuid: number; + let chainId: bigint; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + chainId = await fetchChainId(polkadotJs); + + // Create 3+ blocks so PendingKey is populated (needs 2 blocks for the + // AuthorKeys → NextKey → PendingKey pipeline to fill). The subsequent setup + // transactions each create additional blocks, so 2 here is sufficient. + await context.createBlock([]); + await context.createBlock([]); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy submitted via MEVShield submit_encrypted is decrypted and executed in the same block", + test: async () => { + // Use PendingKey — this is the key the current block's proposer checks against. + // NextKey is one rotation ahead; encrypting with it would require waiting an extra + // block for it to advance to PendingKey, which doesn't happen automatically in + // manual-seal mode. + const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); + if ((pendingKeyRaw as any).isNone) + throw new Error("MEVShield PendingKey not available — create more blocks first"); + const nextKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); + + const signedOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: null, + chainId, + }); + + // Get alice's current nonce so we can pre-sign the inner tx at nonce+1 + const aliceNonce = ( + (await polkadotJs.query.system.account(alice.address)) as any + ).nonce.toNumber() as number; + + // Sign the inner execute_orders tx at nonce+1, then get its raw bytes + const innerTx = await polkadotJs.tx.limitOrders + .executeOrders([signedOrder]) + .signAsync(alice, { nonce: aliceNonce + 1 }); + const innerTxBytes = innerTx.toU8a(); + + // Encrypt the inner tx with the MEVShield NextKey + const ciphertext = await encryptTransaction(innerTxBytes, nextKeyBytes); + + // submit_encrypted requires a mortal era — immortal is rejected by CheckMortality. + // Anchor to the PARENT block, not the current best block. + // + // try_decode_shielded_tx is a runtime API call executed at parent_hash (block B's + // state). CheckMortality::implicit() looks up BlockHash[birth]. In block B's state, + // only blocks 0..B-1 are stored — BlockHash[B] is populated when block B+1 + // initializes. If we sign with { current: B }, birth = B and the lookup fails + // (AncientBirthBlock), check() returns Err, and try_decode_shielded_tx returns None, + // so the outer tx is included as a plain tx with no inner tx extracted. + // Anchoring to B-1 (the parent) means birth = B-1, which IS in BlockHash at block + // B's state, so implicit() succeeds and the signature verifies correctly. + const header = await polkadotJs.rpc.chain.getHeader(); + const blockNumber = header.number.toNumber() - 1; + const blockHash = header.parentHash; + const era = polkadotJs.createType("ExtrinsicEra", { current: blockNumber, period: 8 }); + + // Submit the wrapper directly to the pool (not via createBlock) so the proposer + // scans the pool naturally and runs shielded-tx detection. + const signedWrapper = await polkadotJs.tx.mevShield + .submitEncrypted(u8aToHex(ciphertext)) + .signAsync(alice, { nonce: aliceNonce, era, blockHash }); + await polkadotJs.rpc.author.submitExtrinsic(signedWrapper.toHex()); + + // Seal a block — the proposer detects the shielded tx in the pool, decrypts the + // inner execute_orders, and includes both in the same block. + await context.createBlock([]); + + // Assert the order is Fulfilled + const id = orderId(polkadotJs, signedOrder.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + + it({ + id: "T02", + title: "LimitBuy with a designated relayer is executed when the relayer submits via MEVShield", + test: async () => { + const relayer = generateKeyringPair("sr25519"); + await devForceSetBalance(polkadotJs, context, relayer.address, tao(100)); + + const pendingKeyRaw = await polkadotJs.query.mevShield.pendingKey(); + if ((pendingKeyRaw as any).isNone) + throw new Error("MEVShield PendingKey not available — create more blocks first"); + const pendingKeyBytes = (pendingKeyRaw as any).unwrap().toU8a(true); + + const signedOrder = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: aliceHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + relayer: [relayer.address], + chainId, + }); + + // The relayer submits the encrypted execute_orders tx on Alice's behalf. + // relayerNonce+0 = outer submit_encrypted, relayerNonce+1 = inner execute_orders. + const relayerNonce = ( + (await polkadotJs.query.system.account(relayer.address)) as any + ).nonce.toNumber() as number; + + const innerTx = await polkadotJs.tx.limitOrders + .executeOrders([signedOrder]) + .signAsync(relayer, { nonce: relayerNonce + 1 }); + const innerTxBytes = innerTx.toU8a(); + + const ciphertext = await encryptTransaction(innerTxBytes, pendingKeyBytes); + + const header = await polkadotJs.rpc.chain.getHeader(); + const blockNumber = header.number.toNumber() - 1; + const blockHash = header.parentHash; + const era = polkadotJs.createType("ExtrinsicEra", { current: blockNumber, period: 8 }); + + const signedWrapper = await polkadotJs.tx.mevShield + .submitEncrypted(u8aToHex(ciphertext)) + .signAsync(relayer, { nonce: relayerNonce, era, blockHash }); + await polkadotJs.rpc.author.submitExtrinsic(signedWrapper.toHex()); + + await context.createBlock([]); + + const id = orderId(polkadotJs, signedOrder.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts new file mode 100644 index 0000000000..64423152ee --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-pallet-status.ts @@ -0,0 +1,107 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao } from "../../../../utils"; +import { devForceSetBalance } from "../../../../utils/dev-helpers.js"; +import { buildSignedOrder, FAR_FUTURE, filterEvents, registerLimitOrderTypes } from "../../../../utils/limit-orders.js"; + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_STATUS", + title: "set_pallet_status", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + registerLimitOrderTypes(polkadotJs); + await devForceSetBalance(polkadotJs, context, alice.address, tao(1_000)); + }); + + it({ + id: "T01", + title: "root can disable the pallet", + test: async () => { + await context.createBlock([ + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.limitOrders.setPalletStatus(false)).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + const statusEvent = filterEvents(events, "LimitOrdersPalletStatusChanged"); + expect(statusEvent.length).toBe(1); + expect(statusEvent[0].event.data[0].isTrue).toBe(false); + }, + }); + + it({ + id: "T02", + title: "execute_orders is blocked when pallet is disabled", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid: 1, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeOrders([signed]).signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("LimitOrdersDisabled"); + }, + }); + + it({ + id: "T03", + title: "execute_batched_orders is blocked when pallet is disabled", + test: async () => { + const signed = buildSignedOrder(polkadotJs, { + signer: alice, + hotkey: alice.address, + netuid: 1, + orderType: "LimitBuy", + amount: tao(1), + limitPrice: BigInt(2_000_000_000), + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: alice.address, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(1, [signed]).signAsync(alice), + ]); + + expect(attempt.successful).toEqual(false); + expect(attempt.error.name).toEqual("LimitOrdersDisabled"); + }, + }); + + it({ + id: "T04", + title: "root can re-enable the pallet", + test: async () => { + await context.createBlock([ + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.limitOrders.setPalletStatus(true)).signAsync(alice), + ]); + + const events = await polkadotJs.query.system.events(); + const statusEvent = filterEvents(events, "LimitOrdersPalletStatusChanged"); + expect(statusEvent.length).toBe(1); + expect(statusEvent[0].event.data[0].isTrue).toBe(true); + }, + }); + }, +}); diff --git a/ts-tests/utils/balance.ts b/ts-tests/utils/balance.ts index f6fe83d3b0..b172bf1546 100644 --- a/ts-tests/utils/balance.ts +++ b/ts-tests/utils/balance.ts @@ -1,6 +1,6 @@ import { waitForTransactionWithRetry } from "./transactions.js"; import type { TypedApi } from "polkadot-api"; -import { type subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import type { subtensor } from "@polkadot-api/descriptors"; import { Keyring } from "@polkadot/keyring"; export const TAO = BigInt(1000000000); // 10^9 RAO per TAO @@ -19,6 +19,7 @@ export async function forceSetBalance( ss58Address: string, amount: bigint = tao(1e10) ): Promise { + const { MultiAddress } = await import("@polkadot-api/descriptors"); const keyring = new Keyring({ type: "sr25519" }); const alice = keyring.addFromUri("//Alice"); const internalCall = api.tx.Balances.force_set_balance({ diff --git a/ts-tests/utils/dev-helpers.ts b/ts-tests/utils/dev-helpers.ts new file mode 100644 index 0000000000..470b98be8e --- /dev/null +++ b/ts-tests/utils/dev-helpers.ts @@ -0,0 +1,104 @@ +/** + * Polkadot.js (ApiPromise) compatible helpers for dev tests. + * Uses ApiPromise, not PAPI TypedApi — keep them separate. + */ +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { SignedOrder } from "./index.js"; + +export async function devForceSetBalance( + polkadotJs: ApiPromise, + context: any, + address: string, + amount: bigint +): Promise { + await context.createBlock([ + await polkadotJs.tx.sudo + .sudo(polkadotJs.tx.balances.forceSetBalance(address, amount)) + .signAsync(context.keyring.alice), + ]); +} + +export async function devAddStake( + polkadotJs: ApiPromise, + context: any, + coldkey: KeyringPair, + hotkey: string, + netuid: number, + amount: bigint +): Promise { + await context.createBlock([ + await polkadotJs.tx.subtensorModule.addStake(hotkey, netuid, amount).signAsync(coldkey), + ]); +} + +export async function devAssociateHotKey( + polkadotJs: ApiPromise, + context: any, + coldkey: KeyringPair, + hotkey: string +): Promise { + await context.createBlock([await polkadotJs.tx.subtensorModule.tryAssociateHotkey(hotkey).signAsync(coldkey)]); +} + +export async function devGetAlphaStake( + polkadotJs: ApiPromise, + hotkey: string, + coldkey: string, + netuid: number +): Promise { + const value = await polkadotJs.query.subtensorModule.alphaV2(hotkey, coldkey, netuid); + + const mantissa = value.mantissa; + const exponent = value.exponent; + + let result: bigint; + + if (exponent >= 0n) { + result = BigInt(mantissa) * BigInt(10) ** BigInt(exponent); + } else { + result = BigInt(mantissa) / BigInt(10) ** BigInt(-exponent); + } + + return result; +} + +export async function devSudoSetLockReductionInterval( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + interval: number +): Promise { + await context.createBlock([await polkadotJs.tx.adminUtils.sudoSetLockReductionInterval(interval).signAsync(alice)]); +} + +export async function devRegisterSubnet( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + hotkey: KeyringPair +): Promise { + await context.createBlock([await polkadotJs.tx.subtensorModule.registerNetwork(hotkey.address).signAsync(alice)]); + const events = (await polkadotJs.query.system.events()) as any; + const netuid = (events as any[]).filter((e: any) => e.event.method === "NetworkAdded")[0].event.data[0].toNumber(); + return netuid; +} + +export async function devEnableSubtoken( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + netuid: number +): Promise { + await context.createBlock([ + await polkadotJs.tx.sudo.sudo(polkadotJs.tx.adminUtils.sudoSetSubtokenEnabled(netuid, true)).signAsync(alice), + ]); +} +export async function devExecuteOrders( + polkadotJs: ApiPromise, + context: any, + alice: KeyringPair, + orders: SignedOrder[] +): Promise { + await context.createBlock([await polkadotJs.tx.limitOrders.executeOrders(orders).signAsync(alice)]); +} diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts new file mode 100644 index 0000000000..0ffbe177e0 --- /dev/null +++ b/ts-tests/utils/limit-orders.ts @@ -0,0 +1,267 @@ +import type { KeyringPair } from "@moonwall/util"; +import type { TypedApi } from "polkadot-api"; +import type { subtensor } from "@polkadot-api/descriptors"; +import { Keyring } from "@polkadot/keyring"; +import { u8aToHex } from "@polkadot/util"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { waitForTransactionWithRetry } from "./transactions.js"; +import { MultiAddress } from "@polkadot-api/descriptors"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type OrderType = "LimitBuy" | "TakeProfit" | "StopLoss"; + +export interface OrderParams { + signer: KeyringPair; + hotkey: string; + netuid: number; + orderType: OrderType; + amount: bigint; + limitPrice: bigint; + expiry: bigint; + feeRate: number; // Perbill (parts per billion), e.g. 10_000_000 = 1% + feeRecipient: string; + chainId?: bigint; // defaults to 42n (the dev node's EVM chain ID) + relayer?: string[] | null; // Optional: if set, only these accounts may relay the order + maxSlippage?: number | null; // Optional: Perbill (ppb). When set, effective swap limit = limit_price ± limit_price * maxSlippage / 1e9 + partialFillsEnabled?: boolean; // Optional: if true, order can be partially filled (requires relayer) +} + +export interface Order { + signer: string; + hotkey: string; + netuid: number; + order_type: OrderType; + amount: bigint; + limit_price: bigint; + expiry: bigint; + fee_rate: number; + fee_recipient: string; + relayer: string[] | null; + max_slippage: number | null; + chain_id: bigint; + partial_fills_enabled: boolean; +} + +export interface VersionedOrder { + V1: Order; +} + +export interface SignedOrder { + order: VersionedOrder; + signature: { Sr25519: `0x${string}` } | { Ed25519: `0x${string}` } | { Ecdsa: `0x${string}` }; + partial_fill: number | null; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const PERBILL_ONE_PERCENT = 10_000_000; +export const FAR_FUTURE = BigInt("18446744073709551615"); // u64::MAX +export const EXPIRED = BigInt(1); // 1ms — always in the past + +// ── Order building & signing ────────────────────────────────────────────────── + +/** + * Build a SignedOrder ready for submission to execute_orders / + * execute_batched_orders. The Order struct is SCALE-encoded via the + * polkadot.js registry and then signed with the signer's sr25519 key. + */ +export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { + const inner: Order = { + signer: params.signer.address, + hotkey: params.hotkey, + netuid: params.netuid, + order_type: params.orderType, + amount: params.amount, + limit_price: params.limitPrice, + expiry: params.expiry, + fee_rate: params.feeRate, + fee_recipient: params.feeRecipient, + relayer: params.relayer ?? null, + max_slippage: params.maxSlippage ?? null, + chain_id: params.chainId ?? 42n, + partial_fills_enabled: params.partialFillsEnabled ?? false, + }; + + const versionedOrder: VersionedOrder = { V1: inner }; + + // SCALE-encode the VersionedOrder so the signature covers the version tag. + const encoded = api.registry.createType("LimitVersionedOrder", versionedOrder); + const sig = params.signer.sign(encoded.toU8a()); + + return { + order: versionedOrder, + signature: { Sr25519: u8aToHex(sig) as `0x${string}` }, + partial_fill: null, + }; +} + +/** + * Compute the on-chain OrderId (blake2_256 of SCALE-encoded VersionedOrder). + * Mirrors `Pallet::derive_order_id` in Rust. + */ +export function orderId(api: any, order: VersionedOrder): `0x${string}` { + const encoded = api.registry.createType("LimitVersionedOrder", order); + return blake2AsHex(encoded.toU8a(), 256) as `0x${string}`; +} + +// ── Registry ────────────────────────────────────────────────────────────────── + +/** + * Register the custom SCALE types used by pallet-limit-orders with the + * polkadot.js ApiPromise registry. Call this once after obtaining the api. + */ +export function registerLimitOrderTypes(api: any): void { + api.registry.register({ + LimitOrderType: { + _enum: ["LimitBuy", "TakeProfit", "StopLoss"], + }, + LimitOrder: { + signer: "AccountId", + hotkey: "AccountId", + netuid: "u16", + order_type: "LimitOrderType", + amount: "u64", + limit_price: "u64", + expiry: "u64", + fee_rate: "u32", // Perbill + fee_recipient: "AccountId", + relayer: "Option>", + max_slippage: "Option", + chain_id: "u64", + partial_fills_enabled: "bool", + }, + LimitVersionedOrder: { + _enum: { + V1: "LimitOrder", + }, + }, + LimitSignedOrder: { + order: "LimitVersionedOrder", + signature: "MultiSignature", + partial_fill: "Option", + }, + LimitOrderStatus: { + _enum: { + Fulfilled: null, + PartiallyFilled: "u64", + Cancelled: null, + }, + }, + }); +} + +// ── Chain helpers ───────────────────────────────────────────────────────────── + +/** Read current SubnetTAO and SubnetAlphaIn to derive spot price (TAO per alpha). */ +export async function getAlphaPrice(api: TypedApi, netuid: number): Promise { + const taoReserve = await api.query.SubtensorModule.SubnetTAO.getValue(netuid); + const alphaIn = await api.query.SubtensorModule.SubnetAlphaIn.getValue(netuid); + if (alphaIn === 0n) return 0n; + return taoReserve / alphaIn; // integer approximation +} + +/** Enable the subtoken for a subnet (required for swaps to work). */ +export async function enableSubtoken(api: TypedApi, netuid: number): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ + netuid, + subtoken_enabled: true, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_subtoken_enabled"); +} + +/** Sudo-enable or disable the limit-orders pallet. */ +export async function setPalletStatus(api: TypedApi, enabled: boolean): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const tx = api.tx.Sudo.sudo({ + call: api.tx.LimitOrders.set_pallet_status({ enabled }).decodedCall, + }); + await waitForTransactionWithRetry(api, tx, alice, "set_pallet_status"); +} + +/** Read the on-chain OrderStatus for a given order id (hex). */ +export async function getOrderStatus( + polkadotJs: any, + id: `0x${string}` +): Promise<"Fulfilled" | "PartiallyFilled" | "Cancelled" | undefined> { + const result = await polkadotJs.query.limitOrders.orders(id); + if (result.isNone) return undefined; + return result.unwrap().type as "Fulfilled" | "PartiallyFilled" | "Cancelled"; +} + +/** Read the on-chain OrderStatus and return the PartiallyFilled amount, or null. */ +export async function getPartiallyFilledAmount(polkadotJs: any, id: `0x${string}`): Promise { + const result = await polkadotJs.query.limitOrders.orders(id); + if (result.isNone) return null; + const status = result.unwrap(); + if (status.type !== "PartiallyFilled") return null; + return BigInt(status.asPartiallyFilled.toString()); +} + +/** Filter system events by method name. */ +export function filterEvents(events: any, method: string): any[] { + return (events as any[]).filter((e: any) => e.event.method === method); +} + +/** Read the EVM chain ID from pallet_evm_chain_id storage. */ +export async function fetchChainId(api: any): Promise { + const result = await api.query.evmChainId.chainId(); + return BigInt(result.toString()); +} + +/** + * Compute the expected `net_amount` field of `GroupExecutionSummary` for a + * mixed buy/sell batch, mirroring the pallet's netting logic. + * + * The runtime API returns `floor(price_actual * 1e9)` as a u64, so our + * bigint replication differs from the on-chain U96F32 result by at most a + * few RAO — use `toBeCloseTo` or a small tolerance window when asserting. + * + * @param polkadotJs polkadot-js ApiPromise + * @param netuid subnet id + * @param buySideTao total net TAO from buy orders (after fees, in RAO) + * @param sellSideAlpha total net alpha from sell orders (in RAO) + * @param side which side dominates ("Buy" | "Sell") + */ +export async function computeNetAmount( + polkadotJs: any, + netuid: number, + buySideTao: bigint, + sellSideAlpha: bigint, + side: "Buy" | "Sell" +): Promise { + // price_scaled = floor(price_actual * 1e9) [RAO per alpha * 1e9 / 1e9 = dimensionless] + const priceRaw = await polkadotJs.call.swapRuntimeApi.currentAlphaPrice(netuid); + const price = BigInt(priceRaw.toString()); + const SCALE = 1_000_000_000n; + + if (side === "Buy") { + // net_amount (TAO) = buy_tao - alpha_to_tao(sell_alpha, price) + // alpha_to_tao ≈ floor(price * sell_alpha / 1e9) + const sellTaoEquiv = (price * sellSideAlpha) / SCALE; + return buySideTao - sellTaoEquiv; + } else { + // net_amount (alpha) = sell_alpha - tao_to_alpha(buy_tao, price) + // tao_to_alpha ≈ floor(buy_tao * 1e9 / price) + const buyAlphaEquiv = (buySideTao * SCALE) / price; + return sellSideAlpha - buyAlphaEquiv; + } +} + +export async function executeBatchedOrders( + api: TypedApi, + netuid: number, + orders: SignedOrder[] +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const tx = api.tx.LimitOrders.execute_batched_orders({ + netuid, + orders, + }); + await waitForTransactionWithRetry(api, tx, alice, "execute_batched_orders"); +}