Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
73da72a
move swap interface to primitives since it's not a pllet
gorka-i Mar 23, 2026
e80c0b3
pallet advancing
gorka-i Mar 23, 2026
4f6b85c
pallet execute_orders_batch and also pallet account
girazoki Mar 24, 2026
e441dc7
start adding tests
girazoki Mar 24, 2026
3010345
fmt plus tests
girazoki Mar 24, 2026
c1e26a1
fmt function
girazoki Mar 24, 2026
ffb1637
fixes here and there
girazoki Mar 24, 2026
bf262b4
readme, refactors and security
girazoki Mar 24, 2026
3f853a9
add an additional test checking we get fees from both sides
girazoki Mar 25, 2026
1407762
Add all order types
girazoki Mar 30, 2026
1c2f173
rename order.side to order_type
girazoki Mar 30, 2026
bf29170
remove order-limits
girazoki Mar 30, 2026
88bfa69
order swap remove
girazoki Mar 30, 2026
7c228a7
remove signature
girazoki Mar 30, 2026
e7d3584
fee shoudl be part of the order, as well as fee account
girazoki Mar 31, 2026
1594eec
add tests regarding fee
girazoki Mar 31, 2026
97eac2d
apply limits, including rates, min stake, etc
girazoki Mar 31, 2026
0a6adbd
keep adding more validations
girazoki Mar 31, 2026
738e7bd
check for order validity
girazoki Mar 31, 2026
c7243cd
first integration tests running
girazoki Mar 31, 2026
2dd7c16
apply filters
girazoki Mar 31, 2026
375f6d8
change errors and add new tests
girazoki Apr 1, 2026
59136d6
fix pallet-iner tests
girazoki Apr 1, 2026
2e70f39
use assert_noop in pallet tests
girazoki Apr 1, 2026
5bb9945
add more tests
girazoki Apr 1, 2026
f3375ed
readme change
girazoki Apr 1, 2026
95397db
better doc
girazoki Apr 1, 2026
ee1520c
be more accurate with numbers plus stoploss test
girazoki Apr 1, 2026
fcedbc0
fee related tests
girazoki Apr 1, 2026
a567b6a
kill switch & fmt
girazoki Apr 1, 2026
358a520
first 2 benchmarks added
girazoki Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ useless_conversion = "allow" # until polkadot is patched
[workspace.dependencies]
node-subtensor-runtime = { path = "runtime", default-features = false }
pallet-admin-utils = { path = "pallets/admin-utils", default-features = false }
pallet-limit-orders = { path = "pallets/limit-orders", default-features = false }
pallet-commitments = { path = "pallets/commitments", default-features = false }
pallet-registry = { path = "pallets/registry", default-features = false }
pallet-crowdloan = { path = "pallets/crowdloan", default-features = false }
Expand All @@ -70,7 +71,7 @@ subtensor-custom-rpc = { default-features = false, path = "pallets/subtensor/rpc
subtensor-custom-rpc-runtime-api = { default-features = false, path = "pallets/subtensor/runtime-api" }
subtensor-precompiles = { default-features = false, path = "precompiles" }
subtensor-runtime-common = { default-features = false, path = "common" }
subtensor-swap-interface = { default-features = false, path = "pallets/swap-interface" }
subtensor-swap-interface = { default-features = false, path = "primitives/swap-interface" }
subtensor-transaction-fee = { default-features = false, path = "pallets/transaction-fee" }
subtensor-chain-extensions = { default-features = false, path = "chain-extensions" }
stp-shield = { git = "https://github.com/opentensor/polkadot-sdk.git", rev = "fb1dd20df37710800aa284ac49bb26193d5539ee", default-features = false }
Expand Down
49 changes: 49 additions & 0 deletions pallets/limit-orders/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[package]
name = "pallet-limit-orders"
version = "0.1.0"
edition.workspace = true

[dependencies]
codec = { workspace = true, features = ["derive"] }
frame-benchmarking = { workspace = true, optional = true }
sp-keyring = { workspace = true, optional = true }
frame-support.workspace = true
frame-system.workspace = true
scale-info.workspace = true
sp-core.workspace = true
sp-runtime.workspace = true
sp-std.workspace = true
substrate-fixed.workspace = true
subtensor-runtime-common.workspace = true
subtensor-swap-interface.workspace = true

[dev-dependencies]
sp-io.workspace = true

[lints]
workspace = true

[features]
default = ["std"]
std = [
"codec/std",
"frame-support/std",
"frame-system/std",
"scale-info/std",
"sp-core/std",
"sp-keyring/std",
"sp-runtime/std",
"sp-std/std",
"substrate-fixed/std",
"subtensor-runtime-common/std",
"subtensor-swap-interface/std",
]

runtime-benchmarks = [
"frame-benchmarking/runtime-benchmarks",
"frame-support/runtime-benchmarks",
"frame-system/runtime-benchmarks",
"sp-runtime/runtime-benchmarks",
"sp-keyring",
"subtensor-runtime-common/runtime-benchmarks"
]
232 changes: 232 additions & 0 deletions pallets/limit-orders/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# pallet-limit-orders

A FRAME pallet for off-chain signed limit orders on Bittensor subnets.

Users sign orders off-chain and submit them to a relayer. The relayer batches
orders targeting the same subnet and submits them via `execute_batched_orders`,
which nets the buy and sell sides, executes a single AMM pool swap for the
residual, and distributes outputs pro-rata to all participants. This minimises
price impact compared to executing each order independently against the pool.

MEV protection is available for free: any caller can wrap `execute_orders` or
`execute_batched_orders` inside `pallet_shield::submit_encrypted` to hide the
batch contents from the mempool until the block is proposed.

---

## Order lifecycle

```
User signs Order off-chain
Relayer submits via execute_orders Relayer submits via execute_batched_orders
(one-by-one, best-effort) (aggregated, atomic)
│ │
├─ Invalid / expired / ├─ Any order invalid / expired /
│ price-not-met → │ price-not-met / root netuid →
│ silently skipped (no state change) │ entire batch fails (DispatchError)
│ │
└─ Valid → executed └─ All orders valid → net pool swap
│ → distribute pro-rata
└─ order_id written to Orders as Fulfilled
(prevents replay)

User can cancel at any time via cancel_order
└─ order_id written to Orders as Cancelled
```

---

## Data structures

### `Order<AccountId>`

The payload that a user signs off-chain. Never stored in full on-chain — only
its `blake2_256` hash (`OrderId`) is persisted.

| Field | Type | Description |
|-----------------|-------------|-------------|
| `signer` | `AccountId` | Coldkey that authorises the order. For buy types: pays TAO. For sell types: owns the staked alpha. |
| `hotkey` | `AccountId` | Hotkey to stake to (buy types) or unstake from (sell types). |
| `netuid` | `NetUid` | Target subnet. |
| `order_type` | `OrderType` | One of `LimitBuy`, `TakeProfit`, or `StopLoss` (see table below). |
| `amount` | `u64` | Input amount in raw units. TAO for buy types; alpha for sell types. |
| `limit_price` | `u64` | Price threshold in TAO/alpha raw units. Trigger direction depends on `OrderType` (see table below). |
| `expiry` | `u64` | Unix timestamp in milliseconds. Order must not execute after this time. |
| `fee_rate` | `Perbill` | Per-order fee as a fraction of the input amount. `Perbill::zero()` = no fee. |
| `fee_recipient` | `AccountId` | Account that receives the fee collected for this order. |

### `OrderType`

| Variant | Action | Triggers when | Use case |
|--------------|---------------|-------------------------|----------|
| `LimitBuy` | Buy alpha | price ≤ `limit_price` | Enter a position at or below a target price. |
| `TakeProfit` | Sell alpha | price ≥ `limit_price` | Exit a position once price rises to a profit target. |
| `StopLoss` | Sell alpha | price ≤ `limit_price` | Exit a position to limit downside if price falls to a floor. |

### `SignedOrder<AccountId, Signature>`

Envelope submitted by the relayer: the `Order` payload plus the user's
sr25519/ed25519 signature over its SCALE encoding. Signature verification
uses `order.signer` as the expected public key.

### `OrderStatus`

Terminal state of a processed order, stored under its `OrderId`.

| Variant | Meaning |
|-------------|---------|
| `Fulfilled` | Order was successfully executed. |
| `Cancelled` | User registered a cancellation intent before execution. |

---

## Storage

### `Orders: StorageMap<H256, OrderStatus>`

Maps an `OrderId` (blake2_256 of the SCALE-encoded `Order`) to its terminal
`OrderStatus`. Absence means the order has never been seen and is still
executable (provided it is valid). Presence means it is permanently closed —
neither `Fulfilled` nor `Cancelled` orders can be re-executed.

---

## Config

| Item | Type | Description |
|-----------------------|---------------------------------------------------|-------------|
| `Signature` | `Verify + ...` | Signature type for off-chain order authorisation. Set to `sp_runtime::MultiSignature` in the subtensor runtime. |
| `SwapInterface` | `OrderSwapInterface<Self::AccountId>` | Full swap + balance execution interface. Implemented by `pallet_subtensor::Pallet<T>`. 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<u32>` (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<PalletId>` (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<Self::AccountId>` (constant) | Hotkey the pallet intermediary account stakes to/from during batch execution. Must be a dedicated hotkey registered on every subnet the pallet may operate on. Operators should register it as a non-validator neuron. |

---

## Extrinsics

### `execute_orders(orders)` — call index 0

**Origin:** any signed account (typically a relayer).

Executes a list of signed limit orders one by one, each interacting with the
AMM pool independently. Orders that fail validation or whose price condition is
not met are silently skipped — a single bad order does not revert the batch.

**Fee handling:** each order's `fee_rate` is deducted from the input amount and
forwarded to that order's `fee_recipient` after execution.

**When to use:** suitable for small batches or when orders target different
subnets. Use `execute_batched_orders` for same-subnet batches to reduce price
impact.

---

### `execute_batched_orders(netuid, orders)` — call index 4

**Origin:** any signed account (typically a relayer).

Aggregates all valid orders targeting `netuid` into a single net pool
interaction:

1. **Validate & classify** — if any order has the wrong netuid, an invalid
signature, an already-processed id, a past expiry, a price condition not met,
or targets the root netuid (0), the **entire call fails** with the
corresponding error. All orders must be valid for execution to proceed. Valid
orders are split into buy-side (`LimitBuy`) and sell-side (`TakeProfit`,
`StopLoss`) groups. For buy orders the net TAO (after fee) is pre-computed
here.

2. **Collect assets** — gross TAO is pulled from each buyer's free balance into
the pallet intermediary account. Gross alpha stake is moved from each seller's
`(coldkey, hotkey)` position to the pallet intermediary's `(pallet_account,
pallet_hotkey)` position.

3. **Net pool swap** — buy TAO and sell alpha are converted to a common TAO
basis at the current spot price and offset against each other. Only the
residual amount touches the pool in a single swap:
- Buy-dominant: residual TAO is sent to the pool; pool returns alpha.
- Sell-dominant: residual alpha is sent to the pool; pool returns TAO.
- Perfectly offset: no pool interaction.

4. **Distribute alpha pro-rata** — every buyer receives their share of the total
available alpha (pool output + seller passthrough alpha). Share is
proportional to each buyer's net TAO contribution. Integer division floors
each share; any remainder stays in the pallet intermediary account as dust.

5. **Distribute TAO pro-rata** — every seller receives their share of the total
available TAO (pool output + buyer passthrough TAO), minus their order's
fee. Share is proportional to each seller's alpha valued at the current spot
price. Integer division floors each share; any remainder stays in the pallet
intermediary account as dust.

6. **Collect fees** — buy-side fees (withheld from each order's TAO input) and
sell-side fees (withheld from each order's TAO output) are accumulated per
unique `fee_recipient` and forwarded in a single transfer per recipient.

7. **Emit `GroupExecutionSummary`.**

> **Note:** rounding dust (alpha and TAO) accumulates in the pallet intermediary
> account between batches. If an emission epoch fires while dust is present, the
> pallet earns emissions it never distributes.

---

### `cancel_order(order)` — call index 1

**Origin:** the order's `signer` (coldkey).

Registers a cancellation intent by writing the `OrderId` into `Orders` as
`Cancelled`. Once cancelled an order can never be executed. The full `Order`
payload is required so the pallet can derive the `OrderId`.

---

## Events

| Event | Fields | Emitted when |
|-------|--------|--------------|
| `OrderExecuted` | `order_id`, `signer`, `netuid`, `side` | An individual order was successfully executed (by either extrinsic). |
| `OrderSkipped` | `order_id` | An order was skipped by `execute_orders` (bad signature, expired, wrong netuid, already processed, price condition not met, or root netuid). Not emitted by `execute_batched_orders` — invalid orders there cause the whole call to fail. |
| `OrderCancelled` | `order_id`, `signer` | The signer registered a cancellation via `cancel_order`. |
| `GroupExecutionSummary` | `netuid`, `net_side`, `net_amount`, `actual_out`, `executed_count` | Emitted once per `execute_batched_orders` call summarising the net pool trade. `net_side` is `Buy` if TAO was sent to the pool, `Sell` if alpha was sent. `net_amount` and `actual_out` are zero when the two sides perfectly offset. |

---

## Errors

| Error | Cause |
|-------|-------|
| `InvalidSignature` | Signature does not match the order payload and signer. Also used as a catch-all for failed validation in `execute_orders`. |
| `OrderAlreadyProcessed` | The `OrderId` is already present in `Orders` (either `Fulfilled` or `Cancelled`). |
| `OrderExpired` | `now > order.expiry`. Only returned as a hard error by `execute_batched_orders`; silently skipped in `execute_orders`. |
| `PriceConditionNotMet` | Current spot price is beyond the order's `limit_price`. Only returned as a hard error by `execute_batched_orders`; silently skipped in `execute_orders`. |
| `OrderNetUidMismatch` | An order inside a `execute_batched_orders` call targets a different netuid than the batch parameter. |
| `RootNetUidNotAllowed` | The order or batch targets netuid 0 (root). Root uses a fixed 1:1 stable mechanism with no AMM — limit orders are not meaningful there. |
| `Unauthorized` | Caller of `cancel_order` is not the order's `signer`. |
| `SwapReturnedZero` | The pool swap returned zero output for a non-zero residual input. |

---

## Fee model

Fees are specified per-order via `fee_rate: Perbill` and `fee_recipient:
AccountId` fields on the `Order` struct. There is no global protocol fee or
admin key.

All fees are collected in TAO regardless of order side.

| Order type | Fee deducted from | Timing |
|-------------------------|-------------------|--------|
| `LimitBuy` | TAO input | Pre-computed in `validate_and_classify`, before pool swap. |
| `TakeProfit`, `StopLoss`| TAO output | Deducted in `distribute_tao_pro_rata`, after pool swap. |

Fee formula: `fee = fee_rate * amount` (using `Perbill` multiplication, which
upcasts to u128 internally to avoid overflow).

At the end of each batch, fees are accumulated per unique `fee_recipient` and
forwarded in a single transfer per recipient. If multiple orders share the same
`fee_recipient`, they result in exactly one transfer rather than one per order.
Loading