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/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..a8926ea9ce 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -2276,6 +2276,155 @@ 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(()) + } + + /// 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(()) + } + + /// 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(()) + } + + /// 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(()) + } + + /// 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/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 0fb24d61c2..907494e529 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -14,6 +14,10 @@ use pallet_subtensor::rpc_info::{ SubnetHyperparams, SubnetHyperparamsV2, SubnetHyperparamsV3, SubnetInfo, SubnetInfov2, }, }; +use pallet_subtensor::derivatives::{ + CloseLongQuote, CloseShortQuote, LongMarketInfo, LongOpenQuote, LongPositionInfo, + ShortMarketInfo, ShortOpenQuote, ShortPositionInfo, +}; use pallet_subtensor::staking::lock::LockState; use sp_runtime::AccountId32; use substrate_fixed::types::U64F64; @@ -81,4 +85,18 @@ 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; + + 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/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index fac924ccf4..eebeccfb79 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -19,6 +19,10 @@ impl Pallet { Self::reveal_crv3_commits(); // --- 4. Run emission through network. Self::run_coinbase(block_emission); + // --- 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 c61a71aa65..27620c908e 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -212,6 +212,11 @@ impl Pallet { Self::finalize_all_subnet_root_dividends(netuid); + // --- 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)?; T::SwapInterface::clear_protocol_liquidity(netuid)?; diff --git a/pallets/subtensor/src/derivatives/long.rs b/pallets/subtensor/src/derivatives/long.rs new file mode 100644 index 0000000000..c745f097ea --- /dev/null +++ b/pallets/subtensor/src/derivatives/long.rs @@ -0,0 +1,683 @@ +//! 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::saturating_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; + } + + /// 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() + && 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 + ); + // 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)?; + // 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)); + // 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(); + 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::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)); + + 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)); + } + // 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); + 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(()) + } + + /// 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`. + 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(LongDefaultGrace::::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)); + + // 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); + 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::cleanup_long_if_empty(netuid); + + 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::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 { + 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) { + 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); + 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); + } + /// Clamped to `[1, 4096]` (see `set_short_max_positions`). + 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 new file mode 100644 index 0000000000..20a6be0844 --- /dev/null +++ b/pallets/subtensor/src/derivatives/mod.rs @@ -0,0 +1,1005 @@ +//! Fixed-liability covered continuous-unwind derivatives (spec v3.6.1). +//! +//! Both sides are implemented and independently gated (`ShortsEnabled` / +//! `LongsEnabled`, both default-off). Shorts live here; the long mirror is in +//! `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 +//! via issuance accounting (burned at open, minted back on restore/close). Pool +//! reserves, `TotalStake`, and issuance move in lockstep. +//! +//! 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 +//! 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}; +use safe_math::FixedExt; +use sp_runtime::traits::AccountIdConversion; +use substrate_fixed::types::I64F64; +use subtensor_runtime_common::Token; + +pub mod long; +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 { + // `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::saturating_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, + ); + } + + // ---- 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 + /// `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::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. + if t_ema <= I64F64::from_num(0) { + t_live + } else { + t_live.min(t_ema) + } + } + + /// 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). 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; + } + 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` 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); + delta + .saturating_add(d2.saturating_mul(I64F64::from_num(0.5))) + .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` (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) + .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); + 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), ShortBaseLtv::::get()) + .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)); + // 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) { + 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 => { + // 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); + + 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 + ); + // 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 + ); + // 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); + // 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); + 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); + 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(()) + } + + /// 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, + 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); + + // 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); + 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); + ShortPositionCount::::mutate(netuid, |c| *c = c.saturating_sub(1)); + Self::cleanup_short_if_empty(netuid); + + 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::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, + 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); + ShortPositionCount::::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. 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) { + 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. + 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); + } + /// 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); + } + 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.clamp(1, 4096)); + } + + // ---- read-only quote (spec §1.2) ----------------------------------- + + /// 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 !ShortsEnabled::::get() || 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), 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)?; + + 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..7c6f7e0c24 --- /dev/null +++ b/pallets/subtensor/src/derivatives/types.rs @@ -0,0 +1,314 @@ +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, +} + +/// 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)] +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, +} + +// ===== 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/lib.rs b/pallets/subtensor/src/lib.rs index 7ce25b65b6..3a964d65c6 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,241 @@ 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] + /// 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) + } + #[pallet::type_value] + /// 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 { + 128 + } + #[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>; + + /// 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 = + 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>; + + /// 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< + _, + 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, + >; + + // ===== 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>; + + /// 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< + _, + 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 7a64acba44..65a4c17b93 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2593,5 +2593,123 @@ 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) + } + + /// 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) + } + + /// 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 46343b6ed1..33c89c2894 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -301,5 +301,45 @@ 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, + /// 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. + 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 918baf1107..7ccebae210 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -631,5 +631,115 @@ 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, + }, + + /// 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 new file mode 100644 index 0000000000..4467bc4095 --- /dev/null +++ b/pallets/subtensor/src/tests/derivatives.rs @@ -0,0 +1,1800 @@ +#![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::{I64F64, 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"); + }); +} + +// 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()); + }); +} + +// 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); + }); +} + +// =========================================================================== +// 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); + // cleanup-on-empty evicts fully-closed subnets from the decay tick. + assert!(!ShortActiveSubnets::::contains_key(netuid)); + assert!(!LongActiveSubnets::::contains_key(netuid)); + }); +} + +// 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)); + 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); + 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" + ); + }); +} + +// 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"); + + // 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); + }); +} + +// 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)); + }); +} + +// 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 + ); + }); +} + +// 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)); + + // 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); + 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)); + 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; 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)); + 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))); + 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)); + 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); + 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() { + 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 + ); + }); +} + +// --------------------------------------------------------------------------- +// 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] +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"); + }); +} + +// --------------------------------------------------------------------------- +// 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_long_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)); + }); +} + +// 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() { + 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() { + 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..eb9b01049a 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 @@ -334,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); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5cf4d7aadb..b99e38060f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2556,6 +2556,76 @@ 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) + } + + 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 { fn get_proxy_types() -> Vec { get_all_proxy_type_infos()