From f25f8e2759c746f0462b5384d31b259b76178b09 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 17:45:02 -0600 Subject: [PATCH 01/21] feat(subtensor): pool-borrowing covered shorts (proposal) Proposal implementing the Fixed-Liability Covered Continuous-Unwind Model (shorting.pdf v3.6.1) as native pool-borrowing derivatives on Alpha/TAO CPMM pools. Launch scope is shorts-first; longs are specified for symmetry but gated off. The whole feature is disabled by default (ShortsEnabled=false) and gated behind governance until trading-games verification. Adds: - derivatives module: open/top-up/partial+full close/permissionless default, per-block O(1) decay + restoration, terminal deregistration settlement. - Per-subnet custody accounting; flow-neutral (no TaoFlow writes); reuses the existing SubnetMovingPrice EMA as the risk/terminal price reference. - Governance params (kappa, base LTV, decay bounds, dust, grace, min input) via admin-utils; runtime-API read layer (quote open/close, materialized position views with health metrics, per-subnet market state). - safe-math: checked_exp; accurate small-delta carry accumulation. - Comprehensive test suite (35 tests) + DESIGN.md and IMPLEMENTATION_PLAN.md. Co-authored-by: Cursor --- docs/derivatives/DESIGN.md | 324 +++++++ docs/derivatives/IMPLEMENTATION_PLAN.md | 180 ++++ pallets/admin-utils/src/lib.rs | 67 ++ pallets/subtensor/runtime-api/src/lib.rs | 11 + pallets/subtensor/src/coinbase/block_step.rs | 2 + pallets/subtensor/src/coinbase/root.rs | 4 + pallets/subtensor/src/derivatives/mod.rs | 767 +++++++++++++++++ pallets/subtensor/src/derivatives/types.rs | 167 ++++ pallets/subtensor/src/lib.rs | 122 +++ pallets/subtensor/src/macros/dispatches.rs | 45 + pallets/subtensor/src/macros/errors.rs | 22 + pallets/subtensor/src/macros/events.rs | 56 ++ pallets/subtensor/src/tests/derivatives.rs | 846 +++++++++++++++++++ pallets/subtensor/src/tests/mod.rs | 1 + primitives/safe-math/src/lib.rs | 40 + runtime/src/lib.rs | 36 + 16 files changed, 2690 insertions(+) create mode 100644 docs/derivatives/DESIGN.md create mode 100644 docs/derivatives/IMPLEMENTATION_PLAN.md create mode 100644 pallets/subtensor/src/derivatives/mod.rs create mode 100644 pallets/subtensor/src/derivatives/types.rs create mode 100644 pallets/subtensor/src/tests/derivatives.rs diff --git a/docs/derivatives/DESIGN.md b/docs/derivatives/DESIGN.md new file mode 100644 index 0000000000..5e3fcad8a3 --- /dev/null +++ b/docs/derivatives/DESIGN.md @@ -0,0 +1,324 @@ +# Covered continuous-unwind derivatives — subtensor design + +Implementation design for the **Fixed-Liability Covered Continuous-Unwind Model v3.6.1** +(`shorting.pdf`) inside `pallet-subtensor`. This document maps the spec onto the existing +runtime, fixes the reserve-accounting model against the real AMM, and locks the storage, +extrinsic, hook, and runtime-API surface. The companion `IMPLEMENTATION_PLAN.md` has the +phased file-by-file plan and diff estimate. + +Launch scope is **shorts only**. Long paths are specified for symmetry but gated behind a +disabled flag (spec §1, §9.3). Everything below is written to add the *fewest* moving parts +by reusing primitives that already exist. + +--- + +## 1. Reality check: what the spec assumes vs. what subtensor has + +The spec is written against a **pure no-fee CPMM** (`x·y=k`). Subtensor's pool is different, +and that single fact drives most of the design decisions. + +| Spec assumption | Subtensor reality | Consequence | +|---|---|---| +| Pure `x·y=k` | **Balancer-weighted** pool (`pallet_subtensor_swap`), weights in `SwapBalancer`, default 0.5/0.5 (CPMM-like only at init) | Use spec closed-forms **only for quoting/sizing**; realize every pool-touching leg through the live fee+weight-aware engine (`SwapHandler::sim_swap` / `swap`). The spec explicitly allows this (§4.4, §14.6). | +| User can remove/add liquidity | User LP is **deprecated** (`add_liquidity`/`remove_liquidity` → `Error::Deprecated`) | The "remove-and-sell-back" open and the restoration/settlement zaps are realized as **protocol reserve mutations**, not user LP ops. | +| Reserves `T`, `A` | `SubnetTAO` (TAO, the quote reserve), `SubnetAlphaIn` (alpha pool reserve), `SubnetAlphaOut` (staked alpha outside the pool) | Short open/restore are mostly `SubnetTAO` mutations; close settlement touches `SubnetAlphaIn`. | +| `pEMA` price reference | **Already exists**: `SubnetMovingPrice` (per-block halving EMA, TAO/alpha) | Reuse directly as the spec's `pEMA`. No new TWAP, no new price EMA. | +| `T_EMA`, `A_EMA` reserve EMAs | **Do not exist** | Derive `T_EMA` from `SubnetMovingPrice × SubnetAlphaIn` instead of storing a new per-block reserve EMA (see §4). | +| Recycle floor `P`, extinguish liability | **Already exists**: `recycle_tao(coldkey, amount)`, `recycle_subnet_alpha`/`burn_subnet_alpha` | Reuse for default and terminal settlement. | +| Per-block decay/unwind step | **Net-new** | One O(1)-per-subnet call added to `block_step()`. | +| Subnet deregistration hook | **Already exists**: `do_dissolve_network` (`coinbase/root.rs`) | Insert terminal derivative settlement before `destroy_alpha_in_out_stakes`. | +| Derivative flow-neutral for emissions | **Free by construction** | We mutate reserves directly and never call `record_tao_inflow/outflow`, so TaoFlow is untouched (spec §4.5). | + +**Key takeaway:** the spec's `pEMA`, recycle, and dereg primitives already exist. The genuinely +new state is (a) the position store, (b) per-side aggregate + decay accumulator, (c) a per-block +decay step, (d) ~4 extrinsics, (e) one runtime-API quote. Risk reserve EMAs are *derived*, not stored. + +--- + +## 2. Notation map (spec symbol → subtensor identifier) + +| Spec | Meaning | Subtensor binding | +|---|---|---| +| `T` | live TAO reserve | `SubnetTAO::::get(netuid)` | +| `A` | live alpha reserve | `SubnetAlphaIn::::get(netuid)` | +| `T_ref` | conservative TAO ref `min(T_live, T_EMA)` | `min(SubnetTAO, pEMA·A_live)` — derived (§4) | +| `pEMA` | EMA price (TAO/alpha) | `Pallet::get_moving_alpha_price(netuid)` (`SubnetMovingPrice`) | +| `P` | user position input / floor | `ShortPosition.p_floor: TaoBalance` | +| `C` | gross collateral (open-time only) | computed, **not stored** | +| `N` | retained proceeds = `R0` | computed at open → `r_stored` | +| `R(t)` | retained buffer (decays) | `ShortPosition.r_stored` × decay factor | +| `Q` | fixed alpha liability | `ShortPosition.q_liability: AlphaBalance` | +| `E(t)` | linked TAO escrow (decays) | `ShortPosition.e_stored: TaoBalance` | +| `B` | utilization footprint `λC` (TAO) | `ShortPosition.b_stored: TaoBalance` | +| `S` | aggregate active footprint | `ShortAgg.b_sigma` | +| `Ω_S` | short decay accumulator | `ShortAgg.omega: U64F64` | +| `Ω_entry` | per-position accumulator snapshot | `ShortPosition.omega_entry: U64F64` | +| `λ`, `λ_eff` | base / effective LTV | governance param `ShortBaseLtv`; `λ_eff` computed | +| `κ_S` | short footprint cap factor | governance param `ShortKappa` | +| `d_min`,`d_max` | decay bounds | `DecayMin`, `DecayMax` | +| `R_dust` | dust threshold | `ShortDust` | +| `K_D(Q)` | terminal liability value | computed at dereg: `max(K_spot,last, Q·pEMA)` | + +--- + +## 3. Reserve-accounting model (the load-bearing part) + +All pool impact is expressed as mutations to `SubnetTAO` / `SubnetAlphaIn`, executed through the +existing helpers so weights and fees stay consistent: + +- `increase_provided_tao_reserve` / `decrease_provided_tao_reserve` +- `increase_provided_alpha_reserve` / `decrease_provided_alpha_reserve` +- `T::SwapInterface::sim_swap` / `swap` with `GetAlphaForTao` / `GetTaoForAlpha` for any + internal swap leg (fee + weight aware). + +### 3.1 Open short — net pool effect + +The spec's remove-and-sell-back (§4.3) on a pure CPMM nets to: **alpha reserve unchanged, TAO +reserve drops by `N + E`**, leaving the trader owing `Q = ϕA` alpha. We realize that directly: + +``` +TAO removed from pool = N + E = ϕ(2-ϕ)·T // = T - (1-ϕ)²T on pure CPMM +SubnetTAO -= (N + E) // the downward price impact +held by protocol = E (escrow) + N (becomes buffer R0) +position liability = Q = ϕ·A (alpha debt, virtual; alpha reserve untouched at open) +``` + +`ϕ`, `N`, `Q`, `E` are first quoted from the spec closed-forms (Appendix A.1), then the realized +TAO leg is taken from a fee-adjusted engine quote so the booked `N`/`E` match what the pool +actually moved. The trader supplies `P = C − N` TAO, held against the floor and recycle-on-default. + +### 3.2 Continuous restoration (per block) — net pool effect + +For a short the decayed amount `dU = dR + dE` is TAO-side. The spec zap (swap min portion to +alpha, re-add balanced) nets, on a CPMM, to **alpha unchanged, TAO `+= dU`, price drifts up** — +exactly reversing the open impact over time: + +``` +restoration_zap(netuid, dU) ≡ increase_provided_tao_reserve(netuid, dU) +``` + +No weight change is needed (we *want* the upward drift), so this is a single reserve increment. +This conserves TAO: the `N + E` removed at open is returned over the position's life. (If +simulation later shows the weighted pool needs the explicit min-swap, swap `z = √(T(T+U)) − T` +via the engine then add the remainder — spec §6.6 — behind the same `restoration_zap` fn.) + +### 3.3 Close (partial fraction ρ, full = ρ=1) — net pool effect + +Trader repays `ρQ` alpha; protocol pairs it with the escrow slice `ρE` via the settlement zap +(§8.5). Net pool effect: `SubnetAlphaIn += ρQ`, `SubnetTAO += ρ·E_remaining_share`, balanced +through an engine min-swap. Trader receives `ρ(P + R)` back. Position `P, Q, R, E, B` reduced +pro-rata; aggregates updated. + +### 3.4 Default (R ≤ R_dust) and terminal dereg + +- **Default:** restore residual `R + E` (restoration zap), `recycle_tao(coldkey, P)` for the floor, + extinguish `Q` (no alpha moves — it was virtual), drop position from aggregates. +- **Dereg terminal:** value liability at `K_D(Q) = max(K_spot,last(Q), Q·pEMA)`; equity = + `max(0, (P+R) − K_D)` paid to trader; `min(P+R, K_D)` recycled via `recycle_tao` outside terminal + distribution; `Q` extinguished. Hooked into `do_dissolve_network` before `destroy_alpha_in_out_stakes`. + +### 3.5 Conservation invariant (must be a test) + +Over any position lifecycle, total TAO returned to `SubnetTAO` via restoration + close-settlement + +default-restore, plus recycled floor/liability-cover, **equals** the `N + E` removed at open plus the +`P` the trader posted, minus equity paid out. This invariant is the acceptance gate for the +reserve math and is the first item in the spec's trading-games suite (§14.5). + +> **Primary implementation risk:** reconciling the spec's CPMM closed-forms with the Balancer +> weights. Mitigation: quote/size from closed-forms, realize from the engine, gate launch on the +> conservation + capacity simulations the spec already mandates (§14.5). `κ_S` starts tiny. + +--- + +## 4. Risk reference reserves without new EMA storage + +The spec wants `T_ref = min(T_live, T_EMA)` to stop a same-block reserve pump from improving open +terms (§3.1–3.2). Subtensor has no reserve EMA, but it has an EMA *price*. Since +`price = (w_base/w_quote)·(T/A)`, we reconstruct: + +``` +T_EMA ≈ pEMA · A_live (pEMA already folds the weight ratio at EMA time) +T_ref = min(SubnetTAO, T_EMA) +``` + +This reuses `SubnetMovingPrice` and adds **zero** per-block EMA maintenance. `A_live` is still +manipulable, but with `κ_S` starting conservative and the footprint cap `S + B ≤ κ_S·T_ref`, the +launch exposure is bounded; a dedicated stored reserve-EMA can be added later if the trading games +show it is needed. Decay utilization uses the same `T_ref` (spec §3.3), so flash trades cannot grief +carry either. + +--- + +## 5. Storage layout + +New module `pallets/subtensor/src/derivatives/`. Storage declared inline in `lib.rs` (the repo's +convention — there is no storage macro file). `#[pallet::without_storage_info]` is already set, so +`MaxEncodedLen` is not required. + +### 5.1 Position struct + +One **merged** short position per `(coldkey, netuid)` — additional same-side opens merge after +materialization (spec §8.6), which keeps the store sparse and avoids a position-id index. + +```rust +#[freeze_struct("")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortPosition { + pub p_floor: TaoBalance, // non-decaying floor (spec P) + pub q_liability: AlphaBalance,// fixed alpha debt (spec Q) + pub r_stored: TaoBalance, // buffer at last materialization (spec R) + pub e_stored: TaoBalance, // escrow at last materialization (spec E) + pub b_stored: TaoBalance, // footprint at last materialization (spec B) + pub omega_entry: U64F64, // Ω_S snapshot at last materialization + pub opened_at: u64, // block, for UX/telemetry only +} +``` + +```rust +// --- DMAP (netuid, coldkey) -> ShortPosition +#[pallet::storage] +pub type ShortPositions = StorageDoubleMap< + _, Identity, NetUid, Blake2_128Concat, T::AccountId, ShortPosition, OptionQuery>; +``` + +### 5.2 Per-subnet aggregate + decay accumulator + +```rust +#[freeze_struct("")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug, Default)] +pub struct ShortAgg { + pub r_sigma: TaoBalance, // Σ current R + pub e_sigma: TaoBalance, // Σ current E + pub b_sigma: TaoBalance, // Σ current B == active footprint S + pub q_sigma: AlphaBalance, // Σ fixed liability (open interest) + pub omega: U64F64, // Ω_S cumulative decay accumulator +} + +#[pallet::storage] +pub type ShortAggregate = + StorageMap<_, Identity, NetUid, ShortAgg, ValueQuery, DefaultShortAgg>; +``` + +Materialization (spec §6.3): `f = exp(-(Ω - Ω_entry))`, multiply `r,e,b` by `f`, snapshot `Ω_entry = Ω`. +Aggregate tick is O(1) per subnet: `R,E,B *= g`, `Ω += -ln g` (spec §6.4). + +### 5.3 Governance parameters (global defaults; per-subnet override optional later) + +Stored as `StorageValue` with `#[pallet::type_value]` defaults; setters in `utils/misc.rs`; exposed +via `pallet-admin-utils` sudo/owner extrinsics (existing pattern). + +| Storage | Type | Default | Spec | +|---|---|---|---| +| `ShortsEnabled` | `bool` | `false` (flip on after games) | §14.1 | +| `LongsEnabled` | `bool` | `false` | §9.3 | +| `ShortBaseLtv` | `U64F64` | `0.50` | §14.1 | +| `ShortKappa` | `U64F64` | small, conservative | §5.1 | +| `DecayMin` | `U64F64` | `0.001`/day | §6.2 | +| `DecayMax` | `U64F64` | `0.015`/day | §6.2 | +| `ShortDust` | `TaoBalance` | `1 TAO` | §7.2 | + +No migration is required: new maps default cleanly; only `ShortsEnabled` flips via governance. + +--- + +## 6. Per-block decay step + +Add `Self::run_derivatives_decay()` to `block_step()` **after** `run_coinbase(...)` and **before** +`update_moving_prices()` (so decay sees post-emission reserves but feeds the same block's price EMA). +For each subnet with `ShortAggregate.b_sigma > 0`: + +``` +u = min(1, b_sigma / (ShortKappa · T_ref)) // EMA-smoothed via T_ref +d_day = DecayMin + (DecayMax - DecayMin)·u² +g = (1 - d_day)^(1 block / blocks_per_day) // const per-block factor +dR = r_sigma·(1-g); dE = e_sigma·(1-g); dB = b_sigma·(1-g) +r_sigma,e_sigma,b_sigma *= g; omega += -ln g +restoration_zap(netuid, dR + dE) // SubnetTAO += dR+dE +``` + +O(1) per active subnet, no per-position iteration. `(1-d_day)^(1/blocks_per_day)` is computed with +the existing `substrate_fixed` helpers; `blocks_per_day ≈ 7200`. + +**Defaults are lazy.** Because the tick never visits individual positions, a position that has decayed +below `R_dust` is settled (a) on its owner's next interaction (materialize → if dust, default), or +(b) by a permissionless `default_short(coldkey, netuid)` poke. This keeps the block hook O(1) and +matches the spec's MEV-insensitive, time-based default (§7.1, §7.4). + +--- + +## 7. Extrinsics (shorts launch) + +Thin dispatch wrappers in `macros/dispatches.rs` → `do_*` in `derivatives/`. Next free +`call_index` is **139**. + +| call_index | Extrinsic | Delegates to | Notes | +|---|---|---|---| +| 139 | `open_short(netuid, hotkey, position_input: TaoBalance, price_limit: TaoBalance)` | `do_open_short` | gated by `ShortsEnabled`; solves `C,N,ϕ,Q,E`; capacity + domain checks; merges into existing position | +| 140 | `top_up_short(netuid, amount: TaoBalance)` | `do_top_up_short` | adds to `R` only (spec §8.2); fresh decaying capital | +| 141 | `close_short(netuid, fraction: U64F64, price_limit: TaoBalance)` | `do_close_short` | partial (`ρ<1`) and full (`ρ=1`); repays `ρQ`, returns `ρ(P+R)` | +| 142 | `default_short(coldkey, netuid)` | `do_default_short` | permissionless; only valid when materialized `R ≤ R_dust` | + +`hotkey` is carried so the position is associated with a `(hotkey, coldkey, netuid)` identity +consistent with the rest of staking, even though the merged position is keyed `(netuid, coldkey)`. +Long extrinsics are **not** added at launch (gated by spec §9; adding them later is symmetric). + +Weights: start with inline `DbWeight::get().reads_writes(r, w)` placeholders (an accepted in-repo +pattern), benchmark before mainnet. + +--- + +## 8. Events & errors + +**Events** (`macros/events.rs`): `ShortOpened { netuid, coldkey, p, n, q, e, phi }`, +`ShortToppedUp`, `ShortClosed { netuid, coldkey, fraction, repaid_q, returned }`, +`ShortDefaulted`, `ShortTerminalSettled { netuid, coldkey, equity, liability_cover }`. + +**Errors** (`macros/errors.rs`): `ShortsDisabled`, `ShortPositionNotFound`, +`EffectiveLtvNonPositive` (`λ_eff ≤ 0`), `RetainedProceedsNonPositive` (`N ≤ 0`), +`ShortCapacityExceeded` (`S + B > κ_S·T_ref`), `ReserveDomainExceeded` (`4N > T_live`), +`PositionNotDefaultEligible`, `SubnetNotDynamic` (mechanism ≠ 1 / root). + +--- + +## 9. Runtime API (read-only quote) + +Extend `runtime-api/src/lib.rs` + `rpc_info/` + `impl_runtime_apis!` (runtime/src/lib.rs). + +```rust +fn quote_open_short(netuid: NetUid, position_input: TaoBalance) -> ShortOpenQuote; +fn get_short_position(coldkey: AccountId32, netuid: NetUid) -> Option; +``` + +`ShortOpenQuote` carries the spec's pre-open trader view (§1.2): `c, n, q, e, phi, lambda_eff, +daily_decay, min/max_time_to_dust, est_close_cost (via sim_swap GetAlphaForTao for Q), +breakeven_close_price`. Pure reads + `sim_swap`; no state change. JSON-RPC wrapper is optional. + +--- + +## 10. Invariants enforced (spec §17) + +1. Shorts-first: `open_short` rejects unless `ShortsEnabled`; longs gated. +2. Covered: `P + N = C` at open. +3. No liquid proceeds: `N` is never paid out; it becomes `R0`. +4. Fixed liability: `Q` changes only on close / default / dereg. +5. Continuous unwind: `R,E,B` decay with one `g`; restored via `restoration_zap`. +6. No price-based liquidation: default iff `R ≤ R_dust`. +7. Limited recourse: residual `Q` extinguished at default/dereg. +8. Footprint cap: `S + B ≤ κ_S·T_ref` (also bounds same-block stacked opens via progressive `S`). +9. Flow neutrality: no `record_tao_*` calls on any derivative leg. +10. Dereg awareness: terminal alpha base read from subnet mode (legacy vs new, per `destroy_alpha_in_out_stakes` rules). +11. Terminal short settlement: `K_D(Q) = max(K_spot,last, Q·pEMA)`. +12. Escrow bound: `E/R = 1/(1−ϕ)` stays bounded by `κ_S`-implied `ϕ_cap`, so dust default is MEV-trivial. + +--- + +## 11. Explicit deferrals (faithful to spec) + +- **Longs**: code-symmetric but flag-gated off (`LongsEnabled=false`). Long open mirrors with + alpha/TAO swapped, `D=ϕT`, ADR-adjusted LTV (§9.2). Not in the launch diff. +- **Derivative TaoFlow** (`χ_S`): off; flow-neutral (§4.5). Not wired. +- **Stored reserve EMA / TWAP**: replaced by derived `T_ref` from `pEMA` (§4). TWAP is an optional + later guard only (§3.4, §11.4). +- **Per-open `ϕ_max`**: not a control; only the `4N ≤ T_live` domain bound is enforced (§5.2). +- **Per-subnet param overrides**: launch uses globals; per-netuid maps can be added later without + touching call sites. diff --git a/docs/derivatives/IMPLEMENTATION_PLAN.md b/docs/derivatives/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..e36790214a --- /dev/null +++ b/docs/derivatives/IMPLEMENTATION_PLAN.md @@ -0,0 +1,180 @@ +# Implementation plan — covered continuous-unwind shorts + +Companion to `DESIGN.md`. Goal: land the spec's **shorts-first** launch with the smallest faithful +diff, reusing `SubnetMovingPrice` (pEMA), the swap engine (fees/weights), recycle, and the dereg +hook. Long paths are written symmetric but flag-gated off. + +Code is **not** written here — this is the build order, the exact files to touch, and the test/gate +plan. + +--- + +## Phase 0 — scaffolding (no behavior change) + +| File | Change | ~LOC | +|---|---|---| +| `pallets/subtensor/src/derivatives/mod.rs` | **new** module tree: `pub mod short; pub mod decay; pub mod settle; pub mod types;` | 10 | +| `pallets/subtensor/src/lib.rs` | `pub mod derivatives;`; add storage items (`ShortPositions`, `ShortAggregate`, governance `StorageValue`s + `type_value` defaults) | ~70 | +| `pallets/subtensor/src/derivatives/types.rs` | `ShortPosition`, `ShortAgg` structs (+ `freeze_struct`, derives) | ~40 | + +No migration (new empty maps default cleanly; confirmed against repo convention). No +`STORAGE_VERSION` bump. + +--- + +## Phase 1 — math core (pure, unit-testable, no extrinsics) + +All in `derivatives/short.rs` as `impl Pallet` helpers. These are the spec +closed-forms (Appendix A.1) used for quoting/sizing. + +| Function | Spec | Returns | +|---|---|---| +| `short_t_ref(netuid)` | §3.1, §4 | `min(SubnetTAO, pEMA·A_live)` | +| `solve_collateral(p, t_ref, lambda, s)` | §4.2 | `(C, N)` via quadratic; reject `N ≤ 0` | +| `lambda_eff(...)` | §4.1 | effective LTV; reject `≤ 0` | +| `solve_phi(n, t_live)` | §4.3 | `ϕ = (1 − √(1 − 4N/T))/2`; reject `4N > T` | +| `decay_factor_g(u)` | §6.2 | per-block `g` from `d_day(u)` | +| `materialize(pos, agg)` | §6.3 | `f = exp(-(Ω−Ω_entry))`, scale `r,e,b` | + +Each gets a focused unit test asserting the spec's worked examples (§1.7–1.8: `C=100`, `N=37.5`, +`ϕ≈0.039`, `Q≈3900`, `E=39`). + +--- + +## Phase 2 — reserve legs (the risky part, isolate + test) + +`derivatives/settle.rs`: the three pool-touching primitives, each a thin wrapper over existing +reserve helpers + the swap engine. + +| Function | Net reserve effect | Built from | +|---|---|---| +| `open_remove_sell_back(netuid, n, e, q)` | `SubnetTAO -= (N+E)`; book `Q` debt | `decrease_provided_tao_reserve`; engine quote to confirm realized `N` | +| `restoration_zap(netuid, dU)` | `SubnetTAO += dU` (price drifts up) | `increase_provided_tao_reserve` (escalate to min-swap form only if sim demands) | +| `settlement_zap(netuid, alpha_in, tao_in)` | balanced add of repaid `Q` + escrow | engine min-swap (§8.5) + `increase_provided_*` | + +**Gate for this phase:** the §3.5 conservation test — open → N blocks of decay → close (or default) +returns exactly the TAO removed plus posted floor, minus equity. Run on a Balancer pool with +non-default weights, not just 0.5/0.5. + +--- + +## Phase 3 — extrinsics + +| File | Change | ~LOC | +|---|---|---| +| `derivatives/short.rs` | `do_open_short`, `do_top_up_short`, `do_close_short`, `do_default_short` (ensure_signed, validate, materialize, mutate, emit) | ~220 | +| `macros/dispatches.rs` | 4 thin wrappers, `call_index` 139–142, placeholder `DbWeight` weights | ~50 | +| `macros/events.rs` | 5 event variants | ~25 | +| `macros/errors.rs` | ~8 error variants | ~12 | + +Validation order in `do_open_short` (spec §8.1): side flag → `SubnetMechanism==1` → solve `C,N` → +reject `N≤0` / `4N>T` / `S+B>κ_S·T_ref` → solve `ϕ,Q,E` → realize legs → store/merge → bump +aggregate. Same-block stacked opens read the progressively updated `b_sigma` (spec §5.2.1) for free, +because each open re-reads `ShortAggregate`. + +--- + +## Phase 4 — per-block decay hook + +| File | Change | ~LOC | +|---|---|---| +| `derivatives/decay.rs` | `run_derivatives_decay()` — iterate subnets with `b_sigma>0`, O(1) tick each (§6.4), call `restoration_zap` | ~70 | +| `coinbase/block_step.rs` | one call after `run_coinbase`, before `update_moving_prices` | ~2 | + +--- + +## Phase 5 — terminal dereg settlement + +| File | Change | ~LOC | +|---|---|---| +| `derivatives/settle.rs` | `settle_shorts_on_dereg(netuid)` — for each short: materialize, `K_D=max(K_spot,last, Q·pEMA)`, pay `equity`, `recycle_tao(liability_cover)`, extinguish `Q`, clear | ~90 | +| `coinbase/root.rs` (`do_dissolve_network`) | call `settle_shorts_on_dereg(netuid)` before `destroy_alpha_in_out_stakes` | ~2 | + +`K_spot,last(Q)` = `sim_swap(GetAlphaForTao, …)` cost to buy `Q` at the final executable state; +`pEMA` = `get_moving_alpha_price`. Buckets stay disjoint (liability-cover recycled outside terminal +distribution — same rule as default), so no terminal fixed-point (spec §11.3). + +--- + +## Phase 6 — runtime API + +| File | Change | ~LOC | +|---|---|---| +| `rpc_info/derivatives_info.rs` | **new** `ShortOpenQuote`, `ShortPositionInfo` DTOs + `quote_open_short`, `get_short_position` | ~110 | +| `rpc_info/mod.rs` | `pub mod derivatives_info;` | 1 | +| `runtime-api/src/lib.rs` | new trait `DerivativesRuntimeApi` (2 methods) + DTO imports | ~20 | +| `runtime/src/lib.rs` | `impl DerivativesRuntimeApi for Runtime` in `impl_runtime_apis!` | ~12 | + +JSON-RPC (`pallets/subtensor/rpc`, `node/src/rpc.rs`) only if external clients need it — deferred. + +--- + +## Phase 7 — governance wiring + +| File | Change | ~LOC | +|---|---|---| +| `utils/misc.rs` | `set_*` for each param (put + event), `get_*` readers | ~60 | +| `admin-utils/src/lib.rs` | sudo/owner extrinsics: `sudo_set_shorts_enabled`, `…_short_kappa`, `…_short_base_ltv`, `…_decay_bounds`, `…_short_dust` | ~90 | + +`ShortsEnabled` stays `false` until the trading-games gate passes. + +--- + +## Phase 8 — tests & trading-games gate (spec §14.5) + +`pallets/subtensor/src/tests/derivatives.rs` (+ eco-tests for adversarial sims). The spec makes these +the launch gate, not optional: + +1. **Conservation** (§3.5) on weighted pools. +2. **Same-block stacked opens** cannot bypass `S+B ≤ κ_S·T_ref` (§5.2.1). +3. **Worked examples** (§1.7–1.8, §15) reproduce exactly. +4. **Dust/escrow bound** `E/R ≤ 1/(1−ϕ_cap)` holds through top-ups/partials (§7.3). +5. **Short-driven dereg**: no free terminal extraction; payout bounded by `K_D(Q)` (§10.7). +6. **Flow neutrality**: assert `SubnetTaoFlow` unchanged across every derivative leg (§4.5). +7. **Decay schedule**: 365-day remaining-fraction table (§14.3) within tolerance. + +Only after 1–7 pass on a mainnet-like replica does governance flip `ShortsEnabled` and begin ramping +`κ_S` (spec §5.1, §14.6). + +--- + +## Diff estimate + +| Area | Files touched | New files | ~LOC | +|---|---|---|---| +| Storage + types | `lib.rs` | `derivatives/{mod,types}.rs` | ~120 | +| Math core | — | `derivatives/short.rs` (part) | ~120 | +| Reserve legs | — | `derivatives/settle.rs` (part) | ~140 | +| Extrinsics + FRAME surface | `dispatches.rs`, `events.rs`, `errors.rs` | — | ~90 | +| Decay hook | `coinbase/block_step.rs` | `derivatives/decay.rs` | ~72 | +| Dereg hook | `coinbase/root.rs` | — | ~92 | +| Runtime API | `runtime-api/src/lib.rs`, `runtime/src/lib.rs`, `rpc_info/mod.rs` | `rpc_info/derivatives_info.rs` | ~143 | +| Governance | `utils/misc.rs`, `admin-utils/src/lib.rs` | — | ~150 | +| **Total (excl. tests)** | **~10 edited** | **~6 new** | **~1,000** | + +No on-chain migration. No `STORAGE_VERSION` bump. Reuses pEMA, swap engine, recycle, and dereg +plumbing rather than re-implementing them — which is where the line-count is kept down. + +--- + +## Build / sanity commands + +```bash +# compile the pallet only (fast loop) +cargo check -p pallet-subtensor + +# pallet tests +cargo test -p pallet-subtensor derivatives + +# full runtime build (after runtime-api wiring) +cargo check -p node-subtensor-runtime +``` + +## Open decisions for the author + +1. **Position granularity**: merged-per-`(coldkey,netuid)` (chosen, minimal) vs. multi-position with + an id index. Merge is spec-sanctioned (§8.6); revisit only if UX needs distinct lots. +2. **Restoration realization**: net `SubnetTAO +=` (chosen) vs. explicit min-swap zap. Start with the + net form; escalate only if the conservation test on weighted pools fails. +3. **`hotkey` association**: carry it for identity/precompile parity, or drop it and key purely on + coldkey. Carrying it is cheap and keeps consistency with staking. diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index f972facca6..5beb1f070d 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2276,6 +2276,73 @@ pub mod pallet { Ok(()) } + + /// Enable or disable short-side covered derivatives (launch gate). + #[pallet::call_index(96)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_shorts_enabled(origin: OriginFor, enabled: bool) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_shorts_enabled(enabled); + Ok(()) + } + + /// Set the short footprint-cap factor `κ_S` (scaled by 1e9). + #[pallet::call_index(97)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_kappa(origin: OriginFor, kappa_ppb: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_kappa_ppb(kappa_ppb); + Ok(()) + } + + /// Set the base short LTV `λ` (scaled by 1e9). + #[pallet::call_index(98)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_base_ltv(origin: OriginFor, ltv_ppb: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_base_ltv_ppb(ltv_ppb); + Ok(()) + } + + /// Set the daily decay bounds `d_min`, `d_max` (scaled by 1e9). + #[pallet::call_index(99)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 2))] + pub fn sudo_set_short_decay_bounds( + origin: OriginFor, + min_ppb: u64, + max_ppb: u64, + ) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_decay_bounds_ppb(min_ppb, max_ppb); + Ok(()) + } + + /// Set the retained-buffer dust threshold `R_dust` (in rao). + #[pallet::call_index(100)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_dust(origin: OriginFor, dust_rao: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_dust(dust_rao.into()); + Ok(()) + } + + /// Set the anti-snipe default grace period (in blocks). + #[pallet::call_index(101)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_default_grace(origin: OriginFor, blocks: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_default_grace(blocks); + Ok(()) + } + + /// Set the minimum short open input (in rao). + #[pallet::call_index(102)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_min_input(origin: OriginFor, min_input_rao: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_min_input(min_input_rao.into()); + Ok(()) + } } } diff --git a/pallets/subtensor/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 0fb24d61c2..7151f40f29 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -14,6 +14,9 @@ use pallet_subtensor::rpc_info::{ SubnetHyperparams, SubnetHyperparamsV2, SubnetHyperparamsV3, SubnetInfo, SubnetInfov2, }, }; +use pallet_subtensor::derivatives::{ + CloseShortQuote, ShortMarketInfo, ShortOpenQuote, ShortPositionInfo, +}; use pallet_subtensor::staking::lock::LockState; use sp_runtime::AccountId32; use substrate_fixed::types::U64F64; @@ -81,4 +84,12 @@ sp_api::decl_runtime_apis! { fn get_proxy_types() -> Vec; fn get_proxy_filter(proxy_type: Option) -> Vec; } + + pub trait DerivativesRuntimeApi { + fn quote_open_short(netuid: NetUid, position_input: TaoBalance) -> Option; + fn quote_close_short(coldkey: AccountId32, netuid: NetUid, fraction_ppb: u64) -> Option; + fn get_short_position(coldkey: AccountId32, netuid: NetUid) -> Option>; + fn get_short_positions(coldkey: AccountId32) -> Vec>; + fn get_subnet_short_state(netuid: NetUid) -> Option; + } } diff --git a/pallets/subtensor/src/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index fac924ccf4..818ad603c1 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -19,6 +19,8 @@ impl Pallet { Self::reveal_crv3_commits(); // --- 4. Run emission through network. Self::run_coinbase(block_emission); + // --- 4b. Decay covered-short positions and restore unwound TAO to pools. + Self::run_short_decay(); // --- 5. Update moving prices AFTER using them for emissions. Self::update_moving_prices(); // --- 6. Update roop prop AFTER using them for emissions. diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index c61a71aa65..a941ab1535 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -212,6 +212,10 @@ impl Pallet { Self::finalize_all_subnet_root_dividends(netuid); + // --- Settle covered shorts before the pool is drained, so restored + // escrow joins terminal distribution and liabilities are bounded. + Self::settle_shorts_on_dereg(netuid); + // --- Perform the cleanup before removing the network. Self::destroy_alpha_in_out_stakes(netuid)?; T::SwapInterface::clear_protocol_liquidity(netuid)?; diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs new file mode 100644 index 0000000000..f7e7f60a30 --- /dev/null +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -0,0 +1,767 @@ +//! Fixed-liability covered continuous-unwind derivatives (spec v3.6.1). +//! +//! Launch scope is shorts (longs are gated by `LongsEnabled` and not yet built). +//! Pool impact is realized as `SubnetTAO` mutations plus a dedicated per-subnet +//! custody account that holds parked floor/buffer/escrow TAO, so pool reserves, +//! `TotalStake`, and issuance stay consistent and derivative legs never write +//! TaoFlow. + +use super::*; +use frame_support::traits::tokens::{Fortitude, Precision, Preservation, fungible::Balanced}; +use safe_math::FixedExt; +use sp_runtime::traits::AccountIdConversion; +use substrate_fixed::types::I64F64; +use subtensor_runtime_common::Token; + +pub mod types; +pub use types::*; + +/// 12s blocks → 7200 per day. Decay rates are pro-rated per block. +const BLOCKS_PER_DAY: u64 = 7200; +/// Bisection tolerance for fixed-point square roots. +fn sqrt_eps() -> I64F64 { + I64F64::from_num(0.000_000_001) +} + +impl Pallet { + // ---- conversions ---------------------------------------------------- + + fn tao_f(t: TaoBalance) -> I64F64 { + I64F64::from_num(t.to_u64()) + } + fn alpha_f(a: AlphaBalance) -> I64F64 { + I64F64::from_num(a.to_u64()) + } + fn to_tao(x: I64F64) -> TaoBalance { + TaoBalance::from(x.max(I64F64::from_num(0)).saturating_to_num::()) + } + fn to_alpha(x: I64F64) -> AlphaBalance { + AlphaBalance::from(x.max(I64F64::from_num(0)).saturating_to_num::()) + } + fn mul_tao(t: TaoBalance, f: I64F64) -> TaoBalance { + Self::to_tao(Self::tao_f(t).saturating_mul(f)) + } + fn mul_alpha(a: AlphaBalance, f: I64F64) -> AlphaBalance { + Self::to_alpha(Self::alpha_f(a).saturating_mul(f)) + } + + // ---- accounts ------------------------------------------------------- + + /// Per-subnet account holding parked derivative TAO (floor + buffer + escrow). + /// Distinct from the subnet pool account so pool reserves are never polluted. + pub fn short_custody_account(netuid: NetUid) -> T::AccountId { + T::SubtensorPalletId::get().into_sub_account_truncating(("shrt", u16::from(netuid))) + } + + /// Recycle TAO out of the protocol custody account (reduce issuance). Unlike + /// `recycle_tao`, this does not preserve an existential deposit, so the + /// custody account can be drained to zero. + fn recycle_custody_tao(custody: &T::AccountId, amount: TaoBalance) { + if amount.is_zero() { + return; + } + // Never recycle (and never reduce issuance by) more than is actually + // held: caps an `Exact` withdraw failure that would desync issuance. + let amt = Self::get_coldkey_balance(custody).min(amount.into()); + TotalIssuance::::mutate(|ti| *ti = ti.saturating_sub(amt)); + let _ = ::Currency::withdraw( + custody, + amt, + Precision::Exact, + Preservation::Expendable, + Fortitude::Force, + ); + } + + // ---- references (spec §3, §4) -------------------------------------- + + /// Conservative TAO reference `T_ref = min(T_live, T_EMA)`, with + /// `T_EMA = pEMA · A_live` reconstructed from the existing price EMA. + fn short_t_ref(netuid: NetUid) -> I64F64 { + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let t_ema = pema.saturating_mul(a_live); + // A cold price EMA (`pema == 0`, e.g. a freshly created subnet) must not + // lock the market; fall back to the live reserve until it warms up. + if t_ema <= I64F64::from_num(0) { + t_live + } else { + t_live.min(t_ema) + } + } + + /// Current daily decay rate `d(u) = d_min + (d_max − d_min)·u²` (spec §6.2). + fn short_daily_decay(netuid: NetUid, b_sigma: TaoBalance) -> I64F64 { + let t_ref = Self::short_t_ref(netuid); + let cap = ShortKappa::::get().saturating_mul(t_ref); + let u = if cap > I64F64::from_num(0) { + Self::tao_f(b_sigma).safe_div(cap).min(I64F64::from_num(1)) + } else { + I64F64::from_num(0) + }; + let dmin = DecayMin::::get(); + let dmax = DecayMax::::get(); + dmin.saturating_add(dmax.saturating_sub(dmin).saturating_mul(u).saturating_mul(u)) + } + + // ---- open-time math (spec §4.1–4.3, Appendix A.1) ------------------- + + /// Solve gross collateral `C` and retained proceeds `N` from input `P` + /// (spec §4.2). Returns `None` if `N ≤ 0` (effective LTV non-positive). + fn solve_collateral(p: I64F64, t_ref: I64F64, s: I64F64) -> Option<(I64F64, I64F64)> { + let lambda = ShortBaseLtv::::get(); + if t_ref <= I64F64::from_num(0) || lambda <= I64F64::from_num(0) { + return None; + } + let one = I64F64::from_num(1); + let two = I64F64::from_num(2); + let four = I64F64::from_num(4); + // a = λ²/T_ref ; b = 1 − λ + 2λS/T_ref + let a = lambda.saturating_mul(lambda).safe_div(t_ref); + let b = one + .saturating_sub(lambda) + .saturating_add(two.saturating_mul(lambda).saturating_mul(s).safe_div(t_ref)); + // C = (−b + √(b² + 4aP)) / 2a + let disc = b + .saturating_mul(b) + .saturating_add(four.saturating_mul(a).saturating_mul(p)); + let root = disc.checked_sqrt(sqrt_eps())?; + let c = root + .saturating_sub(b) + .safe_div(two.saturating_mul(a)); + let n = c.saturating_sub(p); + if n <= I64F64::from_num(0) || c <= I64F64::from_num(0) { + return None; + } + Some((c, n)) + } + + /// Pool fraction `ϕ = (1 − √(1 − 4N/T))/2` (spec §4.3). Returns `None` if the + /// remove-and-sell-back domain `4N ≤ T` fails. + fn solve_phi(n: I64F64, t_live: I64F64) -> Option { + if t_live <= I64F64::from_num(0) { + return None; + } + let one = I64F64::from_num(1); + let four = I64F64::from_num(4); + let frac = four.saturating_mul(n).safe_div(t_live); + if frac > one { + return None; + } + let root = one.saturating_sub(frac).checked_sqrt(sqrt_eps())?; + Some(one.saturating_sub(root).safe_div(I64F64::from_num(2))) + } + + /// Keep the active-short-subnet set in sync with the aggregate: a subnet is + /// tracked iff it still has any live short state. The per-block decay tick + /// iterates only this set instead of every subnet. + fn sync_active_short(netuid: NetUid, agg: &ShortAgg) { + if agg.r_sigma.is_zero() + && agg.e_sigma.is_zero() + && agg.b_sigma.is_zero() + && agg.q_sigma.is_zero() + { + ShortActiveSubnets::::remove(netuid); + } else { + ShortActiveSubnets::::insert(netuid, ()); + } + } + + /// `−ln(1 − δ) = δ + δ²/2 + δ³/3 + …` for the small per-block decay `δ`. + /// + /// Computed directly from the series rather than `checked_ln(1 − δ)`, which + /// is imprecise (and can return the wrong sign) for arguments just below 1. + /// This keeps the aggregate factor `g = 1 − δ` and the per-position factor + /// `exp(−ΔΩ) = Π g` exactly consistent. + fn neg_ln_one_minus(delta: I64F64) -> I64F64 { + let d2 = delta.saturating_mul(delta); + let d3 = d2.saturating_mul(delta); + delta + .saturating_add(d2.saturating_mul(I64F64::from_num(0.5))) + .saturating_add(d3.saturating_mul(I64F64::from_num(1.0 / 3.0))) + } + + /// Materialize a position to the current accumulator: `f = exp(−(Ω − Ω_entry))`. + fn materialize_short(pos: &mut ShortPosition, omega_now: I64F64) { + let arg = pos.omega_entry.saturating_sub(omega_now); // ≤ 0 + let f = arg.checked_exp().unwrap_or_else(|| I64F64::from_num(0)); + pos.r_stored = Self::mul_tao(pos.r_stored, f); + pos.e_stored = Self::mul_tao(pos.e_stored, f); + pos.b_stored = Self::mul_tao(pos.b_stored, f); + pos.omega_entry = omega_now; + } + + // ---- user operations (spec §8) ------------------------------------- + + /// Open (or merge into) a covered short (spec §8.1, §8.6). + pub fn do_open_short( + origin: OriginFor, + hotkey: T::AccountId, + netuid: NetUid, + position_input: TaoBalance, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!(ShortsEnabled::::get(), Error::::ShortsDisabled); + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + ensure!( + SubnetMechanism::::get(netuid) == 1, + Error::::SubnetNotDynamic + ); + ensure!( + position_input >= ShortMinInput::::get(), + Error::::AmountTooLow + ); + + let mut agg = ShortAggregate::::get(netuid); + let t_ref = Self::short_t_ref(netuid); + let p = Self::tao_f(position_input); + + let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma)) + .ok_or(Error::::EffectiveLtvNonPositive)?; + let b = ShortBaseLtv::::get().saturating_mul(c); + + // Capacity: S + B ≤ κ_S · T_ref (also bounds same-block stacked opens). + ensure!( + Self::tao_f(agg.b_sigma).saturating_add(b) <= ShortKappa::::get().saturating_mul(t_ref), + Error::::ShortCapacityExceeded + ); + + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let phi = Self::solve_phi(n, t_live).ok_or(Error::::ReserveDomainExceeded)?; + + let n_tao = Self::to_tao(n); + let e_tao = Self::to_tao(phi.saturating_mul(t_live)); + let b_tao = Self::to_tao(b); + let q_alpha = Self::to_alpha(phi.saturating_mul(a_live)); + ensure!(!n_tao.is_zero(), Error::::RetainedProceedsNonPositive); + + let custody = Self::short_custody_account(netuid); + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + + // 1. Trader posts floor P into custody (fails early if underfunded). + Self::transfer_tao(&coldkey, &custody, position_input.into())?; + // 2. Remove N+E TAO from the pool into custody (the downward price impact). + let removed = n_tao.saturating_add(e_tao); + Self::transfer_tao(&subnet_account, &custody, removed.into())?; + Self::decrease_provided_tao_reserve(netuid, removed); + TotalStake::::mutate(|t| *t = t.saturating_sub(removed)); + + let block = Self::get_current_block_as_u64(); + let pos = match ShortPositions::::get(netuid, &coldkey) { + Some(mut existing) => { + // A merge must target the same hotkey, otherwise the liability + // alpha repaid on close would be drawn from the wrong stake. + ensure!(existing.hotkey == hotkey, Error::::ShortHotkeyMismatch); + Self::materialize_short(&mut existing, agg.omega); + existing.p_floor = existing.p_floor.saturating_add(position_input); + existing.q_liability = existing.q_liability.saturating_add(q_alpha); + existing.r_stored = existing.r_stored.saturating_add(n_tao); + existing.e_stored = existing.e_stored.saturating_add(e_tao); + existing.b_stored = existing.b_stored.saturating_add(b_tao); + existing.last_active = block; + existing + } + None => ShortPosition { + hotkey, + p_floor: position_input, + q_liability: q_alpha, + r_stored: n_tao, + e_stored: e_tao, + b_stored: b_tao, + omega_entry: agg.omega, + last_active: block, + }, + }; + ShortPositions::::insert(netuid, &coldkey, pos); + + agg.r_sigma = agg.r_sigma.saturating_add(n_tao); + agg.e_sigma = agg.e_sigma.saturating_add(e_tao); + agg.b_sigma = agg.b_sigma.saturating_add(b_tao); + agg.q_sigma = agg.q_sigma.saturating_add(q_alpha); + ShortAggregate::::insert(netuid, agg); + ShortActiveSubnets::::insert(netuid, ()); + + Self::deposit_event(Event::ShortOpened { + coldkey, + netuid, + position_input, + retained_proceeds: n_tao, + alpha_liability: q_alpha, + escrow: e_tao, + }); + Ok(()) + } + + /// Top up the carry buffer `R` with fresh capital (spec §8.2). + pub fn do_top_up_short( + origin: OriginFor, + netuid: NetUid, + amount: TaoBalance, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!(!amount.is_zero(), Error::::AmountTooLow); + let mut pos = + ShortPositions::::get(netuid, &coldkey).ok_or(Error::::ShortPositionNotFound)?; + let mut agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + + Self::transfer_tao(&coldkey, &Self::short_custody_account(netuid), amount.into())?; + pos.r_stored = pos.r_stored.saturating_add(amount); + pos.last_active = Self::get_current_block_as_u64(); + agg.r_sigma = agg.r_sigma.saturating_add(amount); + + ShortPositions::::insert(netuid, &coldkey, pos); + ShortAggregate::::insert(netuid, agg); + Self::deposit_event(Event::ShortToppedUp { + coldkey, + netuid, + amount, + }); + Ok(()) + } + + /// Partial (`fraction_ppb < 1e9`) or full (`= 1e9`) close (spec §8.3–8.5). + pub fn do_close_short( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!( + fraction_ppb > 0 && fraction_ppb <= 1_000_000_000, + Error::::InvalidCloseFraction + ); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + + let mut pos = + ShortPositions::::get(netuid, &coldkey).ok_or(Error::::ShortPositionNotFound)?; + let mut agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + + let q_close = Self::mul_alpha(pos.q_liability, rho); + let r_close = Self::mul_tao(pos.r_stored, rho); + let e_close = Self::mul_tao(pos.e_stored, rho); + let p_close = Self::mul_tao(pos.p_floor, rho); + let b_close = Self::mul_tao(pos.b_stored, rho); + + // Trader repays ρQ alpha from staked balance at the position hotkey. + ensure!( + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid) + >= q_close, + Error::::InsufficientAlphaToClose + ); + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, q_close); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(q_close)); + Self::increase_provided_alpha_reserve(netuid, q_close); + + let custody = Self::short_custody_account(netuid); + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + // Settle escrow ρE back to the pool, return ρ(P+R) to the trader. + if !e_close.is_zero() { + Self::transfer_tao(&custody, &subnet_account, e_close.into())?; + Self::increase_provided_tao_reserve(netuid, e_close); + TotalStake::::mutate(|t| *t = t.saturating_add(e_close)); + } + let returned = p_close.saturating_add(r_close); + if !returned.is_zero() { + Self::transfer_tao(&custody, &coldkey, returned.into())?; + } + + pos.q_liability = pos.q_liability.saturating_sub(q_close); + pos.r_stored = pos.r_stored.saturating_sub(r_close); + pos.e_stored = pos.e_stored.saturating_sub(e_close); + pos.p_floor = pos.p_floor.saturating_sub(p_close); + pos.b_stored = pos.b_stored.saturating_sub(b_close); + + agg.q_sigma = agg.q_sigma.saturating_sub(q_close); + agg.r_sigma = agg.r_sigma.saturating_sub(r_close); + agg.e_sigma = agg.e_sigma.saturating_sub(e_close); + agg.b_sigma = agg.b_sigma.saturating_sub(b_close); + Self::sync_active_short(netuid, &agg); + ShortAggregate::::insert(netuid, agg); + + if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { + ShortPositions::::remove(netuid, &coldkey); + } else { + ShortPositions::::insert(netuid, &coldkey, pos); + } + Self::deposit_event(Event::ShortClosed { + coldkey, + netuid, + fraction_ppb, + repaid_alpha: q_close, + returned, + }); + Ok(()) + } + + /// Permissionless default once the buffer has decayed to dust (spec §7.4). + pub fn do_default_short( + origin: OriginFor, + coldkey: T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + ensure_signed(origin)?; + let mut pos = + ShortPositions::::get(netuid, &coldkey).ok_or(Error::::ShortPositionNotFound)?; + let mut agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + ensure!( + pos.r_stored <= ShortDust::::get(), + Error::::PositionNotDefaultEligible + ); + // Anti-snipe: a third party cannot default within the grace window after + // the owner's last action, so the owner always has time to top up. + ensure!( + Self::get_current_block_as_u64() + >= pos.last_active.saturating_add(ShortDefaultGrace::::get()), + Error::::PositionNotDefaultEligible + ); + + let custody = Self::short_custody_account(netuid); + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + // Restore residual R+E to the pool; recycle the floor P; extinguish Q. + let residual = pos.r_stored.saturating_add(pos.e_stored); + if !residual.is_zero() { + Self::transfer_tao(&custody, &subnet_account, residual.into())?; + Self::increase_provided_tao_reserve(netuid, residual); + TotalStake::::mutate(|t| *t = t.saturating_add(residual)); + } + Self::recycle_custody_tao(&custody, pos.p_floor); + + agg.r_sigma = agg.r_sigma.saturating_sub(pos.r_stored); + agg.e_sigma = agg.e_sigma.saturating_sub(pos.e_stored); + agg.b_sigma = agg.b_sigma.saturating_sub(pos.b_stored); + agg.q_sigma = agg.q_sigma.saturating_sub(pos.q_liability); + Self::sync_active_short(netuid, &agg); + ShortAggregate::::insert(netuid, agg); + ShortPositions::::remove(netuid, &coldkey); + + Self::deposit_event(Event::ShortDefaulted { coldkey, netuid }); + Ok(()) + } + + // ---- per-block decay + restoration (spec §6.4–6.5, §12.4) ---------- + + /// O(1)-per-subnet aggregate decay tick with one-sided TAO restoration zap. + /// Iterates only subnets with live short state (`ShortActiveSubnets`). + pub fn run_short_decay() { + let active: Vec = ShortActiveSubnets::::iter_keys().collect(); + for netuid in active { + let mut agg = ShortAggregate::::get(netuid); + if agg.r_sigma.is_zero() && agg.e_sigma.is_zero() && agg.b_sigma.is_zero() { + continue; + } + let d_day = Self::short_daily_decay(netuid, agg.b_sigma); + let delta = d_day.safe_div(I64F64::from_num(BLOCKS_PER_DAY)); + if delta <= I64F64::from_num(0) { + continue; + } + let dr = Self::mul_tao(agg.r_sigma, delta); + let de = Self::mul_tao(agg.e_sigma, delta); + let db = Self::mul_tao(agg.b_sigma, delta); + agg.r_sigma = agg.r_sigma.saturating_sub(dr); + agg.e_sigma = agg.e_sigma.saturating_sub(de); + agg.b_sigma = agg.b_sigma.saturating_sub(db); + // Ω ← Ω + (−ln(1−δ)), so a later exp(−ΔΩ) reproduces Π(1−δ) exactly. + agg.omega = agg.omega.saturating_add(Self::neg_ln_one_minus(delta)); + ShortAggregate::::insert(netuid, agg); + + // Restoration zap: decayed R+E flows back into the pool (price drifts up). + // Credit reserves ONLY if the TAO actually moved, so a short custody + // can never inflate `SubnetTAO` / `TotalStake`. + let restore = dr.saturating_add(de); + if !restore.is_zero() + && let Some(subnet_account) = Self::get_subnet_account_id(netuid) + && Self::transfer_tao( + &Self::short_custody_account(netuid), + &subnet_account, + restore.into(), + ) + .is_ok() + { + Self::increase_provided_tao_reserve(netuid, restore); + TotalStake::::mutate(|t| *t = t.saturating_add(restore)); + } + } + } + + // ---- terminal deregistration settlement (spec §11.4) --------------- + + /// Settle all shorts on a subnet at deregistration. Must run before the + /// pool is drained so restored escrow joins the terminal distribution. + pub fn settle_shorts_on_dereg(netuid: NetUid) { + let agg = ShortAggregate::::get(netuid); + let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let custody = Self::short_custody_account(netuid); + let subnet_account = match Self::get_subnet_account_id(netuid) { + Some(a) => a, + None => return, + }; + + let positions: Vec<(T::AccountId, ShortPosition)> = + ShortPositions::::iter_prefix(netuid).collect(); + for (coldkey, mut pos) in positions { + Self::materialize_short(&mut pos, agg.omega); + + // Escrow returns to the pool (joins terminal distribution). Credit + // reserves only on a successful transfer. + if !pos.e_stored.is_zero() + && Self::transfer_tao(&custody, &subnet_account, pos.e_stored.into()).is_ok() + { + Self::increase_provided_tao_reserve(netuid, pos.e_stored); + TotalStake::::mutate(|t| *t = t.saturating_add(pos.e_stored)); + } + + // K_D(Q) = max(K_spot,last(Q), Q·pEMA). + let c = Self::tao_f(pos.p_floor).saturating_add(Self::tao_f(pos.r_stored)); + let k_ema = Self::alpha_f(pos.q_liability).saturating_mul(pema); + let k_spot = Self::short_spot_close_cost(netuid, pos.q_liability); + let k_d = k_ema.max(k_spot); + + let equity = Self::to_tao(c.saturating_sub(k_d)); + let cover = Self::to_tao(c.min(k_d)); + if !equity.is_zero() { + let _ = Self::transfer_tao(&custody, &coldkey, equity.into()); + } + Self::recycle_custody_tao(&custody, cover); + + ShortPositions::::remove(netuid, &coldkey); + Self::deposit_event(Event::ShortTerminalSettled { + coldkey, + netuid, + equity, + liability_cover: cover, + }); + } + // Sweep any residual custody dust (rounding drift) so no TAO is orphaned + // in the per-subnet custody account after the subnet is gone. + Self::recycle_custody_tao(&custody, TaoBalance::MAX); + ShortAggregate::::remove(netuid); + ShortActiveSubnets::::remove(netuid); + } + + /// Slippage-aware TAO cost to buy `q` alpha on the live pool (CPMM core). + fn short_spot_close_cost(netuid: NetUid, q: AlphaBalance) -> I64F64 { + let t = Self::tao_f(SubnetTAO::::get(netuid)); + let a = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let qf = Self::alpha_f(q); + if a <= qf { + // Liability un-buyable from the pool: saturate so cover = C, equity = 0. + return I64F64::from_num(1e18); + } + t.saturating_mul(qf).safe_div(a.saturating_sub(qf)) + } + + // ---- governance setters (spec §14.6) ------------------------------- + + pub fn set_shorts_enabled(enabled: bool) { + ShortsEnabled::::put(enabled); + } + pub fn set_longs_enabled(enabled: bool) { + LongsEnabled::::put(enabled); + } + /// `κ_S`, supplied scaled by 1e9. + pub fn set_short_kappa_ppb(kappa_ppb: u64) { + ShortKappa::::put(I64F64::from_num(kappa_ppb).safe_div(I64F64::from_num(1_000_000_000u64))); + } + /// `λ`, supplied scaled by 1e9. Clamped to `(0, 1)` so the open quadratic + /// stays well-formed. + pub fn set_short_base_ltv_ppb(ltv_ppb: u64) { + let ltv = ltv_ppb.clamp(1, 999_999_999); + ShortBaseLtv::::put(I64F64::from_num(ltv).safe_div(I64F64::from_num(1_000_000_000u64))); + } + /// `d_min`, `d_max`, supplied scaled by 1e9. Each is clamped to `[0, 1.0]` + /// per day (so the per-block factor `g = 1 − d/blocks_per_day` stays in + /// `(0, 1]`) and `d_min ≤ d_max` is enforced. + pub fn set_decay_bounds_ppb(min_ppb: u64, max_ppb: u64) { + let scale = I64F64::from_num(1_000_000_000u64); + let lo = min_ppb.min(1_000_000_000); + let hi = max_ppb.clamp(lo, 1_000_000_000); + DecayMin::::put(I64F64::from_num(lo).safe_div(scale)); + DecayMax::::put(I64F64::from_num(hi).safe_div(scale)); + } + pub fn set_short_dust(dust: TaoBalance) { + ShortDust::::put(dust); + } + pub fn set_short_default_grace(blocks: u64) { + ShortDefaultGrace::::put(blocks); + } + pub fn set_short_min_input(min_input: TaoBalance) { + ShortMinInput::::put(min_input); + } + + // ---- read-only quote (spec §1.2) ----------------------------------- + + /// Pure pre-open quote for a given input `P`. + pub fn quote_open_short(netuid: NetUid, position_input: TaoBalance) -> Option { + if SubnetMechanism::::get(netuid) != 1 { + return None; + } + let agg = ShortAggregate::::get(netuid); + let t_ref = Self::short_t_ref(netuid); + let p = Self::tao_f(position_input); + let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma))?; + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let phi = Self::solve_phi(n, t_live)?; + + let q_alpha = Self::to_alpha(phi.saturating_mul(a_live)); + let scale = I64F64::from_num(1_000_000_000u64); + let lambda_eff = n.safe_div(c).saturating_mul(scale).saturating_to_num::(); + let daily_decay = Self::short_daily_decay(netuid, agg.b_sigma) + .saturating_mul(scale) + .saturating_to_num::(); + Some(ShortOpenQuote { + gross_collateral: Self::to_tao(c), + retained_proceeds: Self::to_tao(n), + alpha_liability: q_alpha, + escrow: Self::to_tao(phi.saturating_mul(t_live)), + effective_ltv: lambda_eff, + daily_decay, + est_close_cost: Self::to_tao(Self::short_spot_close_cost(netuid, q_alpha)), + }) + } + + /// Estimated blocks until `r_current` decays to dust at the current rate. + /// `u64::MAX` when decay is effectively zero. + fn short_blocks_to_dust(netuid: NetUid, r_current: TaoBalance, b_sigma: TaoBalance) -> u64 { + let dust = ShortDust::::get(); + if r_current <= dust || dust.is_zero() { + return if r_current <= dust { 0 } else { u64::MAX }; + } + let delta = Self::short_daily_decay(netuid, b_sigma) + .safe_div(I64F64::from_num(BLOCKS_PER_DAY)); + if delta <= I64F64::from_num(0) { + return u64::MAX; + } + let neg_ln_g = Self::neg_ln_one_minus(delta); + if neg_ln_g <= I64F64::from_num(0) { + return u64::MAX; + } + let ratio = Self::tao_f(r_current).safe_div(Self::tao_f(dust)); + match ratio.checked_ln() { + Some(ln_ratio) if ln_ratio > I64F64::from_num(0) => ln_ratio + .safe_div(neg_ln_g) + .saturating_to_num::(), + _ => 0, + } + } + + /// Materialized, health-rich view of one position (decayed to the current block). + pub fn get_short_position( + coldkey: &T::AccountId, + netuid: NetUid, + ) -> Option> { + let mut pos = ShortPositions::::get(netuid, coldkey)?; + let agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + + let scale = I64F64::from_num(1_000_000_000u64); + let daily_decay = Self::short_daily_decay(netuid, agg.b_sigma) + .saturating_mul(scale) + .saturating_to_num::(); + let now = Self::get_current_block_as_u64(); + let defaultable_at_block = pos.last_active.saturating_add(ShortDefaultGrace::::get()); + let default_eligible = pos.r_stored <= ShortDust::::get() && now >= defaultable_at_block; + let alpha_held = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, coldkey, netuid); + + Some(ShortPositionInfo { + netuid, + hotkey: pos.hotkey.clone(), + floor: pos.p_floor, + alpha_liability: pos.q_liability, + buffer: pos.r_stored, + escrow: pos.e_stored, + collateral_claim: pos.p_floor.saturating_add(pos.r_stored), + daily_decay, + blocks_to_dust: Self::short_blocks_to_dust(netuid, pos.r_stored, agg.b_sigma), + default_eligible, + defaultable_at_block, + est_close_cost: Self::to_tao(Self::short_spot_close_cost(netuid, pos.q_liability)), + alpha_held, + alpha_needed: AlphaBalance::from( + pos.q_liability.to_u64().saturating_sub(alpha_held.to_u64()), + ), + }) + } + + /// All of a coldkey's short positions across subnets. + pub fn get_short_positions(coldkey: &T::AccountId) -> Vec> { + Self::get_all_subnet_netuids() + .into_iter() + .filter_map(|netuid| Self::get_short_position(coldkey, netuid)) + .collect() + } + + /// Per-subnet short market state for sizing and capacity decisions. + pub fn get_subnet_short_state(netuid: NetUid) -> Option { + if !Self::if_subnet_exist(netuid) { + return None; + } + let agg = ShortAggregate::::get(netuid); + let t_ref = Self::short_t_ref(netuid); + let cap = ShortKappa::::get().saturating_mul(t_ref); + let used = Self::tao_f(agg.b_sigma); + let scale = I64F64::from_num(1_000_000_000u64); + let ppb = |x: I64F64| x.saturating_mul(scale).saturating_to_num::(); + + Some(ShortMarketInfo { + shorts_enabled: ShortsEnabled::::get(), + base_ltv: ppb(ShortBaseLtv::::get()), + kappa: ppb(ShortKappa::::get()), + decay_min: ppb(DecayMin::::get()), + decay_max: ppb(DecayMax::::get()), + current_daily_decay: ppb(Self::short_daily_decay(netuid, agg.b_sigma)), + t_ref: Self::to_tao(t_ref), + footprint_used: agg.b_sigma, + footprint_cap: Self::to_tao(cap), + footprint_remaining: Self::to_tao(cap.saturating_sub(used)), + open_interest_alpha: agg.q_sigma, + buffer_total: agg.r_sigma, + escrow_total: agg.e_sigma, + dust_threshold: ShortDust::::get(), + min_input: ShortMinInput::::get(), + default_grace: ShortDefaultGrace::::get(), + }) + } + + /// Pre-close quote for `fraction_ppb / 1e9` of a position. + pub fn quote_close_short( + coldkey: &T::AccountId, + netuid: NetUid, + fraction_ppb: u64, + ) -> Option { + if fraction_ppb == 0 || fraction_ppb > 1_000_000_000 { + return None; + } + let mut pos = ShortPositions::::get(netuid, coldkey)?; + let agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + + let repay_alpha = Self::mul_alpha(pos.q_liability, rho); + let returned_tao = + Self::mul_tao(pos.p_floor, rho).saturating_add(Self::mul_tao(pos.r_stored, rho)); + let escrow_settled = Self::mul_tao(pos.e_stored, rho); + let alpha_held = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, coldkey, netuid); + + Some(CloseShortQuote { + repay_alpha, + returned_tao, + escrow_settled, + est_buyback_cost: Self::to_tao(Self::short_spot_close_cost(netuid, repay_alpha)), + alpha_held, + alpha_needed: AlphaBalance::from( + repay_alpha.to_u64().saturating_sub(alpha_held.to_u64()), + ), + }) + } +} diff --git a/pallets/subtensor/src/derivatives/types.rs b/pallets/subtensor/src/derivatives/types.rs new file mode 100644 index 0000000000..8f5e91026e --- /dev/null +++ b/pallets/subtensor/src/derivatives/types.rs @@ -0,0 +1,167 @@ +use codec::{Decode, DecodeWithMemTracking, Encode}; +use scale_info::TypeInfo; +use substrate_fixed::types::I64F64; +use subtensor_macros::freeze_struct; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; + +/// A merged covered short position for one `(coldkey, netuid)` (spec §2.2). +/// +/// `C`, `N`, `λ_eff`, and `ϕ` are open-time derivations and are deliberately not +/// persisted. `r/e/b_stored` are the values at the last materialization; current +/// values are recovered by multiplying by `exp(-(Ω_S − omega_entry))`. +#[freeze_struct("43ae40d25be019c8")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortPosition { + /// Hotkey the liability alpha is repaid from on close. + pub hotkey: AccountId, + /// Non-decaying TAO floor supplied by the trader (spec `P`). + pub p_floor: TaoBalance, + /// Fixed alpha liability (spec `Q`); changes only on close/default/dereg. + pub q_liability: AlphaBalance, + /// Retained-proceeds buffer at last materialization (spec `R`). + pub r_stored: TaoBalance, + /// Linked TAO escrow at last materialization (spec `E`). + pub e_stored: TaoBalance, + /// Utilization footprint at last materialization (spec `B = λC`). + pub b_stored: TaoBalance, + /// Value of `Ω_S` at last materialization (spec `Ω_entry`). + pub omega_entry: I64F64, + /// Block of the last owner action (open / merge / top-up). Permissionless + /// default is gated to `last_active + grace`, so an owner always has a + /// window to top up before a third party can default them. + pub last_active: u64, +} + +/// Per-subnet short-side aggregate and decay accumulator (spec §2.4, §6.3). +#[freeze_struct("376a8ccf882d6dea")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortAgg { + /// Σ current retained buffer. + pub r_sigma: TaoBalance, + /// Σ current escrow. + pub e_sigma: TaoBalance, + /// Σ current footprint == active utilization `S`. + pub b_sigma: TaoBalance, + /// Σ fixed alpha liability (open interest `Q_Σ`). + pub q_sigma: AlphaBalance, + /// Cumulative monotone decay accumulator `Ω_S` (`Ω ← Ω − ln g`). + pub omega: I64F64, +} + +impl ShortAgg { + /// Empty short-side aggregate. + pub fn zero() -> Self { + Self { + r_sigma: TaoBalance::ZERO, + e_sigma: TaoBalance::ZERO, + b_sigma: TaoBalance::ZERO, + q_sigma: AlphaBalance::ZERO, + omega: I64F64::from_num(0), + } + } +} + +/// Pre-open trader quote (spec §1.2). Pure derivation, no state change. +#[freeze_struct("54beac46977b1ec5")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortOpenQuote { + /// Gross open-time collateral `C = P + N`. + pub gross_collateral: TaoBalance, + /// Retained proceeds `N` (becomes the initial buffer `R0`). + pub retained_proceeds: TaoBalance, + /// Fixed alpha liability `Q`. + pub alpha_liability: AlphaBalance, + /// Linked TAO escrow `E`. + pub escrow: TaoBalance, + /// Effective LTV `λ_eff`, scaled by 1e9. + pub effective_ltv: u64, + /// Current daily decay/carry rate, scaled by 1e9. + pub daily_decay: u64, + /// Estimated TAO cost to repay `Q` at the current pool (slippage-aware). + pub est_close_cost: TaoBalance, +} + +/// Live, materialized view of a trader's short position (decayed to the current +/// block) plus the health metrics a client needs to manage it. +#[freeze_struct("9f6810752569e314")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortPositionInfo { + pub netuid: NetUid, + pub hotkey: AccountId, + /// Non-decaying floor `P`. + pub floor: TaoBalance, + /// Fixed alpha liability `Q`. + pub alpha_liability: AlphaBalance, + /// Current retained buffer `R(t)` after decay. + pub buffer: TaoBalance, + /// Current linked escrow `E(t)` after decay. + pub escrow: TaoBalance, + /// Current TAO collateral claim `C = P + R(t)`. + pub collateral_claim: TaoBalance, + /// Current daily carry/decay rate, scaled by 1e9. + pub daily_decay: u64, + /// Estimated blocks until `R` decays to dust at the current rate + /// (`u64::MAX` if decay is effectively zero). + pub blocks_to_dust: u64, + /// Whether the position can be defaulted right now. + pub default_eligible: bool, + /// Earliest block a third party could default once dusted (`last_active + grace`). + pub defaultable_at_block: u64, + /// Slippage-aware TAO cost to repay the full liability `Q` now. + pub est_close_cost: TaoBalance, + /// Alpha already staked at the position hotkey (counts toward `Q`). + pub alpha_held: AlphaBalance, + /// Incremental alpha still to acquire before a full close (spec §1.6). + pub alpha_needed: AlphaBalance, +} + +/// Per-subnet short market state for sizing and capacity decisions. +#[freeze_struct("b87648108ccb15")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct ShortMarketInfo { + pub shorts_enabled: bool, + /// Base LTV `λ`, scaled by 1e9. + pub base_ltv: u64, + /// Footprint-cap factor `κ_S`, scaled by 1e9. + pub kappa: u64, + /// Daily decay bounds, scaled by 1e9. + pub decay_min: u64, + pub decay_max: u64, + /// Current daily decay at the live utilization, scaled by 1e9. + pub current_daily_decay: u64, + /// Conservative TAO reference `T_ref`. + pub t_ref: TaoBalance, + /// Active footprint `S` (used capacity). + pub footprint_used: TaoBalance, + /// Footprint cap `κ_S · T_ref`. + pub footprint_cap: TaoBalance, + /// Remaining openable footprint. + pub footprint_remaining: TaoBalance, + /// Aggregate fixed alpha liability (open interest). + pub open_interest_alpha: AlphaBalance, + /// Aggregate retained buffer and escrow. + pub buffer_total: TaoBalance, + pub escrow_total: TaoBalance, + /// Dust threshold, minimum input, and default grace. + pub dust_threshold: TaoBalance, + pub min_input: TaoBalance, + pub default_grace: u64, +} + +/// Pre-close quote for a fraction of a position (spec §1.5–1.6). +#[freeze_struct("e5828d301fddd1a1")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct CloseShortQuote { + /// Alpha that must be repaid for this close fraction. + pub repay_alpha: AlphaBalance, + /// TAO returned to the trader (floor + buffer fraction). + pub returned_tao: TaoBalance, + /// Escrow settled back into the pool. + pub escrow_settled: TaoBalance, + /// Slippage-aware TAO cost to acquire `repay_alpha` now. + pub est_buyback_cost: TaoBalance, + /// Alpha already held toward the repayment. + pub alpha_held: AlphaBalance, + /// Incremental alpha still to acquire (`max(0, repay_alpha − held)`). + pub alpha_needed: AlphaBalance, +} diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 7ce25b65b6..9eefb083ef 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -34,6 +34,7 @@ mod benchmarks; // ==== Pallet Imports ===== // ========================= pub mod coinbase; +pub mod derivatives; pub mod epoch; pub mod extensions; pub mod guards; @@ -1381,6 +1382,127 @@ pub mod pallet { #[pallet::storage] pub type SubnetAlphaOut = StorageMap<_, Identity, NetUid, AlphaBalance, ValueQuery, DefaultZeroAlpha>; + + // ===== Covered continuous-unwind derivatives (spec v3.6.1) ===== + + #[pallet::type_value] + /// Shorts are gated off until the trading-games suite passes. + pub fn DefaultDisabled() -> bool { + false + } + #[pallet::type_value] + /// Base short LTV `λ` = 0.50. + pub fn DefaultShortBaseLtv() -> substrate_fixed::types::I64F64 { + substrate_fixed::types::I64F64::from_num(0.5) + } + #[pallet::type_value] + /// Conservative short footprint-cap factor `κ_S`. + pub fn DefaultShortKappa() -> substrate_fixed::types::I64F64 { + substrate_fixed::types::I64F64::from_num(0.05) + } + #[pallet::type_value] + /// `d_min` = 0.1%/day. + pub fn DefaultDecayMin() -> substrate_fixed::types::I64F64 { + substrate_fixed::types::I64F64::from_num(0.001) + } + #[pallet::type_value] + /// `d_max` = 1.5%/day. + pub fn DefaultDecayMax() -> substrate_fixed::types::I64F64 { + substrate_fixed::types::I64F64::from_num(0.015) + } + #[pallet::type_value] + /// Dust threshold `R_dust` = 1 TAO. + pub fn DefaultShortDust() -> TaoBalance { + TaoBalance::from(1_000_000_000u64) + } + #[pallet::type_value] + /// Anti-snipe grace: blocks after the last owner action during which a + /// permissionless default is rejected (~1.2h at 12s blocks). + pub fn DefaultShortDefaultGrace() -> u64 { + 360 + } + #[pallet::type_value] + /// Minimum short open input = 0.1 TAO. Bounds dust-spam and terminal load. + pub fn DefaultShortMinInput() -> TaoBalance { + TaoBalance::from(100_000_000u64) + } + #[pallet::type_value] + /// Empty short-side aggregate. + pub fn DefaultShortAgg() -> crate::derivatives::ShortAgg { + crate::derivatives::ShortAgg::zero() + } + + /// Short-side master enablement flag. + #[pallet::storage] + pub type ShortsEnabled = StorageValue<_, bool, ValueQuery, DefaultDisabled>; + + /// Long-side master enablement flag (gated; long mechanics not yet built). + #[pallet::storage] + pub type LongsEnabled = StorageValue<_, bool, ValueQuery, DefaultDisabled>; + + /// Base short LTV `λ`. + #[pallet::storage] + pub type ShortBaseLtv = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultShortBaseLtv>; + + /// Short footprint-cap factor `κ_S`. + #[pallet::storage] + pub type ShortKappa = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultShortKappa>; + + /// Minimum daily decay rate `d_min`. + #[pallet::storage] + pub type DecayMin = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultDecayMin>; + + /// Maximum daily decay rate `d_max`. + #[pallet::storage] + pub type DecayMax = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultDecayMax>; + + /// Retained-buffer dust threshold `R_dust`. + #[pallet::storage] + pub type ShortDust = + StorageValue<_, TaoBalance, ValueQuery, DefaultShortDust>; + + /// Anti-snipe default grace period, in blocks. + #[pallet::storage] + pub type ShortDefaultGrace = + StorageValue<_, u64, ValueQuery, DefaultShortDefaultGrace>; + + /// Minimum short open input. + #[pallet::storage] + pub type ShortMinInput = + StorageValue<_, TaoBalance, ValueQuery, DefaultShortMinInput>; + + /// --- SET ( netuid ) of subnets with live short state, so the per-block + /// decay tick iterates only active subnets instead of all of them. + #[pallet::storage] + pub type ShortActiveSubnets = + StorageMap<_, Identity, NetUid, (), OptionQuery>; + + /// --- MAP ( netuid ) --> short-side aggregate + decay accumulator. + #[pallet::storage] + pub type ShortAggregate = StorageMap< + _, + Identity, + NetUid, + crate::derivatives::ShortAgg, + ValueQuery, + DefaultShortAgg, + >; + + /// --- DMAP ( netuid, coldkey ) --> merged covered short position. + #[pallet::storage] + pub type ShortPositions = StorageDoubleMap< + _, + Identity, + NetUid, + Blake2_128Concat, + T::AccountId, + crate::derivatives::ShortPosition, + OptionQuery, + >; /// --- MAP ( netuid ) --> protocol_alpha | Returns the protocol-owned alpha cached for the subnet. #[pallet::storage] pub type SubnetProtocolAlpha = diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 7a64acba44..c06900533c 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2593,5 +2593,50 @@ mod dispatches { let coldkey = ensure_signed(origin)?; Self::do_set_perpetual_lock(&coldkey, netuid, enabled) } + + /// Open (or merge into) a covered short with floor input `position_input`. + #[pallet::call_index(139)] + #[pallet::weight(::DbWeight::get().reads_writes(12, 8))] + pub fn open_short( + origin: OriginFor, + hotkey: T::AccountId, + netuid: NetUid, + position_input: TaoBalance, + ) -> DispatchResult { + Self::do_open_short(origin, hotkey, netuid, position_input) + } + + /// Top up a covered short's carry buffer with fresh capital. + #[pallet::call_index(140)] + #[pallet::weight(::DbWeight::get().reads_writes(5, 4))] + pub fn top_up_short( + origin: OriginFor, + netuid: NetUid, + amount: TaoBalance, + ) -> DispatchResult { + Self::do_top_up_short(origin, netuid, amount) + } + + /// Close `fraction_ppb / 1e9` of a covered short (`1e9` = full close). + #[pallet::call_index(141)] + #[pallet::weight(::DbWeight::get().reads_writes(10, 8))] + pub fn close_short( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + Self::do_close_short(origin, netuid, fraction_ppb) + } + + /// Permissionlessly default a covered short whose buffer reached dust. + #[pallet::call_index(142)] + #[pallet::weight(::DbWeight::get().reads_writes(7, 6))] + pub fn default_short( + origin: OriginFor, + coldkey: T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + Self::do_default_short(origin, coldkey, netuid) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 46343b6ed1..edbc115298 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -301,5 +301,27 @@ mod errors { CannotUseSystemAccount, /// Trying to unlock more than locked UnlockAmountTooHigh, + /// Short-side derivatives are disabled. + ShortsDisabled, + /// The subnet is not a dynamic (AMM) subnet. + SubnetNotDynamic, + /// No short position exists for this coldkey on the subnet. + ShortPositionNotFound, + /// Effective LTV is non-positive at current utilization. + EffectiveLtvNonPositive, + /// Retained proceeds would be non-positive. + RetainedProceedsNonPositive, + /// Open would exceed the active short footprint cap. + ShortCapacityExceeded, + /// Open violates the remove-and-sell-back square-root domain. + ReserveDomainExceeded, + /// Close fraction must be in (0, 1e9]. + InvalidCloseFraction, + /// Trader does not hold enough alpha to repay the liability. + InsufficientAlphaToClose, + /// Position has not decayed to dust and is not default-eligible. + PositionNotDefaultEligible, + /// Additional open targets a different hotkey than the existing position. + ShortHotkeyMismatch, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 918baf1107..af8e97ab15 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -631,5 +631,61 @@ mod events { /// Whether this coldkey's locks are now perpetual. enabled: bool, }, + + /// A covered short was opened (or merged). + ShortOpened { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the short is on. + netuid: NetUid, + /// Floor TAO supplied by the trader. + position_input: TaoBalance, + /// Retained proceeds booked as the initial buffer. + retained_proceeds: TaoBalance, + /// Fixed alpha liability created. + alpha_liability: AlphaBalance, + /// Linked TAO escrow created. + escrow: TaoBalance, + }, + /// A covered short's carry buffer was topped up. + ShortToppedUp { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the short is on. + netuid: NetUid, + /// TAO added to the buffer. + amount: TaoBalance, + }, + /// A covered short was (partially) closed. + ShortClosed { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the short is on. + netuid: NetUid, + /// Closed fraction in parts-per-billion. + fraction_ppb: u64, + /// Alpha repaid to extinguish the liability slice. + repaid_alpha: AlphaBalance, + /// TAO (floor + buffer) returned to the trader. + returned: TaoBalance, + }, + /// A covered short defaulted after its buffer reached dust. + ShortDefaulted { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the short was on. + netuid: NetUid, + }, + /// A covered short was settled at subnet deregistration. + ShortTerminalSettled { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet that deregistered. + netuid: NetUid, + /// Terminal equity paid to the trader. + equity: TaoBalance, + /// Liability-cover recycled outside terminal distribution. + liability_cover: TaoBalance, + }, } } diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs new file mode 100644 index 0000000000..0ac176a572 --- /dev/null +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -0,0 +1,846 @@ +#![allow(clippy::arithmetic_side_effects, clippy::unwrap_used)] +//! Covered continuous-unwind short derivatives — edge-case suite. +//! +//! Covers subnet creation, low liquidity, capacity/anti-split, decay + +//! restoration, the full close/default/top-up lifecycle, value conservation, +//! and subnet deregistration (in-the-money and underwater terminal settlement). + +use super::mock::*; +use crate::*; +use frame_support::{assert_noop, assert_ok}; +use sp_core::U256; +use substrate_fixed::types::I96F32; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; + +const TAO: u64 = 1_000_000_000; + +fn t(v: u64) -> TaoBalance { + TaoBalance::from(v) +} + +fn bal(acc: &U256) -> u64 { + Balances::free_balance(acc).into() +} + +fn custody_bal(netuid: NetUid) -> u64 { + bal(&SubtensorModule::short_custody_account(netuid)) +} + +fn assert_approx(a: u64, b: u64, tol: u64, what: &str) { + let d = a.abs_diff(b); + assert!(d <= tol, "{what}: {a} vs {b} (diff {d} > tol {tol})"); +} + +/// Dynamic subnet with balance-backed reserves, a warmed price EMA, shorts +/// enabled, and a generous footprint cap. Returns the netuid. +fn setup_market(tao_reserve: u64, alpha_reserve: u64, price: f64) -> NetUid { + let owner_c = U256::from(1); + let owner_h = U256::from(2); + let netuid = add_dynamic_network(&owner_h, &owner_c); + setup_reserves(netuid, t(tao_reserve), AlphaBalance::from(alpha_reserve)); + // Back the pool TAO with real balance so custody transfers can move it. + let sa = SubtensorModule::get_subnet_account_id(netuid).unwrap(); + add_balance_to_coldkey_account(&sa, t(tao_reserve)); + SubnetMovingPrice::::insert(netuid, I96F32::from_num(price)); + SubtensorModule::set_shorts_enabled(true); + SubtensorModule::set_short_kappa_ppb(900_000_000); // κ = 0.9, generous + netuid +} + +/// Credit `q` alpha as stake at `(hotkey, coldkey)` without touching the pool, +/// mirroring the `SubnetAlphaOut` bump a real stake performs. +fn give_alpha(hotkey: U256, coldkey: U256, netuid: NetUid, q: AlphaBalance) { + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, q); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(q)); +} + +// --------------------------------------------------------------------------- +// Gating & subnet-kind edges +// --------------------------------------------------------------------------- + +#[test] +fn open_short_rejected_when_disabled() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + SubtensorModule::set_shorts_enabled(false); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + Error::::ShortsDisabled + ); + }); +} + +#[test] +fn open_short_rejected_on_stable_subnet() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + SubnetMechanism::::insert(netuid, 0); // stable + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + Error::::SubnetNotDynamic + ); + }); +} + +#[test] +fn open_short_rejects_zero_input() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(0)), + Error::::AmountTooLow + ); + }); +} + +// --------------------------------------------------------------------------- +// Open math vs spec worked example (§1.7–1.8) +// --------------------------------------------------------------------------- + +#[test] +fn quote_matches_spec_worked_example() { + new_test_ext(1).execute_with(|| { + // Pool 1000 TAO / 100_000 alpha, price 0.01, pre-trade S = 100 TAO. + let netuid = setup_market(1000 * TAO, 100_000 * TAO, 0.01); + let mut agg = ShortAggregate::::get(netuid); + agg.b_sigma = t(100 * TAO); + ShortAggregate::::insert(netuid, agg); + + let q = SubtensorModule::quote_open_short(netuid, t(62_500_000_000)).unwrap(); // P = 62.5 TAO + assert_approx(q.gross_collateral.to_u64(), 100 * TAO, TAO / 10, "C"); + assert_approx(q.retained_proceeds.to_u64(), 37_500_000_000, TAO / 10, "N"); + assert_approx(q.alpha_liability.to_u64(), 3902 * TAO, 10 * TAO, "Q"); + assert_approx(q.escrow.to_u64(), 39 * TAO, TAO / 2, "E"); + assert_approx(q.effective_ltv, 375_000_000, 2_000_000, "lambda_eff"); + }); +} + +#[test] +fn open_matches_quote_and_moves_pool() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + let p = 100 * TAO; + + let quote = SubtensorModule::quote_open_short(netuid, t(p)).unwrap(); + let tao_before = SubnetTAO::::get(netuid).to_u64(); + let trader_before = bal(&trader); + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p))); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + // Position fields equal the pure quote (same code path). + assert_eq!(pos.r_stored, quote.retained_proceeds); + assert_eq!(pos.q_liability, quote.alpha_liability); + assert_eq!(pos.e_stored, quote.escrow); + assert_eq!(pos.p_floor, t(p)); + assert_eq!(pos.hotkey, hotkey); + assert!(pos.b_stored.to_u64() > 0); + + let n = quote.retained_proceeds.to_u64(); + let e = quote.escrow.to_u64(); + // Pool lost exactly N+E TAO; trader paid exactly P; custody holds P+N+E. + assert_eq!(SubnetTAO::::get(netuid).to_u64(), tao_before - n - e); + assert_eq!(bal(&trader), trader_before - p); + assert_eq!(custody_bal(netuid), p + n + e); + + // Aggregate reflects the single position. + let agg = ShortAggregate::::get(netuid); + assert_eq!(agg.r_sigma, pos.r_stored); + assert_eq!(agg.e_sigma, pos.e_stored); + assert_eq!(agg.b_sigma, pos.b_stored); + assert_eq!(agg.q_sigma, pos.q_liability); + }); +} + +// --------------------------------------------------------------------------- +// Capacity / anti-split (§5.1–5.2.1) +// --------------------------------------------------------------------------- + +#[test] +fn open_rejected_when_capacity_exceeded() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + SubtensorModule::set_short_kappa_ppb(1_000_000); // κ = 0.001 → cap ≈ 1 TAO + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + Error::::ShortCapacityExceeded + ); + }); +} + +#[test] +fn stacked_opens_share_capacity() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + // Cap ≈ 70 TAO: one P=50 open (B≈47 TAO) fits; a second does not. + SubtensorModule::set_short_kappa_ppb(70_000_000); + let a = U256::from(10); + let b = U256::from(20); + add_balance_to_coldkey_account(&a, t(1000 * TAO)); + add_balance_to_coldkey_account(&b, t(1000 * TAO)); + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO))); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO)), + Error::::ShortCapacityExceeded + ); + }); +} + +// --------------------------------------------------------------------------- +// Low liquidity (§4.1: λ_eff ≤ 0 rejects oversized opens) +// --------------------------------------------------------------------------- + +#[test] +fn low_liquidity_rejects_oversized_open() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10 * TAO, 10 * TAO, 1.0); // tiny pool + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + // P far larger than the pool can collateralize → retained proceeds ≤ 0. + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + Error::::EffectiveLtvNonPositive + ); + }); +} + +#[test] +fn small_open_on_fresh_subnet_with_cold_ema() { + new_test_ext(1).execute_with(|| { + // No price EMA set (cold start): T_ref falls back to live reserve. + let owner_c = U256::from(1); + let owner_h = U256::from(2); + let netuid = add_dynamic_network(&owner_h, &owner_c); + setup_reserves(netuid, t(1000 * TAO), AlphaBalance::from(1000 * TAO)); + let sa = SubtensorModule::get_subnet_account_id(netuid).unwrap(); + add_balance_to_coldkey_account(&sa, t(1000 * TAO)); + SubtensorModule::set_shorts_enabled(true); + SubtensorModule::set_short_kappa_ppb(900_000_000); + assert_eq!(SubtensorModule::get_moving_alpha_price(netuid), 0); // cold + + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO))); + assert!(ShortPositions::::get(netuid, trader).is_some()); + }); +} + +// --------------------------------------------------------------------------- +// Decay + restoration (§6) +// --------------------------------------------------------------------------- + +#[test] +fn decay_shrinks_buffer_and_restores_tao() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); + let tao0 = SubnetTAO::::get(netuid).to_u64(); + let custody0 = custody_bal(netuid); + let omega0 = ShortAggregate::::get(netuid).omega; + + for _ in 0..200 { + SubtensorModule::run_short_decay(); + } + + let agg = ShortAggregate::::get(netuid); + let r1 = agg.r_sigma.to_u64(); + let tao1 = SubnetTAO::::get(netuid).to_u64(); + let custody1 = custody_bal(netuid); + + assert!(r1 < r0, "buffer must decay: {r1} !< {r0}"); + assert!(agg.omega > omega0, "omega must increase"); + let restored = tao1 - tao0; + let drained = custody0 - custody1; + assert!(restored > 0, "TAO must be restored to the pool"); + // Conservation of the restoration leg: custody out == pool in. + assert_eq!(restored, drained); + }); +} + +#[test] +fn block_step_runs_decay() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); + step_block(5); + assert!(ShortAggregate::::get(netuid).r_sigma.to_u64() < r0); + }); +} + +// --------------------------------------------------------------------------- +// Top-up (§8.2) +// --------------------------------------------------------------------------- + +#[test] +fn top_up_adds_buffer_only() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + let pos0 = ShortPositions::::get(netuid, trader).unwrap(); + let custody0 = custody_bal(netuid); + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(10 * TAO))); + let pos1 = ShortPositions::::get(netuid, trader).unwrap(); + + assert_eq!(pos1.r_stored, pos0.r_stored + t(10 * TAO)); + assert_eq!(pos1.q_liability, pos0.q_liability); // unchanged + assert_eq!(pos1.e_stored, pos0.e_stored); + assert_eq!(pos1.b_stored, pos0.b_stored); + assert_eq!(custody_bal(netuid), custody0 + 10 * TAO); + }); +} + +#[test] +fn top_up_requires_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_noop!( + SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO)), + Error::::ShortPositionNotFound + ); + }); +} + +// --------------------------------------------------------------------------- +// Merge (§8.6) +// --------------------------------------------------------------------------- + +#[test] +fn additional_open_merges_into_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + let p1 = ShortPositions::::get(netuid, trader).unwrap(); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + let p2 = ShortPositions::::get(netuid, trader).unwrap(); + + assert_eq!(p2.p_floor, t(100 * TAO)); + assert!(p2.q_liability > p1.q_liability); + assert!(p2.r_stored > p1.r_stored); + // Single merged position, not two entries. + assert_eq!(ShortPositions::::iter_prefix(netuid).count(), 1); + }); +} + +// --------------------------------------------------------------------------- +// Close (§8.3–8.5) + conservation +// --------------------------------------------------------------------------- + +#[test] +fn full_close_conserves_value() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + let p = 100 * TAO; + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p))); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let (n, e, q) = (pos.r_stored.to_u64(), pos.e_stored.to_u64(), pos.q_liability); + let tao_after_open = SubnetTAO::::get(netuid).to_u64(); + let alpha_after_open = SubnetAlphaIn::::get(netuid).to_u64(); + + // Trader acquires the liability alpha (seeded) and closes fully. + give_alpha(hotkey, trader, netuid, AlphaBalance::from(q.to_u64() + 10 * TAO)); + let trader_before_close = bal(&trader); + + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + + // Position gone, aggregate empty. + assert!(ShortPositions::::get(netuid, trader).is_none()); + let agg = ShortAggregate::::get(netuid); + assert_eq!(agg.r_sigma.to_u64(), 0); + assert_eq!(agg.q_sigma.to_u64(), 0); + + // Custody fully drained; pool regained escrow + repaid alpha. + assert_eq!(custody_bal(netuid), 0); + assert_eq!(SubnetTAO::::get(netuid).to_u64(), tao_after_open + e); + assert_eq!(SubnetAlphaIn::::get(netuid).to_u64(), alpha_after_open + q.to_u64()); + // Trader received floor + remaining buffer = P + N. + assert_eq!(bal(&trader), trader_before_close + p + n); + }); +} + +#[test] +fn partial_close_reduces_prorata() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + + let pos0 = ShortPositions::::get(netuid, trader).unwrap(); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(pos0.q_liability.to_u64())); + + // Close half. + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 500_000_000)); + let pos1 = ShortPositions::::get(netuid, trader).unwrap(); + + assert_approx(pos1.p_floor.to_u64(), pos0.p_floor.to_u64() / 2, 2, "p/2"); + assert_approx(pos1.q_liability.to_u64(), pos0.q_liability.to_u64() / 2, 2, "q/2"); + assert_approx(pos1.r_stored.to_u64(), pos0.r_stored.to_u64() / 2, 2, "r/2"); + assert_approx(pos1.e_stored.to_u64(), pos0.e_stored.to_u64() / 2, 2, "e/2"); + }); +} + +#[test] +fn close_without_alpha_rejected() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + // No alpha staked at the hotkey → cannot repay the liability. + assert_noop!( + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000), + Error::::InsufficientAlphaToClose + ); + }); +} + +#[test] +fn close_invalid_fraction_rejected() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_noop!( + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 0), + Error::::InvalidCloseFraction + ); + assert_noop!( + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_001), + Error::::InvalidCloseFraction + ); + }); +} + +// --------------------------------------------------------------------------- +// Default (§7) +// --------------------------------------------------------------------------- + +#[test] +fn default_rejected_when_buffer_above_dust() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + let poker = U256::from(99); + assert_noop!( + SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid), + Error::::PositionNotDefaultEligible + ); + }); +} + +#[test] +fn default_recycles_floor_and_restores_residual() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let (p, n, e) = (pos.p_floor.to_u64(), pos.r_stored.to_u64(), pos.e_stored.to_u64()); + // Make the whole buffer dust so the position is default-eligible now. + SubtensorModule::set_short_dust(t(1000 * TAO)); + SubtensorModule::set_short_default_grace(0); // no anti-snipe delay for this test + + let tao0 = SubnetTAO::::get(netuid).to_u64(); + let ti0 = TotalIssuance::::get(); + let poker = U256::from(99); + assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid)); + + // Position removed; residual R+E restored to pool; floor P recycled (TI down). + assert!(ShortPositions::::get(netuid, trader).is_none()); + assert_eq!(SubnetTAO::::get(netuid).to_u64(), tao0 + n + e); + assert_eq!(custody_bal(netuid), 0); + assert_eq!(TotalIssuance::::get(), ti0 - t(p)); + let agg = ShortAggregate::::get(netuid); + assert_eq!(agg.r_sigma.to_u64(), 0); + }); +} + +#[test] +fn default_requires_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + assert_noop!( + SubtensorModule::default_short(RuntimeOrigin::signed(U256::from(99)), U256::from(10), netuid), + Error::::ShortPositionNotFound + ); + }); +} + +// --------------------------------------------------------------------------- +// Subnet deregistration terminal settlement (§11.4) +// --------------------------------------------------------------------------- + +#[test] +fn dereg_settles_in_the_money_short() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let c = pos.p_floor.to_u64() + pos.r_stored.to_u64(); // P + R + let trader_before = bal(&trader); + + // Settle terminal. With pEMA = 1 and a bounded liability, equity > 0. + SubtensorModule::settle_shorts_on_dereg(netuid); + + assert!(ShortPositions::::get(netuid, trader).is_none()); + assert_eq!(custody_bal(netuid), 0); + // Trader received positive equity, strictly less than the full claim. + let gained = bal(&trader) - trader_before; + assert!(gained > 0 && gained < c, "equity {gained} not in (0,{c})"); + }); +} + +#[test] +fn dereg_settles_underwater_short_with_zero_equity() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + + // Drive the EMA liability reference far above the collateral claim. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(50.0)); + let trader_before = bal(&trader); + let ti0 = TotalIssuance::::get(); + + SubtensorModule::settle_shorts_on_dereg(netuid); + + assert!(ShortPositions::::get(netuid, trader).is_none()); + assert_eq!(custody_bal(netuid), 0); + // No equity paid; the full claim was recycled (issuance fell). + assert_eq!(bal(&trader), trader_before); + assert!(TotalIssuance::::get() < ti0); + }); +} + +#[test] +fn dissolve_network_clears_shorts() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert!(ShortPositions::::get(netuid, trader).is_some()); + + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + + // Terminal hook fired: positions and aggregate cleared. + assert!(ShortPositions::::get(netuid, trader).is_none()); + assert!(!ShortAggregate::::contains_key(netuid)); + assert!(!ShortActiveSubnets::::contains_key(netuid)); + }); +} + +// --------------------------------------------------------------------------- +// Audit fixes +// --------------------------------------------------------------------------- + +// Fix: additional open must target the same hotkey (else close would repay from +// the wrong stake). +#[test] +fn merge_with_mismatched_hotkey_rejected() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO))); + // Second open with a different hotkey must be rejected, leaving state intact. + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), netuid, t(50 * TAO)), + Error::::ShortHotkeyMismatch + ); + let pos = ShortPositions::::get(netuid, trader).unwrap(); + assert_eq!(pos.hotkey, U256::from(11)); + assert_eq!(pos.p_floor, t(50 * TAO)); // unchanged by the rejected merge + }); +} + +// Fix: opens below the minimum input are rejected (dust-spam / terminal-load bound). +#[test] +fn open_below_min_input_rejected() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + SubtensorModule::set_short_min_input(t(TAO)); // 1 TAO floor + + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO / 2)), + Error::::AmountTooLow + ); + // At/above the floor it succeeds. + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO))); + }); +} + +// Fix: a third party cannot snipe a default within the grace window after the +// owner's last action; after the window it is allowed. +#[test] +fn permissionless_default_respects_grace_window() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + // Make the buffer dust-eligible, set a short grace window. + SubtensorModule::set_short_dust(t(1000 * TAO)); + SubtensorModule::set_short_default_grace(5); + let poker = U256::from(99); + + // Within the grace window: rejected even though the buffer is dust. + assert_noop!( + SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid), + Error::::PositionNotDefaultEligible + ); + + // After the grace window: allowed. + step_block(6); + assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid)); + assert!(ShortPositions::::get(netuid, trader).is_none()); + }); +} + +// Fix: the owner can defeat a snipe by topping up, which resets the grace window. +#[test] +fn top_up_resets_default_grace() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + SubtensorModule::set_short_dust(t(1000 * TAO)); + SubtensorModule::set_short_default_grace(5); + + step_block(6); // grace from open has elapsed + // Owner tops up, resetting last_active to the current block. + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO))); + + // A snipe is now blocked again for another grace window. + let poker = U256::from(99); + assert_noop!( + SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid), + Error::::PositionNotDefaultEligible + ); + }); +} + +// Fix: only subnets with live short state are tracked for the per-block decay +// tick; membership is added on open and removed when the last position closes. +#[test] +fn active_subnet_set_tracks_membership() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + + // No shorts yet → not tracked. + assert!(!ShortActiveSubnets::::contains_key(netuid)); + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert!(ShortActiveSubnets::::contains_key(netuid)); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(pos.q_liability.to_u64() + 10 * TAO)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + + // Fully closed → no longer tracked, so decay skips this subnet. + assert!(!ShortActiveSubnets::::contains_key(netuid)); + }); +} + +// --------------------------------------------------------------------------- +// Read / RPC layer +// --------------------------------------------------------------------------- + +// The position view materializes decay to the current block, while raw storage +// stays at the last materialization. +#[test] +fn position_view_materializes_decay() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); // strong decay + + let raw = ShortPositions::::get(netuid, trader).unwrap().r_stored.to_u64(); + for _ in 0..2000 { + SubtensorModule::run_short_decay(); + } + + let info = SubtensorModule::get_short_position(&trader, netuid).unwrap(); + // View reflects decay; raw storage is still the last-materialized value. + assert!(info.buffer.to_u64() < raw, "view buffer {} !< raw {}", info.buffer.to_u64(), raw); + assert_eq!(ShortPositions::::get(netuid, trader).unwrap().r_stored.to_u64(), raw); + assert_eq!( + info.collateral_claim.to_u64(), + info.floor.to_u64() + info.buffer.to_u64() + ); + assert!(info.daily_decay > 0); + assert!(info.blocks_to_dust > 0 && info.blocks_to_dust < u64::MAX); + assert_eq!(info.alpha_needed, info.alpha_liability); // holds none yet + }); +} + +// The view's default-eligibility tracks the grace window. +#[test] +fn position_view_reports_default_window() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + SubtensorModule::set_short_dust(t(1000 * TAO)); // buffer is dust + SubtensorModule::set_short_default_grace(5); + + let info = SubtensorModule::get_short_position(&trader, netuid).unwrap(); + assert!(!info.default_eligible, "within grace, not yet defaultable"); + + step_block(6); + let info2 = SubtensorModule::get_short_position(&trader, netuid).unwrap(); + assert!(info2.default_eligible, "after grace, defaultable"); + assert_eq!(info2.defaultable_at_block, info.defaultable_at_block); + }); +} + +// Market view exposes capacity and parameters. +#[test] +fn market_view_reports_capacity() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let m = SubtensorModule::get_subnet_short_state(netuid).unwrap(); + assert!(m.shorts_enabled); + assert!(m.footprint_used.to_u64() > 0); + assert!(m.footprint_cap.to_u64() > m.footprint_used.to_u64()); + assert_eq!( + m.footprint_remaining.to_u64(), + m.footprint_cap.to_u64() - m.footprint_used.to_u64() + ); + assert_eq!(m.open_interest_alpha, pos.q_liability); + assert_eq!(m.buffer_total, pos.r_stored); + assert!(m.current_daily_decay > 0); + }); +} + +// Close quote matches the amounts an actual full close moves. +#[test] +fn close_quote_matches_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + let pos = ShortPositions::::get(netuid, trader).unwrap(); + + let full = SubtensorModule::quote_close_short(&trader, netuid, 1_000_000_000).unwrap(); + assert_eq!(full.repay_alpha, pos.q_liability); + assert_eq!( + full.returned_tao.to_u64(), + pos.p_floor.to_u64() + pos.r_stored.to_u64() + ); + assert_eq!(full.alpha_needed, pos.q_liability); // holds none + assert!(full.est_buyback_cost.to_u64() > 0); + + let half = SubtensorModule::quote_close_short(&trader, netuid, 500_000_000).unwrap(); + assert_approx(half.repay_alpha.to_u64(), full.repay_alpha.to_u64() / 2, 2, "half repay"); + assert_approx(half.returned_tao.to_u64(), full.returned_tao.to_u64() / 2, 2, "half return"); + }); +} + +// Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the +// per-position materialized buffer stays consistent with the aggregate. +#[test] +fn decay_rate_matches_closed_form() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); // d = 1.0/day + + let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); + for _ in 0..7200 { + SubtensorModule::run_short_decay(); // one day of blocks + } + let r1 = ShortAggregate::::get(netuid).r_sigma.to_u64(); + + // (1 − 1/7200)^7200 ≈ e⁻¹ ≈ 0.3679 of the original buffer. + let expected = (r0 as f64 * 0.3679) as u64; + assert_approx(r1, expected, r0 / 50, "one-day decay ≈ e^-1"); // within 2% + + // Per-position view (single position) matches the aggregate. + let info = SubtensorModule::get_short_position(&trader, netuid).unwrap(); + assert_approx(info.buffer.to_u64(), r1, r0 / 100, "position == aggregate"); + }); +} + +// Listing returns every position a coldkey holds across subnets. +#[test] +fn list_positions_across_subnets() { + new_test_ext(1).execute_with(|| { + let n1 = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let n2 = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), n1, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), n2, t(50 * TAO))); + + let all = SubtensorModule::get_short_positions(&trader); + assert_eq!(all.len(), 2); + let mut netuids: Vec<_> = all.iter().map(|p| p.netuid).collect(); + netuids.sort(); + let mut want = vec![n1, n2]; + want.sort(); + assert_eq!(netuids, want); + }); +} diff --git a/pallets/subtensor/src/tests/mod.rs b/pallets/subtensor/src/tests/mod.rs index be37a9227b..6bc29c2bab 100644 --- a/pallets/subtensor/src/tests/mod.rs +++ b/pallets/subtensor/src/tests/mod.rs @@ -5,6 +5,7 @@ mod claim_root; mod coinbase; mod consensus; mod delegate_info; +mod derivatives; mod emission; mod ensure; mod epoch; diff --git a/primitives/safe-math/src/lib.rs b/primitives/safe-math/src/lib.rs index 966dbb4de4..77e8898f17 100644 --- a/primitives/safe-math/src/lib.rs +++ b/primitives/safe-math/src/lib.rs @@ -172,6 +172,46 @@ pub trait FixedExt: Fixed { ln_y.checked_add(exp_ln2) } + /// Exponential (base e). + /// + /// Range reduction `exp(x) = 2^k · exp(r)` with `k = round(x / ln 2)` and + /// `r = x − k·ln 2`, so `|r| ≤ ln 2 / 2` and the Taylor series converges fast. + /// Mirrors `checked_ln` (its inverse); valid for positive and negative `x`. + fn checked_exp(&self) -> Option { + let one = Self::from_num(1); + let ln2 = Self::from_num(LN_2); + + // k = round(x / ln 2) = floor(x / ln 2 + 0.5) + let k_fixed = self + .checked_div(ln2)? + .checked_add(Self::from_num(0.5))? + .checked_floor()?; + let k_int: i32 = k_fixed.saturating_to_num::(); + + // r = x − k·ln 2, with |r| ≤ ln 2 / 2 ≈ 0.347 + let r = self.checked_sub(k_fixed.checked_mul(ln2)?)?; + + // exp(r) = 1 + r + r²/2 + r³/6 + r⁴/24 + r⁵/120 + r⁶/720 + r⁷/5040 + let r2 = r.checked_mul(r)?; + let r3 = r2.checked_mul(r)?; + let r4 = r3.checked_mul(r)?; + let r5 = r4.checked_mul(r)?; + let r6 = r5.checked_mul(r)?; + let r7 = r6.checked_mul(r)?; + let exp_r = one + .checked_add(r)? + .checked_add(r2.checked_mul(Self::from_num(0.5))?)? + .checked_add(r3.checked_mul(Self::from_num(1.0 / 6.0))?)? + .checked_add(r4.checked_mul(Self::from_num(1.0 / 24.0))?)? + .checked_add(r5.checked_mul(Self::from_num(1.0 / 120.0))?)? + .checked_add(r6.checked_mul(Self::from_num(1.0 / 720.0))?)? + .checked_add(r7.checked_mul(Self::from_num(1.0 / 5040.0))?)?; + + // 2^k (checked_pow handles negative k as 1 / 2^|k|) + let two_k = Self::from_num(2).checked_pow(k_int)?; + exp_r.checked_mul(two_k) + } + /// Logarithm with arbitrary base fn checked_log(&self, base: Self) -> Option { // Check for invalid base diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5cf4d7aadb..6495dbcf70 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2556,6 +2556,42 @@ impl_runtime_apis! { } } + impl subtensor_custom_rpc_runtime_api::DerivativesRuntimeApi for Runtime { + fn quote_open_short( + netuid: NetUid, + position_input: TaoBalance, + ) -> Option { + SubtensorModule::quote_open_short(netuid, position_input) + } + + fn quote_close_short( + coldkey: AccountId32, + netuid: NetUid, + fraction_ppb: u64, + ) -> Option { + SubtensorModule::quote_close_short(&coldkey, netuid, fraction_ppb) + } + + fn get_short_position( + coldkey: AccountId32, + netuid: NetUid, + ) -> Option> { + SubtensorModule::get_short_position(&coldkey, netuid) + } + + fn get_short_positions( + coldkey: AccountId32, + ) -> Vec> { + SubtensorModule::get_short_positions(&coldkey) + } + + fn get_subnet_short_state( + netuid: NetUid, + ) -> Option { + SubtensorModule::get_subnet_short_state(netuid) + } + } + impl subtensor_custom_rpc_runtime_api::ProxyFilterRuntimeApi for Runtime { fn get_proxy_types() -> Vec { get_all_proxy_type_infos() From 0154be099c35eb10e5f425e067082231588c1f11 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 17:54:57 -0600 Subject: [PATCH 02/21] harden(derivatives): clamp materialization factor to <=1 (no inflation) Defense-in-depth: even if a position's omega_entry ever exceeded the aggregate Omega, materialize must not produce f>1 and inflate the position. Adds tests for the clamp and an open/close round-trip no-profit invariant. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/mod.rs | 8 +++- pallets/subtensor/src/tests/derivatives.rs | 50 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index f7e7f60a30..fb68a8dd4b 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -184,7 +184,13 @@ impl Pallet { /// Materialize a position to the current accumulator: `f = exp(−(Ω − Ω_entry))`. fn materialize_short(pos: &mut ShortPosition, omega_now: I64F64) { - let arg = pos.omega_entry.saturating_sub(omega_now); // ≤ 0 + // `Ω` only ever grows, so `arg ≤ 0` and `f ≤ 1`. Clamp defensively: a + // positive `arg` (which should be impossible) must never inflate a + // position by producing `f > 1`. + let arg = pos + .omega_entry + .saturating_sub(omega_now) + .min(I64F64::from_num(0)); let f = arg.checked_exp().unwrap_or_else(|| I64F64::from_num(0)); pos.r_stored = Self::mul_tao(pos.r_stored, f); pos.e_stored = Self::mul_tao(pos.e_stored, f); diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 0ac176a572..a3c87b49e8 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -9,7 +9,7 @@ use super::mock::*; use crate::*; use frame_support::{assert_noop, assert_ok}; use sp_core::U256; -use substrate_fixed::types::I96F32; +use substrate_fixed::types::{I64F64, I96F32}; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; const TAO: u64 = 1_000_000_000; @@ -797,6 +797,54 @@ fn close_quote_matches_position() { }); } +// Materialization can never inflate a position: even with a (impossible) +// entry accumulator above the aggregate, the factor is clamped to ≤ 1. +#[test] +fn materialize_never_inflates() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + + // Corrupt the invariant: set omega_entry far above the aggregate omega. + let mut pos = ShortPositions::::get(netuid, trader).unwrap(); + let buf = pos.r_stored; + pos.omega_entry = I64F64::from_num(1000); + ShortPositions::::insert(netuid, trader, pos); + + // The materialized view must not exceed the stored buffer (no inflation). + let info = SubtensorModule::get_short_position(&trader, netuid).unwrap(); + assert!(info.buffer <= buf, "materialize inflated: {} > {}", info.buffer.to_u64(), buf.to_u64()); + }); +} + +// Open immediately followed by full close cannot be a rounding-profit loop: the +// trader gets back at most floor + buffer and must repay the full liability. +#[test] +fn open_close_roundtrip_is_not_profitable() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + + let before = bal(&trader); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let n = pos.r_stored.to_u64(); + // Seed exactly the liability alpha so the round trip is self-contained. + give_alpha(hotkey, trader, netuid, pos.q_liability); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + + // TAO-only delta is +N (the retained proceeds); the trader still had to + // source Q alpha, whose pool buy-cost strictly exceeds N — so no free TAO. + assert_eq!(bal(&trader), before + n); + let buy_cost = SubtensorModule::get_subnet_short_state(netuid); // sanity: market still consistent + assert!(buy_cost.is_some()); + }); +} + // Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the // per-position materialized buffer stays consistent with the aggregate. #[test] From 26847513e134c5bd7a94afe832b178b8b7dde9fa Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:01:38 -0600 Subject: [PATCH 03/21] harden(derivatives): position-count cap, alpha-mint guard, quote gate Addresses thermo branch-audit findings: - M4: cap open short positions per subnet (ShortMaxPositions, governable) and maintain a per-subnet counter, bounding deregistration-settlement work so a heavily-shorted subnet stays prunable. - L3: guard close against minting alpha if SubnetAlphaOut would underflow. - L2: quote_open_short returns None while shorts are disabled. Adds 3 tests; suite now 40 passing. Co-authored-by: Cursor --- pallets/admin-utils/src/lib.rs | 9 +++ pallets/subtensor/src/derivatives/mod.rs | 47 ++++++++++---- pallets/subtensor/src/lib.rs | 15 +++++ pallets/subtensor/src/macros/errors.rs | 2 + pallets/subtensor/src/tests/derivatives.rs | 71 ++++++++++++++++++++++ 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index 5beb1f070d..4246b4b20b 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2343,6 +2343,15 @@ pub mod pallet { pallet_subtensor::Pallet::::set_short_min_input(min_input_rao.into()); Ok(()) } + + /// Set the maximum number of open short positions per subnet. + #[pallet::call_index(103)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_short_max_positions(origin: OriginFor, max: u32) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_short_max_positions(max); + Ok(()) + } } } diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index fb68a8dd4b..0a3c159c36 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -270,16 +270,26 @@ impl Pallet { existing.last_active = block; existing } - None => ShortPosition { - hotkey, - p_floor: position_input, - q_liability: q_alpha, - r_stored: n_tao, - e_stored: e_tao, - b_stored: b_tao, - omega_entry: agg.omega, - last_active: block, - }, + None => { + // New position: enforce and bump the per-subnet position count + // so deregistration settlement work stays bounded. + let count = ShortPositionCount::::get(netuid); + ensure!( + count < ShortMaxPositions::::get(), + Error::::ShortPositionLimit + ); + ShortPositionCount::::insert(netuid, count.saturating_add(1)); + ShortPosition { + hotkey, + p_floor: position_input, + q_liability: q_alpha, + r_stored: n_tao, + e_stored: e_tao, + b_stored: b_tao, + omega_entry: agg.omega, + last_active: block, + } + } }; ShortPositions::::insert(netuid, &coldkey, pos); @@ -359,6 +369,12 @@ impl Pallet { >= q_close, Error::::InsufficientAlphaToClose ); + // Guard against minting alpha: the repaid `q_close` must come out of + // outstanding stake, never saturate `SubnetAlphaOut` to zero. + ensure!( + SubnetAlphaOut::::get(netuid) >= q_close, + Error::::InsufficientAlphaToClose + ); Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, q_close); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(q_close)); Self::increase_provided_alpha_reserve(netuid, q_close); @@ -392,6 +408,7 @@ impl Pallet { if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { ShortPositions::::remove(netuid, &coldkey); + ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); } else { ShortPositions::::insert(netuid, &coldkey, pos); } @@ -447,6 +464,7 @@ impl Pallet { Self::sync_active_short(netuid, &agg); ShortAggregate::::insert(netuid, agg); ShortPositions::::remove(netuid, &coldkey); + ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); Self::deposit_event(Event::ShortDefaulted { coldkey, netuid }); Ok(()) @@ -550,6 +568,7 @@ impl Pallet { Self::recycle_custody_tao(&custody, TaoBalance::MAX); ShortAggregate::::remove(netuid); ShortActiveSubnets::::remove(netuid); + ShortPositionCount::::remove(netuid); } /// Slippage-aware TAO cost to buy `q` alpha on the live pool (CPMM core). @@ -601,12 +620,16 @@ impl Pallet { pub fn set_short_min_input(min_input: TaoBalance) { ShortMinInput::::put(min_input); } + pub fn set_short_max_positions(max: u32) { + ShortMaxPositions::::put(max); + } // ---- read-only quote (spec §1.2) ----------------------------------- - /// Pure pre-open quote for a given input `P`. + /// Pure pre-open quote for a given input `P`. Returns `None` when shorts are + /// disabled or the subnet is not a dynamic market. pub fn quote_open_short(netuid: NetUid, position_input: TaoBalance) -> Option { - if SubnetMechanism::::get(netuid) != 1 { + if !ShortsEnabled::::get() || SubnetMechanism::::get(netuid) != 1 { return None; } let agg = ShortAggregate::::get(netuid); diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 9eefb083ef..27975b5bde 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1427,6 +1427,12 @@ pub mod pallet { TaoBalance::from(100_000_000u64) } #[pallet::type_value] + /// Max open short positions per subnet, bounding deregistration settlement + /// work so a heavily-shorted subnet stays prunable. + pub fn DefaultShortMaxPositions() -> u32 { + 4096 + } + #[pallet::type_value] /// Empty short-side aggregate. pub fn DefaultShortAgg() -> crate::derivatives::ShortAgg { crate::derivatives::ShortAgg::zero() @@ -1481,6 +1487,15 @@ pub mod pallet { pub type ShortActiveSubnets = StorageMap<_, Identity, NetUid, (), OptionQuery>; + /// Max open short positions per subnet (deregistration-work bound). + #[pallet::storage] + pub type ShortMaxPositions = + StorageValue<_, u32, ValueQuery, DefaultShortMaxPositions>; + + /// --- MAP ( netuid ) --> count of open short positions on the subnet. + #[pallet::storage] + pub type ShortPositionCount = StorageMap<_, Identity, NetUid, u32, ValueQuery>; + /// --- MAP ( netuid ) --> short-side aggregate + decay accumulator. #[pallet::storage] pub type ShortAggregate = StorageMap< diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index edbc115298..902f1a5e4c 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -323,5 +323,7 @@ mod errors { PositionNotDefaultEligible, /// Additional open targets a different hotkey than the existing position. ShortHotkeyMismatch, + /// The subnet has reached its maximum number of open short positions. + ShortPositionLimit, } } diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index a3c87b49e8..591cda00c7 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -845,6 +845,77 @@ fn open_close_roundtrip_is_not_profitable() { }); } +// Fix (L3): close must never mint alpha by saturating SubnetAlphaOut to zero. +#[test] +fn close_guards_against_alpha_mint() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + let pos = ShortPositions::::get(netuid, trader).unwrap(); + give_alpha(hotkey, trader, netuid, pos.q_liability); + + // Corrupt outstanding alpha below the liability: close must refuse rather + // than push SubnetAlphaIn up while SubnetAlphaOut saturates (a mint). + SubnetAlphaOut::::insert(netuid, AlphaBalance::from(0)); + let alpha_in_before = SubnetAlphaIn::::get(netuid); + assert_noop!( + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000), + Error::::InsufficientAlphaToClose + ); + assert_eq!(SubnetAlphaIn::::get(netuid), alpha_in_before); // no mint + }); +} + +// Fix (L2): the open quote is unavailable while shorts are disabled. +#[test] +fn open_quote_gated_by_enable_flag() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + assert!(SubtensorModule::quote_open_short(netuid, t(100 * TAO)).is_some()); + SubtensorModule::set_shorts_enabled(false); + assert!(SubtensorModule::quote_open_short(netuid, t(100 * TAO)).is_none()); + }); +} + +// Fix (M4): per-subnet open-position count is capped and maintained, bounding +// deregistration-settlement work. +#[test] +fn position_count_cap_enforced_and_maintained() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + SubtensorModule::set_short_max_positions(2); + let (a, b, c) = (U256::from(10), U256::from(20), U256::from(30)); + for k in [a, b, c] { + add_balance_to_coldkey_account(&k, t(1000 * TAO)); + } + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(20 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(20 * TAO))); + assert_eq!(ShortPositionCount::::get(netuid), 2); + + // Third distinct position exceeds the cap. + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO)), + Error::::ShortPositionLimit + ); + + // Closing one frees a slot; the count is decremented and reusable. + let pos = ShortPositions::::get(netuid, a).unwrap(); + give_alpha(U256::from(11), a, netuid, pos.q_liability); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000)); + assert_eq!(ShortPositionCount::::get(netuid), 1); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO))); + assert_eq!(ShortPositionCount::::get(netuid), 2); + + // A merge (same coldkey, same hotkey) does not consume a new slot. + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO))); + assert_eq!(ShortPositionCount::::get(netuid), 2); + }); +} + // Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the // per-position materialized buffer stays consistent with the aggregate. #[test] From 404bb58bc16d340a4be47ecaf31f7c4885764044 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:21:14 -0600 Subject: [PATCH 04/21] feat(derivatives): symmetric long side (gated, independent flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements covered longs as the mirror of shorts (spec §9): Alpha collateral/ buffer/escrow, fixed TAO liability D. Longs need no TAO custody account — parked Alpha is tracked via issuance accounting (removed from SubnetAlphaIn/Out at open, minted back on restoration/close, left burned = recycled on default/cover); the only TAO movement is the trader repaying D into the pool at close. - long.rs: open/top_up/close/default, run_long_decay, settle_longs_on_dereg, governance setters. Reuses shared math (solve_collateral now takes lambda, decay_curve/utilization extracted). - Storage + params: LongPositions/LongAggregate/LongActiveSubnets/ LongPositionCount; LongBaseLtv/LongKappa/LongDust/LongMinInput/LongMaxPositions. - Dispatches 143-146, events, errors; admin-utils setters 104-109 incl. sudo_set_longs_enabled. Both sides default-disabled and independently flaggable. - 7 new tests (gating, alpha-issuance conservation on open/close, decay, default, dereg, flag independence). Suite now 47 passing; shorts + regressions unaffected. Co-authored-by: Cursor --- pallets/admin-utils/src/lib.rs | 54 +++ pallets/subtensor/src/coinbase/block_step.rs | 4 +- pallets/subtensor/src/coinbase/root.rs | 3 +- pallets/subtensor/src/derivatives/long.rs | 399 +++++++++++++++++++ pallets/subtensor/src/derivatives/mod.rs | 44 +- pallets/subtensor/src/derivatives/types.rs | 54 +++ pallets/subtensor/src/lib.rs | 74 ++++ pallets/subtensor/src/macros/dispatches.rs | 45 +++ pallets/subtensor/src/macros/errors.rs | 12 + pallets/subtensor/src/macros/events.rs | 54 +++ pallets/subtensor/src/tests/derivatives.rs | 176 ++++++++ 11 files changed, 903 insertions(+), 16 deletions(-) create mode 100644 pallets/subtensor/src/derivatives/long.rs diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index 4246b4b20b..c1a4285fc5 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2352,6 +2352,60 @@ pub mod pallet { pallet_subtensor::Pallet::::set_short_max_positions(max); Ok(()) } + + /// Enable or disable long-side covered derivatives (launch gate). + #[pallet::call_index(104)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_longs_enabled(origin: OriginFor, enabled: bool) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_longs_enabled(enabled); + Ok(()) + } + + /// Set the long footprint-cap factor `κ_L` (scaled by 1e9). + #[pallet::call_index(105)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_kappa(origin: OriginFor, kappa_ppb: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_kappa_ppb(kappa_ppb); + Ok(()) + } + + /// Set the base long LTV `λ_L` (scaled by 1e9). + #[pallet::call_index(106)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_base_ltv(origin: OriginFor, ltv_ppb: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_base_ltv_ppb(ltv_ppb); + Ok(()) + } + + /// Set the long retained-buffer dust threshold (in rao of Alpha). + #[pallet::call_index(107)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_dust(origin: OriginFor, dust_rao: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_dust(dust_rao.into()); + Ok(()) + } + + /// Set the minimum long open input (in rao of Alpha). + #[pallet::call_index(108)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_min_input(origin: OriginFor, min_input_rao: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_min_input(min_input_rao.into()); + Ok(()) + } + + /// Set the maximum number of open long positions per subnet. + #[pallet::call_index(109)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_max_positions(origin: OriginFor, max: u32) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_max_positions(max); + Ok(()) + } } } diff --git a/pallets/subtensor/src/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index 818ad603c1..eebeccfb79 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -19,8 +19,10 @@ impl Pallet { Self::reveal_crv3_commits(); // --- 4. Run emission through network. Self::run_coinbase(block_emission); - // --- 4b. Decay covered-short positions and restore unwound TAO to pools. + // --- 4b. Decay covered derivative positions and restore unwound + // collateral to pools (shorts return TAO, longs return Alpha). Self::run_short_decay(); + Self::run_long_decay(); // --- 5. Update moving prices AFTER using them for emissions. Self::update_moving_prices(); // --- 6. Update roop prop AFTER using them for emissions. diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index a941ab1535..27620c908e 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -212,9 +212,10 @@ impl Pallet { Self::finalize_all_subnet_root_dividends(netuid); - // --- Settle covered shorts before the pool is drained, so restored + // --- Settle covered derivatives before the pool is drained, so restored // escrow joins terminal distribution and liabilities are bounded. Self::settle_shorts_on_dereg(netuid); + Self::settle_longs_on_dereg(netuid); // --- Perform the cleanup before removing the network. Self::destroy_alpha_in_out_stakes(netuid)?; diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs new file mode 100644 index 0000000000..e9e3b98fb1 --- /dev/null +++ b/pallets/subtensor/src/derivatives/long.rs @@ -0,0 +1,399 @@ +//! Covered continuous-unwind LONGS — the mirror of shorts with Alpha and TAO +//! swapped (spec §9). Collateral/buffer/escrow are Alpha; the fixed liability +//! `D` is TAO. +//! +//! Unlike shorts, longs need no TAO custody account: the parked Alpha is +//! tracked purely via issuance accounting (removed from `SubnetAlphaIn` / +//! `SubnetAlphaOut` at open, minted back on restoration/close, left burned = +//! recycled on default/cover). The only TAO movement is the trader paying the +//! `D` liability into the pool at close. Shared math (`solve_collateral`, +//! `solve_phi`, `neg_ln_one_minus`, `decay_curve`, conversions) is reused from +//! the parent module. + +use super::*; +use safe_math::FixedExt; +use substrate_fixed::types::I64F64; +use subtensor_runtime_common::Token; + +const BLOCKS_PER_DAY: u64 = 7200; + +impl Pallet { + /// Conservative Alpha reference `A_ref = min(A_live, A_EMA)`, with + /// `A_EMA = T_live / pEMA` reconstructed from the price EMA. Cold EMA falls + /// back to the live reserve. + fn long_a_ref(netuid: NetUid) -> I64F64 { + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + if pema <= I64F64::from_num(0) { + return a_live; + } + a_live.min(t_live.safe_div(pema)) + } + + /// Current long daily decay rate at the live long footprint. + fn long_daily_decay(netuid: NetUid, b_sigma: AlphaBalance) -> I64F64 { + let cap = LongKappa::::get().saturating_mul(Self::long_a_ref(netuid)); + Self::decay_curve(Self::utilization(Self::alpha_f(b_sigma), cap)) + } + + fn materialize_long(pos: &mut LongPosition, omega_now: I64F64) { + let arg = pos + .omega_entry + .saturating_sub(omega_now) + .min(I64F64::from_num(0)); + let f = arg.checked_exp().unwrap_or_else(|| I64F64::from_num(0)); + pos.r_stored = Self::mul_alpha(pos.r_stored, f); + pos.e_stored = Self::mul_alpha(pos.e_stored, f); + pos.b_stored = Self::mul_alpha(pos.b_stored, f); + pos.omega_entry = omega_now; + } + + fn sync_active_long(netuid: NetUid, agg: &LongAgg) { + if agg.r_sigma.is_zero() + && agg.e_sigma.is_zero() + && agg.b_sigma.is_zero() + && agg.d_sigma.is_zero() + { + LongActiveSubnets::::remove(netuid); + } else { + LongActiveSubnets::::insert(netuid, ()); + } + } + + // ---- user operations (spec §9, mirror of §8) ----------------------- + + /// Open (or merge into) a covered long. Trader posts `position_input` Alpha + /// (drawn from stake at `hotkey`). + pub fn do_open_long( + origin: OriginFor, + hotkey: T::AccountId, + netuid: NetUid, + position_input: AlphaBalance, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!(LongsEnabled::::get(), Error::::LongsDisabled); + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + ensure!( + SubnetMechanism::::get(netuid) == 1, + Error::::SubnetNotDynamic + ); + ensure!( + position_input >= LongMinInput::::get(), + Error::::AmountTooLow + ); + + let mut agg = LongAggregate::::get(netuid); + let a_ref = Self::long_a_ref(netuid); + let p = Self::alpha_f(position_input); + let (c, n) = + Self::solve_collateral(p, a_ref, Self::alpha_f(agg.b_sigma), LongBaseLtv::::get()) + .ok_or(Error::::EffectiveLtvNonPositive)?; + let b = LongBaseLtv::::get().saturating_mul(c); + + ensure!( + Self::alpha_f(agg.b_sigma).saturating_add(b) <= LongKappa::::get().saturating_mul(a_ref), + Error::::LongCapacityExceeded + ); + + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let phi = Self::solve_phi(n, a_live).ok_or(Error::::ReserveDomainExceeded)?; + + let n_alpha = Self::to_alpha(n); + let e_alpha = Self::to_alpha(phi.saturating_mul(a_live)); + let b_alpha = Self::to_alpha(b); + let d_tao = Self::to_tao(phi.saturating_mul(t_live)); + ensure!(!n_alpha.is_zero(), Error::::RetainedProceedsNonPositive); + + // Trader posts P Alpha from stake; remove N+E Alpha from the pool. All + // of this leaves issuance (held off-chain in the position numbers). + ensure!( + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid) + >= position_input, + Error::::InsufficientCollateral + ); + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(position_input)); + Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); + + let block = Self::get_current_block_as_u64(); + let pos = match LongPositions::::get(netuid, &coldkey) { + Some(mut existing) => { + ensure!(existing.hotkey == hotkey, Error::::LongHotkeyMismatch); + Self::materialize_long(&mut existing, agg.omega); + existing.p_floor = existing.p_floor.saturating_add(position_input); + existing.d_liability = existing.d_liability.saturating_add(d_tao); + existing.r_stored = existing.r_stored.saturating_add(n_alpha); + existing.e_stored = existing.e_stored.saturating_add(e_alpha); + existing.b_stored = existing.b_stored.saturating_add(b_alpha); + existing.last_active = block; + existing + } + None => { + let count = LongPositionCount::::get(netuid); + ensure!(count < LongMaxPositions::::get(), Error::::LongPositionLimit); + LongPositionCount::::insert(netuid, count.saturating_add(1)); + LongPosition { + hotkey, + p_floor: position_input, + d_liability: d_tao, + r_stored: n_alpha, + e_stored: e_alpha, + b_stored: b_alpha, + omega_entry: agg.omega, + last_active: block, + } + } + }; + LongPositions::::insert(netuid, &coldkey, pos); + + agg.r_sigma = agg.r_sigma.saturating_add(n_alpha); + agg.e_sigma = agg.e_sigma.saturating_add(e_alpha); + agg.b_sigma = agg.b_sigma.saturating_add(b_alpha); + agg.d_sigma = agg.d_sigma.saturating_add(d_tao); + LongAggregate::::insert(netuid, agg); + LongActiveSubnets::::insert(netuid, ()); + + Self::deposit_event(Event::LongOpened { + coldkey, + netuid, + position_input, + retained_proceeds: n_alpha, + tao_liability: d_tao, + escrow: e_alpha, + }); + Ok(()) + } + + /// Top up the carry buffer `R` with fresh Alpha (drawn from stake). + pub fn do_top_up_long( + origin: OriginFor, + netuid: NetUid, + amount: AlphaBalance, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!(!amount.is_zero(), Error::::AmountTooLow); + let mut pos = + LongPositions::::get(netuid, &coldkey).ok_or(Error::::LongPositionNotFound)?; + let mut agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + + ensure!( + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid) >= amount, + Error::::InsufficientCollateral + ); + Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, amount); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(amount)); + + pos.r_stored = pos.r_stored.saturating_add(amount); + pos.last_active = Self::get_current_block_as_u64(); + agg.r_sigma = agg.r_sigma.saturating_add(amount); + LongPositions::::insert(netuid, &coldkey, pos); + LongAggregate::::insert(netuid, agg); + Self::deposit_event(Event::LongToppedUp { + coldkey, + netuid, + amount, + }); + Ok(()) + } + + /// Partial or full close. Trader repays `ρD` TAO into the pool and receives + /// `ρ(P+R)` Alpha back as stake. + pub fn do_close_long( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!( + fraction_ppb > 0 && fraction_ppb <= 1_000_000_000, + Error::::InvalidCloseFraction + ); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + let mut pos = + LongPositions::::get(netuid, &coldkey).ok_or(Error::::LongPositionNotFound)?; + let mut agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + + let d_close = Self::mul_tao(pos.d_liability, rho); + let r_close = Self::mul_alpha(pos.r_stored, rho); + let e_close = Self::mul_alpha(pos.e_stored, rho); + let p_close = Self::mul_alpha(pos.p_floor, rho); + let b_close = Self::mul_alpha(pos.b_stored, rho); + + // Trader repays ρD TAO into the pool (strict transfer). + if !d_close.is_zero() { + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + Self::transfer_tao(&coldkey, &subnet_account, d_close.into())?; + Self::increase_provided_tao_reserve(netuid, d_close); + TotalStake::::mutate(|t| *t = t.saturating_add(d_close)); + } + // Settle escrow back to the pool; return floor+buffer as stake (mint). + Self::increase_provided_alpha_reserve(netuid, e_close); + let returned = p_close.saturating_add(r_close); + if !returned.is_zero() { + Self::increase_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, returned); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(returned)); + } + + pos.d_liability = pos.d_liability.saturating_sub(d_close); + pos.r_stored = pos.r_stored.saturating_sub(r_close); + pos.e_stored = pos.e_stored.saturating_sub(e_close); + pos.p_floor = pos.p_floor.saturating_sub(p_close); + pos.b_stored = pos.b_stored.saturating_sub(b_close); + + agg.d_sigma = agg.d_sigma.saturating_sub(d_close); + agg.r_sigma = agg.r_sigma.saturating_sub(r_close); + agg.e_sigma = agg.e_sigma.saturating_sub(e_close); + agg.b_sigma = agg.b_sigma.saturating_sub(b_close); + Self::sync_active_long(netuid, &agg); + LongAggregate::::insert(netuid, agg); + + if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { + LongPositions::::remove(netuid, &coldkey); + LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + } else { + LongPositions::::insert(netuid, &coldkey, pos); + } + Self::deposit_event(Event::LongClosed { + coldkey, + netuid, + fraction_ppb, + repaid_tao: d_close, + returned, + }); + Ok(()) + } + + /// Permissionless default once the buffer is dust and the grace window has + /// elapsed. Restores residual Alpha, recycles the floor (left burned), + /// extinguishes `D`. + pub fn do_default_long( + origin: OriginFor, + coldkey: T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + ensure_signed(origin)?; + let mut pos = + LongPositions::::get(netuid, &coldkey).ok_or(Error::::LongPositionNotFound)?; + let mut agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + ensure!( + pos.r_stored <= LongDust::::get(), + Error::::PositionNotDefaultEligible + ); + ensure!( + Self::get_current_block_as_u64() + >= pos.last_active.saturating_add(ShortDefaultGrace::::get()), + Error::::PositionNotDefaultEligible + ); + + // Restore residual R+E Alpha to the pool; floor stays burned (recycled). + Self::increase_provided_alpha_reserve(netuid, pos.r_stored.saturating_add(pos.e_stored)); + + agg.r_sigma = agg.r_sigma.saturating_sub(pos.r_stored); + agg.e_sigma = agg.e_sigma.saturating_sub(pos.e_stored); + agg.b_sigma = agg.b_sigma.saturating_sub(pos.b_stored); + agg.d_sigma = agg.d_sigma.saturating_sub(pos.d_liability); + Self::sync_active_long(netuid, &agg); + LongAggregate::::insert(netuid, agg); + LongPositions::::remove(netuid, &coldkey); + LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + + Self::deposit_event(Event::LongDefaulted { coldkey, netuid }); + Ok(()) + } + + // ---- per-block decay + restoration --------------------------------- + + /// O(1)-per-subnet long decay tick; restores decayed Alpha to the pool by + /// minting it back into `SubnetAlphaIn`. + pub fn run_long_decay() { + let active: Vec = LongActiveSubnets::::iter_keys().collect(); + for netuid in active { + let mut agg = LongAggregate::::get(netuid); + if agg.r_sigma.is_zero() && agg.e_sigma.is_zero() && agg.b_sigma.is_zero() { + continue; + } + let delta = Self::long_daily_decay(netuid, agg.b_sigma) + .safe_div(I64F64::from_num(BLOCKS_PER_DAY)); + if delta <= I64F64::from_num(0) { + continue; + } + let dr = Self::mul_alpha(agg.r_sigma, delta); + let de = Self::mul_alpha(agg.e_sigma, delta); + let db = Self::mul_alpha(agg.b_sigma, delta); + agg.r_sigma = agg.r_sigma.saturating_sub(dr); + agg.e_sigma = agg.e_sigma.saturating_sub(de); + agg.b_sigma = agg.b_sigma.saturating_sub(db); + agg.omega = agg.omega.saturating_add(Self::neg_ln_one_minus(delta)); + LongAggregate::::insert(netuid, agg); + + // Restoration: mint decayed R+E Alpha back into the pool reserve. + Self::increase_provided_alpha_reserve(netuid, dr.saturating_add(de)); + } + } + + // ---- terminal deregistration settlement (spec §11.5) --------------- + + /// Settle all longs on a subnet at deregistration: escrow Alpha rejoins the + /// pool; collateral is valued at the price EMA; the alpha covering the TAO + /// debt stays burned (recycled); the equity remainder returns as stake. + pub fn settle_longs_on_dereg(netuid: NetUid) { + let agg = LongAggregate::::get(netuid); + let price = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let positions: Vec<(T::AccountId, LongPosition)> = + LongPositions::::iter_prefix(netuid).collect(); + for (coldkey, mut pos) in positions { + Self::materialize_long(&mut pos, agg.omega); + // Escrow rejoins the pool / terminal distribution. + Self::increase_provided_alpha_reserve(netuid, pos.e_stored); + + let c_l = Self::alpha_f(pos.p_floor.saturating_add(pos.r_stored)); + let d = Self::tao_f(pos.d_liability); + // Alpha needed to cover the TAO debt at the terminal price. + let cover = if price > I64F64::from_num(0) { + c_l.min(d.safe_div(price)) + } else { + c_l + }; + let equity = Self::to_alpha(c_l.saturating_sub(cover)); + if !equity.is_zero() { + Self::increase_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, equity); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(equity)); + } + // The cover portion of the collateral stays burned (recycled). + LongPositions::::remove(netuid, &coldkey); + Self::deposit_event(Event::LongTerminalSettled { + coldkey, + netuid, + equity, + }); + } + LongAggregate::::remove(netuid); + LongActiveSubnets::::remove(netuid); + LongPositionCount::::remove(netuid); + } + + // ---- governance setters -------------------------------------------- + + pub fn set_long_kappa_ppb(kappa_ppb: u64) { + LongKappa::::put(I64F64::from_num(kappa_ppb).safe_div(I64F64::from_num(1_000_000_000u64))); + } + pub fn set_long_base_ltv_ppb(ltv_ppb: u64) { + let ltv = ltv_ppb.clamp(1, 999_999_999); + LongBaseLtv::::put(I64F64::from_num(ltv).safe_div(I64F64::from_num(1_000_000_000u64))); + } + pub fn set_long_dust(dust: AlphaBalance) { + LongDust::::put(dust); + } + pub fn set_long_min_input(min_input: AlphaBalance) { + LongMinInput::::put(min_input); + } + pub fn set_long_max_positions(max: u32) { + LongMaxPositions::::put(max); + } +} diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 0a3c159c36..ccbd1eccb2 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -13,6 +13,7 @@ use sp_runtime::traits::AccountIdConversion; use substrate_fixed::types::I64F64; use subtensor_runtime_common::Token; +pub mod long; pub mod types; pub use types::*; @@ -91,26 +92,41 @@ impl Pallet { } } - /// Current daily decay rate `d(u) = d_min + (d_max − d_min)·u²` (spec §6.2). - fn short_daily_decay(netuid: NetUid, b_sigma: TaoBalance) -> I64F64 { - let t_ref = Self::short_t_ref(netuid); - let cap = ShortKappa::::get().saturating_mul(t_ref); - let u = if cap > I64F64::from_num(0) { - Self::tao_f(b_sigma).safe_div(cap).min(I64F64::from_num(1)) - } else { - I64F64::from_num(0) - }; + /// Convex decay curve `d(u) = d_min + (d_max − d_min)·u²` (spec §6.2), + /// shared by both sides (the rate is denomination-agnostic). + fn decay_curve(u: I64F64) -> I64F64 { let dmin = DecayMin::::get(); let dmax = DecayMax::::get(); dmin.saturating_add(dmax.saturating_sub(dmin).saturating_mul(u).saturating_mul(u)) } + /// Utilization ratio `min(1, S / cap)`. + fn utilization(s: I64F64, cap: I64F64) -> I64F64 { + if cap > I64F64::from_num(0) { + s.safe_div(cap).min(I64F64::from_num(1)) + } else { + I64F64::from_num(0) + } + } + + /// Current short daily decay rate at the live short footprint. + fn short_daily_decay(netuid: NetUid, b_sigma: TaoBalance) -> I64F64 { + let cap = ShortKappa::::get().saturating_mul(Self::short_t_ref(netuid)); + Self::decay_curve(Self::utilization(Self::tao_f(b_sigma), cap)) + } + // ---- open-time math (spec §4.1–4.3, Appendix A.1) ------------------- /// Solve gross collateral `C` and retained proceeds `N` from input `P` - /// (spec §4.2). Returns `None` if `N ≤ 0` (effective LTV non-positive). - fn solve_collateral(p: I64F64, t_ref: I64F64, s: I64F64) -> Option<(I64F64, I64F64)> { - let lambda = ShortBaseLtv::::get(); + /// (spec §4.2). Side-agnostic: `ref_reserve` is `T_ref` for shorts / `A_ref` + /// for longs, `lambda` the per-side base LTV. Returns `None` if `N ≤ 0`. + fn solve_collateral( + p: I64F64, + ref_reserve: I64F64, + s: I64F64, + lambda: I64F64, + ) -> Option<(I64F64, I64F64)> { + let t_ref = ref_reserve; if t_ref <= I64F64::from_num(0) || lambda <= I64F64::from_num(0) { return None; } @@ -223,7 +239,7 @@ impl Pallet { let t_ref = Self::short_t_ref(netuid); let p = Self::tao_f(position_input); - let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma)) + let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma), ShortBaseLtv::::get()) .ok_or(Error::::EffectiveLtvNonPositive)?; let b = ShortBaseLtv::::get().saturating_mul(c); @@ -635,7 +651,7 @@ impl Pallet { let agg = ShortAggregate::::get(netuid); let t_ref = Self::short_t_ref(netuid); let p = Self::tao_f(position_input); - let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma))?; + let (c, n) = Self::solve_collateral(p, t_ref, Self::tao_f(agg.b_sigma), ShortBaseLtv::::get())?; let t_live = Self::tao_f(SubnetTAO::::get(netuid)); let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); let phi = Self::solve_phi(n, t_live)?; diff --git a/pallets/subtensor/src/derivatives/types.rs b/pallets/subtensor/src/derivatives/types.rs index 8f5e91026e..72e833f17b 100644 --- a/pallets/subtensor/src/derivatives/types.rs +++ b/pallets/subtensor/src/derivatives/types.rs @@ -32,6 +32,60 @@ pub struct ShortPosition { pub last_active: u64, } +/// A merged covered long position for one `(coldkey, netuid)` (spec §2.3). +/// +/// The mirror of `ShortPosition` with Alpha and TAO swapped: collateral/buffer/ +/// escrow/footprint are Alpha; the fixed liability `D` is TAO. +#[freeze_struct("fba4427847673b78")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct LongPosition { + /// Hotkey the collateral alpha is sourced from / returned to. + pub hotkey: AccountId, + /// Non-decaying Alpha floor supplied by the trader (spec `P`). + pub p_floor: AlphaBalance, + /// Fixed TAO liability (spec `D`); changes only on close/default/dereg. + pub d_liability: TaoBalance, + /// Retained Alpha-proceeds buffer at last materialization (spec `R`). + pub r_stored: AlphaBalance, + /// Linked Alpha escrow at last materialization (spec `E`). + pub e_stored: AlphaBalance, + /// Utilization footprint at last materialization (spec `B = λ_L·C`). + pub b_stored: AlphaBalance, + /// Value of `Ω_L` at last materialization. + pub omega_entry: I64F64, + /// Block of the last owner action (open / merge / top-up). + pub last_active: u64, +} + +/// Per-subnet long-side aggregate and decay accumulator. +#[freeze_struct("571ef9a642107d3b")] +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct LongAgg { + /// Σ current retained Alpha buffer. + pub r_sigma: AlphaBalance, + /// Σ current Alpha escrow. + pub e_sigma: AlphaBalance, + /// Σ current Alpha footprint == active utilization `S_L`. + pub b_sigma: AlphaBalance, + /// Σ fixed TAO liability (open interest `D_Σ`). + pub d_sigma: TaoBalance, + /// Cumulative monotone long-side decay accumulator `Ω_L`. + pub omega: I64F64, +} + +impl LongAgg { + /// Empty long-side aggregate. + pub fn zero() -> Self { + Self { + r_sigma: AlphaBalance::ZERO, + e_sigma: AlphaBalance::ZERO, + b_sigma: AlphaBalance::ZERO, + d_sigma: TaoBalance::ZERO, + omega: I64F64::from_num(0), + } + } +} + /// Per-subnet short-side aggregate and decay accumulator (spec §2.4, §6.3). #[freeze_struct("376a8ccf882d6dea")] #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, PartialEq, Eq, Debug)] diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 27975b5bde..6b34a2acfe 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1518,6 +1518,80 @@ pub mod pallet { crate::derivatives::ShortPosition, OptionQuery, >; + + // ===== Long side (mirror; Alpha collateral, TAO liability) ===== + + #[pallet::type_value] + /// Long dust threshold = 1 Alpha. + pub fn DefaultLongDust() -> AlphaBalance { + AlphaBalance::from(1_000_000_000u64) + } + #[pallet::type_value] + /// Minimum long open input = 0.1 Alpha. + pub fn DefaultLongMinInput() -> AlphaBalance { + AlphaBalance::from(100_000_000u64) + } + #[pallet::type_value] + /// Empty long-side aggregate. + pub fn DefaultLongAgg() -> crate::derivatives::LongAgg { + crate::derivatives::LongAgg::zero() + } + + /// Base long LTV `λ_L` (shares the short LTV default; ADR-adjustment TBD). + #[pallet::storage] + pub type LongBaseLtv = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultShortBaseLtv>; + + /// Long footprint-cap factor `κ_L`. + #[pallet::storage] + pub type LongKappa = + StorageValue<_, substrate_fixed::types::I64F64, ValueQuery, DefaultShortKappa>; + + /// Long retained-buffer dust threshold (Alpha). + #[pallet::storage] + pub type LongDust = StorageValue<_, AlphaBalance, ValueQuery, DefaultLongDust>; + + /// Minimum long open input (Alpha). + #[pallet::storage] + pub type LongMinInput = + StorageValue<_, AlphaBalance, ValueQuery, DefaultLongMinInput>; + + /// Max open long positions per subnet (deregistration-work bound). + #[pallet::storage] + pub type LongMaxPositions = + StorageValue<_, u32, ValueQuery, DefaultShortMaxPositions>; + + /// --- MAP ( netuid ) --> long-side aggregate + decay accumulator. + #[pallet::storage] + pub type LongAggregate = StorageMap< + _, + Identity, + NetUid, + crate::derivatives::LongAgg, + ValueQuery, + DefaultLongAgg, + >; + + /// --- DMAP ( netuid, coldkey ) --> merged covered long position. + #[pallet::storage] + pub type LongPositions = StorageDoubleMap< + _, + Identity, + NetUid, + Blake2_128Concat, + T::AccountId, + crate::derivatives::LongPosition, + OptionQuery, + >; + + /// --- SET ( netuid ) of subnets with live long state. + #[pallet::storage] + pub type LongActiveSubnets = + StorageMap<_, Identity, NetUid, (), OptionQuery>; + + /// --- MAP ( netuid ) --> count of open long positions on the subnet. + #[pallet::storage] + pub type LongPositionCount = StorageMap<_, Identity, NetUid, u32, ValueQuery>; /// --- MAP ( netuid ) --> protocol_alpha | Returns the protocol-owned alpha cached for the subnet. #[pallet::storage] pub type SubnetProtocolAlpha = diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index c06900533c..bde455d82b 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2638,5 +2638,50 @@ mod dispatches { ) -> DispatchResult { Self::do_default_short(origin, coldkey, netuid) } + + /// Open (or merge into) a covered long with floor Alpha `position_input`. + #[pallet::call_index(143)] + #[pallet::weight(::DbWeight::get().reads_writes(12, 8))] + pub fn open_long( + origin: OriginFor, + hotkey: T::AccountId, + netuid: NetUid, + position_input: AlphaBalance, + ) -> DispatchResult { + Self::do_open_long(origin, hotkey, netuid, position_input) + } + + /// Top up a covered long's carry buffer with fresh Alpha. + #[pallet::call_index(144)] + #[pallet::weight(::DbWeight::get().reads_writes(5, 4))] + pub fn top_up_long( + origin: OriginFor, + netuid: NetUid, + amount: AlphaBalance, + ) -> DispatchResult { + Self::do_top_up_long(origin, netuid, amount) + } + + /// Close `fraction_ppb / 1e9` of a covered long (`1e9` = full close). + #[pallet::call_index(145)] + #[pallet::weight(::DbWeight::get().reads_writes(10, 8))] + pub fn close_long( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + Self::do_close_long(origin, netuid, fraction_ppb) + } + + /// Permissionlessly default a covered long whose buffer reached dust. + #[pallet::call_index(146)] + #[pallet::weight(::DbWeight::get().reads_writes(7, 6))] + pub fn default_long( + origin: OriginFor, + coldkey: T::AccountId, + netuid: NetUid, + ) -> DispatchResult { + Self::do_default_long(origin, coldkey, netuid) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 902f1a5e4c..a377af7105 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -325,5 +325,17 @@ mod errors { ShortHotkeyMismatch, /// The subnet has reached its maximum number of open short positions. ShortPositionLimit, + /// Long-side derivatives are disabled. + LongsDisabled, + /// No long position exists for this coldkey on the subnet. + LongPositionNotFound, + /// Open would exceed the active long footprint cap. + LongCapacityExceeded, + /// The subnet has reached its maximum number of open long positions. + LongPositionLimit, + /// Additional open targets a different hotkey than the existing position. + LongHotkeyMismatch, + /// Trader does not hold enough alpha collateral to open/extend the long. + InsufficientCollateral, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index af8e97ab15..7ccebae210 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -687,5 +687,59 @@ mod events { /// Liability-cover recycled outside terminal distribution. liability_cover: TaoBalance, }, + + /// A covered long was opened (or merged). + LongOpened { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the long is on. + netuid: NetUid, + /// Floor Alpha supplied by the trader. + position_input: AlphaBalance, + /// Retained Alpha proceeds booked as the initial buffer. + retained_proceeds: AlphaBalance, + /// Fixed TAO liability created. + tao_liability: TaoBalance, + /// Linked Alpha escrow created. + escrow: AlphaBalance, + }, + /// A covered long's carry buffer was topped up. + LongToppedUp { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the long is on. + netuid: NetUid, + /// Alpha added to the buffer. + amount: AlphaBalance, + }, + /// A covered long was (partially) closed. + LongClosed { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the long is on. + netuid: NetUid, + /// Closed fraction in parts-per-billion. + fraction_ppb: u64, + /// TAO repaid to extinguish the liability slice. + repaid_tao: TaoBalance, + /// Alpha (floor + buffer) returned to the trader. + returned: AlphaBalance, + }, + /// A covered long defaulted after its buffer reached dust. + LongDefaulted { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet the long was on. + netuid: NetUid, + }, + /// A covered long was settled at subnet deregistration. + LongTerminalSettled { + /// Position owner coldkey. + coldkey: T::AccountId, + /// Subnet that deregistered. + netuid: NetUid, + /// Terminal equity (Alpha) returned to the trader. + equity: AlphaBalance, + }, } } diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 591cda00c7..82c248a4d6 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -943,6 +943,182 @@ fn decay_rate_matches_closed_form() { }); } +// --------------------------------------------------------------------------- +// Longs (mirror) + side independence +// --------------------------------------------------------------------------- + +fn setup_long(tao_reserve: u64, alpha_reserve: u64, price: f64) -> NetUid { + let netuid = setup_market(tao_reserve, alpha_reserve, price); + SubtensorModule::set_longs_enabled(true); + SubtensorModule::set_long_kappa_ppb(900_000_000); + netuid +} + +fn alpha_issuance(netuid: NetUid) -> u64 { + SubnetAlphaIn::::get(netuid).to_u64() + SubnetAlphaOut::::get(netuid).to_u64() +} + +#[test] +fn open_long_rejected_when_disabled() { + new_test_ext(1).execute_with(|| { + // setup_market enables shorts only; longs remain off by default. + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + Error::::LongsDisabled + ); + }); +} + +#[test] +fn open_long_moves_alpha_off_issuance() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + + let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); + let stake0 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid).to_u64(); + + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + let (n, e, d) = (pos.r_stored.to_u64(), pos.e_stored.to_u64(), pos.d_liability.to_u64()); + + assert!(n > 0 && e > 0 && d > 0); + assert_eq!(pos.p_floor.to_u64(), 100 * TAO); + // Pool alpha dropped by N+E; trader stake dropped by the floor P. + assert_eq!(SubnetAlphaIn::::get(netuid).to_u64(), alpha_in0 - n - e); + assert_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid).to_u64(), + stake0 - 100 * TAO + ); + let agg = LongAggregate::::get(netuid); + assert_eq!(agg.d_sigma, pos.d_liability); + assert!(LongActiveSubnets::::contains_key(netuid)); + }); +} + +#[test] +fn full_close_long_conserves_value() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); // TAO to repay D + + let iss0 = alpha_issuance(netuid); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + let d = pos.d_liability.to_u64(); + let tao0 = SubnetTAO::::get(netuid).to_u64(); + + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + + assert!(LongPositions::::get(netuid, trader).is_none()); + assert!(!LongActiveSubnets::::contains_key(netuid)); + // Alpha issuance is fully restored (mint == earlier burn); TAO liability paid into pool. + assert_eq!(alpha_issuance(netuid), iss0); + assert_eq!(SubnetTAO::::get(netuid).to_u64(), tao0 + d); + let agg = LongAggregate::::get(netuid); + assert_eq!(agg.r_sigma.to_u64(), 0); + assert_eq!(agg.d_sigma.to_u64(), 0); + }); +} + +#[test] +fn long_decay_restores_alpha_to_pool() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + + let r0 = LongAggregate::::get(netuid).r_sigma.to_u64(); + let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); + for _ in 0..300 { + SubtensorModule::run_long_decay(); + } + assert!(LongAggregate::::get(netuid).r_sigma.to_u64() < r0); + assert!(SubnetAlphaIn::::get(netuid).to_u64() > alpha_in0); // alpha minted back to pool + }); +} + +#[test] +fn long_default_recycles_floor_and_restores_residual() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + let (p, n, e) = (pos.p_floor.to_u64(), pos.r_stored.to_u64(), pos.e_stored.to_u64()); + SubtensorModule::set_long_dust(AlphaBalance::from(1000 * TAO)); + SubtensorModule::set_short_default_grace(0); + + let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); + let iss0 = alpha_issuance(netuid); + assert_ok!(SubtensorModule::default_long(RuntimeOrigin::signed(U256::from(99)), trader, netuid)); + + assert!(LongPositions::::get(netuid, trader).is_none()); + // Residual R+E minted back to the pool; floor P stays burned (recycled). + assert_eq!(SubnetAlphaIn::::get(netuid).to_u64(), alpha_in0 + n + e); + assert_eq!(alpha_issuance(netuid), iss0 + n + e); // P remains out of issuance + assert_eq!(p, 100 * TAO); + }); +} + +#[test] +fn dereg_settles_longs() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert!(LongPositions::::get(netuid, trader).is_some()); + + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + assert!(LongPositions::::get(netuid, trader).is_none()); + assert!(!LongActiveSubnets::::contains_key(netuid)); + }); +} + +// Shorts and longs are independently flaggable on the same subnet. +#[test] +fn short_and_long_flags_are_independent() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); // shorts on, longs off + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + + // Shorts enabled, longs disabled. + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO)), + Error::::LongsDisabled + ); + + // Flip: longs enabled, shorts disabled. + SubtensorModule::set_shorts_enabled(false); + SubtensorModule::set_longs_enabled(true); + SubtensorModule::set_long_kappa_ppb(900_000_000); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(U256::from(20)), hotkey, netuid, t(50 * TAO)), + Error::::ShortsDisabled + ); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO))); + }); +} + // Listing returns every position a coldkey holds across subnets. #[test] fn list_positions_across_subnets() { From 50476e57583a9d5b564c4bbd5061ce2f6166e5d6 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:29:58 -0600 Subject: [PATCH 05/21] test(derivatives): global value-conservation proofs - proof_full_lifecycle_conserves_tao_and_alpha: mixed shorts+longs through decay, top-up, partial+full close; asserts TAO supply exactly conserved, Alpha never minted and within bounded rounding dust, custody drained, all positions/counts cleared. - proof_default_recycles_exactly_the_floor: default reduces TAO (short) / Alpha (long) issuance by EXACTLY the recycled floor. Suite now 49 passing. Co-authored-by: Cursor --- pallets/subtensor/src/tests/derivatives.rs | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 82c248a4d6..28a45f89dc 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -916,6 +916,109 @@ fn position_count_cap_enforced_and_maintained() { }); } +// =========================================================================== +// PROOF: global value conservation across the full mixed lifecycle. +// +// Exercises the real dispatch path for both sides (open/top-up/partial+full +// close) plus continuous decay, and asserts that no TAO and no Alpha is minted +// or destroyed once every position is closed. Decay is driven directly (not via +// step_block) so coinbase emissions don't perturb issuance. +// =========================================================================== +#[test] +fn proof_full_lifecycle_conserves_tao_and_alpha() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); // both sides enabled + let (s_cold, s_hot) = (U256::from(10), U256::from(11)); + let (l_cold, l_hot) = (U256::from(20), U256::from(21)); + // Fund: short needs TAO (floor + top-up) and Alpha (repay Q); long needs + // Alpha (collateral) and TAO (repay D). + add_balance_to_coldkey_account(&s_cold, t(1000 * TAO)); + add_balance_to_coldkey_account(&l_cold, t(1000 * TAO)); + give_alpha(s_hot, s_cold, netuid, AlphaBalance::from(5000 * TAO)); + give_alpha(l_hot, l_cold, netuid, AlphaBalance::from(500 * TAO)); + + // Baseline after all seeding. + let tao0 = TotalIssuance::::get().to_u64(); + let alpha0 = alpha_issuance(netuid); + + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO))); + + // Continuous unwind on both sides. + for _ in 0..500 { + SubtensorModule::run_short_decay(); + SubtensorModule::run_long_decay(); + } + + // Mid-life owner actions. + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(s_cold), netuid, t(10 * TAO))); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 500_000_000)); // half + + // Close everything out. + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(l_cold), netuid, 1_000_000_000)); + + // CONSERVATION. + // TAO only ever *moves* between accounts (no recycle on this all-close + // path), so total TAO supply is conserved exactly. + assert_eq!(TotalIssuance::::get().to_u64(), tao0, "TAO supply not conserved"); + + // Alpha is burned/minted around the pool; fixed-point flooring means the + // restored amount is never ABOVE baseline (no value minted) and is below + // it only by bounded rounding dust. + let alpha1 = alpha_issuance(netuid); + const DUST_TOL: u64 = 1_000_000; // 0.001 Alpha; observed drift is ~5e2 rao + assert!(alpha1 <= alpha0, "Alpha was minted: {alpha1} > {alpha0}"); + assert!(alpha0 - alpha1 <= DUST_TOL, "Alpha loss {} exceeds dust tol", alpha0 - alpha1); + assert!(custody_bal(netuid) <= DUST_TOL, "short custody dust too large"); + + // Positions and counts are cleared exactly; fixed liabilities net to 0. + assert!(ShortPositions::::get(netuid, s_cold).is_none()); + assert!(LongPositions::::get(netuid, l_cold).is_none()); + assert_eq!(ShortPositionCount::::get(netuid), 0); + assert_eq!(LongPositionCount::::get(netuid), 0); + assert_eq!(ShortAggregate::::get(netuid).q_sigma.to_u64(), 0); + assert_eq!(LongAggregate::::get(netuid).d_sigma.to_u64(), 0); + }); +} + +// PROOF: default reduces issuance by EXACTLY the recycled floor — no more, no +// less — on both sides. +#[test] +fn proof_default_recycles_exactly_the_floor() { + new_test_ext(1).execute_with(|| { + // Short side: TotalIssuance (TAO) drops by exactly the floor P. + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let (s_cold, s_hot) = (U256::from(10), U256::from(11)); + add_balance_to_coldkey_account(&s_cold, t(1000 * TAO)); + SubtensorModule::set_short_default_grace(0); + SubtensorModule::set_short_dust(t(10_000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO))); + let tao_before = TotalIssuance::::get().to_u64(); + assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(U256::from(99)), s_cold, netuid)); + assert_eq!( + TotalIssuance::::get().to_u64(), + tao_before - 100 * TAO, + "short default must recycle exactly the floor" + ); + + // Long side: Alpha issuance drops by exactly the floor P. + let (l_cold, l_hot) = (U256::from(20), U256::from(21)); + give_alpha(l_hot, l_cold, netuid, AlphaBalance::from(500 * TAO)); + SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); + // Measure BEFORE open: long open burns alpha, default restores all but the + // floor, so the net effect of open+default is exactly −floor. + let alpha_before = alpha_issuance(netuid); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::default_long(RuntimeOrigin::signed(U256::from(98)), l_cold, netuid)); + assert_eq!( + alpha_issuance(netuid), + alpha_before - 100 * TAO, + "long default must recycle exactly the floor" + ); + }); +} + // Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the // per-position materialized buffer stays consistent with the aggregate. #[test] From 001991702525044828b20ddbfae3559050932c43 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:35:19 -0600 Subject: [PATCH 06/21] fix(derivatives): respect stake locks when consuming staked alpha Long open/top-up and short close decrement stake via the share pool directly, bypassing validate_remove_stake's ensure_available_to_unstake. That let locked alpha (subnet-ownership conviction lock) be used as long collateral and then freed via close, circumventing the lock. Now all three paths call ensure_available_to_unstake before decreasing stake. +1 test (50 total). Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 5 +++++ pallets/subtensor/src/derivatives/mod.rs | 2 ++ pallets/subtensor/src/tests/derivatives.rs | 23 ++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index e9e3b98fb1..eae7be4623 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -113,6 +113,10 @@ impl Pallet { >= position_input, Error::::InsufficientCollateral ); + // Respect stake locks: collateral must be unlocked alpha, exactly as a + // normal unstake requires (otherwise a long open+close would free locked + // alpha and bypass the subnet-ownership lock). + Self::ensure_available_to_unstake(&coldkey, netuid, position_input)?; Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(position_input)); Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); @@ -183,6 +187,7 @@ impl Pallet { Self::get_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid) >= amount, Error::::InsufficientCollateral ); + Self::ensure_available_to_unstake(&coldkey, netuid, amount)?; Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, amount); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(amount)); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index ccbd1eccb2..2566dfb21e 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -391,6 +391,8 @@ impl Pallet { SubnetAlphaOut::::get(netuid) >= q_close, Error::::InsufficientAlphaToClose ); + // The repayment alpha must be unlocked (respect stake locks like unstake). + Self::ensure_available_to_unstake(&coldkey, netuid, q_close)?; Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, q_close); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(q_close)); Self::increase_provided_alpha_reserve(netuid, q_close); diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 28a45f89dc..7998f35184 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1193,6 +1193,29 @@ fn dereg_settles_longs() { }); } +// Fix: long collateral must be UNLOCKED alpha — opening a long against +// locked alpha (which a normal unstake would block) is rejected, so it can't +// be used to free locked stake. +#[test] +fn open_long_respects_stake_lock() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let cold = U256::from(10); + let hot = U256::from(11); + register_ok_neuron(netuid, hot, cold, 0); + give_alpha(hot, cold, netuid, AlphaBalance::from(200 * TAO)); + + // Lock almost all the staked alpha. + assert_ok!(SubtensorModule::do_lock_stake(&cold, netuid, &hot, AlphaBalance::from(195 * TAO))); + + // A long against the locked alpha is rejected (would otherwise free it). + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(cold), hot, netuid, AlphaBalance::from(100 * TAO)), + Error::::StakeUnavailable + ); + }); +} + // Shorts and longs are independently flaggable on the same subnet. #[test] fn short_and_long_flags_are_independent() { From 1b01a700c20029723aef82fd0cf91930c04cbe45 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:45:37 -0600 Subject: [PATCH 07/21] fix(derivatives): act on thermos review (active-set, grace, kappa, docs, exp tests) - Cleanup-on-empty: when the last position on a subnet closes, drop the aggregate + active-set entry so the per-block decay tick stops visiting a fully-closed subnet forever (fixes active-set growth / perpetual O(1) tick). - Decouple long default grace from shorts: add LongDefaultGrace param + setter + admin extrinsic (110); long default no longer governed by ShortDefaultGrace. - Clamp short/long kappa setters to (0, 2.0] (governance can't freeze the market or remove the capacity guard). - Bound deregistration-settlement work: lower default max positions/side 4096 -> 128 (H1 over-weight prune-block mitigation); production should move to incremental terminal settlement before raising it. - Docs: correct stale module header (longs are built), state the custody solvency invariant honestly (consistent to within floor rounding, safe direction), and clarify the materialize unwrap_or(0) is correct decay->0. - safe-math: add checked_exp unit tests (vs f64::exp, round-trip, underflow). Suite: safe-math 11, derivatives 50; regressions green. Co-authored-by: Cursor --- pallets/admin-utils/src/lib.rs | 9 ++++ pallets/subtensor/src/derivatives/long.rs | 16 ++++++- pallets/subtensor/src/derivatives/mod.rs | 53 +++++++++++++++++----- pallets/subtensor/src/lib.rs | 14 ++++-- pallets/subtensor/src/tests/derivatives.rs | 6 ++- primitives/safe-math/src/lib.rs | 33 ++++++++++++++ 6 files changed, 114 insertions(+), 17 deletions(-) diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index c1a4285fc5..5df9680587 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2406,6 +2406,15 @@ pub mod pallet { pallet_subtensor::Pallet::::set_long_max_positions(max); Ok(()) } + + /// Set the long-side anti-snipe default grace period (in blocks). + #[pallet::call_index(110)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_long_default_grace(origin: OriginFor, blocks: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_long_default_grace(blocks); + Ok(()) + } } } diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index eae7be4623..3f140aa1b8 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -49,6 +49,15 @@ impl Pallet { pos.omega_entry = omega_now; } + /// Drop the aggregate + active-set entry once the last long position closes, + /// so the decay tick stops visiting a subnet that only holds rounding dust. + fn cleanup_long_if_empty(netuid: NetUid) { + if LongPositionCount::::get(netuid) == 0 { + LongAggregate::::remove(netuid); + LongActiveSubnets::::remove(netuid); + } + } + fn sync_active_long(netuid: NetUid, agg: &LongAgg) { if agg.r_sigma.is_zero() && agg.e_sigma.is_zero() @@ -260,6 +269,7 @@ impl Pallet { if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { LongPositions::::remove(netuid, &coldkey); LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_long_if_empty(netuid); } else { LongPositions::::insert(netuid, &coldkey, pos); } @@ -292,7 +302,7 @@ impl Pallet { ); ensure!( Self::get_current_block_as_u64() - >= pos.last_active.saturating_add(ShortDefaultGrace::::get()), + >= pos.last_active.saturating_add(LongDefaultGrace::::get()), Error::::PositionNotDefaultEligible ); @@ -307,6 +317,7 @@ impl Pallet { LongAggregate::::insert(netuid, agg); LongPositions::::remove(netuid, &coldkey); LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_long_if_empty(netuid); Self::deposit_event(Event::LongDefaulted { coldkey, netuid }); Ok(()) @@ -386,7 +397,8 @@ impl Pallet { // ---- governance setters -------------------------------------------- pub fn set_long_kappa_ppb(kappa_ppb: u64) { - LongKappa::::put(I64F64::from_num(kappa_ppb).safe_div(I64F64::from_num(1_000_000_000u64))); + let k = kappa_ppb.clamp(1, 2_000_000_000); + LongKappa::::put(I64F64::from_num(k).safe_div(I64F64::from_num(1_000_000_000u64))); } pub fn set_long_base_ltv_ppb(ltv_ppb: u64) { let ltv = ltv_ppb.clamp(1, 999_999_999); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 2566dfb21e..4da960091f 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -1,10 +1,21 @@ //! Fixed-liability covered continuous-unwind derivatives (spec v3.6.1). //! -//! Launch scope is shorts (longs are gated by `LongsEnabled` and not yet built). -//! Pool impact is realized as `SubnetTAO` mutations plus a dedicated per-subnet -//! custody account that holds parked floor/buffer/escrow TAO, so pool reserves, -//! `TotalStake`, and issuance stay consistent and derivative legs never write -//! TaoFlow. +//! Both sides are implemented and independently gated (`ShortsEnabled` / +//! `LongsEnabled`, both default-off). Shorts live here; the long mirror is in +//! `long.rs`. The client/RPC read layer (`quote_*`, `get_*`) currently exists +//! for shorts only — long RPC parity is a tracked follow-up. +//! +//! Custody model. Shorts park floor/buffer/escrow TAO in a dedicated per-subnet +//! custody account; longs have no custody account and instead track parked Alpha +//! via issuance accounting (burned at open, minted back on restore/close). Pool +//! reserves, `TotalStake`, and issuance move in lockstep and derivative legs +//! never write TaoFlow. +//! +//! Custody solvency invariant. `custody_balance(netuid)` (shorts) and the burned +//! Alpha (longs) equal `Σ materialized (P + R(t) + E(t))` **to within per-block +//! floor rounding**. The aggregate Σ-decay floors faster than the per-position +//! `exp` decay, so the drift is always in the safe direction (custody ≥ +//! obligations); residual dust is reclaimed by the terminal sweep at dereg. use super::*; use frame_support::traits::tokens::{Fortitude, Precision, Preservation, fungible::Balanced}; @@ -189,7 +200,8 @@ impl Pallet { /// Computed directly from the series rather than `checked_ln(1 − δ)`, which /// is imprecise (and can return the wrong sign) for arguments just below 1. /// This keeps the aggregate factor `g = 1 − δ` and the per-position factor - /// `exp(−ΔΩ) = Π g` exactly consistent. + /// `exp(−ΔΩ) = Π g` consistent to within per-block floor rounding (the + /// 3-term series and `checked_exp`'s 7-term series are both truncations). fn neg_ln_one_minus(delta: I64F64) -> I64F64 { let d2 = delta.saturating_mul(delta); let d3 = d2.saturating_mul(delta); @@ -198,11 +210,23 @@ impl Pallet { .saturating_add(d3.saturating_mul(I64F64::from_num(1.0 / 3.0))) } + /// When the last position on a subnet closes, drop the aggregate and the + /// active-set entry so the per-block decay tick stops visiting it (otherwise + /// floor-rounding dust in `r_sigma` keeps the subnet "active" forever). Any + /// residual custody dust is reclaimed by the terminal sweep at dereg. + fn cleanup_short_if_empty(netuid: NetUid) { + if ShortPositionCount::::get(netuid) == 0 { + ShortAggregate::::remove(netuid); + ShortActiveSubnets::::remove(netuid); + } + } + /// Materialize a position to the current accumulator: `f = exp(−(Ω − Ω_entry))`. fn materialize_short(pos: &mut ShortPosition, omega_now: I64F64) { - // `Ω` only ever grows, so `arg ≤ 0` and `f ≤ 1`. Clamp defensively: a - // positive `arg` (which should be impossible) must never inflate a - // position by producing `f > 1`. + // `Ω` only ever grows, so `arg ≤ 0` and `f ≤ 1` (decay never inflates). + // The `unwrap_or(0)` below is correct, not a silent failure: a large + // negative `arg` legitimately decays the buffer toward 0. Clamp `arg ≤ 0` + // defensively so an (impossible) positive `arg` can't yield `f > 1`. let arg = pos .omega_entry .saturating_sub(omega_now) @@ -427,6 +451,7 @@ impl Pallet { if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { ShortPositions::::remove(netuid, &coldkey); ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_short_if_empty(netuid); } else { ShortPositions::::insert(netuid, &coldkey, pos); } @@ -483,6 +508,7 @@ impl Pallet { ShortAggregate::::insert(netuid, agg); ShortPositions::::remove(netuid, &coldkey); ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_short_if_empty(netuid); Self::deposit_event(Event::ShortDefaulted { coldkey, netuid }); Ok(()) @@ -609,9 +635,11 @@ impl Pallet { pub fn set_longs_enabled(enabled: bool) { LongsEnabled::::put(enabled); } - /// `κ_S`, supplied scaled by 1e9. + /// `κ_S`, supplied scaled by 1e9. Clamped to `(0, 2.0]` so governance can't + /// freeze the market (`κ=0`) or remove the capacity guard entirely. pub fn set_short_kappa_ppb(kappa_ppb: u64) { - ShortKappa::::put(I64F64::from_num(kappa_ppb).safe_div(I64F64::from_num(1_000_000_000u64))); + let k = kappa_ppb.clamp(1, 2_000_000_000); + ShortKappa::::put(I64F64::from_num(k).safe_div(I64F64::from_num(1_000_000_000u64))); } /// `λ`, supplied scaled by 1e9. Clamped to `(0, 1)` so the open quadratic /// stays well-formed. @@ -635,6 +663,9 @@ impl Pallet { pub fn set_short_default_grace(blocks: u64) { ShortDefaultGrace::::put(blocks); } + pub fn set_long_default_grace(blocks: u64) { + LongDefaultGrace::::put(blocks); + } pub fn set_short_min_input(min_input: TaoBalance) { ShortMinInput::::put(min_input); } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 6b34a2acfe..6af2e1b666 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1427,10 +1427,12 @@ pub mod pallet { TaoBalance::from(100_000_000u64) } #[pallet::type_value] - /// Max open short positions per subnet, bounding deregistration settlement - /// work so a heavily-shorted subnet stays prunable. + /// Max open positions per subnet per side. Bounds deregistration-settlement + /// work so a heavily-traded subnet stays prunable within block weight. + /// Kept conservative; production should move to incremental/paginated + /// terminal settlement before raising it materially. pub fn DefaultShortMaxPositions() -> u32 { - 4096 + 128 } #[pallet::type_value] /// Empty short-side aggregate. @@ -1561,6 +1563,12 @@ pub mod pallet { pub type LongMaxPositions = StorageValue<_, u32, ValueQuery, DefaultShortMaxPositions>; + /// Long-side anti-snipe default grace period, in blocks (independent of the + /// short grace so the two sides can be tuned separately). + #[pallet::storage] + pub type LongDefaultGrace = + StorageValue<_, u64, ValueQuery, DefaultShortDefaultGrace>; + /// --- MAP ( netuid ) --> long-side aggregate + decay accumulator. #[pallet::storage] pub type LongAggregate = StorageMap< diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 7998f35184..f151489818 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -979,6 +979,9 @@ fn proof_full_lifecycle_conserves_tao_and_alpha() { assert_eq!(LongPositionCount::::get(netuid), 0); assert_eq!(ShortAggregate::::get(netuid).q_sigma.to_u64(), 0); assert_eq!(LongAggregate::::get(netuid).d_sigma.to_u64(), 0); + // cleanup-on-empty evicts fully-closed subnets from the decay tick. + assert!(!ShortActiveSubnets::::contains_key(netuid)); + assert!(!LongActiveSubnets::::contains_key(netuid)); }); } @@ -1006,6 +1009,7 @@ fn proof_default_recycles_exactly_the_floor() { let (l_cold, l_hot) = (U256::from(20), U256::from(21)); give_alpha(l_hot, l_cold, netuid, AlphaBalance::from(500 * TAO)); SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); + SubtensorModule::set_long_default_grace(0); // Measure BEFORE open: long open burns alpha, default restores all but the // floor, so the net effect of open+default is exactly −floor. let alpha_before = alpha_issuance(netuid); @@ -1163,7 +1167,7 @@ fn long_default_recycles_floor_and_restores_residual() { let pos = LongPositions::::get(netuid, trader).unwrap(); let (p, n, e) = (pos.p_floor.to_u64(), pos.r_stored.to_u64(), pos.e_stored.to_u64()); SubtensorModule::set_long_dust(AlphaBalance::from(1000 * TAO)); - SubtensorModule::set_short_default_grace(0); + SubtensorModule::set_long_default_grace(0); let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); let iss0 = alpha_issuance(netuid); diff --git a/primitives/safe-math/src/lib.rs b/primitives/safe-math/src/lib.rs index 77e8898f17..eb9b01049a 100644 --- a/primitives/safe-math/src/lib.rs +++ b/primitives/safe-math/src/lib.rs @@ -374,6 +374,39 @@ mod tests { assert!(I64F64::from_num(0.0).checked_ln().is_none()); } + #[test] + fn test_checked_exp() { + // exp(0) == 1 + assert_eq!(I64F64::from_num(0).checked_exp(), Some(I64F64::from_num(1))); + + // exp(1) ≈ e + assert!( + I64F64::from_num(1) + .checked_exp() + .unwrap() + .abs_diff(I64F64::from_num(core::f64::consts::E)) + < I64F64::from_num(0.0001) + ); + + // Direct accuracy vs f64::exp across positive and negative args. + for x in [-5.0_f64, -1.0, 0.5, 2.0, 5.0] { + let got = I64F64::from_num(x).checked_exp().unwrap(); + let want = x.exp(); + assert!( + got.abs_diff(I64F64::from_num(want)) < I64F64::from_num(want * 0.0001 + 0.0001), + "exp({x}) = {got}, want {want}" + ); + } + + // Negative argument: 0 < exp(x) <= 1, and exp(-1) ≈ 1/e. + let neg = I64F64::from_num(-1).checked_exp().unwrap(); + assert!(neg > I64F64::from_num(0) && neg <= I64F64::from_num(1)); + assert!(neg.abs_diff(I64F64::from_num(1.0 / core::f64::consts::E)) < I64F64::from_num(0.0001)); + + // Large negative argument underflows toward 0 without panicking. + assert!(I64F64::from_num(-50).checked_exp().unwrap() < I64F64::from_num(0.0001)); + } + #[test] fn test_checked_log() { let x = I64F64::from_num(10.0); From f7bcb2887349942e9c5c791974cb18aaa252e8da Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:49:44 -0600 Subject: [PATCH 08/21] test(derivatives): cover the review-flagged gaps (8 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proof_multi_position_decay_conserves: 3 shorts + 2 longs, 300 decay blocks, close all — TAO supply exact, Alpha within dust, custody drained, active sets evicted. (The aggregate-vs-Σ solvency case single-position tests can't reach.) - short_many_partial_closes_drain_cleanly: 9x partial + full close drains custody. - governance_setters_clamp_ranges: kappa (0/huge) and decay-bound clamps. - cleanup_evicts_only_after_last_short_closes: active-set eviction timing. - long_capacity_cap_enforced, long_partial_close_reduces_prorata. - long_dereg_underwater_pays_zero_equity: terminal cover=C, equity=0. - default_grace_independent_per_side: short/long grace decoupled. Suite now 58 passing. Co-authored-by: Cursor --- pallets/subtensor/src/tests/derivatives.rs | 219 +++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index f151489818..2ea0dc51e1 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1023,6 +1023,225 @@ fn proof_default_recycles_exactly_the_floor() { }); } +// PROOF (multi-position): the aggregate Σ-decay and per-position lazy decay +// stay solvent across MANY heterogeneous positions on both sides through a long +// decay horizon — the configuration the single-position tests can't exercise. +#[test] +fn proof_multi_position_decay_conserves() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(10_000 * TAO, 10_000 * TAO, 1.0); + let shorts: [(U256, U256, u64); 3] = [ + (U256::from(10), U256::from(11), 50 * TAO), + (U256::from(12), U256::from(13), 100 * TAO), + (U256::from(14), U256::from(15), 30 * TAO), + ]; + let longs: [(U256, U256, u64); 2] = [ + (U256::from(20), U256::from(21), 40 * TAO), + (U256::from(22), U256::from(23), 60 * TAO), + ]; + for (c, h, _) in shorts { + add_balance_to_coldkey_account(&c, t(2000 * TAO)); + give_alpha(h, c, netuid, AlphaBalance::from(5000 * TAO)); // to repay Q + } + for (c, h, _) in longs { + add_balance_to_coldkey_account(&c, t(2000 * TAO)); // to repay D + give_alpha(h, c, netuid, AlphaBalance::from(1000 * TAO)); // collateral + } + + let tao0 = TotalIssuance::::get().to_u64(); + let alpha0 = alpha_issuance(netuid); + + for (c, h, p) in shorts { + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), h, netuid, t(p))); + } + for (c, h, p) in longs { + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(c), h, netuid, AlphaBalance::from(p))); + } + + for _ in 0..300 { + SubtensorModule::run_short_decay(); + SubtensorModule::run_long_decay(); + } + + for (c, _, _) in shorts { + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(c), netuid, 1_000_000_000)); + } + for (c, _, _) in longs { + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(c), netuid, 1_000_000_000)); + } + + const TOL: u64 = 10_000_000; // 0.01 token + assert_eq!(TotalIssuance::::get().to_u64(), tao0, "TAO supply not conserved"); + let alpha1 = alpha_issuance(netuid); + assert!(alpha1 <= alpha0, "Alpha minted across many positions"); + assert!(alpha0 - alpha1 <= TOL, "Alpha drift {} > tol", alpha0 - alpha1); + assert!(custody_bal(netuid) <= TOL, "custody not drained across many positions"); + assert_eq!(ShortPositionCount::::get(netuid), 0); + assert_eq!(LongPositionCount::::get(netuid), 0); + assert!(!ShortActiveSubnets::::contains_key(netuid)); + assert!(!LongActiveSubnets::::contains_key(netuid)); + }); +} + +// Many partial closes followed by a full close drain the position cleanly (the +// floor-rounding residue path), with TAO conserved and custody emptied. +#[test] +fn short_many_partial_closes_drain_cleanly() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(5000 * TAO)); + + let tao0 = TotalIssuance::::get().to_u64(); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + for _ in 0..9 { + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 100_000_000)); // 10% of remaining + } + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + + assert!(ShortPositions::::get(netuid, trader).is_none()); + assert_eq!(TotalIssuance::::get().to_u64(), tao0); + assert!(custody_bal(netuid) <= 10_000, "custody dust after partial closes"); + assert!(!ShortActiveSubnets::::contains_key(netuid)); + }); +} + +// Governance setters clamp out-of-range inputs (kappa can't freeze the market +// or remove the cap; decay bounds stay ordered and ≤ 1.0/day). +#[test] +fn governance_setters_clamp_ranges() { + new_test_ext(1).execute_with(|| { + let one = I64F64::from_num(1); + let two = I64F64::from_num(2); + + SubtensorModule::set_short_kappa_ppb(0); + assert!(ShortKappa::::get() > I64F64::from_num(0), "kappa=0 must clamp above 0"); + SubtensorModule::set_short_kappa_ppb(10_000_000_000); // 10.0 + assert_eq!(ShortKappa::::get(), two, "kappa clamps to 2.0"); + SubtensorModule::set_long_kappa_ppb(0); + assert!(LongKappa::::get() > I64F64::from_num(0)); + + // min > max → enforced min ≤ max. + SubtensorModule::set_decay_bounds_ppb(500_000_000, 100_000_000); + assert!(DecayMax::::get() >= DecayMin::::get()); + // max > 1.0/day → clamped so per-block delta stays < 1. + SubtensorModule::set_decay_bounds_ppb(0, 5_000_000_000); + assert!(DecayMax::::get() <= one, "decay max clamps to 1.0/day"); + }); +} + +// Cleanup-on-empty only evicts a subnet from the decay tick once its LAST +// position closes — not while others remain. +#[test] +fn cleanup_evicts_only_after_last_short_closes() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + let (a, b) = (U256::from(10), U256::from(20)); + for k in [a, b] { + add_balance_to_coldkey_account(&k, t(1000 * TAO)); + } + give_alpha(U256::from(11), a, netuid, AlphaBalance::from(5000 * TAO)); + give_alpha(U256::from(21), b, netuid, AlphaBalance::from(5000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO))); + + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000)); + assert!(ShortActiveSubnets::::contains_key(netuid), "still active while b open"); + + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(b), netuid, 1_000_000_000)); + assert!(!ShortActiveSubnets::::contains_key(netuid), "evicted after last close"); + }); +} + +// Long capacity cap is enforced. +#[test] +fn long_capacity_cap_enforced() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + SubtensorModule::set_long_kappa_ppb(1_000_000); // κ_L = 0.001 + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + Error::::LongCapacityExceeded + ); + }); +} + +// Long partial close reduces all legs pro-rata. +#[test] +fn long_partial_close_reduces_prorata() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let p0 = LongPositions::::get(netuid, trader).unwrap(); + + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 500_000_000)); + let p1 = LongPositions::::get(netuid, trader).unwrap(); + assert_approx(p1.p_floor.to_u64(), p0.p_floor.to_u64() / 2, 2, "p/2"); + assert_approx(p1.d_liability.to_u64(), p0.d_liability.to_u64() / 2, 2, "d/2"); + assert_approx(p1.r_stored.to_u64(), p0.r_stored.to_u64() / 2, 2, "r/2"); + }); +} + +// Long terminal settlement is underwater (equity 0) when the collateral can't +// cover the TAO debt at the terminal price. +#[test] +fn long_dereg_underwater_pays_zero_equity() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + + // Crash the price: D/price ≫ collateral ⇒ cover = C_L, equity = 0. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(0.0001)); + let stake_before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid).to_u64(); + + SubtensorModule::settle_longs_on_dereg(netuid); + + assert!(LongPositions::::get(netuid, trader).is_none()); + let stake_after = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid).to_u64(); + assert_eq!(stake_after, stake_before, "underwater long must return no equity"); + assert!(!LongActiveSubnets::::contains_key(netuid)); + }); +} + +// Short and long default-grace windows are governed independently. +#[test] +fn default_grace_independent_per_side() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let (sc, sh) = (U256::from(10), U256::from(11)); + let (lc, lh) = (U256::from(20), U256::from(21)); + add_balance_to_coldkey_account(&sc, t(1000 * TAO)); + give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sc), sh, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); + + SubtensorModule::set_short_dust(t(10_000 * TAO)); + SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); + SubtensorModule::set_short_default_grace(0); // shorts: no grace + SubtensorModule::set_long_default_grace(5); // longs: still gated + + let poker = U256::from(99); + // Short is immediately defaultable; long is not (independent grace). + assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(poker), sc, netuid)); + assert_noop!( + SubtensorModule::default_long(RuntimeOrigin::signed(poker), lc, netuid), + Error::::PositionNotDefaultEligible + ); + }); +} + // Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the // per-position materialized buffer stays consistent with the aggregate. #[test] From 4d46b84a3ea1ed63c003f4986647b4aab6d62042 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 17 Jun 2026 18:53:21 -0600 Subject: [PATCH 09/21] harden(derivatives): symmetric SubnetAlphaOut guard on long open/top-up Final thermos pass (no Critical/High): adds the SubnetAlphaOut >= amount guard to long open/top-up, mirroring the short-close guard, so a share-pool rounding edge can't under-decrement outstanding alpha and let close mint it back. +4 long-side tests (alpha-mint guard, top-up, merge mismatch + position cap, invalid fraction/min-input). Suite now 62 passing. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 10 +++ pallets/subtensor/src/tests/derivatives.rs | 90 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 3f140aa1b8..d1a1910425 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -126,6 +126,12 @@ impl Pallet { // normal unstake requires (otherwise a long open+close would free locked // alpha and bypass the subnet-ownership lock). Self::ensure_available_to_unstake(&coldkey, netuid, position_input)?; + // Symmetric to the short-close guard: never `saturating_sub` below the + // collateral, which would later let close mint alpha back unbacked. + ensure!( + SubnetAlphaOut::::get(netuid) >= position_input, + Error::::InsufficientCollateral + ); Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(position_input)); Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); @@ -197,6 +203,10 @@ impl Pallet { Error::::InsufficientCollateral ); Self::ensure_available_to_unstake(&coldkey, netuid, amount)?; + ensure!( + SubnetAlphaOut::::get(netuid) >= amount, + Error::::InsufficientCollateral + ); Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, amount); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(amount)); diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 2ea0dc51e1..46900ced18 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1215,6 +1215,96 @@ fn long_dereg_underwater_pays_zero_equity() { }); } +// Fix (L1): long open won't mint alpha by saturating SubnetAlphaOut to zero. +#[test] +fn open_long_guards_against_alpha_mint() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + // Corrupt outstanding alpha below the collateral; open must refuse. + SubnetAlphaOut::::insert(netuid, AlphaBalance::from(0)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + Error::::InsufficientCollateral + ); + }); +} + +// Long top-up adds Alpha buffer (from stake) and resets the grace clock. +#[test] +fn long_top_up_adds_buffer_and_resets_grace() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let r0 = LongPositions::::get(netuid, trader).unwrap().r_stored; + let stake0 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid); + + assert_ok!(SubtensorModule::top_up_long(RuntimeOrigin::signed(trader), netuid, AlphaBalance::from(10 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + assert_eq!(pos.r_stored, r0 + AlphaBalance::from(10 * TAO)); + assert_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid), + stake0 - AlphaBalance::from(10 * TAO) + ); + }); +} + +// Long merge must target the same hotkey; long position cap is enforced. +#[test] +fn long_merge_mismatch_and_position_cap() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let a = U256::from(10); + give_alpha(U256::from(11), a, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(11), netuid, AlphaBalance::from(20 * TAO))); + // Same coldkey, different hotkey → rejected. + give_alpha(U256::from(12), a, netuid, AlphaBalance::from(100 * TAO)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(12), netuid, AlphaBalance::from(20 * TAO)), + Error::::LongHotkeyMismatch + ); + + // Position cap: with max=1, a second distinct coldkey is rejected. + SubtensorModule::set_long_max_positions(1); + let b = U256::from(20); + give_alpha(U256::from(21), b, netuid, AlphaBalance::from(100 * TAO)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(b), U256::from(21), netuid, AlphaBalance::from(20 * TAO)), + Error::::LongPositionLimit + ); + }); +} + +// Long close rejects invalid fractions and below-min-input opens. +#[test] +fn long_close_invalid_fraction_and_min_input() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + SubtensorModule::set_long_min_input(AlphaBalance::from(TAO)); + assert_noop!( + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(TAO / 2)), + Error::::AmountTooLow + ); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_noop!( + SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 0), + Error::::InvalidCloseFraction + ); + assert_noop!( + SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_001), + Error::::InvalidCloseFraction + ); + }); +} + // Short and long default-grace windows are governed independently. #[test] fn default_grace_independent_per_side() { From 32ffea6dc51993991748837382ae275dd976f591 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 09:15:56 -0600 Subject: [PATCH 10/21] harden(derivatives): non-panicking conversions + clamp max-positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a 4-agent adversarial review (no free-money / no P0 found): - tao_f/alpha_f and the pEMA conversions use saturating_from_num (they run in the non-transactional on_initialize decay path; a from_num overflow panic would halt consensus — not reachable under supply caps, but defense-in-depth). - set_short/long_max_positions clamped to [1, 4096] so governance can't lift the deregistration-settlement blast radius to a chain-halting size. Suite 62 passing. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 7 ++++--- pallets/subtensor/src/derivatives/mod.rs | 16 +++++++++++----- pallets/subtensor/src/tests/derivatives.rs | 8 ++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index d1a1910425..57230e1144 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -24,7 +24,7 @@ impl Pallet { fn long_a_ref(netuid: NetUid) -> I64F64 { let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); let t_live = Self::tao_f(SubnetTAO::::get(netuid)); - let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); if pema <= I64F64::from_num(0) { return a_live; } @@ -370,7 +370,7 @@ impl Pallet { /// debt stays burned (recycled); the equity remainder returns as stake. pub fn settle_longs_on_dereg(netuid: NetUid) { let agg = LongAggregate::::get(netuid); - let price = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let price = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); let positions: Vec<(T::AccountId, LongPosition)> = LongPositions::::iter_prefix(netuid).collect(); for (coldkey, mut pos) in positions { @@ -420,7 +420,8 @@ impl Pallet { pub fn set_long_min_input(min_input: AlphaBalance) { LongMinInput::::put(min_input); } + /// Clamped to `[1, 4096]` (see `set_short_max_positions`). pub fn set_long_max_positions(max: u32) { - LongMaxPositions::::put(max); + LongMaxPositions::::put(max.clamp(1, 4096)); } } diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 4da960091f..d5f1c89224 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -39,10 +39,13 @@ impl Pallet { // ---- conversions ---------------------------------------------------- fn tao_f(t: TaoBalance) -> I64F64 { - I64F64::from_num(t.to_u64()) + // `saturating_from_num`, not `from_num`: these run in the non-transactional + // `on_initialize` decay path, so a panic would halt consensus. Saturating + // is safe (balances are supply-capped well below I64F64's range). + I64F64::saturating_from_num(t.to_u64()) } fn alpha_f(a: AlphaBalance) -> I64F64 { - I64F64::from_num(a.to_u64()) + I64F64::saturating_from_num(a.to_u64()) } fn to_tao(x: I64F64) -> TaoBalance { TaoBalance::from(x.max(I64F64::from_num(0)).saturating_to_num::()) @@ -92,7 +95,7 @@ impl Pallet { fn short_t_ref(netuid: NetUid) -> I64F64 { let t_live = Self::tao_f(SubnetTAO::::get(netuid)); let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); - let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); let t_ema = pema.saturating_mul(a_live); // A cold price EMA (`pema == 0`, e.g. a freshly created subnet) must not // lock the market; fall back to the live reserve until it warms up. @@ -565,7 +568,7 @@ impl Pallet { /// pool is drained so restored escrow joins the terminal distribution. pub fn settle_shorts_on_dereg(netuid: NetUid) { let agg = ShortAggregate::::get(netuid); - let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); let custody = Self::short_custody_account(netuid); let subnet_account = match Self::get_subnet_account_id(netuid) { Some(a) => a, @@ -669,8 +672,11 @@ impl Pallet { pub fn set_short_min_input(min_input: TaoBalance) { ShortMinInput::::put(min_input); } + /// Clamped to `[1, 4096]` so governance can't lift the dereg-settlement + /// blast radius to a chain-halting size (terminal settlement is O(positions) + /// in a single block until incremental settlement lands). pub fn set_short_max_positions(max: u32) { - ShortMaxPositions::::put(max); + ShortMaxPositions::::put(max.clamp(1, 4096)); } // ---- read-only quote (spec §1.2) ----------------------------------- diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 46900ced18..de9f32efba 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1129,6 +1129,14 @@ fn governance_setters_clamp_ranges() { // max > 1.0/day → clamped so per-block delta stays < 1. SubtensorModule::set_decay_bounds_ppb(0, 5_000_000_000); assert!(DecayMax::::get() <= one, "decay max clamps to 1.0/day"); + + // Max-positions clamped so root can't lift the dereg blast radius. + SubtensorModule::set_short_max_positions(u32::MAX); + assert_eq!(ShortMaxPositions::::get(), 4096); + SubtensorModule::set_short_max_positions(0); + assert_eq!(ShortMaxPositions::::get(), 1); + SubtensorModule::set_long_max_positions(u32::MAX); + assert_eq!(LongMaxPositions::::get(), 4096); }); } From dc600b3bb2ea087a656b201259b2963336a03ffb Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 09:39:15 -0600 Subject: [PATCH 11/21] feat(derivatives): long-side RPC/view parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the short read layer for longs so the two sides are equivalent to any client (the UI-symmetry goal — internal custody plumbing stays as-is): - Types: LongOpenQuote, LongPositionInfo, LongMarketInfo, CloseLongQuote (Alpha collateral / TAO liability denominations). - Pallet views: quote_open_long, get_long_position(s), get_subnet_long_state, quote_close_long, long_blocks_to_dust. - Runtime API: 5 new DerivativesRuntimeApi methods + runtime impl. - 6 mirror tests (quote==position, enable gating, decay materialization, market capacity, close quote, cross-subnet listing). Suite now 68. Co-authored-by: Cursor --- pallets/subtensor/runtime-api/src/lib.rs | 9 +- pallets/subtensor/src/derivatives/long.rs | 151 +++++++++++++++++++++ pallets/subtensor/src/derivatives/mod.rs | 4 +- pallets/subtensor/src/derivatives/types.rs | 93 +++++++++++++ pallets/subtensor/src/tests/derivatives.rs | 126 +++++++++++++++++ runtime/src/lib.rs | 34 +++++ 6 files changed, 414 insertions(+), 3 deletions(-) diff --git a/pallets/subtensor/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 7151f40f29..907494e529 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -15,7 +15,8 @@ use pallet_subtensor::rpc_info::{ }, }; use pallet_subtensor::derivatives::{ - CloseShortQuote, ShortMarketInfo, ShortOpenQuote, ShortPositionInfo, + CloseLongQuote, CloseShortQuote, LongMarketInfo, LongOpenQuote, LongPositionInfo, + ShortMarketInfo, ShortOpenQuote, ShortPositionInfo, }; use pallet_subtensor::staking::lock::LockState; use sp_runtime::AccountId32; @@ -91,5 +92,11 @@ sp_api::decl_runtime_apis! { fn get_short_position(coldkey: AccountId32, netuid: NetUid) -> Option>; fn get_short_positions(coldkey: AccountId32) -> Vec>; fn get_subnet_short_state(netuid: NetUid) -> Option; + + fn quote_open_long(netuid: NetUid, position_input: AlphaBalance) -> Option; + fn quote_close_long(coldkey: AccountId32, netuid: NetUid, fraction_ppb: u64) -> Option; + fn get_long_position(coldkey: AccountId32, netuid: NetUid) -> Option>; + fn get_long_positions(coldkey: AccountId32) -> Vec>; + fn get_subnet_long_state(netuid: NetUid) -> Option; } } diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 57230e1144..438362d135 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -424,4 +424,155 @@ impl Pallet { pub fn set_long_max_positions(max: u32) { LongMaxPositions::::put(max.clamp(1, 4096)); } + + // ---- read-only views (mirror of the short read layer) -------------- + + /// Estimated blocks until `r_current` (Alpha) decays to dust at the current + /// rate. `u64::MAX` when decay is effectively zero. + fn long_blocks_to_dust(netuid: NetUid, r_current: AlphaBalance, b_sigma: AlphaBalance) -> u64 { + let dust = LongDust::::get(); + if r_current <= dust || dust.is_zero() { + return if r_current <= dust { 0 } else { u64::MAX }; + } + let delta = Self::long_daily_decay(netuid, b_sigma).safe_div(I64F64::from_num(BLOCKS_PER_DAY)); + if delta <= I64F64::from_num(0) { + return u64::MAX; + } + let neg_ln_g = Self::neg_ln_one_minus(delta); + if neg_ln_g <= I64F64::from_num(0) { + return u64::MAX; + } + let ratio = Self::alpha_f(r_current).safe_div(Self::alpha_f(dust)); + match ratio.checked_ln() { + Some(ln_ratio) if ln_ratio > I64F64::from_num(0) => { + ln_ratio.safe_div(neg_ln_g).saturating_to_num::() + } + _ => 0, + } + } + + /// Pure pre-open long quote. `None` when longs are disabled or the subnet is + /// not a dynamic market. + pub fn quote_open_long(netuid: NetUid, position_input: AlphaBalance) -> Option { + if !LongsEnabled::::get() || SubnetMechanism::::get(netuid) != 1 { + return None; + } + let agg = LongAggregate::::get(netuid); + let a_ref = Self::long_a_ref(netuid); + let p = Self::alpha_f(position_input); + let (c, n) = + Self::solve_collateral(p, a_ref, Self::alpha_f(agg.b_sigma), LongBaseLtv::::get())?; + let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let t_live = Self::tao_f(SubnetTAO::::get(netuid)); + let phi = Self::solve_phi(n, a_live)?; + + let scale = I64F64::from_num(1_000_000_000u64); + let effective_ltv = n.safe_div(c).saturating_mul(scale).saturating_to_num::(); + let daily_decay = Self::long_daily_decay(netuid, agg.b_sigma) + .saturating_mul(scale) + .saturating_to_num::(); + Some(LongOpenQuote { + gross_collateral: Self::to_alpha(c), + retained_proceeds: Self::to_alpha(n), + tao_liability: Self::to_tao(phi.saturating_mul(t_live)), + escrow: Self::to_alpha(phi.saturating_mul(a_live)), + effective_ltv, + daily_decay, + }) + } + + /// Materialized, health-rich view of one long position. + pub fn get_long_position( + coldkey: &T::AccountId, + netuid: NetUid, + ) -> Option> { + let mut pos = LongPositions::::get(netuid, coldkey)?; + let agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + + let scale = I64F64::from_num(1_000_000_000u64); + let daily_decay = Self::long_daily_decay(netuid, agg.b_sigma) + .saturating_mul(scale) + .saturating_to_num::(); + let now = Self::get_current_block_as_u64(); + let defaultable_at_block = pos.last_active.saturating_add(LongDefaultGrace::::get()); + let default_eligible = pos.r_stored <= LongDust::::get() && now >= defaultable_at_block; + + Some(LongPositionInfo { + netuid, + hotkey: pos.hotkey.clone(), + floor: pos.p_floor, + tao_liability: pos.d_liability, + buffer: pos.r_stored, + escrow: pos.e_stored, + collateral_claim: pos.p_floor.saturating_add(pos.r_stored), + daily_decay, + blocks_to_dust: Self::long_blocks_to_dust(netuid, pos.r_stored, agg.b_sigma), + default_eligible, + defaultable_at_block, + tao_to_close: pos.d_liability, + }) + } + + /// All of a coldkey's long positions across subnets. + pub fn get_long_positions(coldkey: &T::AccountId) -> Vec> { + Self::get_all_subnet_netuids() + .into_iter() + .filter_map(|netuid| Self::get_long_position(coldkey, netuid)) + .collect() + } + + /// Per-subnet long market state for sizing and capacity decisions. + pub fn get_subnet_long_state(netuid: NetUid) -> Option { + if !Self::if_subnet_exist(netuid) { + return None; + } + let agg = LongAggregate::::get(netuid); + let a_ref = Self::long_a_ref(netuid); + let cap = LongKappa::::get().saturating_mul(a_ref); + let used = Self::alpha_f(agg.b_sigma); + let scale = I64F64::from_num(1_000_000_000u64); + let ppb = |x: I64F64| x.saturating_mul(scale).saturating_to_num::(); + + Some(LongMarketInfo { + longs_enabled: LongsEnabled::::get(), + base_ltv: ppb(LongBaseLtv::::get()), + kappa: ppb(LongKappa::::get()), + decay_min: ppb(DecayMin::::get()), + decay_max: ppb(DecayMax::::get()), + current_daily_decay: ppb(Self::long_daily_decay(netuid, agg.b_sigma)), + a_ref: Self::to_alpha(a_ref), + footprint_used: agg.b_sigma, + footprint_cap: Self::to_alpha(cap), + footprint_remaining: Self::to_alpha(cap.saturating_sub(used)), + open_interest_tao: agg.d_sigma, + buffer_total: agg.r_sigma, + escrow_total: agg.e_sigma, + dust_threshold: LongDust::::get(), + min_input: LongMinInput::::get(), + default_grace: LongDefaultGrace::::get(), + }) + } + + /// Pre-close quote for `fraction_ppb / 1e9` of a long position. + pub fn quote_close_long( + coldkey: &T::AccountId, + netuid: NetUid, + fraction_ppb: u64, + ) -> Option { + if fraction_ppb == 0 || fraction_ppb > 1_000_000_000 { + return None; + } + let mut pos = LongPositions::::get(netuid, coldkey)?; + let agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + + Some(CloseLongQuote { + repay_tao: Self::mul_tao(pos.d_liability, rho), + returned_alpha: Self::mul_alpha(pos.p_floor, rho) + .saturating_add(Self::mul_alpha(pos.r_stored, rho)), + escrow_settled: Self::mul_alpha(pos.e_stored, rho), + }) + } } diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index d5f1c89224..e0e3d670ec 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -2,8 +2,8 @@ //! //! Both sides are implemented and independently gated (`ShortsEnabled` / //! `LongsEnabled`, both default-off). Shorts live here; the long mirror is in -//! `long.rs`. The client/RPC read layer (`quote_*`, `get_*`) currently exists -//! for shorts only — long RPC parity is a tracked follow-up. +//! `long.rs`. Both sides expose a symmetric client/RPC read layer (`quote_*`, +//! `get_*` via `DerivativesRuntimeApi`), so the two are equivalent to clients. //! //! Custody model. Shorts park floor/buffer/escrow TAO in a dedicated per-subnet //! custody account; longs have no custody account and instead track parked Alpha diff --git a/pallets/subtensor/src/derivatives/types.rs b/pallets/subtensor/src/derivatives/types.rs index 72e833f17b..7c6f7e0c24 100644 --- a/pallets/subtensor/src/derivatives/types.rs +++ b/pallets/subtensor/src/derivatives/types.rs @@ -219,3 +219,96 @@ pub struct CloseShortQuote { /// Incremental alpha still to acquire (`max(0, repay_alpha − held)`). pub alpha_needed: AlphaBalance, } + +// ===== Long-side read DTOs (mirror; Alpha collateral, TAO liability) ===== + +/// Pre-open long quote (spec §1.2 mirror). Pure derivation, no state change. +#[freeze_struct("be9ea5284be96d19")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct LongOpenQuote { + /// Gross open-time Alpha collateral `C = P + N`. + pub gross_collateral: AlphaBalance, + /// Retained Alpha proceeds `N` (becomes the initial buffer `R0`). + pub retained_proceeds: AlphaBalance, + /// Fixed TAO liability `D` (also the TAO required to close). + pub tao_liability: TaoBalance, + /// Linked Alpha escrow `E`. + pub escrow: AlphaBalance, + /// Effective LTV `λ_eff`, scaled by 1e9. + pub effective_ltv: u64, + /// Current daily decay/carry rate, scaled by 1e9. + pub daily_decay: u64, +} + +/// Live, materialized view of a trader's long position plus health metrics. +#[freeze_struct("bf02f609ef130edd")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct LongPositionInfo { + pub netuid: NetUid, + pub hotkey: AccountId, + /// Non-decaying Alpha floor `P`. + pub floor: AlphaBalance, + /// Fixed TAO liability `D`. + pub tao_liability: TaoBalance, + /// Current retained Alpha buffer `R(t)` after decay. + pub buffer: AlphaBalance, + /// Current linked Alpha escrow `E(t)` after decay. + pub escrow: AlphaBalance, + /// Current Alpha collateral claim `C = P + R(t)` (returned on close). + pub collateral_claim: AlphaBalance, + /// Current daily carry/decay rate, scaled by 1e9. + pub daily_decay: u64, + /// Estimated blocks until `R` decays to dust (`u64::MAX` if ~zero). + pub blocks_to_dust: u64, + /// Whether the position can be defaulted right now. + pub default_eligible: bool, + /// Earliest block a third party could default once dusted. + pub defaultable_at_block: u64, + /// TAO required to fully close (repay the liability `D`). + pub tao_to_close: TaoBalance, +} + +/// Per-subnet long market state for sizing and capacity decisions. +#[freeze_struct("bcf0bbff93530f91")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct LongMarketInfo { + pub longs_enabled: bool, + /// Base LTV `λ_L`, scaled by 1e9. + pub base_ltv: u64, + /// Footprint-cap factor `κ_L`, scaled by 1e9. + pub kappa: u64, + /// Daily decay bounds, scaled by 1e9. + pub decay_min: u64, + pub decay_max: u64, + /// Current daily decay at the live utilization, scaled by 1e9. + pub current_daily_decay: u64, + /// Conservative Alpha reference `A_ref`. + pub a_ref: AlphaBalance, + /// Active footprint `S_L` (used capacity). + pub footprint_used: AlphaBalance, + /// Footprint cap `κ_L · A_ref`. + pub footprint_cap: AlphaBalance, + /// Remaining openable footprint. + pub footprint_remaining: AlphaBalance, + /// Aggregate fixed TAO liability (open interest). + pub open_interest_tao: TaoBalance, + /// Aggregate retained buffer and escrow (Alpha). + pub buffer_total: AlphaBalance, + pub escrow_total: AlphaBalance, + /// Dust threshold, minimum input (Alpha), and default grace (blocks). + pub dust_threshold: AlphaBalance, + pub min_input: AlphaBalance, + pub default_grace: u64, +} + +/// Pre-close quote for a fraction of a long position. +#[freeze_struct("5ab2d9afb4b4d199")] +#[derive(Encode, Decode, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct CloseLongQuote { + /// TAO that must be repaid for this close fraction. + pub repay_tao: TaoBalance, + /// Alpha returned to the trader (floor + buffer fraction). + pub returned_alpha: AlphaBalance, + /// Escrow settled back into the pool (Alpha). + pub escrow_settled: AlphaBalance, +} diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index de9f32efba..d96df730fc 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1340,6 +1340,132 @@ fn default_grace_independent_per_side() { }); } +// --------------------------------------------------------------------------- +// Long read/RPC layer (mirror of the short views) +// --------------------------------------------------------------------------- + +#[test] +fn long_open_quote_matches_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + + let q = SubtensorModule::quote_open_long(netuid, AlphaBalance::from(100 * TAO)).unwrap(); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + assert_eq!(pos.r_stored, q.retained_proceeds); + assert_eq!(pos.d_liability, q.tao_liability); + assert_eq!(pos.e_stored, q.escrow); + assert_eq!(pos.p_floor, AlphaBalance::from(100 * TAO)); + assert!(q.effective_ltv > 0 && q.gross_collateral.to_u64() > 100 * TAO); + }); +} + +#[test] +fn long_open_quote_gated_by_enable_flag() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + assert!(SubtensorModule::quote_open_long(netuid, AlphaBalance::from(100 * TAO)).is_some()); + SubtensorModule::set_longs_enabled(false); + assert!(SubtensorModule::quote_open_long(netuid, AlphaBalance::from(100 * TAO)).is_none()); + }); +} + +#[test] +fn long_position_view_materializes_decay() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); + + let raw = LongPositions::::get(netuid, trader).unwrap().r_stored.to_u64(); + for _ in 0..2000 { + SubtensorModule::run_long_decay(); + } + let info = SubtensorModule::get_long_position(&trader, netuid).unwrap(); + assert!(info.buffer.to_u64() < raw, "view buffer {} !< raw {}", info.buffer.to_u64(), raw); + assert_eq!(LongPositions::::get(netuid, trader).unwrap().r_stored.to_u64(), raw); + assert_eq!(info.collateral_claim.to_u64(), info.floor.to_u64() + info.buffer.to_u64()); + assert!(info.daily_decay > 0); + assert!(info.blocks_to_dust > 0 && info.blocks_to_dust < u64::MAX); + assert_eq!(info.tao_to_close, info.tao_liability); + }); +} + +#[test] +fn long_market_view_reports_capacity() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + + let pos = LongPositions::::get(netuid, trader).unwrap(); + let m = SubtensorModule::get_subnet_long_state(netuid).unwrap(); + assert!(m.longs_enabled); + assert!(m.footprint_used.to_u64() > 0); + assert!(m.footprint_cap.to_u64() > m.footprint_used.to_u64()); + assert_eq!( + m.footprint_remaining.to_u64(), + m.footprint_cap.to_u64() - m.footprint_used.to_u64() + ); + assert_eq!(m.open_interest_tao, pos.d_liability); + assert_eq!(m.buffer_total, pos.r_stored); + assert!(m.current_daily_decay > 0); + }); +} + +#[test] +fn long_close_quote_matches_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let hotkey = U256::from(11); + give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + let pos = LongPositions::::get(netuid, trader).unwrap(); + + let full = SubtensorModule::quote_close_long(&trader, netuid, 1_000_000_000).unwrap(); + assert_eq!(full.repay_tao, pos.d_liability); + assert_eq!( + full.returned_alpha.to_u64(), + pos.p_floor.to_u64() + pos.r_stored.to_u64() + ); + assert_eq!(full.escrow_settled, pos.e_stored); + + let half = SubtensorModule::quote_close_long(&trader, netuid, 500_000_000).unwrap(); + assert_approx(half.repay_tao.to_u64(), full.repay_tao.to_u64() / 2, 2, "half repay"); + assert_approx(half.returned_alpha.to_u64(), full.returned_alpha.to_u64() / 2, 2, "half return"); + }); +} + +#[test] +fn list_long_positions_across_subnets() { + new_test_ext(1).execute_with(|| { + let n1 = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let n2 = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + give_alpha(U256::from(11), trader, n1, AlphaBalance::from(200 * TAO)); + give_alpha(U256::from(12), trader, n2, AlphaBalance::from(200 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(11), n1, AlphaBalance::from(50 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(12), n2, AlphaBalance::from(50 * TAO))); + + let all = SubtensorModule::get_long_positions(&trader); + assert_eq!(all.len(), 2); + let mut netuids: Vec<_> = all.iter().map(|p| p.netuid).collect(); + netuids.sort(); + let mut want = vec![n1, n2]; + want.sort(); + assert_eq!(netuids, want); + }); +} + // Decay rate matches the closed form: one day at 1.0/day leaves ≈ e⁻¹, and the // per-position materialized buffer stays consistent with the aggregate. #[test] diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 6495dbcf70..b99e38060f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2590,6 +2590,40 @@ impl_runtime_apis! { ) -> Option { SubtensorModule::get_subnet_short_state(netuid) } + + fn quote_open_long( + netuid: NetUid, + position_input: AlphaBalance, + ) -> Option { + SubtensorModule::quote_open_long(netuid, position_input) + } + + fn quote_close_long( + coldkey: AccountId32, + netuid: NetUid, + fraction_ppb: u64, + ) -> Option { + SubtensorModule::quote_close_long(&coldkey, netuid, fraction_ppb) + } + + fn get_long_position( + coldkey: AccountId32, + netuid: NetUid, + ) -> Option> { + SubtensorModule::get_long_position(&coldkey, netuid) + } + + fn get_long_positions( + coldkey: AccountId32, + ) -> Vec> { + SubtensorModule::get_long_positions(&coldkey) + } + + fn get_subnet_long_state( + netuid: NetUid, + ) -> Option { + SubtensorModule::get_subnet_long_state(netuid) + } } impl subtensor_custom_rpc_runtime_api::ProxyFilterRuntimeApi for Runtime { From 629550ee92ddc588f694564a030578502eaacb16 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 10:10:29 -0600 Subject: [PATCH 12/21] feat(derivatives): write subnet TaoFlow (governance factor chi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec §4.5, derivative TAO movements now write TaoFlow scaled by a governance factor chi (DerivativeFlowFactor), so the core purpose — expressing negative flow — works: a short removing TAO writes negative flow; TAO returned on unwind/close/dereg (incl. a long close paying its D liability) writes positive flow. chi=0 restores the previous flow-neutral behavior; default 1.0. - Storage DerivativeFlowFactor + DefaultDerivativeFlowFactor (1.0). - scale_flow/record_derivative_{in,out}flow helpers wired into every derivative SubnetTAO movement (short open/decay/close/default/dereg; long close). - Setter set_derivative_flow_factor_ppb (clamped [0,1]) + admin extrinsic 111. - Test derivatives_write_subnet_flow (short→negative, long close→positive, chi=0 neutral). Suite 69. Co-authored-by: Cursor --- pallets/admin-utils/src/lib.rs | 10 +++++ pallets/subtensor/src/derivatives/long.rs | 2 + pallets/subtensor/src/derivatives/mod.rs | 48 +++++++++++++++++++++- pallets/subtensor/src/lib.rs | 17 ++++++++ pallets/subtensor/src/tests/derivatives.rs | 35 ++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index 5df9680587..a8926ea9ce 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2415,6 +2415,16 @@ pub mod pallet { pallet_subtensor::Pallet::::set_long_default_grace(blocks); Ok(()) } + + /// Set the derivative emissions-flow factor `χ` (scaled by 1e9; `0` = + /// flow-neutral). Governs how strongly shorts/longs move subnet TaoFlow. + #[pallet::call_index(111)] + #[pallet::weight(::DbWeight::get().reads_writes(0, 1))] + pub fn sudo_set_derivative_flow_factor(origin: OriginFor, chi_ppb: u64) -> DispatchResult { + ensure_root(origin)?; + pallet_subtensor::Pallet::::set_derivative_flow_factor_ppb(chi_ppb); + Ok(()) + } } } diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 438362d135..f9f67e3aa0 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -254,6 +254,8 @@ impl Pallet { Self::transfer_tao(&coldkey, &subnet_account, d_close.into())?; Self::increase_provided_tao_reserve(netuid, d_close); TotalStake::::mutate(|t| *t = t.saturating_add(d_close)); + // Long close pays D TAO into the pool: positive TaoFlow. + Self::record_derivative_inflow(netuid, d_close); } // Settle escrow back to the pool; return floor+buffer as stake (mint). Self::increase_provided_alpha_reserve(netuid, e_close); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index e0e3d670ec..c9340163d7 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -8,8 +8,13 @@ //! Custody model. Shorts park floor/buffer/escrow TAO in a dedicated per-subnet //! custody account; longs have no custody account and instead track parked Alpha //! via issuance accounting (burned at open, minted back on restore/close). Pool -//! reserves, `TotalStake`, and issuance move in lockstep and derivative legs -//! never write TaoFlow. +//! reserves, `TotalStake`, and issuance move in lockstep. +//! +//! Emissions flow. Derivative TAO movements write TaoFlow scaled by the +//! governance factor `χ` (`DerivativeFlowFactor`): a short removing TAO from the +//! pool writes negative flow (bearish demand), and TAO returned on +//! unwind/close/dereg — including a long close paying its `D` liability — writes +//! positive flow. `χ = 0` restores flow-neutral behavior (spec §4.5). //! //! Custody solvency invariant. `custody_balance(netuid)` (shorts) and the burned //! Alpha (longs) equal `Σ materialized (P + R(t) + E(t))` **to within per-block @@ -88,6 +93,31 @@ impl Pallet { ); } + // ---- emissions-flow accounting (spec §4.5) ------------------------- + + /// `χ`-scaled TAO amount for TaoFlow writes. `χ = 0` ⇒ flow-neutral. + fn scale_flow(tao: TaoBalance) -> TaoBalance { + Self::to_tao(Self::tao_f(tao).saturating_mul(DerivativeFlowFactor::::get())) + } + + /// Negative TaoFlow for TAO a derivative removes from the subnet pool + /// (a short open expresses bearish demand on the subnet). + fn record_derivative_outflow(netuid: NetUid, tao: TaoBalance) { + let amt = Self::scale_flow(tao); + if !amt.is_zero() { + Self::record_tao_outflow(netuid, amt); + } + } + + /// Positive TaoFlow for TAO a derivative returns to the subnet pool + /// (short unwinds, and a long close pays its TAO liability into the pool). + fn record_derivative_inflow(netuid: NetUid, tao: TaoBalance) { + let amt = Self::scale_flow(tao); + if !amt.is_zero() { + Self::record_tao_inflow(netuid, amt); + } + } + // ---- references (spec §3, §4) -------------------------------------- /// Conservative TAO reference `T_ref = min(T_live, T_EMA)`, with @@ -297,6 +327,8 @@ impl Pallet { Self::transfer_tao(&subnet_account, &custody, removed.into())?; Self::decrease_provided_tao_reserve(netuid, removed); TotalStake::::mutate(|t| *t = t.saturating_sub(removed)); + // Express bearish demand: a short removing TAO writes negative TaoFlow. + Self::record_derivative_outflow(netuid, removed); let block = Self::get_current_block_as_u64(); let pos = match ShortPositions::::get(netuid, &coldkey) { @@ -432,6 +464,7 @@ impl Pallet { Self::transfer_tao(&custody, &subnet_account, e_close.into())?; Self::increase_provided_tao_reserve(netuid, e_close); TotalStake::::mutate(|t| *t = t.saturating_add(e_close)); + Self::record_derivative_inflow(netuid, e_close); } let returned = p_close.saturating_add(r_close); if !returned.is_zero() { @@ -500,6 +533,7 @@ impl Pallet { Self::transfer_tao(&custody, &subnet_account, residual.into())?; Self::increase_provided_tao_reserve(netuid, residual); TotalStake::::mutate(|t| *t = t.saturating_add(residual)); + Self::record_derivative_inflow(netuid, residual); } Self::recycle_custody_tao(&custody, pos.p_floor); @@ -558,6 +592,7 @@ impl Pallet { { Self::increase_provided_tao_reserve(netuid, restore); TotalStake::::mutate(|t| *t = t.saturating_add(restore)); + Self::record_derivative_inflow(netuid, restore); } } } @@ -587,6 +622,7 @@ impl Pallet { { Self::increase_provided_tao_reserve(netuid, pos.e_stored); TotalStake::::mutate(|t| *t = t.saturating_add(pos.e_stored)); + Self::record_derivative_inflow(netuid, pos.e_stored); } // K_D(Q) = max(K_spot,last(Q), Q·pEMA). @@ -666,6 +702,14 @@ impl Pallet { pub fn set_short_default_grace(blocks: u64) { ShortDefaultGrace::::put(blocks); } + /// Emissions-flow factor `χ`, supplied scaled by 1e9. Clamped to `[0, 1.0]`; + /// `0` restores flow-neutral behavior. + pub fn set_derivative_flow_factor_ppb(chi_ppb: u64) { + let c = chi_ppb.min(1_000_000_000); + DerivativeFlowFactor::::put( + I64F64::from_num(c).safe_div(I64F64::from_num(1_000_000_000u64)), + ); + } pub fn set_long_default_grace(blocks: u64) { LongDefaultGrace::::put(blocks); } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 6af2e1b666..3a964d65c6 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1422,6 +1422,14 @@ pub mod pallet { 360 } #[pallet::type_value] + /// Derivative emissions-flow activation factor `χ` (spec §4.5). Scales the + /// negative/positive TaoFlow written by derivative TAO movements so that + /// shorts express negative flow and longs (at close) positive flow. + /// `0` = flow-neutral. Defaults to `1.0` (full effect). + pub fn DefaultDerivativeFlowFactor() -> substrate_fixed::types::I64F64 { + substrate_fixed::types::I64F64::from_num(1) + } + #[pallet::type_value] /// Minimum short open input = 0.1 TAO. Bounds dust-spam and terminal load. pub fn DefaultShortMinInput() -> TaoBalance { TaoBalance::from(100_000_000u64) @@ -1478,6 +1486,15 @@ pub mod pallet { pub type ShortDefaultGrace = StorageValue<_, u64, ValueQuery, DefaultShortDefaultGrace>; + /// Derivative emissions-flow activation factor `χ` (shared across sides). + #[pallet::storage] + pub type DerivativeFlowFactor = StorageValue< + _, + substrate_fixed::types::I64F64, + ValueQuery, + DefaultDerivativeFlowFactor, + >; + /// Minimum short open input. #[pallet::storage] pub type ShortMinInput = diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index d96df730fc..73aada2c64 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1313,6 +1313,41 @@ fn long_close_invalid_fraction_and_min_input() { }); } +// Shorts express negative subnet flow; a long close pays TAO in (positive +// flow); χ = 0 restores flow-neutral behavior. +#[test] +fn derivatives_write_subnet_flow() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); + let s = U256::from(10); + add_balance_to_coldkey_account(&s, t(1000 * TAO)); + + // Default χ = 1.0: a short open removes TAO and writes negative flow. + let f0 = SubnetTaoFlow::::get(netuid); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s), U256::from(11), netuid, t(100 * TAO))); + let f1 = SubnetTaoFlow::::get(netuid); + assert!(f1 < f0, "short open must write negative flow: {f1} !< {f0}"); + + // A long close pays D TAO into the pool → positive flow. + let lc = U256::from(20); + let lh = U256::from(21); + give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); + add_balance_to_coldkey_account(&lc, t(1000 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); + let f2 = SubnetTaoFlow::::get(netuid); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000)); + assert!(SubnetTaoFlow::::get(netuid) > f2, "long close must write positive flow"); + + // χ = 0 → flow-neutral: another short open leaves flow untouched. + SubtensorModule::set_derivative_flow_factor_ppb(0); + let s2 = U256::from(30); + add_balance_to_coldkey_account(&s2, t(1000 * TAO)); + let f3 = SubnetTaoFlow::::get(netuid); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s2), U256::from(31), netuid, t(100 * TAO))); + assert_eq!(SubnetTaoFlow::::get(netuid), f3, "χ=0 must be flow-neutral"); + }); +} + // Short and long default-grace windows are governed independently. #[test] fn default_grace_independent_per_side() { From 39fe5e5bbbfba4ead259bcdb53db5fde67c532e1 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 10:21:50 -0600 Subject: [PATCH 13/21] refactor(derivatives): simpler one-shot flow signal at open Flow is now a single TaoFlow write at open based on the TAO swapped through the pool, instead of tracking every reserve movement: - short open: sells alpha, extracts N TAO -> negative flow (chi*N). - long open: routes D TAO through the pool to buy alpha -> positive flow (chi*D). Unwinds (decay/close/default/dereg) no longer touch flow; the flow EMA decays the open-time pulse. chi=0 stays flow-neutral. Updated derivatives_write_subnet_flow (long flow now asserted at open). Suite 69. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 5 +++-- pallets/subtensor/src/derivatives/mod.rs | 21 ++++++++++----------- pallets/subtensor/src/tests/derivatives.rs | 8 +++----- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index f9f67e3aa0..d7157c3ed5 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -135,6 +135,9 @@ impl Pallet { Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(position_input)); Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); + // Bullish demand: the long routes `D` TAO through the pool to buy alpha, + // recorded as positive flow at open (one-shot; the flow EMA decays it). + Self::record_derivative_inflow(netuid, d_tao); let block = Self::get_current_block_as_u64(); let pos = match LongPositions::::get(netuid, &coldkey) { @@ -254,8 +257,6 @@ impl Pallet { Self::transfer_tao(&coldkey, &subnet_account, d_close.into())?; Self::increase_provided_tao_reserve(netuid, d_close); TotalStake::::mutate(|t| *t = t.saturating_add(d_close)); - // Long close pays D TAO into the pool: positive TaoFlow. - Self::record_derivative_inflow(netuid, d_close); } // Settle escrow back to the pool; return floor+buffer as stake (mint). Self::increase_provided_alpha_reserve(netuid, e_close); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index c9340163d7..73f09354ec 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -10,11 +10,12 @@ //! via issuance accounting (burned at open, minted back on restore/close). Pool //! reserves, `TotalStake`, and issuance move in lockstep. //! -//! Emissions flow. Derivative TAO movements write TaoFlow scaled by the -//! governance factor `χ` (`DerivativeFlowFactor`): a short removing TAO from the -//! pool writes negative flow (bearish demand), and TAO returned on -//! unwind/close/dereg — including a long close paying its `D` liability — writes -//! positive flow. `χ = 0` restores flow-neutral behavior (spec §4.5). +//! Emissions flow. A single TaoFlow signal is written **at open**, scaled by the +//! governance factor `χ` (`DerivativeFlowFactor`), based on the TAO swapped +//! through the pool: a short sells alpha and extracts `N` TAO → negative flow; +//! a long routes `D` TAO through the pool to buy alpha → positive flow. Unwinds +//! (decay/close/default/dereg) do not write flow — the flow EMA decays the +//! open-time pulse. `χ = 0` restores flow-neutral behavior (spec §4.5). //! //! Custody solvency invariant. `custody_balance(netuid)` (shorts) and the burned //! Alpha (longs) equal `Σ materialized (P + R(t) + E(t))` **to within per-block @@ -327,8 +328,10 @@ impl Pallet { Self::transfer_tao(&subnet_account, &custody, removed.into())?; Self::decrease_provided_tao_reserve(netuid, removed); TotalStake::::mutate(|t| *t = t.saturating_sub(removed)); - // Express bearish demand: a short removing TAO writes negative TaoFlow. - Self::record_derivative_outflow(netuid, removed); + // Bearish demand: the alpha sold through the pool extracts `N` TAO, which + // is the subnet's negative flow signal (one-shot at open; the flow EMA + // decays it). Unwinds do not reverse it. + Self::record_derivative_outflow(netuid, n_tao); let block = Self::get_current_block_as_u64(); let pos = match ShortPositions::::get(netuid, &coldkey) { @@ -464,7 +467,6 @@ impl Pallet { Self::transfer_tao(&custody, &subnet_account, e_close.into())?; Self::increase_provided_tao_reserve(netuid, e_close); TotalStake::::mutate(|t| *t = t.saturating_add(e_close)); - Self::record_derivative_inflow(netuid, e_close); } let returned = p_close.saturating_add(r_close); if !returned.is_zero() { @@ -533,7 +535,6 @@ impl Pallet { Self::transfer_tao(&custody, &subnet_account, residual.into())?; Self::increase_provided_tao_reserve(netuid, residual); TotalStake::::mutate(|t| *t = t.saturating_add(residual)); - Self::record_derivative_inflow(netuid, residual); } Self::recycle_custody_tao(&custody, pos.p_floor); @@ -592,7 +593,6 @@ impl Pallet { { Self::increase_provided_tao_reserve(netuid, restore); TotalStake::::mutate(|t| *t = t.saturating_add(restore)); - Self::record_derivative_inflow(netuid, restore); } } } @@ -622,7 +622,6 @@ impl Pallet { { Self::increase_provided_tao_reserve(netuid, pos.e_stored); TotalStake::::mutate(|t| *t = t.saturating_add(pos.e_stored)); - Self::record_derivative_inflow(netuid, pos.e_stored); } // K_D(Q) = max(K_spot,last(Q), Q·pEMA). diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 73aada2c64..9405d1e6d6 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1328,15 +1328,13 @@ fn derivatives_write_subnet_flow() { let f1 = SubnetTaoFlow::::get(netuid); assert!(f1 < f0, "short open must write negative flow: {f1} !< {f0}"); - // A long close pays D TAO into the pool → positive flow. + // A long open routes D TAO through the pool to buy alpha → positive flow. let lc = U256::from(20); let lh = U256::from(21); give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); - add_balance_to_coldkey_account(&lc, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); let f2 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000)); - assert!(SubnetTaoFlow::::get(netuid) > f2, "long close must write positive flow"); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); + assert!(SubnetTaoFlow::::get(netuid) > f2, "long open must write positive flow"); // χ = 0 → flow-neutral: another short open leaves flow untouched. SubtensorModule::set_derivative_flow_factor_ppb(0); From 7b4760f912da7942b76d5ce03d218a913f92004e Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 10:28:33 -0600 Subject: [PATCH 14/21] feat(derivatives): close reverses the open flow signal Open and close now write opposite TaoFlow (swap-direction symmetric): - short open sells alpha (-N); short close rebuys rhoQ alpha with TAO, valued at the EMA price (+, flash-resistant, reversing the open). - long open buys alpha with D TAO (+D); long close sells the exposure back (-rhoD). Decay/default/dereg still write no flow. Extended derivatives_write_subnet_flow to assert both reversals. Suite 69. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 3 +++ pallets/subtensor/src/derivatives/mod.rs | 19 +++++++++++++------ pallets/subtensor/src/tests/derivatives.rs | 17 +++++++++++++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index d7157c3ed5..ad9a77c187 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -258,6 +258,9 @@ impl Pallet { Self::increase_provided_tao_reserve(netuid, d_close); TotalStake::::mutate(|t| *t = t.saturating_add(d_close)); } + // Closing a long sells the alpha exposure back for TAO: negative flow, + // reversing the open buy. + Self::record_derivative_outflow(netuid, d_close); // Settle escrow back to the pool; return floor+buffer as stake (mint). Self::increase_provided_alpha_reserve(netuid, e_close); let returned = p_close.saturating_add(r_close); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 73f09354ec..09464279ae 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -10,12 +10,15 @@ //! via issuance accounting (burned at open, minted back on restore/close). Pool //! reserves, `TotalStake`, and issuance move in lockstep. //! -//! Emissions flow. A single TaoFlow signal is written **at open**, scaled by the -//! governance factor `χ` (`DerivativeFlowFactor`), based on the TAO swapped -//! through the pool: a short sells alpha and extracts `N` TAO → negative flow; -//! a long routes `D` TAO through the pool to buy alpha → positive flow. Unwinds -//! (decay/close/default/dereg) do not write flow — the flow EMA decays the -//! open-time pulse. `χ = 0` restores flow-neutral behavior (spec §4.5). +//! Emissions flow. Open and close write opposite TaoFlow signals, scaled by the +//! governance factor `χ` (`DerivativeFlowFactor`), tracking the alpha/TAO swap +//! direction: +//! - short open: sell alpha, extract `N` TAO → negative flow; +//! - short close: rebuy `ρQ` alpha with TAO (valued at the EMA price) → positive; +//! - long open: route `D` TAO through the pool to buy alpha → positive flow; +//! - long close: sell the alpha exposure back for `ρD` TAO → negative. +//! Decay/default/dereg do not write flow — the flow EMA decays the residual +//! open-side signal. `χ = 0` restores flow-neutral behavior (spec §4.5). //! //! Custody solvency invariant. `custody_balance(netuid)` (shorts) and the burned //! Alpha (longs) equal `Σ materialized (P + R(t) + E(t))` **to within per-block @@ -458,6 +461,10 @@ impl Pallet { Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, q_close); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(q_close)); Self::increase_provided_alpha_reserve(netuid, q_close); + // Closing rebuys `ρQ` alpha with TAO: positive flow, reversing the open + // sell. Valued at the EMA price (bounded / not flash-manipulable). + let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + Self::record_derivative_inflow(netuid, Self::to_tao(Self::alpha_f(q_close).saturating_mul(pema))); let custody = Self::short_custody_account(netuid); let subnet_account = diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 9405d1e6d6..b37f7da784 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1322,19 +1322,28 @@ fn derivatives_write_subnet_flow() { let s = U256::from(10); add_balance_to_coldkey_account(&s, t(1000 * TAO)); - // Default χ = 1.0: a short open removes TAO and writes negative flow. + // Default χ = 1.0: a short open sells alpha → negative flow; closing it + // rebuys the alpha → positive flow (reversal). + let shk = U256::from(11); + give_alpha(shk, s, netuid, AlphaBalance::from(5000 * TAO)); // to repay Q on close let f0 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s), shk, netuid, t(100 * TAO))); let f1 = SubnetTaoFlow::::get(netuid); assert!(f1 < f0, "short open must write negative flow: {f1} !< {f0}"); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s), netuid, 1_000_000_000)); + assert!(SubnetTaoFlow::::get(netuid) > f1, "short close must reverse to positive flow"); - // A long open routes D TAO through the pool to buy alpha → positive flow. + // A long open buys alpha with D TAO → positive; closing sells back → negative. let lc = U256::from(20); let lh = U256::from(21); give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); + add_balance_to_coldkey_account(&lc, t(1000 * TAO)); // to repay D on close let f2 = SubnetTaoFlow::::get(netuid); assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); - assert!(SubnetTaoFlow::::get(netuid) > f2, "long open must write positive flow"); + let f3 = SubnetTaoFlow::::get(netuid); + assert!(f3 > f2, "long open must write positive flow"); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000)); + assert!(SubnetTaoFlow::::get(netuid) < f3, "long close must reverse to negative flow"); // χ = 0 → flow-neutral: another short open leaves flow untouched. SubtensorModule::set_derivative_flow_factor_ppb(0); From e48e7e97f9d488034777606a871705f737899cc1 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 10:47:39 -0600 Subject: [PATCH 15/21] fix(derivatives): symmetric flow basis so round-trips net ~0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit H1: short open debited `N` while close credited `Q·pEMA = E > N`, so a round-tripped short manufactured net-positive flow, inverting the bearish intent. Open now uses the same `Q·pEMA` basis as close. Audit M1 + symmetry: close AND default reverse the open-side flow on a single per-side basis (short `Q·pEMA`, long `D`), so standing flow tracks only live positions and an abandoned position can't leave a lasting bias. L1: use saturating_from_num for the moving price in the close path. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 9 +++- pallets/subtensor/src/derivatives/mod.rs | 46 +++++++++++------- pallets/subtensor/src/tests/derivatives.rs | 54 ++++++++++++++++++++-- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index ad9a77c187..47f341513d 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -135,8 +135,8 @@ impl Pallet { Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(position_input)); Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); - // Bullish demand: the long routes `D` TAO through the pool to buy alpha, - // recorded as positive flow at open (one-shot; the flow EMA decays it). + // Bullish flow: the long's `D` TAO liability is the positive signal at + // open; close/default reverse it on the same `D` basis (round-trip ~0). Self::record_derivative_inflow(netuid, d_tao); let block = Self::get_current_block_as_u64(); @@ -325,6 +325,11 @@ impl Pallet { // Restore residual R+E Alpha to the pool; floor stays burned (recycled). Self::increase_provided_alpha_reserve(netuid, pos.r_stored.saturating_add(pos.e_stored)); + // Default ends the position: reverse its remaining open-side `+D` flow so + // an abandoned long can't leave a lasting positive-flow bias for only the + // cost of the forfeited floor. + Self::record_derivative_outflow(netuid, pos.d_liability); + agg.r_sigma = agg.r_sigma.saturating_sub(pos.r_stored); agg.e_sigma = agg.e_sigma.saturating_sub(pos.e_stored); agg.b_sigma = agg.b_sigma.saturating_sub(pos.b_stored); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 09464279ae..8a4a0d19cd 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -10,15 +10,17 @@ //! via issuance accounting (burned at open, minted back on restore/close). Pool //! reserves, `TotalStake`, and issuance move in lockstep. //! -//! Emissions flow. Open and close write opposite TaoFlow signals, scaled by the -//! governance factor `χ` (`DerivativeFlowFactor`), tracking the alpha/TAO swap -//! direction: -//! - short open: sell alpha, extract `N` TAO → negative flow; -//! - short close: rebuy `ρQ` alpha with TAO (valued at the EMA price) → positive; -//! - long open: route `D` TAO through the pool to buy alpha → positive flow; -//! - long close: sell the alpha exposure back for `ρD` TAO → negative. -//! Decay/default/dereg do not write flow — the flow EMA decays the residual -//! open-side signal. `χ = 0` restores flow-neutral behavior (spec §4.5). +//! Emissions flow. Open and position-end (close/default) write opposite TaoFlow +//! signals on a SINGLE per-side basis, scaled by the governance factor `χ` +//! (`DerivativeFlowFactor`), so a round-trip nets ~0 and standing flow reflects +//! only live positions: +//! - short: `Q·pEMA` notional — open writes `−χ·Q·pEMA`; close/default write +//! `+χ·(ρQ)·pEMA`. (Same basis at both ends; EMA price, not spot, so it +//! can't be flash-manipulated. A nonzero residual survives only if the EMA +//! moved while open — the realized directional impact.) +//! - long: `D` TAO liability — open writes `+χ·D`; close/default write `−χ·ρD`. +//! Decay and dereg do not write flow (decay is gradual; dereg removes the +//! ledger). `χ = 0` restores flow-neutral behavior (spec §4.5). //! //! Custody solvency invariant. `custody_balance(netuid)` (shorts) and the burned //! Alpha (longs) equal `Σ materialized (P + R(t) + E(t))` **to within per-block @@ -331,10 +333,13 @@ impl Pallet { Self::transfer_tao(&subnet_account, &custody, removed.into())?; Self::decrease_provided_tao_reserve(netuid, removed); TotalStake::::mutate(|t| *t = t.saturating_sub(removed)); - // Bearish demand: the alpha sold through the pool extracts `N` TAO, which - // is the subnet's negative flow signal (one-shot at open; the flow EMA - // decays it). Unwinds do not reverse it. - Self::record_derivative_outflow(netuid, n_tao); + // Bearish flow: the short sells `Q` alpha, marked at the EMA price. Open + // and close/default use the SAME `Q·pEMA` basis so a round-trip nets ~0 + // (a residual only survives if the EMA price moved while the short was + // open — i.e. the realized directional impact). EMA, not spot, so it + // can't be flash-manipulated. + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); + Self::record_derivative_outflow(netuid, Self::to_tao(Self::alpha_f(q_alpha).saturating_mul(pema))); let block = Self::get_current_block_as_u64(); let pos = match ShortPositions::::get(netuid, &coldkey) { @@ -461,9 +466,9 @@ impl Pallet { Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, q_close); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_sub(q_close)); Self::increase_provided_alpha_reserve(netuid, q_close); - // Closing rebuys `ρQ` alpha with TAO: positive flow, reversing the open - // sell. Valued at the EMA price (bounded / not flash-manipulable). - let pema = I64F64::from_num(Self::get_moving_alpha_price(netuid)); + // Closing rebuys `ρQ` alpha: positive flow on the same `Q·pEMA` basis as + // the open, reversing it proportionally. + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); Self::record_derivative_inflow(netuid, Self::to_tao(Self::alpha_f(q_close).saturating_mul(pema))); let custody = Self::short_custody_account(netuid); @@ -545,6 +550,15 @@ impl Pallet { } Self::recycle_custody_tao(&custody, pos.p_floor); + // Default ends the position: reverse its remaining open-side flow on the + // same `Q·pEMA` basis, so standing flow only reflects live positions + // (abandoning can't cheaply leave a lasting flow bias). + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); + Self::record_derivative_inflow( + netuid, + Self::to_tao(Self::alpha_f(pos.q_liability).saturating_mul(pema)), + ); + agg.r_sigma = agg.r_sigma.saturating_sub(pos.r_stored); agg.e_sigma = agg.e_sigma.saturating_sub(pos.e_stored); agg.b_sigma = agg.b_sigma.saturating_sub(pos.b_stored); diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index b37f7da784..4467bc4095 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -1322,8 +1322,9 @@ fn derivatives_write_subnet_flow() { let s = U256::from(10); add_balance_to_coldkey_account(&s, t(1000 * TAO)); - // Default χ = 1.0: a short open sells alpha → negative flow; closing it - // rebuys the alpha → positive flow (reversal). + // Same-block round-trip must net ~0 on the EMA price: a short open sells + // alpha → negative flow; a full close rebuys it on the SAME `Q·pEMA` + // basis → flow returns to baseline (no positive residual — H1 regression). let shk = U256::from(11); give_alpha(shk, s, netuid, AlphaBalance::from(5000 * TAO)); // to repay Q on close let f0 = SubnetTaoFlow::::get(netuid); @@ -1331,9 +1332,32 @@ fn derivatives_write_subnet_flow() { let f1 = SubnetTaoFlow::::get(netuid); assert!(f1 < f0, "short open must write negative flow: {f1} !< {f0}"); assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s), netuid, 1_000_000_000)); - assert!(SubnetTaoFlow::::get(netuid) > f1, "short close must reverse to positive flow"); + let f_rt = SubnetTaoFlow::::get(netuid); + let tol = (TAO as i64) / 1000; // generous rounding tolerance + assert!(f_rt > f1, "short close must reverse toward positive flow"); + assert!( + (f_rt - f0).abs() <= tol, + "short round-trip must net ~0, not positive: f0={f0} f_rt={f_rt}" + ); + + // Defaulting a short must ALSO reverse its open flow (standing flow tracks + // only live positions; abandoning leaves no lasting bias). + let sd = U256::from(40); + let sdh = U256::from(41); + add_balance_to_coldkey_account(&sd, t(1000 * TAO)); + let fd0 = SubnetTaoFlow::::get(netuid); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sd), sdh, netuid, t(100 * TAO))); + SubtensorModule::set_short_dust(t(10_000 * TAO)); + SubtensorModule::set_short_default_grace(0); + assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(U256::from(99)), sd, netuid)); + assert!( + (SubnetTaoFlow::::get(netuid) - fd0).abs() <= tol, + "short default must reverse the open flow" + ); + SubtensorModule::set_short_dust(t(1)); - // A long open buys alpha with D TAO → positive; closing sells back → negative. + // A long open buys alpha with D TAO → positive; full close sells back on + // the same `D` basis → flow returns to baseline. let lc = U256::from(20); let lh = U256::from(21); give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); @@ -1343,7 +1367,27 @@ fn derivatives_write_subnet_flow() { let f3 = SubnetTaoFlow::::get(netuid); assert!(f3 > f2, "long open must write positive flow"); assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000)); - assert!(SubnetTaoFlow::::get(netuid) < f3, "long close must reverse to negative flow"); + let lf_rt = SubnetTaoFlow::::get(netuid); + assert!(lf_rt < f3, "long close must reverse toward negative flow"); + assert!( + (lf_rt - f2).abs() <= tol, + "long round-trip must net ~0: f2={f2} lf_rt={lf_rt}" + ); + + // Defaulting a long must reverse its open `+D` flow (M1 regression). + let ld = U256::from(50); + let ldh = U256::from(51); + give_alpha(ldh, ld, netuid, AlphaBalance::from(500 * TAO)); + let lfd0 = SubnetTaoFlow::::get(netuid); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(ld), ldh, netuid, AlphaBalance::from(100 * TAO))); + SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); + SubtensorModule::set_long_default_grace(0); + assert_ok!(SubtensorModule::default_long(RuntimeOrigin::signed(U256::from(98)), ld, netuid)); + assert!( + (SubnetTaoFlow::::get(netuid) - lfd0).abs() <= tol, + "long default must reverse the open flow" + ); + SubtensorModule::set_long_dust(AlphaBalance::from(1)); // χ = 0 → flow-neutral: another short open leaves flow untouched. SubtensorModule::set_derivative_flow_factor_ppb(0); From 764d9e1f3fb48a26506e120cf809d1d57cda17ec Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 18 Jun 2026 19:55:08 -0600 Subject: [PATCH 16/21] feat(derivatives): self-covering cash-settled close for shorts and longs Add close_short_self / close_long_self dispatches that settle the liability against the pool from the position's own floor+buffer, so no pre-held Alpha (short) or TAO (long) is required. Reject underwater closes via the new CloseCostExceedsClaim error. Co-authored-by: Cursor --- .gitignore | 8 +- pallets/subtensor/src/derivatives/long.rs | 94 ++++++++++++++++++++++ pallets/subtensor/src/derivatives/mod.rs | 90 +++++++++++++++++++++ pallets/subtensor/src/macros/dispatches.rs | 28 +++++++ pallets/subtensor/src/macros/errors.rs | 4 + 5 files changed, 223 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ffaa69152f..653620d5ef 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,10 @@ __pycache__/ # Claude Code configuration (skills are checked in; everything else is ignored) .claude/* -!.claude/skills/ \ No newline at end of file +!.claude/skills/ + +# Local-only clones (not tracked) +/bittensor/ +/btcli/ +/derivtest/ +shorting.pdf \ No newline at end of file diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 47f341513d..c745f097ea 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -299,6 +299,100 @@ impl Pallet { Ok(()) } + /// Alpha that must be sold into the live pool to raise `d` TAO (CPMM spot). + /// Mirrors `short_spot_close_cost`. Saturates when the pool can't yield `d`. + fn long_spot_close_cost(netuid: NetUid, d: TaoBalance) -> I64F64 { + let t = Self::tao_f(SubnetTAO::::get(netuid)); + let a = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let df = Self::tao_f(d); + if t <= df { + return I64F64::from_num(1e18); + } + a.saturating_mul(df).safe_div(t.saturating_sub(df)) + } + + /// Self-covering close (cash-settled): the protocol sells just enough of the + /// `ρ(P+R)` Alpha claim into the pool to raise the `ρD` TAO liability and + /// settle it, so **no pre-held TAO is required** — a long is Alpha-in / + /// Alpha-out. Selling `K'` Alpha for `ρD` TAO and returning the TAO to settle + /// is TAO-neutral, netting to a one-sided Alpha injection (`K'` + escrow). + /// Rejected when `K'` exceeds the claim (underwater). + pub fn do_close_long_self( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!( + fraction_ppb > 0 && fraction_ppb <= 1_000_000_000, + Error::::InvalidCloseFraction + ); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + let mut pos = + LongPositions::::get(netuid, &coldkey).ok_or(Error::::LongPositionNotFound)?; + let mut agg = LongAggregate::::get(netuid); + Self::materialize_long(&mut pos, agg.omega); + + let d_close = Self::mul_tao(pos.d_liability, rho); + let r_close = Self::mul_alpha(pos.r_stored, rho); + let e_close = Self::mul_alpha(pos.e_stored, rho); + let p_close = Self::mul_alpha(pos.p_floor, rho); + let b_close = Self::mul_alpha(pos.b_stored, rho); + + // Alpha that must be sold to raise `ρD` TAO, charged to the claim. + let claim = p_close.saturating_add(r_close); + let k = Self::to_alpha(Self::long_spot_close_cost(netuid, d_close)); + ensure!(k <= claim, Error::::CloseCostExceedsClaim); + + // The sell-for-D-and-settle is TAO-neutral; `K'` (sale) + escrow both + // restore the pool's Alpha reserve. No `SubnetTAO` movement occurs. + Self::increase_provided_alpha_reserve(netuid, k.saturating_add(e_close)); + // Closing sells the Alpha exposure back for TAO: negative flow, reversing + // the open buy on the same `D` basis. + Self::record_derivative_outflow(netuid, d_close); + + // Return the remaining claim as Alpha stake (mint), like the explicit close. + let returned = claim.saturating_sub(k); + if !returned.is_zero() { + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + &pos.hotkey, + &coldkey, + netuid, + returned, + ); + SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(returned)); + } + + pos.d_liability = pos.d_liability.saturating_sub(d_close); + pos.r_stored = pos.r_stored.saturating_sub(r_close); + pos.e_stored = pos.e_stored.saturating_sub(e_close); + pos.p_floor = pos.p_floor.saturating_sub(p_close); + pos.b_stored = pos.b_stored.saturating_sub(b_close); + + agg.d_sigma = agg.d_sigma.saturating_sub(d_close); + agg.r_sigma = agg.r_sigma.saturating_sub(r_close); + agg.e_sigma = agg.e_sigma.saturating_sub(e_close); + agg.b_sigma = agg.b_sigma.saturating_sub(b_close); + Self::sync_active_long(netuid, &agg); + LongAggregate::::insert(netuid, agg); + + if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { + LongPositions::::remove(netuid, &coldkey); + LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_long_if_empty(netuid); + } else { + LongPositions::::insert(netuid, &coldkey, pos); + } + Self::deposit_event(Event::LongClosed { + coldkey, + netuid, + fraction_ppb, + repaid_tao: d_close, + returned, + }); + Ok(()) + } + /// Permissionless default once the buffer is dust and the grace window has /// elapsed. Restores residual Alpha, recycles the floor (left burned), /// extinguishes `D`. diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 8a4a0d19cd..20a6be0844 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -515,6 +515,96 @@ impl Pallet { Ok(()) } + /// Self-covering close (cash-settled): the protocol rebuys the `ρQ` Alpha + /// liability from the pool and charges the TAO cost against the trader's own + /// floor+buffer, so **no pre-held Alpha is required** — a short is TAO-in / + /// TAO-out. Buying `ρQ` and returning it to settle the synthetic debt is + /// Alpha-neutral, so it nets to a one-sided injection of `K` TAO into the + /// pool (`K` = current CPMM buyback cost). Rejected when `K` exceeds the + /// claim `ρ(P+R)` (underwater): close with own funds or let it default. + pub fn do_close_short_self( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + ensure!( + fraction_ppb > 0 && fraction_ppb <= 1_000_000_000, + Error::::InvalidCloseFraction + ); + let rho = I64F64::from_num(fraction_ppb).safe_div(I64F64::from_num(1_000_000_000u64)); + + let mut pos = + ShortPositions::::get(netuid, &coldkey).ok_or(Error::::ShortPositionNotFound)?; + let mut agg = ShortAggregate::::get(netuid); + Self::materialize_short(&mut pos, agg.omega); + + let q_close = Self::mul_alpha(pos.q_liability, rho); + let r_close = Self::mul_tao(pos.r_stored, rho); + let e_close = Self::mul_tao(pos.e_stored, rho); + let p_close = Self::mul_tao(pos.p_floor, rho); + let b_close = Self::mul_tao(pos.b_stored, rho); + + // Buyback cost to rebuy `ρQ` Alpha at the live pool, charged to the claim. + let claim = p_close.saturating_add(r_close); + let k = Self::to_tao(Self::short_spot_close_cost(netuid, q_close)); + ensure!(k <= claim, Error::::CloseCostExceedsClaim); + + let custody = Self::short_custody_account(netuid); + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + + // K (buyback) + escrow E both restore the pool's TAO reserve. The rebuy is + // Alpha-neutral, so no Alpha reserve / `SubnetAlphaOut` movement occurs. + let to_pool = k.saturating_add(e_close); + if !to_pool.is_zero() { + Self::transfer_tao(&custody, &subnet_account, to_pool.into())?; + Self::increase_provided_tao_reserve(netuid, to_pool); + TotalStake::::mutate(|t| *t = t.saturating_add(to_pool)); + } + // Closing rebuys `ρQ` Alpha: positive flow on the same `Q·pEMA` basis as + // the open, reversing it proportionally. + let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); + Self::record_derivative_inflow( + netuid, + Self::to_tao(Self::alpha_f(q_close).saturating_mul(pema)), + ); + + let returned = claim.saturating_sub(k); + if !returned.is_zero() { + Self::transfer_tao(&custody, &coldkey, returned.into())?; + } + + pos.q_liability = pos.q_liability.saturating_sub(q_close); + pos.r_stored = pos.r_stored.saturating_sub(r_close); + pos.e_stored = pos.e_stored.saturating_sub(e_close); + pos.p_floor = pos.p_floor.saturating_sub(p_close); + pos.b_stored = pos.b_stored.saturating_sub(b_close); + + agg.q_sigma = agg.q_sigma.saturating_sub(q_close); + agg.r_sigma = agg.r_sigma.saturating_sub(r_close); + agg.e_sigma = agg.e_sigma.saturating_sub(e_close); + agg.b_sigma = agg.b_sigma.saturating_sub(b_close); + Self::sync_active_short(netuid, &agg); + ShortAggregate::::insert(netuid, agg); + + if fraction_ppb == 1_000_000_000 || pos.p_floor.is_zero() { + ShortPositions::::remove(netuid, &coldkey); + ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_short_if_empty(netuid); + } else { + ShortPositions::::insert(netuid, &coldkey, pos); + } + Self::deposit_event(Event::ShortClosed { + coldkey, + netuid, + fraction_ppb, + repaid_alpha: q_close, + returned, + }); + Ok(()) + } + /// Permissionless default once the buffer has decayed to dust (spec §7.4). pub fn do_default_short( origin: OriginFor, diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index bde455d82b..65a4c17b93 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2683,5 +2683,33 @@ mod dispatches { ) -> DispatchResult { Self::do_default_long(origin, coldkey, netuid) } + + /// Self-covering close of `fraction_ppb / 1e9` of a covered short: the + /// protocol rebuys the Alpha liability from the pool and charges the cost + /// against the position's floor+buffer, so no pre-held Alpha is required + /// (TAO-in / TAO-out). Rejected if underwater (`K > P+R`). + #[pallet::call_index(147)] + #[pallet::weight(::DbWeight::get().reads_writes(10, 8))] + pub fn close_short_self( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + Self::do_close_short_self(origin, netuid, fraction_ppb) + } + + /// Self-covering close of `fraction_ppb / 1e9` of a covered long: the + /// protocol sells just enough of the Alpha claim into the pool to raise + /// and settle the TAO liability, so no pre-held TAO is required + /// (Alpha-in / Alpha-out). Rejected if underwater. + #[pallet::call_index(148)] + #[pallet::weight(::DbWeight::get().reads_writes(10, 8))] + pub fn close_long_self( + origin: OriginFor, + netuid: NetUid, + fraction_ppb: u64, + ) -> DispatchResult { + Self::do_close_long_self(origin, netuid, fraction_ppb) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index a377af7105..33c89c2894 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -319,6 +319,10 @@ mod errors { InvalidCloseFraction, /// Trader does not hold enough alpha to repay the liability. InsufficientAlphaToClose, + /// Self-covering close: the buyback/sellback cost to settle the liability + /// from the pool exceeds the position's floor+buffer claim (the position + /// is underwater). Close with own funds or let it default instead. + CloseCostExceedsClaim, /// Position has not decayed to dust and is not default-eligible. PositionNotDefaultEligible, /// Additional open targets a different hotkey than the existing position. From 0015601f897716956a983d618db797b7acb20248 Mon Sep 17 00:00:00 2001 From: unconst Date: Wed, 24 Jun 2026 12:54:56 -0600 Subject: [PATCH 17/21] feat(derivatives): caller slippage guard + fix close-cost overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slippage/limit-price protection (default off): open/top_up/close/close_self (+ long mirrors) take an optional `limit_price` (alpha price ppb). After the pool mutation the executable price is checked in the adverse direction (floor for price-lowering legs, ceiling for price-raising legs); violations revert with `SlippageExceeded`. Top-up has no pool interaction so the bound is a documented no-op. Also fixes an I64F64 overflow in short/long `spot_close_cost`: the rao-scale `t*q` product (~1e27) saturated I64F64 (~9.2e18), collapsing the buyback cost to ~0 — which made cash-settled close return only escrow (permanent ~N pool drain) and defeated the underwater guard. Reordered to ratio-first. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 20 ++++++- pallets/subtensor/src/derivatives/mod.rs | 62 +++++++++++++++++++++- pallets/subtensor/src/macros/dispatches.rs | 24 ++++++--- pallets/subtensor/src/macros/errors.rs | 3 ++ runtime/src/lib.rs | 2 +- 5 files changed, 100 insertions(+), 11 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index c745f097ea..554bb353ab 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -79,6 +79,7 @@ impl Pallet { hotkey: T::AccountId, netuid: NetUid, position_input: AlphaBalance, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!(LongsEnabled::::get(), Error::::LongsDisabled); @@ -177,6 +178,10 @@ impl Pallet { LongAggregate::::insert(netuid, agg); LongActiveSubnets::::insert(netuid, ()); + // Slippage guard: a long raises the price, so reject if it ended up above + // the caller's ceiling (sandwich/MEV protection). `None` = no bound. + Self::ensure_price_at_most(netuid, limit_price)?; + Self::deposit_event(Event::LongOpened { coldkey, netuid, @@ -193,6 +198,9 @@ impl Pallet { origin: OriginFor, netuid: NetUid, amount: AlphaBalance, + // Accepted for interface symmetry; top-up never touches the pool, so there + // is no execution price to bound. Intentionally unused. + _limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!(!amount.is_zero(), Error::::AmountTooLow); @@ -232,6 +240,7 @@ impl Pallet { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( @@ -268,6 +277,9 @@ impl Pallet { Self::increase_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, returned); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(returned)); } + // Slippage guard: a long unwind pushes the price down, so reject if it + // ended up below the caller's floor (sandwich/MEV protection). + Self::ensure_price_at_least(netuid, limit_price)?; pos.d_liability = pos.d_liability.saturating_sub(d_close); pos.r_stored = pos.r_stored.saturating_sub(r_close); @@ -308,7 +320,9 @@ impl Pallet { if t <= df { return I64F64::from_num(1e18); } - a.saturating_mul(df).safe_div(t.saturating_sub(df)) + // Ratio first to avoid I64F64 overflow on the rao-scale `a·d` product + // (see short_spot_close_cost for the full explanation). + a.saturating_mul(df.safe_div(t.saturating_sub(df))) } /// Self-covering close (cash-settled): the protocol sells just enough of the @@ -321,6 +335,7 @@ impl Pallet { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( @@ -362,6 +377,9 @@ impl Pallet { ); SubnetAlphaOut::::mutate(netuid, |o| *o = o.saturating_add(returned)); } + // Slippage guard: the self-cover sale pushes the price down, so reject if + // it ended up below the caller's floor (sandwich/MEV protection). + Self::ensure_price_at_least(netuid, limit_price)?; pos.d_liability = pos.d_liability.saturating_sub(d_close); pos.r_stored = pos.r_stored.saturating_sub(r_close); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 20a6be0844..31e0a31a62 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -285,6 +285,7 @@ impl Pallet { hotkey: T::AccountId, netuid: NetUid, position_input: TaoBalance, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!(ShortsEnabled::::get(), Error::::ShortsDisabled); @@ -386,6 +387,10 @@ impl Pallet { ShortAggregate::::insert(netuid, agg); ShortActiveSubnets::::insert(netuid, ()); + // Slippage guard: a short lowers the price, so reject if it ended up + // below the caller's floor (sandwich/MEV protection). `None` = no bound. + Self::ensure_price_at_least(netuid, limit_price)?; + Self::deposit_event(Event::ShortOpened { coldkey, netuid, @@ -402,6 +407,10 @@ impl Pallet { origin: OriginFor, netuid: NetUid, amount: TaoBalance, + // Accepted for CLI/interface symmetry. Top-up only credits the carry + // buffer in custody and never touches the pool, so there is no execution + // price and nothing to bound; the parameter is intentionally unused. + _limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!(!amount.is_zero(), Error::::AmountTooLow); @@ -430,6 +439,7 @@ impl Pallet { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( @@ -484,6 +494,9 @@ impl Pallet { if !returned.is_zero() { Self::transfer_tao(&custody, &coldkey, returned.into())?; } + // Slippage guard: settling escrow raises the price, so reject if it ended + // up above the caller's ceiling (sandwich/MEV protection). `None` = no bound. + Self::ensure_price_at_most(netuid, limit_price)?; pos.q_liability = pos.q_liability.saturating_sub(q_close); pos.r_stored = pos.r_stored.saturating_sub(r_close); @@ -526,6 +539,7 @@ impl Pallet { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( @@ -574,6 +588,9 @@ impl Pallet { if !returned.is_zero() { Self::transfer_tao(&custody, &coldkey, returned.into())?; } + // Slippage guard: the buyback raises the price, so reject if it ended up + // above the caller's ceiling (sandwich/MEV protection). `None` = no bound. + Self::ensure_price_at_most(netuid, limit_price)?; pos.q_liability = pos.q_liability.saturating_sub(q_close); pos.r_stored = pos.r_stored.saturating_sub(r_close); @@ -773,7 +790,50 @@ impl Pallet { // Liability un-buyable from the pool: saturate so cover = C, equity = 0. return I64F64::from_num(1e18); } - t.saturating_mul(qf).safe_div(a.saturating_sub(qf)) + // Compute the ratio `q/(a−q)` (which is O(1)) BEFORE multiplying by `t`. + // The naive `t·q` overflows: `t` and `q` are both rao-scale (~1e13–1e15), + // so the product (~1e27) saturates I64F64 (int range ~9.2e18) and collapses + // the cost to a garbage near-zero value — making the close return only the + // escrow to the pool (permanent ~N drain) and defeating the underwater guard. + t.saturating_mul(qf.safe_div(a.saturating_sub(qf))) + } + + // ---- slippage / limit-price protection (caller-supplied) ----------- + + /// Executable alpha price (TAO per alpha) scaled by 1e9, computed in `u128` + /// to avoid the rao×1e9 overflow. `u64::MAX` when the pool has no alpha. + pub fn executable_price_ppb(netuid: NetUid) -> u64 { + let t = u128::from(SubnetTAO::::get(netuid).to_u64()); + let a = u128::from(SubnetAlphaIn::::get(netuid).to_u64()); + if a == 0 { + return u64::MAX; + } + // `a > 0` here, so plain division is safe (and `u128` has no `safe_div`). + u64::try_from(t.saturating_mul(1_000_000_000u128) / a).unwrap_or(u64::MAX) + } + + /// Reject if the post-trade executable price fell below `limit` (used by the + /// price-lowering legs: short open, long close). `None` = no protection. + fn ensure_price_at_least(netuid: NetUid, limit: Option) -> DispatchResult { + if let Some(min) = limit { + ensure!( + Self::executable_price_ppb(netuid) >= min, + Error::::SlippageExceeded + ); + } + Ok(()) + } + + /// Reject if the post-trade executable price rose above `limit` (used by the + /// price-raising legs: short close, long open). `None` = no protection. + fn ensure_price_at_most(netuid: NetUid, limit: Option) -> DispatchResult { + if let Some(max) = limit { + ensure!( + Self::executable_price_ppb(netuid) <= max, + Error::::SlippageExceeded + ); + } + Ok(()) } // ---- governance setters (spec §14.6) ------------------------------- diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 70aa4c8b44..73db85c3af 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2634,8 +2634,9 @@ mod dispatches { hotkey: T::AccountId, netuid: NetUid, position_input: TaoBalance, + limit_price: Option, ) -> DispatchResult { - Self::do_open_short(origin, hotkey, netuid, position_input) + Self::do_open_short(origin, hotkey, netuid, position_input, limit_price) } /// Top up a covered short's carry buffer with fresh capital. @@ -2645,8 +2646,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, amount: TaoBalance, + limit_price: Option, ) -> DispatchResult { - Self::do_top_up_short(origin, netuid, amount) + Self::do_top_up_short(origin, netuid, amount, limit_price) } /// Close `fraction_ppb / 1e9` of a covered short (`1e9` = full close). @@ -2656,8 +2658,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { - Self::do_close_short(origin, netuid, fraction_ppb) + Self::do_close_short(origin, netuid, fraction_ppb, limit_price) } /// Permissionlessly default a covered short whose buffer reached dust. @@ -2679,8 +2682,9 @@ mod dispatches { hotkey: T::AccountId, netuid: NetUid, position_input: AlphaBalance, + limit_price: Option, ) -> DispatchResult { - Self::do_open_long(origin, hotkey, netuid, position_input) + Self::do_open_long(origin, hotkey, netuid, position_input, limit_price) } /// Top up a covered long's carry buffer with fresh Alpha. @@ -2690,8 +2694,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, amount: AlphaBalance, + limit_price: Option, ) -> DispatchResult { - Self::do_top_up_long(origin, netuid, amount) + Self::do_top_up_long(origin, netuid, amount, limit_price) } /// Close `fraction_ppb / 1e9` of a covered long (`1e9` = full close). @@ -2701,8 +2706,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { - Self::do_close_long(origin, netuid, fraction_ppb) + Self::do_close_long(origin, netuid, fraction_ppb, limit_price) } /// Permissionlessly default a covered long whose buffer reached dust. @@ -2726,8 +2732,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { - Self::do_close_short_self(origin, netuid, fraction_ppb) + Self::do_close_short_self(origin, netuid, fraction_ppb, limit_price) } /// Self-covering close of `fraction_ppb / 1e9` of a covered long: the @@ -2740,8 +2747,9 @@ mod dispatches { origin: OriginFor, netuid: NetUid, fraction_ppb: u64, + limit_price: Option, ) -> DispatchResult { - Self::do_close_long_self(origin, netuid, fraction_ppb) + Self::do_close_long_self(origin, netuid, fraction_ppb, limit_price) } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 87cd61dc2b..813f49de90 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -343,6 +343,9 @@ mod errors { LongHotkeyMismatch, /// Trader does not hold enough alpha collateral to open/extend the long. InsufficientCollateral, + /// The post-trade executable price violated the caller-supplied + /// `limit_price` slippage bound (sandwich/MEV protection). + SlippageExceeded, /// The supplied tempo is outside the allowed range. TempoOutOfBounds, /// The supplied activity-cutoff factor is outside the allowed range. diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b65ad47eed..60683a5365 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -234,7 +234,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 421, + spec_version: 425, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, From 996d1ba73317c6a141c8a7dab27466392c8f5421 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 25 Jun 2026 11:00:21 -0600 Subject: [PATCH 18/21] test: thread limit_price arg through derivative test call sites Adds the new optional limit_price (slippage guard) argument to all open/close/top_up short and long extrinsic calls in the derivatives tests so the pallet test build compiles after the slippage-guard change. Co-authored-by: Cursor --- pallets/subtensor/src/tests/derivatives.rs | 234 ++++++++++----------- 1 file changed, 117 insertions(+), 117 deletions(-) diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 4467bc4095..2121277b05 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -66,7 +66,7 @@ fn open_short_rejected_when_disabled() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None), Error::::ShortsDisabled ); }); @@ -80,7 +80,7 @@ fn open_short_rejected_on_stable_subnet() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None), Error::::SubnetNotDynamic ); }); @@ -93,7 +93,7 @@ fn open_short_rejects_zero_input() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(0)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(0), None), Error::::AmountTooLow ); }); @@ -134,7 +134,7 @@ fn open_matches_quote_and_moves_pool() { let tao_before = SubnetTAO::::get(netuid).to_u64(); let trader_before = bal(&trader); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); // Position fields equal the pure quote (same code path). @@ -173,7 +173,7 @@ fn open_rejected_when_capacity_exceeded() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None), Error::::ShortCapacityExceeded ); }); @@ -190,9 +190,9 @@ fn stacked_opens_share_capacity() { add_balance_to_coldkey_account(&a, t(1000 * TAO)); add_balance_to_coldkey_account(&b, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO), None)); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO), None), Error::::ShortCapacityExceeded ); }); @@ -210,7 +210,7 @@ fn low_liquidity_rejects_oversized_open() { add_balance_to_coldkey_account(&trader, t(1000 * TAO)); // P far larger than the pool can collateralize → retained proceeds ≤ 0. assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None), Error::::EffectiveLtvNonPositive ); }); @@ -232,7 +232,7 @@ fn small_open_on_fresh_subnet_with_cold_ema() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO), None)); assert!(ShortPositions::::get(netuid, trader).is_some()); }); } @@ -247,7 +247,7 @@ fn decay_shrinks_buffer_and_restores_tao() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); let tao0 = SubnetTAO::::get(netuid).to_u64(); @@ -279,7 +279,7 @@ fn block_step_runs_decay() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); step_block(5); assert!(ShortAggregate::::get(netuid).r_sigma.to_u64() < r0); @@ -296,11 +296,11 @@ fn top_up_adds_buffer_only() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let pos0 = ShortPositions::::get(netuid, trader).unwrap(); let custody0 = custody_bal(netuid); - assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(10 * TAO))); + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(10 * TAO), None)); let pos1 = ShortPositions::::get(netuid, trader).unwrap(); assert_eq!(pos1.r_stored, pos0.r_stored + t(10 * TAO)); @@ -318,7 +318,7 @@ fn top_up_requires_position() { let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); assert_noop!( - SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO)), + SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO), None), Error::::ShortPositionNotFound ); }); @@ -336,9 +336,9 @@ fn additional_open_merges_into_position() { let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO), None)); let p1 = ShortPositions::::get(netuid, trader).unwrap(); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO), None)); let p2 = ShortPositions::::get(netuid, trader).unwrap(); assert_eq!(p2.p_floor, t(100 * TAO)); @@ -361,7 +361,7 @@ fn full_close_conserves_value() { let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); let p = 100 * TAO; - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(p), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let (n, e, q) = (pos.r_stored.to_u64(), pos.e_stored.to_u64(), pos.q_liability); @@ -372,7 +372,7 @@ fn full_close_conserves_value() { give_alpha(hotkey, trader, netuid, AlphaBalance::from(q.to_u64() + 10 * TAO)); let trader_before_close = bal(&trader); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); // Position gone, aggregate empty. assert!(ShortPositions::::get(netuid, trader).is_none()); @@ -396,13 +396,13 @@ fn partial_close_reduces_prorata() { let trader = U256::from(10); let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); let pos0 = ShortPositions::::get(netuid, trader).unwrap(); give_alpha(hotkey, trader, netuid, AlphaBalance::from(pos0.q_liability.to_u64())); // Close half. - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 500_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 500_000_000, None)); let pos1 = ShortPositions::::get(netuid, trader).unwrap(); assert_approx(pos1.p_floor.to_u64(), pos0.p_floor.to_u64() / 2, 2, "p/2"); @@ -418,10 +418,10 @@ fn close_without_alpha_rejected() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); // No alpha staked at the hotkey → cannot repay the liability. assert_noop!( - SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000), + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None), Error::::InsufficientAlphaToClose ); }); @@ -433,13 +433,13 @@ fn close_invalid_fraction_rejected() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); assert_noop!( - SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 0), + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 0, None), Error::::InvalidCloseFraction ); assert_noop!( - SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_001), + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_001, None), Error::::InvalidCloseFraction ); }); @@ -455,7 +455,7 @@ fn default_rejected_when_buffer_above_dust() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let poker = U256::from(99); assert_noop!( SubtensorModule::default_short(RuntimeOrigin::signed(poker), trader, netuid), @@ -470,7 +470,7 @@ fn default_recycles_floor_and_restores_residual() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let (p, n, e) = (pos.p_floor.to_u64(), pos.r_stored.to_u64(), pos.e_stored.to_u64()); @@ -515,7 +515,7 @@ fn dereg_settles_in_the_money_short() { let trader = U256::from(10); let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let c = pos.p_floor.to_u64() + pos.r_stored.to_u64(); // P + R @@ -539,7 +539,7 @@ fn dereg_settles_underwater_short_with_zero_equity() { let trader = U256::from(10); let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); // Drive the EMA liability reference far above the collateral claim. SubnetMovingPrice::::insert(netuid, I96F32::from_num(50.0)); @@ -562,7 +562,7 @@ fn dissolve_network_clears_shorts() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); assert!(ShortPositions::::get(netuid, trader).is_some()); assert_ok!(SubtensorModule::do_dissolve_network(netuid)); @@ -586,10 +586,10 @@ fn merge_with_mismatched_hotkey_rejected() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(50 * TAO), None)); // Second open with a different hotkey must be rejected, leaving state intact. assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), netuid, t(50 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), netuid, t(50 * TAO), None), Error::::ShortHotkeyMismatch ); let pos = ShortPositions::::get(netuid, trader).unwrap(); @@ -608,11 +608,11 @@ fn open_below_min_input_rejected() { SubtensorModule::set_short_min_input(t(TAO)); // 1 TAO floor assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO / 2)), + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO / 2), None), Error::::AmountTooLow ); // At/above the floor it succeeds. - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(TAO), None)); }); } @@ -624,7 +624,7 @@ fn permissionless_default_respects_grace_window() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); // Make the buffer dust-eligible, set a short grace window. SubtensorModule::set_short_dust(t(1000 * TAO)); @@ -651,13 +651,13 @@ fn top_up_resets_default_grace() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); SubtensorModule::set_short_dust(t(1000 * TAO)); SubtensorModule::set_short_default_grace(5); step_block(6); // grace from open has elapsed // Owner tops up, resetting last_active to the current block. - assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO))); + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(trader), netuid, t(TAO), None)); // A snipe is now blocked again for another grace window. let poker = U256::from(99); @@ -681,12 +681,12 @@ fn active_subnet_set_tracks_membership() { // No shorts yet → not tracked. assert!(!ShortActiveSubnets::::contains_key(netuid)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); assert!(ShortActiveSubnets::::contains_key(netuid)); let pos = ShortPositions::::get(netuid, trader).unwrap(); give_alpha(hotkey, trader, netuid, AlphaBalance::from(pos.q_liability.to_u64() + 10 * TAO)); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); // Fully closed → no longer tracked, so decay skips this subnet. assert!(!ShortActiveSubnets::::contains_key(netuid)); @@ -705,7 +705,7 @@ fn position_view_materializes_decay() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); // strong decay let raw = ShortPositions::::get(netuid, trader).unwrap().r_stored.to_u64(); @@ -734,7 +734,7 @@ fn position_view_reports_default_window() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); SubtensorModule::set_short_dust(t(1000 * TAO)); // buffer is dust SubtensorModule::set_short_default_grace(5); @@ -755,7 +755,7 @@ fn market_view_reports_capacity() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let m = SubtensorModule::get_subnet_short_state(netuid).unwrap(); @@ -779,7 +779,7 @@ fn close_quote_matches_position() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let full = SubtensorModule::quote_close_short(&trader, netuid, 1_000_000_000).unwrap(); @@ -805,7 +805,7 @@ fn materialize_never_inflates() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); // Corrupt the invariant: set omega_entry far above the aggregate omega. let mut pos = ShortPositions::::get(netuid, trader).unwrap(); @@ -830,12 +830,12 @@ fn open_close_roundtrip_is_not_profitable() { add_balance_to_coldkey_account(&trader, t(1000 * TAO)); let before = bal(&trader); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); let n = pos.r_stored.to_u64(); // Seed exactly the liability alpha so the round trip is self-contained. give_alpha(hotkey, trader, netuid, pos.q_liability); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); // TAO-only delta is +N (the retained proceeds); the trader still had to // source Q alpha, whose pool buy-cost strictly exceeds N — so no free TAO. @@ -853,7 +853,7 @@ fn close_guards_against_alpha_mint() { let trader = U256::from(10); let hotkey = U256::from(11); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); let pos = ShortPositions::::get(netuid, trader).unwrap(); give_alpha(hotkey, trader, netuid, pos.q_liability); @@ -862,7 +862,7 @@ fn close_guards_against_alpha_mint() { SubnetAlphaOut::::insert(netuid, AlphaBalance::from(0)); let alpha_in_before = SubnetAlphaIn::::get(netuid); assert_noop!( - SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000), + SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None), Error::::InsufficientAlphaToClose ); assert_eq!(SubnetAlphaIn::::get(netuid), alpha_in_before); // no mint @@ -892,26 +892,26 @@ fn position_count_cap_enforced_and_maintained() { add_balance_to_coldkey_account(&k, t(1000 * TAO)); } - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(20 * TAO))); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(20 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(20 * TAO), None)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(20 * TAO), None)); assert_eq!(ShortPositionCount::::get(netuid), 2); // Third distinct position exceeds the cap. assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO), None), Error::::ShortPositionLimit ); // Closing one frees a slot; the count is decremented and reusable. let pos = ShortPositions::::get(netuid, a).unwrap(); give_alpha(U256::from(11), a, netuid, pos.q_liability); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000, None)); assert_eq!(ShortPositionCount::::get(netuid), 1); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO), None)); assert_eq!(ShortPositionCount::::get(netuid), 2); // A merge (same coldkey, same hotkey) does not consume a new slot. - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), U256::from(31), netuid, t(20 * TAO), None)); assert_eq!(ShortPositionCount::::get(netuid), 2); }); } @@ -941,8 +941,8 @@ fn proof_full_lifecycle_conserves_tao_and_alpha() { let tao0 = TotalIssuance::::get().to_u64(); let alpha0 = alpha_issuance(netuid); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO))); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO), None)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO), None)); // Continuous unwind on both sides. for _ in 0..500 { @@ -951,12 +951,12 @@ fn proof_full_lifecycle_conserves_tao_and_alpha() { } // Mid-life owner actions. - assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(s_cold), netuid, t(10 * TAO))); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 500_000_000)); // half + assert_ok!(SubtensorModule::top_up_short(RuntimeOrigin::signed(s_cold), netuid, t(10 * TAO), None)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 500_000_000, None)); // half // Close everything out. - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 1_000_000_000)); - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(l_cold), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s_cold), netuid, 1_000_000_000, None)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(l_cold), netuid, 1_000_000_000, None)); // CONSERVATION. // TAO only ever *moves* between accounts (no recycle on this all-close @@ -996,7 +996,7 @@ fn proof_default_recycles_exactly_the_floor() { add_balance_to_coldkey_account(&s_cold, t(1000 * TAO)); SubtensorModule::set_short_default_grace(0); SubtensorModule::set_short_dust(t(10_000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s_cold), s_hot, netuid, t(100 * TAO), None)); let tao_before = TotalIssuance::::get().to_u64(); assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(U256::from(99)), s_cold, netuid)); assert_eq!( @@ -1013,7 +1013,7 @@ fn proof_default_recycles_exactly_the_floor() { // Measure BEFORE open: long open burns alpha, default restores all but the // floor, so the net effect of open+default is exactly −floor. let alpha_before = alpha_issuance(netuid); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(l_cold), l_hot, netuid, AlphaBalance::from(100 * TAO), None)); assert_ok!(SubtensorModule::default_long(RuntimeOrigin::signed(U256::from(98)), l_cold, netuid)); assert_eq!( alpha_issuance(netuid), @@ -1052,10 +1052,10 @@ fn proof_multi_position_decay_conserves() { let alpha0 = alpha_issuance(netuid); for (c, h, p) in shorts { - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), h, netuid, t(p))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(c), h, netuid, t(p), None)); } for (c, h, p) in longs { - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(c), h, netuid, AlphaBalance::from(p))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(c), h, netuid, AlphaBalance::from(p), None)); } for _ in 0..300 { @@ -1064,10 +1064,10 @@ fn proof_multi_position_decay_conserves() { } for (c, _, _) in shorts { - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(c), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(c), netuid, 1_000_000_000, None)); } for (c, _, _) in longs { - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(c), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(c), netuid, 1_000_000_000, None)); } const TOL: u64 = 10_000_000; // 0.01 token @@ -1095,11 +1095,11 @@ fn short_many_partial_closes_drain_cleanly() { give_alpha(hotkey, trader, netuid, AlphaBalance::from(5000 * TAO)); let tao0 = TotalIssuance::::get().to_u64(); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(100 * TAO), None)); for _ in 0..9 { - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 100_000_000)); // 10% of remaining + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 100_000_000, None)); // 10% of remaining } - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); assert!(ShortPositions::::get(netuid, trader).is_none()); assert_eq!(TotalIssuance::::get().to_u64(), tao0); @@ -1152,13 +1152,13 @@ fn cleanup_evicts_only_after_last_short_closes() { } give_alpha(U256::from(11), a, netuid, AlphaBalance::from(5000 * TAO)); give_alpha(U256::from(21), b, netuid, AlphaBalance::from(5000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO))); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(a), U256::from(11), netuid, t(50 * TAO), None)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(b), U256::from(21), netuid, t(50 * TAO), None)); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(a), netuid, 1_000_000_000, None)); assert!(ShortActiveSubnets::::contains_key(netuid), "still active while b open"); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(b), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(b), netuid, 1_000_000_000, None)); assert!(!ShortActiveSubnets::::contains_key(netuid), "evicted after last close"); }); } @@ -1173,7 +1173,7 @@ fn long_capacity_cap_enforced() { let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None), Error::::LongCapacityExceeded ); }); @@ -1188,10 +1188,10 @@ fn long_partial_close_reduces_prorata() { let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let p0 = LongPositions::::get(netuid, trader).unwrap(); - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 500_000_000)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 500_000_000, None)); let p1 = LongPositions::::get(netuid, trader).unwrap(); assert_approx(p1.p_floor.to_u64(), p0.p_floor.to_u64() / 2, 2, "p/2"); assert_approx(p1.d_liability.to_u64(), p0.d_liability.to_u64() / 2, 2, "d/2"); @@ -1208,7 +1208,7 @@ fn long_dereg_underwater_pays_zero_equity() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); // Crash the price: D/price ≫ collateral ⇒ cover = C_L, equity = 0. SubnetMovingPrice::::insert(netuid, I96F32::from_num(0.0001)); @@ -1234,7 +1234,7 @@ fn open_long_guards_against_alpha_mint() { // Corrupt outstanding alpha below the collateral; open must refuse. SubnetAlphaOut::::insert(netuid, AlphaBalance::from(0)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None), Error::::InsufficientCollateral ); }); @@ -1248,11 +1248,11 @@ fn long_top_up_adds_buffer_and_resets_grace() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let r0 = LongPositions::::get(netuid, trader).unwrap().r_stored; let stake0 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid); - assert_ok!(SubtensorModule::top_up_long(RuntimeOrigin::signed(trader), netuid, AlphaBalance::from(10 * TAO))); + assert_ok!(SubtensorModule::top_up_long(RuntimeOrigin::signed(trader), netuid, AlphaBalance::from(10 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); assert_eq!(pos.r_stored, r0 + AlphaBalance::from(10 * TAO)); assert_eq!( @@ -1269,11 +1269,11 @@ fn long_merge_mismatch_and_position_cap() { let netuid = setup_long(1000 * TAO, 1000 * TAO, 1.0); let a = U256::from(10); give_alpha(U256::from(11), a, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(11), netuid, AlphaBalance::from(20 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(11), netuid, AlphaBalance::from(20 * TAO), None)); // Same coldkey, different hotkey → rejected. give_alpha(U256::from(12), a, netuid, AlphaBalance::from(100 * TAO)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(12), netuid, AlphaBalance::from(20 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(a), U256::from(12), netuid, AlphaBalance::from(20 * TAO), None), Error::::LongHotkeyMismatch ); @@ -1282,7 +1282,7 @@ fn long_merge_mismatch_and_position_cap() { let b = U256::from(20); give_alpha(U256::from(21), b, netuid, AlphaBalance::from(100 * TAO)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(b), U256::from(21), netuid, AlphaBalance::from(20 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(b), U256::from(21), netuid, AlphaBalance::from(20 * TAO), None), Error::::LongPositionLimit ); }); @@ -1298,16 +1298,16 @@ fn long_close_invalid_fraction_and_min_input() { give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); SubtensorModule::set_long_min_input(AlphaBalance::from(TAO)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(TAO / 2)), + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(TAO / 2), None), Error::::AmountTooLow ); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); assert_noop!( - SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 0), + SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 0, None), Error::::InvalidCloseFraction ); assert_noop!( - SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_001), + SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_001, None), Error::::InvalidCloseFraction ); }); @@ -1328,10 +1328,10 @@ fn derivatives_write_subnet_flow() { let shk = U256::from(11); give_alpha(shk, s, netuid, AlphaBalance::from(5000 * TAO)); // to repay Q on close let f0 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s), shk, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s), shk, netuid, t(100 * TAO), None)); let f1 = SubnetTaoFlow::::get(netuid); assert!(f1 < f0, "short open must write negative flow: {f1} !< {f0}"); - assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(s), netuid, 1_000_000_000, None)); let f_rt = SubnetTaoFlow::::get(netuid); let tol = (TAO as i64) / 1000; // generous rounding tolerance assert!(f_rt > f1, "short close must reverse toward positive flow"); @@ -1346,7 +1346,7 @@ fn derivatives_write_subnet_flow() { let sdh = U256::from(41); add_balance_to_coldkey_account(&sd, t(1000 * TAO)); let fd0 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sd), sdh, netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sd), sdh, netuid, t(100 * TAO), None)); SubtensorModule::set_short_dust(t(10_000 * TAO)); SubtensorModule::set_short_default_grace(0); assert_ok!(SubtensorModule::default_short(RuntimeOrigin::signed(U256::from(99)), sd, netuid)); @@ -1363,10 +1363,10 @@ fn derivatives_write_subnet_flow() { give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); add_balance_to_coldkey_account(&lc, t(1000 * TAO)); // to repay D on close let f2 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO), None)); let f3 = SubnetTaoFlow::::get(netuid); assert!(f3 > f2, "long open must write positive flow"); - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(lc), netuid, 1_000_000_000, None)); let lf_rt = SubnetTaoFlow::::get(netuid); assert!(lf_rt < f3, "long close must reverse toward negative flow"); assert!( @@ -1379,7 +1379,7 @@ fn derivatives_write_subnet_flow() { let ldh = U256::from(51); give_alpha(ldh, ld, netuid, AlphaBalance::from(500 * TAO)); let lfd0 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(ld), ldh, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(ld), ldh, netuid, AlphaBalance::from(100 * TAO), None)); SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); SubtensorModule::set_long_default_grace(0); assert_ok!(SubtensorModule::default_long(RuntimeOrigin::signed(U256::from(98)), ld, netuid)); @@ -1394,7 +1394,7 @@ fn derivatives_write_subnet_flow() { let s2 = U256::from(30); add_balance_to_coldkey_account(&s2, t(1000 * TAO)); let f3 = SubnetTaoFlow::::get(netuid); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s2), U256::from(31), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(s2), U256::from(31), netuid, t(100 * TAO), None)); assert_eq!(SubnetTaoFlow::::get(netuid), f3, "χ=0 must be flow-neutral"); }); } @@ -1408,8 +1408,8 @@ fn default_grace_independent_per_side() { let (lc, lh) = (U256::from(20), U256::from(21)); add_balance_to_coldkey_account(&sc, t(1000 * TAO)); give_alpha(lh, lc, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sc), sh, netuid, t(100 * TAO))); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(sc), sh, netuid, t(100 * TAO), None)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(lc), lh, netuid, AlphaBalance::from(100 * TAO), None)); SubtensorModule::set_short_dust(t(10_000 * TAO)); SubtensorModule::set_long_dust(AlphaBalance::from(10_000 * TAO)); @@ -1439,7 +1439,7 @@ fn long_open_quote_matches_position() { give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); let q = SubtensorModule::quote_open_long(netuid, AlphaBalance::from(100 * TAO)).unwrap(); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); assert_eq!(pos.r_stored, q.retained_proceeds); assert_eq!(pos.d_liability, q.tao_liability); @@ -1466,7 +1466,7 @@ fn long_position_view_materializes_decay() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); let raw = LongPositions::::get(netuid, trader).unwrap().r_stored.to_u64(); @@ -1490,7 +1490,7 @@ fn long_market_view_reports_capacity() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); let m = SubtensorModule::get_subnet_long_state(netuid).unwrap(); @@ -1514,7 +1514,7 @@ fn long_close_quote_matches_position() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); let full = SubtensorModule::quote_close_long(&trader, netuid, 1_000_000_000).unwrap(); @@ -1539,8 +1539,8 @@ fn list_long_positions_across_subnets() { let trader = U256::from(10); give_alpha(U256::from(11), trader, n1, AlphaBalance::from(200 * TAO)); give_alpha(U256::from(12), trader, n2, AlphaBalance::from(200 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(11), n1, AlphaBalance::from(50 * TAO))); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(12), n2, AlphaBalance::from(50 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(11), n1, AlphaBalance::from(50 * TAO), None)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), U256::from(12), n2, AlphaBalance::from(50 * TAO), None)); let all = SubtensorModule::get_long_positions(&trader); assert_eq!(all.len(), 2); @@ -1560,7 +1560,7 @@ fn decay_rate_matches_closed_form() { let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, t(100 * TAO), None)); SubtensorModule::set_decay_bounds_ppb(1_000_000_000, 1_000_000_000); // d = 1.0/day let r0 = ShortAggregate::::get(netuid).r_sigma.to_u64(); @@ -1603,7 +1603,7 @@ fn open_long_rejected_when_disabled() { let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None), Error::::LongsDisabled ); }); @@ -1620,7 +1620,7 @@ fn open_long_moves_alpha_off_issuance() { let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); let stake0 = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &trader, netuid).to_u64(); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); let (n, e, d) = (pos.r_stored.to_u64(), pos.e_stored.to_u64(), pos.d_liability.to_u64()); @@ -1648,12 +1648,12 @@ fn full_close_long_conserves_value() { add_balance_to_coldkey_account(&trader, t(1000 * TAO)); // TAO to repay D let iss0 = alpha_issuance(netuid); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); let d = pos.d_liability.to_u64(); let tao0 = SubnetTAO::::get(netuid).to_u64(); - assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_000)); + assert_ok!(SubtensorModule::close_long(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); assert!(LongPositions::::get(netuid, trader).is_none()); assert!(!LongActiveSubnets::::contains_key(netuid)); @@ -1673,7 +1673,7 @@ fn long_decay_restores_alpha_to_pool() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let r0 = LongAggregate::::get(netuid).r_sigma.to_u64(); let alpha_in0 = SubnetAlphaIn::::get(netuid).to_u64(); @@ -1692,7 +1692,7 @@ fn long_default_recycles_floor_and_restores_residual() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); let pos = LongPositions::::get(netuid, trader).unwrap(); let (p, n, e) = (pos.p_floor.to_u64(), pos.r_stored.to_u64(), pos.e_stored.to_u64()); SubtensorModule::set_long_dust(AlphaBalance::from(1000 * TAO)); @@ -1717,7 +1717,7 @@ fn dereg_settles_longs() { let trader = U256::from(10); let hotkey = U256::from(11); give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(100 * TAO), None)); assert!(LongPositions::::get(netuid, trader).is_some()); assert_ok!(SubtensorModule::do_dissolve_network(netuid)); @@ -1743,7 +1743,7 @@ fn open_long_respects_stake_lock() { // A long against the locked alpha is rejected (would otherwise free it). assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(cold), hot, netuid, AlphaBalance::from(100 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(cold), hot, netuid, AlphaBalance::from(100 * TAO), None), Error::::StakeUnavailable ); }); @@ -1760,9 +1760,9 @@ fn short_and_long_flags_are_independent() { give_alpha(hotkey, trader, netuid, AlphaBalance::from(500 * TAO)); // Shorts enabled, longs disabled. - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hotkey, netuid, t(50 * TAO), None)); assert_noop!( - SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO)), + SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO), None), Error::::LongsDisabled ); @@ -1771,10 +1771,10 @@ fn short_and_long_flags_are_independent() { SubtensorModule::set_longs_enabled(true); SubtensorModule::set_long_kappa_ppb(900_000_000); assert_noop!( - SubtensorModule::open_short(RuntimeOrigin::signed(U256::from(20)), hotkey, netuid, t(50 * TAO)), + SubtensorModule::open_short(RuntimeOrigin::signed(U256::from(20)), hotkey, netuid, t(50 * TAO), None), Error::::ShortsDisabled ); - assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO))); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hotkey, netuid, AlphaBalance::from(50 * TAO), None)); }); } @@ -1786,8 +1786,8 @@ fn list_positions_across_subnets() { let n2 = setup_market(1000 * TAO, 1000 * TAO, 1.0); let trader = U256::from(10); add_balance_to_coldkey_account(&trader, t(1000 * TAO)); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), n1, t(50 * TAO))); - assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), n2, t(50 * TAO))); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), n1, t(50 * TAO), None)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(12), n2, t(50 * TAO), None)); let all = SubtensorModule::get_short_positions(&trader); assert_eq!(all.len(), 2); From e919c377d9b7383eff4c93f93f04c120f822b872 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 25 Jun 2026 11:57:55 -0600 Subject: [PATCH 19/21] feat(derivatives): quote close cost through the swap engine (fee+weight aware) Replace the hand-rolled constant-product close-cost math in short_spot_close_cost / long_spot_close_cost with exact-output quotes through the swap engine, so buyback/sell costs match real pool execution including fees and pool weights (and avoid the I64F64 overflow the old rao-scale product hit). - swap-interface: add SwapHandler::sim_tao_in_for_alpha_out / sim_alpha_in_for_tao_out exact-output quotes. - swap: implement them via a new Balancer::get_quote_needed_for_base (mirror of get_base_needed_for_quote) and a gross_up_fee helper. - longs: settle_longs_on_dereg now covers the D debt at the conservative max(spot, EMA) alpha cost, mirroring the short's K_D, so a stale-high EMA after a fast drop can't under-seize and leak equity to the long. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 39 +++++++------ pallets/subtensor/src/derivatives/mod.rs | 20 +++---- pallets/swap/src/pallet/balancer.rs | 20 +++++++ pallets/swap/src/pallet/impls.rs | 71 +++++++++++++++++++++++ primitives/swap-interface/src/lib.rs | 16 +++++ 5 files changed, 136 insertions(+), 30 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 554bb353ab..ac5d35784a 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -311,18 +311,15 @@ impl Pallet { Ok(()) } - /// Alpha that must be sold into the live pool to raise `d` TAO (CPMM spot). - /// Mirrors `short_spot_close_cost`. Saturates when the pool can't yield `d`. + /// Fee+weight-aware Alpha that must be sold into the live pool to raise `d` + /// TAO, quoted through the swap engine (exact-output). Mirrors + /// `short_spot_close_cost`. Saturates to a sentinel when the pool can't + /// yield `d`. fn long_spot_close_cost(netuid: NetUid, d: TaoBalance) -> I64F64 { - let t = Self::tao_f(SubnetTAO::::get(netuid)); - let a = Self::alpha_f(SubnetAlphaIn::::get(netuid)); - let df = Self::tao_f(d); - if t <= df { - return I64F64::from_num(1e18); + match T::SwapInterface::sim_alpha_in_for_tao_out(netuid.into(), d) { + Ok(alpha) => Self::alpha_f(alpha), + Err(_) => I64F64::from_num(1e18), } - // Ratio first to avoid I64F64 overflow on the rao-scale `a·d` product - // (see short_spot_close_cost for the full explanation). - a.saturating_mul(df.safe_div(t.saturating_sub(df))) } /// Self-covering close (cash-settled): the protocol sells just enough of the @@ -489,8 +486,9 @@ impl Pallet { // ---- terminal deregistration settlement (spec §11.5) --------------- /// Settle all longs on a subnet at deregistration: escrow Alpha rejoins the - /// pool; collateral is valued at the price EMA; the alpha covering the TAO - /// debt stays burned (recycled); the equity remainder returns as stake. + /// pool; the `D` TAO debt is covered at the conservative + /// `max(spot, EMA)` alpha cost (mirror of the short's `K_D`); the cover + /// alpha stays burned (recycled); the equity remainder returns as stake. pub fn settle_longs_on_dereg(netuid: NetUid) { let agg = LongAggregate::::get(netuid); let price = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); @@ -502,13 +500,20 @@ impl Pallet { Self::increase_provided_alpha_reserve(netuid, pos.e_stored); let c_l = Self::alpha_f(pos.p_floor.saturating_add(pos.r_stored)); - let d = Self::tao_f(pos.d_liability); - // Alpha needed to cover the TAO debt at the terminal price. - let cover = if price > I64F64::from_num(0) { - c_l.min(d.safe_div(price)) + // Cover the `D` TAO debt at the price that is conservative for the + // pool: the MORE alpha-expensive of the live spot cost and the EMA + // valuation. Mirrors the short's `K_D = max(K_spot, Q·pEMA)`. Using + // the EMA alone (`D/pEMA`) would let a stale-high EMA after a fast + // price drop under-seize, leaking equity to the long. + let k_spot = Self::long_spot_close_cost(netuid, pos.d_liability); + // Flat EMA cover `D/pEMA`; zero when the EMA is cold so the `max` + // falls back to spot (mirrors the short's `Q·pEMA == 0` case). + let k_ema = if price > I64F64::from_num(0) { + Self::tao_f(pos.d_liability).safe_div(price) } else { - c_l + I64F64::from_num(0) }; + let cover = c_l.min(k_spot.max(k_ema)); let equity = Self::to_alpha(c_l.saturating_sub(cover)); if !equity.is_zero() { Self::increase_stake_for_hotkey_and_coldkey_on_subnet(&pos.hotkey, &coldkey, netuid, equity); diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 31e0a31a62..370402c7c6 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -34,6 +34,7 @@ use safe_math::FixedExt; use sp_runtime::traits::AccountIdConversion; use substrate_fixed::types::I64F64; use subtensor_runtime_common::Token; +use subtensor_swap_interface::SwapHandler; pub mod long; pub mod types; @@ -781,21 +782,14 @@ impl Pallet { ShortPositionCount::::remove(netuid); } - /// Slippage-aware TAO cost to buy `q` alpha on the live pool (CPMM core). + /// Fee+weight-aware TAO cost to buy `q` alpha on the live pool, quoted + /// through the swap engine (exact-output). Saturates to a sentinel when the + /// pool cannot supply `q` so cover = C, equity = 0. fn short_spot_close_cost(netuid: NetUid, q: AlphaBalance) -> I64F64 { - let t = Self::tao_f(SubnetTAO::::get(netuid)); - let a = Self::alpha_f(SubnetAlphaIn::::get(netuid)); - let qf = Self::alpha_f(q); - if a <= qf { - // Liability un-buyable from the pool: saturate so cover = C, equity = 0. - return I64F64::from_num(1e18); + match T::SwapInterface::sim_tao_in_for_alpha_out(netuid.into(), q) { + Ok(tao) => Self::tao_f(tao), + Err(_) => I64F64::from_num(1e18), } - // Compute the ratio `q/(a−q)` (which is O(1)) BEFORE multiplying by `t`. - // The naive `t·q` overflows: `t` and `q` are both rao-scale (~1e13–1e15), - // so the product (~1e27) saturates I64F64 (int range ~9.2e18) and collapses - // the cost to a garbage near-zero value — making the close return only the - // escrow to the pool (permanent ~N drain) and defeating the underwater guard. - t.saturating_mul(qf.safe_div(a.saturating_sub(qf))) } // ---- slippage / limit-price protection (caller-supplied) ----------- diff --git a/pallets/swap/src/pallet/balancer.rs b/pallets/swap/src/pallet/balancer.rs index 2ffd04fdba..225f33d5f4 100644 --- a/pallets/swap/src/pallet/balancer.rs +++ b/pallets/swap/src/pallet/balancer.rs @@ -394,6 +394,26 @@ impl Balancer { .saturating_mul(e.saturating_sub(one)) .saturating_to_num::() } + + /// Calculates amount of TAO that needs to be paid in to buy a given amount + /// of Alpha out. Mirror of `get_base_needed_for_quote` with the currencies + /// swapped (∆x = delta_alpha is the base taken out, ∆y the quote paid in): + /// + /// ∆y = y * ((x / (x - ∆x))^(w1/w2) - 1) + pub fn get_quote_needed_for_base( + &self, + tao_reserve: u64, + alpha_reserve: u64, + delta_alpha: u64, + ) -> u64 { + let e = self.exp_scaled(alpha_reserve, (delta_alpha as i128).neg(), true); + let one = U64F64::from_num(1); + let tao_reserve_fixed = U64F64::from_num(tao_reserve); + // e > 1 in this case + tao_reserve_fixed + .saturating_mul(e.saturating_sub(one)) + .saturating_to_num::() + } } // cargo test --package pallet-subtensor-swap --lib -- pallet::balancer::tests --nocapture diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index c3e0b2f1d3..1e9a82ebf4 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -247,6 +247,23 @@ impl Pallet { } } + /// Gross up a net (post-fee) swap input by the subnet fee, so the result is + /// the total a trader pays in. Inverse of `swap_step`'s + /// `net = amount·(1 − rate)` where `rate = FeeRate / u16::MAX`. Saturates if + /// the fee rate is degenerate (≥ u16::MAX). + pub(crate) fn gross_up_fee(netuid: NetUid, net_in: u64) -> u64 { + let fee_rate = u128::from(FeeRate::::get(netuid)); + let u16_max = u128::from(u16::MAX); + let denom = u16_max.saturating_sub(fee_rate); + if denom == 0 { + return u64::MAX; + } + u128::from(net_in) + .saturating_mul(u16_max) + .safe_div(denom) + .min(u128::from(u64::MAX)) as u64 + } + /// Returns the protocol account ID /// /// # Returns @@ -370,6 +387,60 @@ impl SwapHandler for Pallet { } } + fn sim_tao_in_for_alpha_out( + netuid: NetUid, + alpha_out: AlphaBalance, + ) -> Result { + match T::SubnetInfo::mechanism(netuid) { + 1 => { + let alpha_reserve = T::AlphaReserve::reserve(netuid); + let tao_reserve = T::TaoReserve::reserve(netuid); + ensure!(alpha_reserve > alpha_out, Error::::InsufficientLiquidity); + let balancer = SwapBalancer::::get(netuid); + let net_in = balancer.get_quote_needed_for_base( + tao_reserve.into(), + alpha_reserve.into(), + alpha_out.into(), + ); + Ok(Self::gross_up_fee(netuid, net_in).into()) + } + _ => { + ensure!( + T::SubnetInfo::exists(netuid), + Error::::InsufficientLiquidity + ); + Ok(alpha_out.to_u64().into()) + } + } + } + + fn sim_alpha_in_for_tao_out( + netuid: NetUid, + tao_out: TaoBalance, + ) -> Result { + match T::SubnetInfo::mechanism(netuid) { + 1 => { + let alpha_reserve = T::AlphaReserve::reserve(netuid); + let tao_reserve = T::TaoReserve::reserve(netuid); + ensure!(tao_reserve > tao_out, Error::::InsufficientLiquidity); + let balancer = SwapBalancer::::get(netuid); + let net_in = balancer.get_base_needed_for_quote( + tao_reserve.into(), + alpha_reserve.into(), + tao_out.into(), + ); + Ok(Self::gross_up_fee(netuid, net_in).into()) + } + _ => { + ensure!( + T::SubnetInfo::exists(netuid), + Error::::InsufficientLiquidity + ); + Ok(tao_out.to_u64().into()) + } + } + } + fn approx_fee_amount(netuid: NetUid, amount: C) -> C { Self::calculate_fee_amount(netuid, amount, false) } diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 9980604707..7e95e17736 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -38,6 +38,22 @@ pub trait SwapHandler { where Self: SwapEngine; + /// Exact-output quote: TAO that must be paid in (including fee) to buy + /// `alpha_out` Alpha out of the pool, fee+weight aware. Read-only. + /// `Err` when the pool cannot supply `alpha_out`. + fn sim_tao_in_for_alpha_out( + netuid: NetUid, + alpha_out: AlphaBalance, + ) -> Result; + + /// Exact-output quote: Alpha that must be paid in (including fee) to raise + /// `tao_out` TAO out of the pool, fee+weight aware. Read-only. + /// `Err` when the pool cannot supply `tao_out`. + fn sim_alpha_in_for_tao_out( + netuid: NetUid, + tao_out: TaoBalance, + ) -> Result; + fn approx_fee_amount(netuid: NetUid, amount: T) -> T; fn current_alpha_price(netuid: NetUid) -> U64F64; fn max_price() -> C; From 77007ee86a6b90ffe505ce62c89d09d24179e3b1 Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 25 Jun 2026 12:39:40 -0600 Subject: [PATCH 20/21] feat(swap): SimSwapOpts for fee-toggle on exact-output quotes + engine-routed settlement tests Add a small extensible SimSwapOpts (WITH_FEES/NO_FEES) to the exact-output sim quotes so callers can request fee-inclusive (realistic cover) or fee-excluded (ideal valuation) costs; thread it through gross_up_fee and both derivative close-cost call sites (WITH_FEES). Add engine-routed settlement tests on skewed (non-0.5 weight), fee-charging pools that prove against the live engine (not a re-implemented formula): cover inverts the real swap, diverges >1% from the old fee-less CPMM, the no-fee flag drops exactly the fee, thin pools err to the seize sentinel, short dereg collects max(spot,EMA), self-close rejects underwater, and a full open/decay/close round trip conserves TAO supply exactly. Co-authored-by: Cursor --- pallets/subtensor/src/derivatives/long.rs | 2 +- pallets/subtensor/src/derivatives/mod.rs | 4 +- pallets/subtensor/src/tests/derivatives.rs | 284 ++++++++++++++++++++- pallets/swap/src/pallet/impls.rs | 16 +- primitives/swap-interface/src/lib.rs | 36 ++- 5 files changed, 327 insertions(+), 15 deletions(-) diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index ac5d35784a..0921d6e3d7 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -316,7 +316,7 @@ impl Pallet { /// `short_spot_close_cost`. Saturates to a sentinel when the pool can't /// yield `d`. fn long_spot_close_cost(netuid: NetUid, d: TaoBalance) -> I64F64 { - match T::SwapInterface::sim_alpha_in_for_tao_out(netuid.into(), d) { + match T::SwapInterface::sim_alpha_in_for_tao_out(netuid.into(), d, SimSwapOpts::WITH_FEES) { Ok(alpha) => Self::alpha_f(alpha), Err(_) => I64F64::from_num(1e18), } diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index 370402c7c6..d9ec5dce4e 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -34,7 +34,7 @@ use safe_math::FixedExt; use sp_runtime::traits::AccountIdConversion; use substrate_fixed::types::I64F64; use subtensor_runtime_common::Token; -use subtensor_swap_interface::SwapHandler; +use subtensor_swap_interface::{SimSwapOpts, SwapHandler}; pub mod long; pub mod types; @@ -786,7 +786,7 @@ impl Pallet { /// through the swap engine (exact-output). Saturates to a sentinel when the /// pool cannot supply `q` so cover = C, equity = 0. fn short_spot_close_cost(netuid: NetUid, q: AlphaBalance) -> I64F64 { - match T::SwapInterface::sim_tao_in_for_alpha_out(netuid.into(), q) { + match T::SwapInterface::sim_tao_in_for_alpha_out(netuid.into(), q, SimSwapOpts::WITH_FEES) { Ok(tao) => Self::tao_f(tao), Err(_) => I64F64::from_num(1e18), } diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 2121277b05..3e7466920a 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -9,8 +9,10 @@ use super::mock::*; use crate::*; use frame_support::{assert_noop, assert_ok}; use sp_core::U256; -use substrate_fixed::types::{I64F64, I96F32}; +use safe_math::FixedExt; +use substrate_fixed::types::{I64F64, I96F32, U64F64}; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; +use subtensor_swap_interface::{Order, SimSwapOpts, SwapHandler}; const TAO: u64 = 1_000_000_000; @@ -1778,6 +1780,286 @@ fn short_and_long_flags_are_independent() { }); } +// =========================================================================== +// Engine-routed settlement: weight/fee-aware cover + asymmetry invariants +// +// The cover/settlement spot leg is now quoted through the live swap engine +// (fee + Balancer-weight aware) rather than a hand-rolled fee-less CPMM. These +// tests exercise the derivatives against a pool with NON-0.5 weights and a +// non-trivial fee — the regime where the old formula silently mispriced — and +// prove every quantity that must be invariant against the engine itself, not a +// re-implemented formula. +// =========================================================================== + +/// Set the per-subnet swap fee (u16-normalized) directly. +fn set_fee(netuid: NetUid, rate: u16) { + pallet_subtensor_swap::FeeRate::::insert(netuid, rate); +} + +/// Force the pool onto explicit Balancer weights derived from `price` (so spot +/// becomes `price`; with reserves whose ratio ≠ `price` the weights are ≠ 0.5), +/// and set the swap fee. Marks the pool initialized so later swaps don't reset +/// the weights back to 0.5. +fn skew_pool(netuid: NetUid, price: f64, fee_rate: u16) { + pallet_subtensor_swap::PalSwapInitialized::::insert(netuid, false); + assert_ok!( + pallet_subtensor_swap::Pallet::::maybe_initialize_palswap( + netuid, + Some(U64F64::from_num(price)), + ) + ); + set_fee(netuid, fee_rate); +} + +fn sim_tao_in_for_alpha_out(netuid: NetUid, q: AlphaBalance, opts: SimSwapOpts) -> Option { + ::SwapInterface::sim_tao_in_for_alpha_out(netuid.into(), q, opts) + .ok() + .map(|x| x.to_u64()) +} + +fn sim_alpha_in_for_tao_out(netuid: NetUid, d: TaoBalance, opts: SimSwapOpts) -> Option { + ::SwapInterface::sim_alpha_in_for_tao_out(netuid.into(), d, opts) + .ok() + .map(|x| x.to_u64()) +} + +// PROOF: the exact-output short cover is the true inverse of the engine's +// exact-input buy — under non-0.5 weights AND a fee. Quoting "TAO needed to buy +// Q alpha" and then spending exactly that TAO must yield ~Q alpha back. +#[test] +fn engine_cover_inverts_real_swap_short() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + skew_pool(netuid, 1.6, 2_000); // weights ≈ 0.38/0.62, fee ≈ 3% + let q = AlphaBalance::from(123 * TAO); + + let tao_in = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::WITH_FEES).unwrap(); + let got = ::SwapInterface::sim_swap( + netuid.into(), + GetAlphaForTao::::with_amount(t(tao_in)), + ) + .unwrap() + .amount_paid_out + .to_u64(); + + assert_approx(got, q.to_u64(), q.to_u64() / 1_000 + 10, "buy(quote(Q)) ≈ Q"); + }); +} + +// PROOF: the exact-output long cover inverts the engine's exact-input sell. +#[test] +fn engine_cover_inverts_real_swap_long() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + skew_pool(netuid, 0.7, 2_000); + let d = t(77 * TAO); + + let alpha_in = sim_alpha_in_for_tao_out(netuid, d, SimSwapOpts::WITH_FEES).unwrap(); + let got = ::SwapInterface::sim_swap( + netuid.into(), + GetTaoForAlpha::::with_amount(AlphaBalance::from(alpha_in)), + ) + .unwrap() + .amount_paid_out + .to_u64(); + + assert_approx(got, d.to_u64(), d.to_u64() / 1_000 + 10, "sell(quote(D)) ≈ D"); + }); +} + +// PROOF: under weights+fee the engine cover diverges materially (>1%) from the +// old pure-CPMM fee-less formula `t·q/(a−q)` — i.e. the fix is not a no-op and +// the old path was genuinely mispricing the cover. +#[test] +fn engine_cover_diverges_from_naive_cpmm() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + skew_pool(netuid, 1.6, 2_000); + let q = AlphaBalance::from(200 * TAO); + + let k_engine = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::WITH_FEES).unwrap(); + let tt = SubnetTAO::::get(netuid).to_u64() as u128; + let aa = SubnetAlphaIn::::get(netuid).to_u64() as u128; + let qq = q.to_u64() as u128; + let k_cpmm = (tt.saturating_mul(qq) / (aa - qq)) as u64; // pure, fee-less + + assert!( + k_engine.abs_diff(k_cpmm) as u128 * 100 > k_cpmm as u128, + "engine {k_engine} vs naive cpmm {k_cpmm}: divergence < 1% (fix would be a no-op)" + ); + }); +} + +// PROOF: `SimSwapOpts::NO_FEES` removes exactly the swap fee — the no-fee quote +// grossed up by the fee rate equals the fee-inclusive quote. +#[test] +fn sim_no_fees_flag_drops_exactly_the_fee() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + let fee: u16 = 2_000; + skew_pool(netuid, 1.3, fee); + let q = AlphaBalance::from(150 * TAO); + + let with = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::WITH_FEES).unwrap(); + let without = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::NO_FEES).unwrap(); + assert!(with > without, "fee-inclusive cover must exceed no-fee cover"); + + let max = u16::MAX as u128; + let expected_with = (without as u128 * max / (max - fee as u128)) as u64; + assert_approx(with, expected_with, with / 100_000 + 4, "no_fees grosses up to with_fees"); + }); +} + +// PROOF: an exact-output quote for more than the pool can supply errs (the +// derivative cover helpers map this to the seize-full-claim sentinel). +#[test] +fn sim_exact_output_errs_when_pool_too_thin() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1_000 * TAO, 1_000 * TAO, 1.0); + assert!(sim_tao_in_for_alpha_out(netuid, AlphaBalance::from(1_000 * TAO), SimSwapOpts::WITH_FEES).is_none()); + assert!(sim_alpha_in_for_tao_out(netuid, t(1_000 * TAO), SimSwapOpts::WITH_FEES).is_none()); + }); +} + +// ASYMMETRY PROOF (short, fast pump): when spot values the liability above the +// stale-low EMA, terminal settlement collects `max(spot, EMA) = spot`, so the +// lagging EMA can NOT be used to under-collect. Equity equals the spot-based +// formula and never exceeds what an EMA-only settlement would have paid out. +#[test] +fn short_dereg_collects_max_spot_over_stale_low_ema() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(100_000 * TAO, 100_000 * TAO, 1.0); + set_fee(netuid, 1_000); + let trader = U256::from(10); + let hot = U256::from(11); + add_balance_to_coldkey_account(&trader, t(10_000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hot, netuid, t(50 * TAO), None)); + + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let c = pos.p_floor.to_u64() + pos.r_stored.to_u64(); + let q = pos.q_liability; + + // Fast pump: EMA lags low while spot (~1.0) values Q far higher. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(0.2)); + let pema = I64F64::saturating_from_num(SubtensorModule::get_moving_alpha_price(netuid)); + let k_ema = (I64F64::saturating_from_num(q.to_u64()).saturating_mul(pema)).to_num::(); + // Settlement returns escrow E to the TAO reserve BEFORE quoting the spot + // cover, so quote against that same post-escrow reserve to match. + let e = pos.e_stored; + SubnetTAO::::mutate(netuid, |x| *x = x.saturating_add(e)); + let k_spot = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::WITH_FEES).unwrap(); + SubnetTAO::::mutate(netuid, |x| *x = x.saturating_sub(e)); + assert!(k_spot > k_ema, "setup: spot {k_spot} must exceed stale EMA {k_ema} (pump)"); + + let k_d = k_spot.max(k_ema); + let expected_equity = c.saturating_sub(k_d.min(c)); + + let before = bal(&trader); + SubtensorModule::settle_shorts_on_dereg(netuid); + let equity = bal(&trader) - before; + + assert_approx(equity, expected_equity, c / 100_000 + 50, "equity uses max(spot,EMA)=spot"); + let ema_only_equity = c.saturating_sub(k_ema.min(c)); + assert!(equity <= ema_only_equity, "settled spot must not pay more equity than stale EMA"); + }); +} + +// ASYMMETRY PROOF (long, fast drop): the mirror. When spot needs more alpha to +// cover the TAO debt than the stale-HIGH EMA implies, settlement seizes +// `max(spot, EMA) = spot` alpha, so a lagging EMA can NOT under-seize. This is +// the regression guard for the long-dereg `max(spot, EMA)` cover fix. +#[test] +fn long_dereg_collects_max_spot_over_stale_high_ema() { + new_test_ext(1).execute_with(|| { + let netuid = setup_long(100_000 * TAO, 100_000 * TAO, 1.0); + set_fee(netuid, 1_000); + let trader = U256::from(10); + let hot = U256::from(11); + give_alpha(hot, trader, netuid, AlphaBalance::from(50_000 * TAO)); + assert_ok!(SubtensorModule::open_long(RuntimeOrigin::signed(trader), hot, netuid, AlphaBalance::from(50 * TAO), None)); + + let pos = LongPositions::::get(netuid, trader).unwrap(); + let c_l = pos.p_floor.to_u64() + pos.r_stored.to_u64(); + let d = pos.d_liability; + + // Fast drop: EMA lags HIGH, so D/pEMA understates the alpha cover; spot + // (~1.0) needs far more alpha. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(5.0)); + let pema = I64F64::saturating_from_num(SubtensorModule::get_moving_alpha_price(netuid)); + let k_ema = I64F64::saturating_from_num(d.to_u64()).safe_div(pema).to_num::(); + // Settlement returns escrow E to the alpha reserve BEFORE quoting the + // spot cover, so quote against that same post-escrow reserve to match. + let e = pos.e_stored; + SubnetAlphaIn::::mutate(netuid, |x| *x = x.saturating_add(e)); + let k_spot = sim_alpha_in_for_tao_out(netuid, d, SimSwapOpts::WITH_FEES).unwrap(); + SubnetAlphaIn::::mutate(netuid, |x| *x = x.saturating_sub(e)); + assert!(k_spot > k_ema, "setup: spot cover {k_spot} must exceed stale EMA cover {k_ema} (drop)"); + + let cover = c_l.min(k_spot.max(k_ema)); + let expected_equity = c_l.saturating_sub(cover); + + let before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &trader, netuid).to_u64(); + SubtensorModule::settle_longs_on_dereg(netuid); + let equity = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &trader, netuid).to_u64() - before; + + assert_approx(equity, expected_equity, c_l / 100_000 + 50, "alpha equity uses max(spot,EMA)=spot"); + let ema_only_equity = c_l.saturating_sub(k_ema.min(c_l)); + assert!(equity <= ema_only_equity, "settled spot must not return more equity than stale EMA"); + }); +} + +// INVARIANT: the self-cover close refuses to settle underwater — it can never +// charge the trader (or the pool) more than the claim `P + R`. +#[test] +fn short_self_close_rejects_when_underwater() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + let trader = U256::from(10); + let hot = U256::from(11); + add_balance_to_coldkey_account(&trader, t(10_000 * TAO)); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hot, netuid, t(100 * TAO), None)); + let pos = ShortPositions::::get(netuid, trader).unwrap(); + let q = pos.q_liability; + let claim = pos.p_floor.to_u64() + pos.r_stored.to_u64(); + + // Violent pump against the short: 100x the TAO reserve so the alpha + // buyback cost dwarfs the claim (without hitting reserve-thin edges). + SubnetTAO::::insert(netuid, t(1_000_000 * TAO)); + let k = sim_tao_in_for_alpha_out(netuid, q, SimSwapOpts::WITH_FEES).unwrap(); + assert!(k > claim, "setup: buyback {k} must exceed claim {claim}"); + assert_noop!( + SubtensorModule::do_close_short_self(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None), + Error::::CloseCostExceedsClaim + ); + }); +} + +// CONSERVATION under weights + fee: a full open → decay → in-kind close round +// trip conserves TAO supply EXACTLY even on a skewed, fee-charging pool (the +// normal close is settled in-kind, so no engine fee leaks on this path). +#[test] +fn short_lifecycle_conserves_tao_under_weights_and_fee() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(10_000 * TAO, 10_000 * TAO, 1.0); + skew_pool(netuid, 1.4, 2_000); + let trader = U256::from(10); + let hot = U256::from(11); + add_balance_to_coldkey_account(&trader, t(10_000 * TAO)); + give_alpha(hot, trader, netuid, AlphaBalance::from(50_000 * TAO)); + + let tao0 = TotalIssuance::::get().to_u64(); + assert_ok!(SubtensorModule::open_short(RuntimeOrigin::signed(trader), hot, netuid, t(100 * TAO), None)); + for _ in 0..200 { + SubtensorModule::run_short_decay(); + } + assert_ok!(SubtensorModule::close_short(RuntimeOrigin::signed(trader), netuid, 1_000_000_000, None)); + + assert_eq!(TotalIssuance::::get().to_u64(), tao0, "TAO supply not conserved under weights+fee"); + assert!(custody_bal(netuid) <= 1_000_000, "custody not drained"); + assert!(ShortPositions::::get(netuid, trader).is_none()); + }); +} + // Listing returns every position a coldkey holds across subnets. #[test] fn list_positions_across_subnets() { diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index 1e9a82ebf4..771dbdc273 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -10,7 +10,7 @@ use sp_runtime::{DispatchResult, traits::AccountIdConversion}; use substrate_fixed::types::U64F64; use subtensor_runtime_common::{AlphaBalance, NetUid, SubnetInfo, TaoBalance, Token, TokenReserve}; use subtensor_swap_interface::{ - DefaultPriceLimit, Order as OrderT, SwapEngine, SwapHandler, SwapResult, + DefaultPriceLimit, Order as OrderT, SimSwapOpts, SwapEngine, SwapHandler, SwapResult, }; use super::pallet::*; @@ -250,8 +250,12 @@ impl Pallet { /// Gross up a net (post-fee) swap input by the subnet fee, so the result is /// the total a trader pays in. Inverse of `swap_step`'s /// `net = amount·(1 − rate)` where `rate = FeeRate / u16::MAX`. Saturates if - /// the fee rate is degenerate (≥ u16::MAX). - pub(crate) fn gross_up_fee(netuid: NetUid, net_in: u64) -> u64 { + /// the fee rate is degenerate (≥ u16::MAX). With `drop_fees` the net amount + /// is returned unchanged (no-fee simulation). + pub(crate) fn gross_up_fee(netuid: NetUid, net_in: u64, drop_fees: bool) -> u64 { + if drop_fees { + return net_in; + } let fee_rate = u128::from(FeeRate::::get(netuid)); let u16_max = u128::from(u16::MAX); let denom = u16_max.saturating_sub(fee_rate); @@ -390,6 +394,7 @@ impl SwapHandler for Pallet { fn sim_tao_in_for_alpha_out( netuid: NetUid, alpha_out: AlphaBalance, + opts: SimSwapOpts, ) -> Result { match T::SubnetInfo::mechanism(netuid) { 1 => { @@ -402,7 +407,7 @@ impl SwapHandler for Pallet { alpha_reserve.into(), alpha_out.into(), ); - Ok(Self::gross_up_fee(netuid, net_in).into()) + Ok(Self::gross_up_fee(netuid, net_in, opts.drop_fees).into()) } _ => { ensure!( @@ -417,6 +422,7 @@ impl SwapHandler for Pallet { fn sim_alpha_in_for_tao_out( netuid: NetUid, tao_out: TaoBalance, + opts: SimSwapOpts, ) -> Result { match T::SubnetInfo::mechanism(netuid) { 1 => { @@ -429,7 +435,7 @@ impl SwapHandler for Pallet { alpha_reserve.into(), tao_out.into(), ); - Ok(Self::gross_up_fee(netuid, net_in).into()) + Ok(Self::gross_up_fee(netuid, net_in, opts.drop_fees).into()) } _ => { ensure!( diff --git a/primitives/swap-interface/src/lib.rs b/primitives/swap-interface/src/lib.rs index 7e95e17736..9df26d7404 100644 --- a/primitives/swap-interface/src/lib.rs +++ b/primitives/swap-interface/src/lib.rs @@ -38,20 +38,24 @@ pub trait SwapHandler { where Self: SwapEngine; - /// Exact-output quote: TAO that must be paid in (including fee) to buy - /// `alpha_out` Alpha out of the pool, fee+weight aware. Read-only. - /// `Err` when the pool cannot supply `alpha_out`. + /// Exact-output quote: TAO that must be paid in to buy `alpha_out` Alpha out + /// of the pool, weight aware (and fee aware unless `opts.drop_fees`). + /// Read-only — never mutates state. `Err` when the pool cannot supply + /// `alpha_out`. fn sim_tao_in_for_alpha_out( netuid: NetUid, alpha_out: AlphaBalance, + opts: SimSwapOpts, ) -> Result; - /// Exact-output quote: Alpha that must be paid in (including fee) to raise - /// `tao_out` TAO out of the pool, fee+weight aware. Read-only. - /// `Err` when the pool cannot supply `tao_out`. + /// Exact-output quote: Alpha that must be paid in to raise `tao_out` TAO out + /// of the pool, weight aware (and fee aware unless `opts.drop_fees`). + /// Read-only — never mutates state. `Err` when the pool cannot supply + /// `tao_out`. fn sim_alpha_in_for_tao_out( netuid: NetUid, tao_out: TaoBalance, + opts: SimSwapOpts, ) -> Result; fn approx_fee_amount(netuid: NetUid, amount: T) -> T; @@ -210,6 +214,26 @@ where fn default_price_limit() -> C; } +/// Options controlling how a swap is *simulated* (read-only quoting). +/// +/// Kept deliberately small but extensible: the simulation entrypoints take this +/// by value so future knobs (e.g. ignore-price-limit, partial-fill behaviour) +/// can be added as fields without churning every call site. `Default` = a +/// faithful quote (fees included). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SimSwapOpts { + /// Quote as if the subnet swap fee were zero. Use for "ideal" / no-fee + /// valuations; leave `false` for the realistic, fee-inclusive cost. + pub drop_fees: bool, +} + +impl SimSwapOpts { + /// Faithful, fee-inclusive simulation (the default). + pub const WITH_FEES: Self = Self { drop_fees: false }; + /// Fee-excluded simulation (`sim(no_fees = true)`). + pub const NO_FEES: Self = Self { drop_fees: true }; +} + /// Externally used swap result (for RPC) #[freeze_struct("6a03533fc53ccfb8")] #[derive(Decode, Encode, PartialEq, Eq, Clone, Debug, TypeInfo)] From 1a7aa379e89b0d9b05b0ae0ca973e54dd753793b Mon Sep 17 00:00:00 2001 From: unconst Date: Thu, 25 Jun 2026 13:42:24 -0600 Subject: [PATCH 21/21] feat(derivatives): block-lagged Alpha-reserve EMA refs + position key-swap support Add SubnetAlphaInMovingReserve (A_EMA): a block-lagged EMA of SubnetAlphaIn, ticked each block in update_moving_price on the same smoothing as the price EMA. The short/long references (short_t_ref, long_a_ref) now read this lagged depth instead of the live spot reserve, so an in-block reserve nudge can only pull capacity down (via the live floor), never inflate it. Seeded from live reserves by migrate_seed_alpha_in_moving_reserve so refs start warm. Re-home covered positions on key swaps: swap_positions_for_hotkey_swap moves a position's recorded hotkey on the !keep_stake branch (so close/default/settle math follows the migrated stake), and swap_positions_for_coldkey_swap re-keys positions on coldkey swap. Both bounded/weighted; coldkey path guards against clobbering a pre-existing destination position. Adds tests covering the lagged refs and key-swap re-homing, plus design doc updates. Co-authored-by: Cursor --- docs/derivatives/DESIGN.md | 47 ++- docs/derivatives/IMPLEMENTATION_PLAN.md | 2 +- pallets/subtensor/src/derivatives/long.rs | 16 +- pallets/subtensor/src/derivatives/mod.rs | 102 +++++- pallets/subtensor/src/lib.rs | 15 + pallets/subtensor/src/macros/hooks.rs | 5 +- .../migrate_seed_alpha_in_moving_reserve.rs | 58 ++++ pallets/subtensor/src/migrations/mod.rs | 1 + pallets/subtensor/src/staking/stake_utils.rs | 13 + pallets/subtensor/src/swap/swap_coldkey.rs | 3 + pallets/subtensor/src/swap/swap_hotkey.rs | 11 + pallets/subtensor/src/tests/derivatives.rs | 316 ++++++++++++++++++ 12 files changed, 562 insertions(+), 27 deletions(-) create mode 100644 pallets/subtensor/src/migrations/migrate_seed_alpha_in_moving_reserve.rs diff --git a/docs/derivatives/DESIGN.md b/docs/derivatives/DESIGN.md index 5e3fcad8a3..31758cc587 100644 --- a/docs/derivatives/DESIGN.md +++ b/docs/derivatives/DESIGN.md @@ -23,7 +23,7 @@ and that single fact drives most of the design decisions. | User can remove/add liquidity | User LP is **deprecated** (`add_liquidity`/`remove_liquidity` → `Error::Deprecated`) | The "remove-and-sell-back" open and the restoration/settlement zaps are realized as **protocol reserve mutations**, not user LP ops. | | Reserves `T`, `A` | `SubnetTAO` (TAO, the quote reserve), `SubnetAlphaIn` (alpha pool reserve), `SubnetAlphaOut` (staked alpha outside the pool) | Short open/restore are mostly `SubnetTAO` mutations; close settlement touches `SubnetAlphaIn`. | | `pEMA` price reference | **Already exists**: `SubnetMovingPrice` (per-block halving EMA, TAO/alpha) | Reuse directly as the spec's `pEMA`. No new TWAP, no new price EMA. | -| `T_EMA`, `A_EMA` reserve EMAs | **Do not exist** | Derive `T_EMA` from `SubnetMovingPrice × SubnetAlphaIn` instead of storing a new per-block reserve EMA (see §4). | +| `A_EMA` reserve EMA | **Add `SubnetAlphaInMovingReserve`** | One per-block EMA of `SubnetAlphaIn`, ticked on the price EMA's smoothing. `T_EMA = pEMA·A_EMA` (short), `A_EMA` used directly (long). Lagged ⇒ not in-block manipulable (see §4). | | Recycle floor `P`, extinguish liability | **Already exists**: `recycle_tao(coldkey, amount)`, `recycle_subnet_alpha`/`burn_subnet_alpha` | Reuse for default and terminal settlement. | | Per-block decay/unwind step | **Net-new** | One O(1)-per-subnet call added to `block_step()`. | | Subnet deregistration hook | **Already exists**: `do_dissolve_network` (`coinbase/root.rs`) | Insert terminal derivative settlement before `destroy_alpha_in_out_stakes`. | @@ -31,7 +31,8 @@ and that single fact drives most of the design decisions. **Key takeaway:** the spec's `pEMA`, recycle, and dereg primitives already exist. The genuinely new state is (a) the position store, (b) per-side aggregate + decay accumulator, (c) a per-block -decay step, (d) ~4 extrinsics, (e) one runtime-API quote. Risk reserve EMAs are *derived*, not stored. +decay step, (d) ~4 extrinsics, (e) one runtime-API quote, (f) one stored reserve EMA +(`SubnetAlphaInMovingReserve`) backing both risk references (§4). --- @@ -41,7 +42,7 @@ decay step, (d) ~4 extrinsics, (e) one runtime-API quote. Risk reserve EMAs are |---|---|---| | `T` | live TAO reserve | `SubnetTAO::::get(netuid)` | | `A` | live alpha reserve | `SubnetAlphaIn::::get(netuid)` | -| `T_ref` | conservative TAO ref `min(T_live, T_EMA)` | `min(SubnetTAO, pEMA·A_live)` — derived (§4) | +| `T_ref` | conservative TAO ref `min(T_live, T_EMA)` | `min(SubnetTAO, pEMA·A_EMA)` — `A_EMA` lagged (§4) | | `pEMA` | EMA price (TAO/alpha) | `Pallet::get_moving_alpha_price(netuid)` (`SubnetMovingPrice`) | | `P` | user position input / floor | `ShortPosition.p_floor: TaoBalance` | | `C` | gross collateral (open-time only) | computed, **not stored** | @@ -130,23 +131,42 @@ reserve math and is the first item in the spec's trading-games suite (§14.5). --- -## 4. Risk reference reserves without new EMA storage +## 4. Risk reference reserves from a block-lagged reserve EMA The spec wants `T_ref = min(T_live, T_EMA)` to stop a same-block reserve pump from improving open -terms (§3.1–3.2). Subtensor has no reserve EMA, but it has an EMA *price*. Since -`price = (w_base/w_quote)·(T/A)`, we reconstruct: +terms (§3.1–3.2). The reference must be built from **block-lagged** factors only: anything read live +within the extrinsic can be moved by the caller's own swap in the same block. + +We maintain one stored reserve EMA — `SubnetAlphaInMovingReserve` (`A_EMA`), a per-block EMA of +`SubnetAlphaIn` — ticked on the **same smoothing** as the price EMA inside `update_moving_price` +(one extra storage write per subnet per block). Both derivative sides derive their reference from it, +combined with the existing `pEMA` (`SubnetMovingPrice`): ``` -T_EMA ≈ pEMA · A_live (pEMA already folds the weight ratio at EMA time) -T_ref = min(SubnetTAO, T_EMA) +T_EMA = pEMA · A_EMA (lagged price × lagged alpha reserve ⇒ lagged TAO depth) +T_ref = min(SubnetTAO, T_EMA) (short side) +A_ref = min(SubnetAlphaIn, A_EMA) (long side — uses A_EMA directly as alpha depth) ``` -This reuses `SubnetMovingPrice` and adds **zero** per-block EMA maintenance. `A_live` is still -manipulable, but with `κ_S` starting conservative and the footprint cap `S + B ≤ κ_S·T_ref`, the -launch exposure is bounded; a dedicated stored reserve-EMA can be added later if the trading games -show it is needed. Decay utilization uses the same `T_ref` (spec §3.3), so flash trades cannot grief +The `T_EMA` / `A_EMA` upper bound is now a pure function of lagged state, so an in-block reserve nudge +**cannot raise it**; the live `T_live` / `A_live` term only ever pulls the `min` *down* (conservative, +and self-defeating for an attacker). This closes the crossover attack on the earlier `pEMA·A_live` +reference: under the CPMM invariant `T_live·A_live = k`, `min(T_live, pEMA·A_live)` had one increasing +and one decreasing branch in `A_live` and therefore peaked at the crossover `A* = √(k/pEMA)` (the +geometric mean `√(T_live·T_EMA)`), letting a sandwich inflate `T_ref`, retained proceeds `N`, and the +footprint cap `S + B ≤ κ_S·T_ref` for one block — a breach that persisted because the cap is checked +only at open. Decay utilization uses the same lagged `T_ref` (spec §3.3), so flash trades cannot grief carry either. +EMA manipulation is bounded exactly as the price EMA already is: biasing `A_EMA` requires holding the +reserve displaced across the once-per-block sample (post-coinbase), forfeiting atomicity and moving it +only by the small smoothing fraction per block. + +A cold `A_EMA` (`0`, e.g. a freshly created subnet) makes the reference fall back to the live reserve +until it warms. On a live-chain upgrade, `migrate_seed_alpha_in_moving_reserve` seeds `A_EMA` from the +current `SubnetAlphaIn` per subnet so there is no cold-start window; the per-block tick carries it +forward from there. + --- ## 5. Storage layout @@ -317,7 +337,8 @@ breakeven_close_price`. Pure reads + `sim_swap`; no state change. JSON-RPC wrapp - **Longs**: code-symmetric but flag-gated off (`LongsEnabled=false`). Long open mirrors with alpha/TAO swapped, `D=ϕT`, ADR-adjusted LTV (§9.2). Not in the launch diff. - **Derivative TaoFlow** (`χ_S`): off; flow-neutral (§4.5). Not wired. -- **Stored reserve EMA / TWAP**: replaced by derived `T_ref` from `pEMA` (§4). TWAP is an optional +- **Reserve EMA**: one stored EMA (`SubnetAlphaInMovingReserve`, `A_EMA`) backs both references + (§4); `T_EMA = pEMA·A_EMA`. A separate stored `T_EMA` / TWAP is not needed and remains an optional later guard only (§3.4, §11.4). - **Per-open `ϕ_max`**: not a control; only the `4N ≤ T_live` domain bound is enforced (§5.2). - **Per-subnet param overrides**: launch uses globals; per-netuid maps can be added later without diff --git a/docs/derivatives/IMPLEMENTATION_PLAN.md b/docs/derivatives/IMPLEMENTATION_PLAN.md index e36790214a..ba5bb69eee 100644 --- a/docs/derivatives/IMPLEMENTATION_PLAN.md +++ b/docs/derivatives/IMPLEMENTATION_PLAN.md @@ -29,7 +29,7 @@ closed-forms (Appendix A.1) used for quoting/sizing. | Function | Spec | Returns | |---|---|---| -| `short_t_ref(netuid)` | §3.1, §4 | `min(SubnetTAO, pEMA·A_live)` | +| `short_t_ref(netuid)` | §3.1, §4 | `min(SubnetTAO, pEMA·A_EMA)` (`A_EMA` = `SubnetAlphaInMovingReserve`, lagged) | | `solve_collateral(p, t_ref, lambda, s)` | §4.2 | `(C, N)` via quadratic; reject `N ≤ 0` | | `lambda_eff(...)` | §4.1 | effective LTV; reject `≤ 0` | | `solve_phi(n, t_live)` | §4.3 | `ϕ = (1 − √(1 − 4N/T))/2`; reject `4N > T` | diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs index 0921d6e3d7..d55f205005 100644 --- a/pallets/subtensor/src/derivatives/long.rs +++ b/pallets/subtensor/src/derivatives/long.rs @@ -18,17 +18,19 @@ use subtensor_runtime_common::Token; const BLOCKS_PER_DAY: u64 = 7200; impl Pallet { - /// Conservative Alpha reference `A_ref = min(A_live, A_EMA)`, with - /// `A_EMA = T_live / pEMA` reconstructed from the price EMA. Cold EMA falls - /// back to the live reserve. + /// Conservative Alpha reference `A_ref = min(A_live, A_EMA)`, where `A_EMA` + /// is the block-lagged Alpha-reserve EMA (`SubnetAlphaInMovingReserve`, ticked + /// once per block in `update_moving_price`). The lagged factor caps `A_ref` + /// from above so an in-block reserve nudge cannot inflate capacity; the live + /// `A_live` floor can only pull it *down* (conservative). Cold EMA falls back + /// to the live reserve until it warms. fn long_a_ref(netuid: NetUid) -> I64F64 { let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); - let t_live = Self::tao_f(SubnetTAO::::get(netuid)); - let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); - if pema <= I64F64::from_num(0) { + let a_ema = I64F64::saturating_from_num(SubnetAlphaInMovingReserve::::get(netuid)); + if a_ema <= I64F64::from_num(0) { return a_live; } - a_live.min(t_live.safe_div(pema)) + a_live.min(a_ema) } /// Current long daily decay rate at the live long footprint. diff --git a/pallets/subtensor/src/derivatives/mod.rs b/pallets/subtensor/src/derivatives/mod.rs index d9ec5dce4e..43956c44de 100644 --- a/pallets/subtensor/src/derivatives/mod.rs +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -128,14 +128,19 @@ impl Pallet { // ---- references (spec §3, §4) -------------------------------------- /// Conservative TAO reference `T_ref = min(T_live, T_EMA)`, with - /// `T_EMA = pEMA · A_live` reconstructed from the existing price EMA. + /// `T_EMA = pEMA · A_EMA` from two block-lagged factors: the price EMA and + /// the Alpha-reserve EMA (`SubnetAlphaInMovingReserve`). Both are ticked once + /// per block in `update_moving_price`, so the upper bound on `T_ref` cannot be + /// moved by an in-block reserve nudge — only the live `T_live` floor can pull + /// it *down* (conservative, and self-defeating for an attacker). fn short_t_ref(netuid: NetUid) -> I64F64 { let t_live = Self::tao_f(SubnetTAO::::get(netuid)); - let a_live = Self::alpha_f(SubnetAlphaIn::::get(netuid)); + let a_ema = I64F64::saturating_from_num(SubnetAlphaInMovingReserve::::get(netuid)); let pema = I64F64::saturating_from_num(Self::get_moving_alpha_price(netuid)); - let t_ema = pema.saturating_mul(a_live); - // A cold price EMA (`pema == 0`, e.g. a freshly created subnet) must not - // lock the market; fall back to the live reserve until it warms up. + let t_ema = pema.saturating_mul(a_ema); + // A cold EMA (`t_ema == 0`, e.g. a freshly created subnet whose reserve or + // price EMA has not warmed) must not lock the market; fall back to the + // live reserve until it warms up. if t_ema <= I64F64::from_num(0) { t_live } else { @@ -1056,4 +1061,91 @@ impl Pallet { ), }) } + + // ---- key-swap support (called from swap_hotkey / swap_coldkey) ----- + + /// Re-home every short/long position on `netuid` that records `old_hotkey` + /// to `new_hotkey`. Called from the hotkey swap ONLY on the + /// `keep_stake = false` branch, where the backing stake actually migrates: + /// a position's recorded hotkey is the key its close/default/settle math + /// reads and writes stake under (`do_close_short` repays `ρQ` from it; + /// `do_*_long` source/return the collateral alpha from it), so it must follow + /// the stake or the collateral becomes unreachable — and re-opening at the + /// new hotkey is blocked by the open-time merge guard, permanently stranding + /// the position. + /// + /// Every coldkey's matching position is rewritten, not just the swapping + /// owner's: a delegator who opened against `old_hotkey` has their stake moved + /// by the same swap, so leaving their recorded hotkey behind would let a + /// third party silently break their position. Positions are keyed by + /// `(netuid, coldkey)`, so rewriting the value's `hotkey` field never + /// collides (at most one position per coldkey). + /// + /// Bounded by `ShortMaxPositions + LongMaxPositions` (each clamped ≤ 4096) + /// per subnet, matching the existing dereg-settlement iteration. Returns + /// `(reads, writes)` so the caller can charge weight. + pub(crate) fn swap_positions_for_hotkey_swap( + old_hotkey: &T::AccountId, + new_hotkey: &T::AccountId, + netuid: NetUid, + ) -> (u64, u64) { + let mut reads: u64 = 0; + let mut writes: u64 = 0; + + let shorts: Vec<(T::AccountId, ShortPosition)> = + ShortPositions::::iter_prefix(netuid).collect(); + reads = reads.saturating_add(shorts.len() as u64); + for (coldkey, mut pos) in shorts { + if pos.hotkey == *old_hotkey { + pos.hotkey = new_hotkey.clone(); + ShortPositions::::insert(netuid, &coldkey, pos); + writes = writes.saturating_add(1); + } + } + + let longs: Vec<(T::AccountId, LongPosition)> = + LongPositions::::iter_prefix(netuid).collect(); + reads = reads.saturating_add(longs.len() as u64); + for (coldkey, mut pos) in longs { + if pos.hotkey == *old_hotkey { + pos.hotkey = new_hotkey.clone(); + LongPositions::::insert(netuid, &coldkey, pos); + writes = writes.saturating_add(1); + } + } + + (reads, writes) + } + + /// Re-key a coldkey's short/long positions from `old_coldkey` to + /// `new_coldkey` on `netuid` during a coldkey swap. Positions are keyed by + /// coldkey (the recorded hotkey is unchanged, since the hotkey identity does + /// not move), so this is an O(1) take+insert per side rather than the + /// hotkey-swap's bounded iteration. + /// + /// `do_swap_coldkey` asserts `new_coldkey` is fresh, so a pre-existing + /// position on the destination is not expected; if one is found we drop the + /// migrated position and decrement the per-subnet count rather than + /// clobbering it and leaving `ShortPositionCount` / `LongPositionCount` + /// double-counted. + pub(crate) fn swap_positions_for_coldkey_swap( + netuid: NetUid, + old_coldkey: &T::AccountId, + new_coldkey: &T::AccountId, + ) { + if let Some(pos) = ShortPositions::::take(netuid, old_coldkey) { + if ShortPositions::::contains_key(netuid, new_coldkey) { + ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + } else { + ShortPositions::::insert(netuid, new_coldkey, pos); + } + } + if let Some(pos) = LongPositions::::take(netuid, old_coldkey) { + if LongPositions::::contains_key(netuid, new_coldkey) { + LongPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + } else { + LongPositions::::insert(netuid, new_coldkey, pos); + } + } + } } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index b11d13ae9b..0a996a8997 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1041,6 +1041,13 @@ pub mod pallet { I96F32::saturating_from_num(0.0) } + /// Default lagged Alpha-reserve EMA (`A_EMA`). `0` = cold (not yet warmed), + /// which the derivative references read as "fall back to the live reserve". + #[pallet::type_value] + pub fn DefaultAlphaInMovingReserve() -> U64F64 { + U64F64::saturating_from_num(0) + } + /// Default subnet root proportion. #[pallet::type_value] pub fn DefaultRootProp() -> U96F32 { @@ -1334,6 +1341,14 @@ pub mod pallet { pub type SubnetMovingPrice = StorageMap<_, Identity, NetUid, I96F32, ValueQuery, DefaultMovingPrice>; + /// --- MAP ( netuid ) --> A_EMA | Block-lagged EMA of the `SubnetAlphaIn` + /// reserve (rao), ticked each block alongside the price EMA. Derivative + /// references (`short_t_ref`, `long_a_ref`) use this lagged depth instead of + /// the spot reserve so an in-block reserve nudge cannot inflate capacity. + #[pallet::storage] + pub type SubnetAlphaInMovingReserve = + StorageMap<_, Identity, NetUid, U64F64, ValueQuery, DefaultAlphaInMovingReserve>; + /// --- MAP ( netuid ) --> root_prop | The subnet root proportion. #[pallet::storage] pub type RootProp = diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 6371f30e46..23cd9c0b52 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -177,7 +177,10 @@ mod hooks { // Capture the runtime-upgrade block for TAO-in refund cutover. .saturating_add(migrations::migrate_tao_in_refund_deployment_block::migrate_tao_in_refund_deployment_block::()) // Fix lock state left behind by subnet-scoped hotkey swaps. - .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()); + .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()) + // Seed the Alpha-reserve EMA from live reserves so the derivative + // references start warm (no cold-start spot-reserve fallback window). + .saturating_add(migrations::migrate_seed_alpha_in_moving_reserve::migrate_seed_alpha_in_moving_reserve::()); weight } diff --git a/pallets/subtensor/src/migrations/migrate_seed_alpha_in_moving_reserve.rs b/pallets/subtensor/src/migrations/migrate_seed_alpha_in_moving_reserve.rs new file mode 100644 index 0000000000..a97ec407d8 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_seed_alpha_in_moving_reserve.rs @@ -0,0 +1,58 @@ +use alloc::string::String; + +use frame_support::{traits::Get, weights::Weight}; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::Token; + +use super::*; + +/// Seed the Alpha-reserve EMA (`SubnetAlphaInMovingReserve`) from the live +/// `SubnetAlphaIn` for every subnet. +/// +/// The derivative references (`short_t_ref` / `long_a_ref`) read this lagged +/// reserve instead of the spot reserve so an in-block swap cannot inflate +/// capacity. A cold (`0`) EMA makes them fall back to the live reserve until it +/// warms — a brief window where the spot value is read again. Seeding it to the +/// current reserve at upgrade closes that window immediately; the per-block tick +/// in `update_moving_price` carries it forward from there. +pub fn migrate_seed_alpha_in_moving_reserve() -> Weight { + let migration_name = b"migrate_seed_alpha_in_moving_reserve".to_vec(); + 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) + ); + + let mut seeded: u32 = 0; + for (netuid, alpha_in) in SubnetAlphaIn::::iter() { + // Only seed a cold entry; never clobber a value that a block tick may + // already have started warming. + if SubnetAlphaInMovingReserve::::get(netuid) == U64F64::saturating_from_num(0) { + SubnetAlphaInMovingReserve::::insert( + netuid, + U64F64::saturating_from_num(alpha_in.to_u64()), + ); + seeded = seeded.saturating_add(1); + } + weight = weight.saturating_add(T::DbWeight::get().reads_writes(1, 1)); + } + + HasMigrationRun::::insert(&migration_name, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{:?}' completed - seeded {} A_EMA entries.", + String::from_utf8_lossy(&migration_name), + seeded + ); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index e846d325dc..7e4c3a5aca 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -55,6 +55,7 @@ pub mod migrate_reset_bonds_moving_average; pub mod migrate_reset_max_burn; pub mod migrate_reset_tnet_conviction_locks; pub mod migrate_reset_unactive_sn; +pub mod migrate_seed_alpha_in_moving_reserve; pub mod migrate_set_first_emission_block_number; pub mod migrate_set_min_burn; pub mod migrate_set_min_difficulty; diff --git a/pallets/subtensor/src/staking/stake_utils.rs b/pallets/subtensor/src/staking/stake_utils.rs index 2826590c50..ef42a7efba 100644 --- a/pallets/subtensor/src/staking/stake_utils.rs +++ b/pallets/subtensor/src/staking/stake_utils.rs @@ -68,6 +68,19 @@ impl Pallet { let new_moving: I96F32 = I96F32::saturating_from_num(current_price.saturating_add(current_moving)); SubnetMovingPrice::::insert(netuid, new_moving); + + // Tick the Alpha-reserve EMA (`A_EMA`) on the SAME smoothing as the price + // EMA, so derivative references read a block-lagged reserve depth that an + // in-block swap cannot move. Spot `SubnetAlphaIn` only feeds the EMA once + // per block (here), after coinbase — never read live by the references. + let reserve_sample = alpha + .saturating_mul(U64F64::saturating_from_num(SubnetAlphaIn::::get(netuid).to_u64())); + let reserve_carry = one_minus_alpha + .saturating_mul(SubnetAlphaInMovingReserve::::get(netuid)); + SubnetAlphaInMovingReserve::::insert( + netuid, + reserve_sample.saturating_add(reserve_carry), + ); } /// Gets the Median Subnet Alpha Price diff --git a/pallets/subtensor/src/swap/swap_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index 608c61fd57..6b8faa1765 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -27,6 +27,9 @@ impl Pallet { Self::transfer_subnet_ownership(netuid, old_coldkey, new_coldkey); Self::transfer_auto_stake_destination(netuid, old_coldkey, new_coldkey); Self::transfer_coldkey_stake(netuid, old_coldkey, new_coldkey); + // Positions are keyed by coldkey, so the stake moved above would be + // orphaned from the position unless it is re-keyed to the new coldkey. + Self::swap_positions_for_coldkey_swap(netuid, old_coldkey, new_coldkey); } Self::transfer_staking_hotkeys(old_coldkey, new_coldkey); Self::transfer_hotkeys_ownership(old_coldkey, new_coldkey)?; diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index a266798aeb..26dc06ed28 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -595,6 +595,17 @@ impl Pallet { // 8. Swap dividend records if !keep_stake { + // 8.0 Re-home covered short/long positions recorded against + // `old_hotkey`. The backing stake migrates in this same `!keep_stake` + // branch (section 8.5 below), so the positions' recorded hotkey must + // move with it or their close/default/settle math would chase empty + // stake — and the open-time merge guard blocks re-opening at the new + // hotkey, permanently stranding the position. Bounded by the + // per-subnet position caps; charge the iteration weight. + let (pos_reads, pos_writes) = + Self::swap_positions_for_hotkey_swap(old_hotkey, new_hotkey, netuid); + weight.saturating_accrue(T::DbWeight::get().reads_writes(pos_reads, pos_writes)); + // 8.1 Swap TotalHotkeyAlphaLastEpoch let old_alpha = TotalHotkeyAlphaLastEpoch::::take(old_hotkey, netuid); let new_total_hotkey_alpha = TotalHotkeyAlphaLastEpoch::::get(new_hotkey, netuid); diff --git a/pallets/subtensor/src/tests/derivatives.rs b/pallets/subtensor/src/tests/derivatives.rs index 3e7466920a..2a02b130ef 100644 --- a/pallets/subtensor/src/tests/derivatives.rs +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -44,6 +44,9 @@ fn setup_market(tao_reserve: u64, alpha_reserve: u64, price: f64) -> NetUid { let sa = SubtensorModule::get_subnet_account_id(netuid).unwrap(); add_balance_to_coldkey_account(&sa, t(tao_reserve)); SubnetMovingPrice::::insert(netuid, I96F32::from_num(price)); + // Warm the Alpha-reserve EMA to the live reserve (production warms it over + // blocks via `update_moving_price`; tests start warm so references are stable). + SubnetAlphaInMovingReserve::::insert(netuid, U64F64::from_num(alpha_reserve)); SubtensorModule::set_shorts_enabled(true); SubtensorModule::set_short_kappa_ppb(900_000_000); // κ = 0.9, generous netuid @@ -2080,3 +2083,316 @@ fn list_positions_across_subnets() { assert_eq!(netuids, want); }); } + +// --------------------------------------------------------------------------- +// Key-swap re-homing (hotkey / coldkey swaps must follow the position) +// --------------------------------------------------------------------------- + +/// `keep_stake = false` hotkey swap migrates the trader's stake AND re-homes the +/// recorded position hotkey, so the staked-alpha close path keeps working. +/// Without the re-home, `do_close_short` would chase the now-empty old hotkey +/// and revert `InsufficientAlphaToClose`. +#[test] +fn hotkey_swap_rehomes_short_and_close_still_works() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let trader = U256::from(10); + let old_hotkey = U256::from(11); + let new_hotkey = U256::from(12); + // Trader must own the hotkey to swap it. + register_ok_neuron(netuid, old_hotkey, trader, 0); + add_balance_to_coldkey_account(&trader, t(1000 * TAO)); + + assert_ok!(SubtensorModule::open_short( + RuntimeOrigin::signed(trader), + old_hotkey, + netuid, + t(100 * TAO), + None + )); + let q = ShortPositions::::get(netuid, trader).unwrap().q_liability; + give_alpha(old_hotkey, trader, netuid, AlphaBalance::from(q.to_u64() + 10 * TAO)); + + add_balance_to_coldkey_account( + &trader, + (SubtensorModule::get_key_swap_cost() + 1000.into()).into(), + ); + assert_ok!(SubtensorModule::do_swap_hotkey( + RuntimeOrigin::signed(trader), + &old_hotkey, + &new_hotkey, + None, + false, + )); + + // The position followed the stake to the new hotkey. + let pos = ShortPositions::::get(netuid, trader).unwrap(); + assert_eq!(pos.hotkey, new_hotkey, "position hotkey must re-home"); + assert_eq!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&old_hotkey, &trader, netuid) + .to_u64(), + 0, + "stake left the old hotkey" + ); + assert!( + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&new_hotkey, &trader, netuid) + >= q, + "stake now reachable at the new hotkey" + ); + + // The staked-alpha close path resolves against the migrated stake. + assert_ok!(SubtensorModule::close_short( + RuntimeOrigin::signed(trader), + netuid, + 1_000_000_000, + None + )); + assert!(ShortPositions::::get(netuid, trader).is_none()); + }); +} + +/// A third party's swap of their own hotkey must re-home a *delegator's* +/// position recorded against that hotkey — the delegator's stake is moved by the +/// same swap, so leaving their position behind would silently strand it. +#[test] +fn hotkey_swap_rehomes_third_party_delegator_position() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let owner = U256::from(20); + let old_hotkey = U256::from(21); + let new_hotkey = U256::from(22); + let delegator = U256::from(30); + register_ok_neuron(netuid, old_hotkey, owner, 0); + add_balance_to_coldkey_account(&delegator, t(1000 * TAO)); + + // Delegator opens a short against the owner's hotkey and stakes there. + assert_ok!(SubtensorModule::open_short( + RuntimeOrigin::signed(delegator), + old_hotkey, + netuid, + t(100 * TAO), + None + )); + let q = ShortPositions::::get(netuid, delegator).unwrap().q_liability; + give_alpha(old_hotkey, delegator, netuid, AlphaBalance::from(q.to_u64() + 10 * TAO)); + + // The hotkey OWNER swaps — not the delegator. + add_balance_to_coldkey_account( + &owner, + (SubtensorModule::get_key_swap_cost() + 1000.into()).into(), + ); + assert_ok!(SubtensorModule::do_swap_hotkey( + RuntimeOrigin::signed(owner), + &old_hotkey, + &new_hotkey, + None, + false, + )); + + let pos = ShortPositions::::get(netuid, delegator).unwrap(); + assert_eq!(pos.hotkey, new_hotkey, "delegator position must re-home"); + assert_ok!(SubtensorModule::close_short( + RuntimeOrigin::signed(delegator), + netuid, + 1_000_000_000, + None + )); + assert!(ShortPositions::::get(netuid, delegator).is_none()); + }); +} + +/// A coldkey swap re-keys the position to the new coldkey (hotkey unchanged) so +/// the new coldkey can still close against the migrated stake. +#[test] +fn coldkey_swap_rekeys_short_and_close_still_works() { + new_test_ext(1).execute_with(|| { + let netuid = setup_market(1000 * TAO, 1000 * TAO, 1.0); + let old_cold = U256::from(40); + let new_cold = U256::from(41); + let hotkey = U256::from(42); + add_balance_to_coldkey_account(&old_cold, t(1000 * TAO)); + + assert_ok!(SubtensorModule::open_short( + RuntimeOrigin::signed(old_cold), + hotkey, + netuid, + t(100 * TAO), + None + )); + let q = ShortPositions::::get(netuid, old_cold).unwrap().q_liability; + give_alpha(hotkey, old_cold, netuid, AlphaBalance::from(q.to_u64() + 10 * TAO)); + + assert_ok!(SubtensorModule::do_swap_coldkey(&old_cold, &new_cold)); + + // Position re-keyed old → new, hotkey preserved. + assert!(ShortPositions::::get(netuid, old_cold).is_none()); + let pos = ShortPositions::::get(netuid, new_cold).unwrap(); + assert_eq!(pos.hotkey, hotkey, "hotkey unchanged on coldkey swap"); + assert_eq!( + ShortPositionCount::::get(netuid), + 1, + "count not double-charged" + ); + + assert_ok!(SubtensorModule::close_short( + RuntimeOrigin::signed(new_cold), + netuid, + 1_000_000_000, + None + )); + assert!(ShortPositions::::get(netuid, new_cold).is_none()); + }); +} + +// --------------------------------------------------------------------------- +// T_ref manipulation resistance (regression guards for the `A_EMA` reference) +// +// `short_t_ref = min(T_live, pEMA·A_EMA)` now derives its upper bound from two +// block-lagged factors (the price EMA and the Alpha-reserve EMA), so an in-block +// reserve nudge along the CPMM curve `T_live·A_live = k` cannot move it up. The +// live `T_live` term only pulls the reference *down* (conservative). These tests +// guard against regressing to the old spot-`A_live` reference, where the `min` +// peaked at the crossover `A* = √(k/pEMA)` and let an attacker inflate T_ref, +// retained proceeds, and capacity. +// --------------------------------------------------------------------------- + +// A naive over-pump still can't raise T_ref (it only collapses the live floor). +#[test] +fn naive_single_side_pump_cannot_raise_t_ref() { + new_test_ext(1).execute_with(|| { + // Honest: T_live=1500, A_live=1000 (spot 1.5), pEMA=1.0, A_EMA=1000. + // T_ref = min(1500, 1.0·1000) = 1000 TAO. + let netuid = setup_market(1500 * TAO, 1000 * TAO, 1.0); + let honest = SubtensorModule::get_subnet_short_state(netuid).unwrap().t_ref.to_u64(); + assert_approx(honest, 1000 * TAO, TAO, "honest T_ref = lagged branch"); + + // Dump alpha so A_live=4000 (T_live=k/A=375). The EMA factor (A_EMA=1000) + // is unchanged, so the upper bound stays 1000; the live floor drops to 375 + // and the min selects it — never above honest. + setup_reserves(netuid, t(375 * TAO), AlphaBalance::from(4000 * TAO)); + let pumped = SubtensorModule::get_subnet_short_state(netuid).unwrap().t_ref.to_u64(); + assert!( + pumped <= honest, + "pump must not raise T_ref: pumped {pumped} !<= honest {honest}" + ); + }); +} + +// GUARD (read/quote level): a two-sided nudge to the would-be crossover no longer +// moves T_ref, retained proceeds, or capacity headroom — the EMA factor is frozen +// intra-block, so the upper bound is immune and the live floor stays above it. +#[test] +fn crossover_nudge_does_not_inflate_t_ref_proceeds_or_capacity() { + new_test_ext(1).execute_with(|| { + // Honest: spot 1.5, pEMA 1.0, A_EMA 1000 ⇒ T_ref = min(1500, 1000) = 1000. + let netuid = setup_market(1500 * TAO, 1000 * TAO, 1.0); + let p = t(100 * TAO); + + let honest_state = SubtensorModule::get_subnet_short_state(netuid).unwrap(); + let honest_t_ref = honest_state.t_ref.to_u64(); + let honest_headroom = honest_state.footprint_remaining.to_u64(); + let honest_n = SubtensorModule::quote_open_short(netuid, p).unwrap().retained_proceeds.to_u64(); + + // Nudge live reserves to the old crossover A* = √k ≈ 1224.74 (product k + // fixed). With the spot reference this peaked T_ref at the geometric mean; + // with the lagged reference the EMA factor is untouched, so nothing moves. + let cross = 1_224_744_871_000u64; + setup_reserves(netuid, t(cross), AlphaBalance::from(cross)); + + let attack_state = SubtensorModule::get_subnet_short_state(netuid).unwrap(); + let attack_t_ref = attack_state.t_ref.to_u64(); + let attack_headroom = attack_state.footprint_remaining.to_u64(); + let attack_n = SubtensorModule::quote_open_short(netuid, p).unwrap().retained_proceeds.to_u64(); + + // T_ref pinned to the lagged upper bound pEMA·A_EMA = 1000 (live T_live now + // 1224.7 ≥ that, so the min still selects the lagged value, unchanged). + assert_approx(attack_t_ref, honest_t_ref, TAO, "T_ref immune to in-block nudge"); + assert!( + attack_t_ref <= honest_t_ref, + "nudged T_ref {attack_t_ref} must not exceed honest {honest_t_ref}" + ); + assert!( + attack_n <= honest_n, + "nudged retained proceeds {attack_n} must not exceed honest {honest_n}" + ); + assert!( + attack_headroom <= honest_headroom, + "nudged headroom {attack_headroom} must not exceed honest {honest_headroom}" + ); + }); +} + +// GUARD (end-to-end): the sandwich that previously bypassed the capacity cap now +// fails. An open rejected at the honest reserve is STILL rejected after nudging +// reserves to the old crossover, because T_ref no longer reads the spot reserve. +#[test] +fn sandwich_open_cannot_breach_capacity_cap() { + new_test_ext(1).execute_with(|| { + // spot 1.5, pEMA 1.0, A_EMA 1000 ⇒ T_ref = 1000. κ = 0.08 ⇒ cap = 80 TAO. + // A P=100 open needs B≈92 TAO > 80, so it must be rejected either way. + let netuid = setup_market(1500 * TAO, 1000 * TAO, 1.0); + SubtensorModule::set_short_kappa_ppb(80_000_000); // κ = 0.08 + let p = t(100 * TAO); + + let honest_cap = SubtensorModule::get_subnet_short_state(netuid).unwrap().footprint_cap.to_u64(); + assert_approx(honest_cap, 80 * TAO, TAO, "honest cap = κ·T_ref_honest"); + + // CONTROL: at the honest reserve, this open exceeds κ·T_ref_honest. + let ctrl = U256::from(98); + add_balance_to_coldkey_account(&ctrl, t(10_000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(ctrl), U256::from(97), netuid, p, None), + Error::::ShortCapacityExceeded + ); + + // ATTACK: nudge reserves to the old crossover. T_ref is pinned to the + // lagged bound (1000), so the cap is unchanged and the open still fails. + let cross = 1_224_744_871_000u64; + setup_reserves(netuid, t(cross), AlphaBalance::from(cross)); + let trader = U256::from(10); + add_balance_to_coldkey_account(&trader, t(10_000 * TAO)); + assert_noop!( + SubtensorModule::open_short(RuntimeOrigin::signed(trader), U256::from(11), netuid, p, None), + Error::::ShortCapacityExceeded + ); + + // No position was created; footprint stays empty, cap unchanged. + assert!(ShortPositions::::get(netuid, trader).is_none()); + let b_sigma = ShortAggregate::::get(netuid).b_sigma.to_u64(); + assert_eq!(b_sigma, 0, "no footprint: open rejected under the lagged reference"); + }); +} + +// GUARD (long mirror): the symmetric reference `A_ref = min(A_live, A_EMA)` is +// likewise immune to an in-block reserve nudge. Previously `A_ref = min(A_live, +// T_live/pEMA)` peaked at the same crossover; with the lagged `A_EMA` the upper +// bound is frozen intra-block. +#[test] +fn crossover_nudge_does_not_inflate_a_ref_or_capacity() { + new_test_ext(1).execute_with(|| { + // spot 1.5, pEMA 1.0, A_EMA 1000 ⇒ A_ref = min(1000, 1000) = 1000. + let netuid = setup_long(1500 * TAO, 1000 * TAO, 1.0); + + let honest = SubtensorModule::get_subnet_long_state(netuid).unwrap(); + let honest_a_ref = honest.a_ref.to_u64(); + let honest_headroom = honest.footprint_remaining.to_u64(); + assert_approx(honest_a_ref, 1000 * TAO, TAO, "honest A_ref = lagged branch"); + + // Nudge to the old crossover A* = √k ≈ 1224.74 (product k fixed). The old + // spot reference peaked A_ref here; the lagged reference does not budge. + let cross = 1_224_744_871_000u64; + setup_reserves(netuid, t(cross), AlphaBalance::from(cross)); + + let attack = SubtensorModule::get_subnet_long_state(netuid).unwrap(); + assert!( + attack.a_ref.to_u64() <= honest_a_ref, + "nudged A_ref {} must not exceed honest {honest_a_ref}", + attack.a_ref.to_u64() + ); + assert!( + attack.footprint_remaining.to_u64() <= honest_headroom, + "nudged long headroom {} must not exceed honest {honest_headroom}", + attack.footprint_remaining.to_u64() + ); + }); +}