Pool borrowing spec: covered continuous-unwind shorts (proposal)#2764
Pool borrowing spec: covered continuous-unwind shorts (proposal)#2764unconst wants to merge 21 commits into
Conversation
Proposal implementing the Fixed-Liability Covered Continuous-Unwind Model (shorting.pdf v3.6.1) as native pool-borrowing derivatives on Alpha/TAO CPMM pools. Launch scope is shorts-first; longs are specified for symmetry but gated off. The whole feature is disabled by default (ShortsEnabled=false) and gated behind governance until trading-games verification. Adds: - derivatives module: open/top-up/partial+full close/permissionless default, per-block O(1) decay + restoration, terminal deregistration settlement. - Per-subnet custody accounting; flow-neutral (no TaoFlow writes); reuses the existing SubnetMovingPrice EMA as the risk/terminal price reference. - Governance params (kappa, base LTV, decay bounds, dust, grace, min input) via admin-utils; runtime-API read layer (quote open/close, materialized position views with health metrics, per-subnet market state). - safe-math: checked_exp; accurate small-delta carry accumulation. - Comprehensive test suite (35 tests) + DESIGN.md and IMPLEMENTATION_PLAN.md. Co-authored-by: Cursor <cursoragent@cursor.com>
| let positions: Vec<(T::AccountId, ShortPosition<T::AccountId>)> = | ||
| ShortPositions::<T>::iter_prefix(netuid).collect(); | ||
| for (coldkey, mut pos) in positions { |
There was a problem hiding this comment.
[HIGH] Deregistration can process an unbounded number of short positions
settle_shorts_on_dereg collects and iterates every ShortPositions entry for the subnet, but the caller path keeps a fixed weight. This is not only root-only: do_register_network calls do_dissolve_network(prune_netuid) when the subnet limit is reached, so a normal registration can trigger this settlement for a pruned subnet. An attacker can create many minimum-size short positions across coldkeys, making pruning/deregistration perform unbounded storage iteration and multiple balance transfers inside one block. Bound this path before enabling shorts, for example by keeping a bounded per-subnet position index and settling in chunks, preventing deregistration while positions exist, or charging/weighting against a hard maximum position count.
🛡️ AI Review — Skeptic (security review)VERDICT: VULNERABLE Baseline scrutiny: established repo writer with substantive subtensor history and write permission; no trusted Gittensor allowlist hit; co-authored Cursor commits; branch feat/pool-borrowing-spec -> devnet-ready. Findings
Prior-comment reconciliation
ConclusionThe PR does not look malicious, but the derivative dispatches still perform balance/reserve/storage mutations before later fallible checks, including caller slippage guards. Because these calls are not wrapped in a storage transaction, failed dispatches can leave funds, reserves, position state, and flow accounting partially mutated. 📜 Previous run (superseded)
# 🔍 AI Review — Auditor (domain review) has not yet run on this PR. |
|
🔄 AI review updated — Skeptic: VULNERABLE |
Defense-in-depth: even if a position's omega_entry ever exceeded the aggregate Omega, materialize must not produce f>1 and inflate the position. Adds tests for the clamp and an open/close round-trip no-profit invariant. Co-authored-by: Cursor <cursoragent@cursor.com>
| let positions: Vec<(T::AccountId, ShortPosition<T::AccountId>)> = | ||
| ShortPositions::<T>::iter_prefix(netuid).collect(); |
There was a problem hiding this comment.
[HIGH] Deregistration can process an unbounded number of short positions
settle_shorts_on_dereg collects and then settles every ShortPositions entry for the subnet, and this is called synchronously from do_dissolve_network before the subnet can be removed. ShortPositions is one entry per (netuid, coldkey) with no hard per-subnet bound, so after shorts are enabled an attacker can create many minimum-size positions and make subnet deregistration perform arbitrary storage reads/writes, transfers, and event deposits in one runtime call. The default-off launch gate only postpones the issue; it is still a steady-state DoS risk once governance enables the feature. Bound the number of positions per subnet, or convert terminal settlement into an incremental, cursor-based process with explicit per-call limits/weights before allowing deregistration to finish.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Addresses thermo branch-audit findings: - M4: cap open short positions per subnet (ShortMaxPositions, governable) and maintain a per-subnet counter, bounding deregistration-settlement work so a heavily-shorted subnet stays prunable. - L3: guard close against minting alpha if SubnetAlphaOut would underflow. - L2: quote_open_short returns None while shorts are disabled. Adds 3 tests; suite now 40 passing. Co-authored-by: Cursor <cursoragent@cursor.com>
| pub fn open_short( | ||
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short takes only position_input, so the call executes against whatever pool state exists at inclusion time. The quote path is read-only and this PR's design table even specifies a price_limit, but the signed extrinsic has no max-liability / min-output / price guard. A searcher can move the Alpha/TAO pool before the transaction lands and force the user into worse phi / q_liability terms for the same floor input. Add a caller-supplied limit to the dispatch and enforce it inside do_open_short before any transfers or storage mutations.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Implements covered longs as the mirror of shorts (spec §9): Alpha collateral/ buffer/escrow, fixed TAO liability D. Longs need no TAO custody account — parked Alpha is tracked via issuance accounting (removed from SubnetAlphaIn/Out at open, minted back on restoration/close, left burned = recycled on default/cover); the only TAO movement is the trader repaying D into the pool at close. - long.rs: open/top_up/close/default, run_long_decay, settle_longs_on_dereg, governance setters. Reuses shared math (solve_collateral now takes lambda, decay_curve/utilization extracted). - Storage + params: LongPositions/LongAggregate/LongActiveSubnets/ LongPositionCount; LongBaseLtv/LongKappa/LongDust/LongMinInput/LongMaxPositions. - Dispatches 143-146, events, errors; admin-utils setters 104-109 incl. sudo_set_longs_enabled. Both sides default-disabled and independently flaggable. - 7 new tests (gating, alpha-issuance conservation on open/close, decay, default, dereg, flag independence). Suite now 47 passing; shorts + regressions unaffected. Co-authored-by: Cursor <cursoragent@cursor.com>
Update: long side now implemented (symmetric, still gated)The earlier status said longs were "specified but not built" — they are now built as a full mirror of shorts. Both sides remain disabled by default and are independently flaggable ( Covered longs (spec §9): Alpha collateral/buffer/escrow, fixed TAO liability Implementation note worth a reviewer's eye: longs need no TAO custody account. Parked Alpha is tracked via issuance accounting — removed from Added: Tests: +7 (gating, alpha-issuance conservation on open/full-close, decay→pool restoration, default recycles floor, dereg settlement, and short/long flag independence). Suite now 47 passing; short suite and Follow-up for full symmetry: long read-layer RPC views ( |
- proof_full_lifecycle_conserves_tao_and_alpha: mixed shorts+longs through decay, top-up, partial+full close; asserts TAO supply exactly conserved, Alpha never minted and within bounded rounding dust, custody drained, all positions/counts cleared. - proof_default_recycles_exactly_the_floor: default reduces TAO (short) / Alpha (long) issuance by EXACTLY the recycled floor. Suite now 49 passing. Co-authored-by: Cursor <cursoragent@cursor.com>
| pub fn open_short( | ||
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short only signs position_input; it does not include a caller-supplied bound on the live execution terms. do_open_short then samples SubnetTAO / SubnetAlphaIn at inclusion time and computes phi plus q_alpha from those reserves, so a preceding trade or short can force the user into a materially larger alpha liability or different retained/escrow terms than the quote they accepted. Add signed execution limits, for example max alpha liability and/or minimum retained proceeds, and reject if the live calculation violates them.
| pub fn open_long( | ||
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: AlphaBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on long opens
open_long has the same unbounded execution surface as shorts: the signed call fixes only position_input, while do_open_long computes phi and the TAO liability from live pool reserves at inclusion time. If longs are enabled, an attacker can move the pool just before this extrinsic and make the user accept worse liability/retained-proceeds terms than their quote. Add caller-signed execution limits, for example max TAO liability and/or minimum retained Alpha, and reject when the live calculation exceeds those limits.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Long open/top-up and short close decrement stake via the share pool directly, bypassing validate_remove_stake's ensure_available_to_unstake. That let locked alpha (subnet-ownership conviction lock) be used as long collateral and then freed via close, circumventing the lock. Now all three paths call ensure_available_to_unstake before decreasing stake. +1 test (50 total). Co-authored-by: Cursor <cursoragent@cursor.com>
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short accepts only position_input and then derives N, phi, Q, and E from the live reserves at execution time. A user can quote one set of terms, but an attacker can move the pool before inclusion so the signed transaction opens a materially different short with no caller-enforced max_alpha_liability, max_escrow, or price bound. Add an execution limit to the extrinsic and reject if the computed liability/escrow exceeds the signed bound.
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: AlphaBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on long opens
open_long has the same issue on the long side: the caller signs only position_input, while the TAO liability and escrow are computed from live pool reserves during execution. Once longs are enabled, an attacker can sandwich the open so the position is created with worse terms than the user quoted. Add a caller-signed execution bound such as max_tao_liability / max_escrow / price limit and enforce it after computing the open terms.
|
🔄 AI review updated — Skeptic: VULNERABLE |
…cs, exp tests) - Cleanup-on-empty: when the last position on a subnet closes, drop the aggregate + active-set entry so the per-block decay tick stops visiting a fully-closed subnet forever (fixes active-set growth / perpetual O(1) tick). - Decouple long default grace from shorts: add LongDefaultGrace param + setter + admin extrinsic (110); long default no longer governed by ShortDefaultGrace. - Clamp short/long kappa setters to (0, 2.0] (governance can't freeze the market or remove the capacity guard). - Bound deregistration-settlement work: lower default max positions/side 4096 -> 128 (H1 over-weight prune-block mitigation); production should move to incremental terminal settlement before raising it. - Docs: correct stale module header (longs are built), state the custody solvency invariant honestly (consistent to within floor rounding, safe direction), and clarify the materialize unwrap_or(0) is correct decay->0. - safe-math: add checked_exp unit tests (vs f64::exp, round-trip, underflow). Suite: safe-math 11, derivatives 50; regressions green. Co-authored-by: Cursor <cursoragent@cursor.com>
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short still commits only position_input, then do_open_short recomputes phi, q_alpha, and e_tao from the live pool at inclusion time. A user can quote off-chain/RPC, sign this call, and be sandwiched into materially worse liability or escrow terms before the extrinsic lands. Add a caller-signed execution bound, such as max alpha liability, max escrow, min retained proceeds, or a price limit, and enforce it before moving funds.
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: AlphaBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on long opens
open_long has the same execution-risk gap: the caller signs only the alpha input while do_open_long derives phi, d_tao, and escrow from mutable live reserves at inclusion time. That lets a preceding pool move turn a quote into worse debt/escrow terms without the transaction reverting. Add a caller-signed bound for the accepted terms and check it inside the long open path before mutating stake or reserves.
|
🔄 AI review updated — Skeptic: VULNERABLE |
- proof_multi_position_decay_conserves: 3 shorts + 2 longs, 300 decay blocks, close all — TAO supply exact, Alpha within dust, custody drained, active sets evicted. (The aggregate-vs-Σ solvency case single-position tests can't reach.) - short_many_partial_closes_drain_cleanly: 9x partial + full close drains custody. - governance_setters_clamp_ranges: kappa (0/huge) and decay-bound clamps. - cleanup_evicts_only_after_last_short_closes: active-set eviction timing. - long_capacity_cap_enforced, long_partial_close_reduces_prorata. - long_dereg_underwater_pays_zero_equity: terminal cover=C, equity=0. - default_grace_independent_per_side: short/long grace decoupled. Suite now 58 passing. Co-authored-by: Cursor <cursoragent@cursor.com>
Updates since opening (changelog)This PR has been substantially hardened and extended since the original description. Summary of what's changed, in order: Feature
Security / correctness fixes (from self-audit + a thermos dual review)
Tests / proof
Known pre-enablement follow-ups (intentionally deferred; this is a gated RFC)
The trading-games gate in |
| // 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::<T>::mutate(|t| *t = t.saturating_sub(removed)); | ||
|
|
||
| let block = Self::get_current_block_as_u64(); | ||
| let pos = match ShortPositions::<T>::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::<T>::ShortHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Short open mutates funds before later fallible checks
do_open_short transfers the caller's TAO to custody, transfers N+E from the subnet account, decreases SubnetTAO, and decrements TotalStake before checking ShortHotkeyMismatch and ShortPositionLimit. This call is not wrapped in #[transactional], so a later ensure! error can persist the earlier balance/reserve mutations without inserting or updating a position. Once shorts are enabled, a failed merge with the wrong hotkey, or any new open after the subnet reaches ShortMaxPositions, can strand funds in custody and corrupt the pool/accounting state. Move every fallible eligibility check that does not depend on prior mutations before the transfers/reserve writes, or make the dispatch path explicitly transactional.
| Self::ensure_available_to_unstake(&coldkey, netuid, position_input)?; | ||
| Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); | ||
| SubnetAlphaOut::<T>::mutate(netuid, |o| *o = o.saturating_sub(position_input)); | ||
| Self::decrease_provided_alpha_reserve(netuid, n_alpha.saturating_add(e_alpha)); | ||
|
|
||
| let block = Self::get_current_block_as_u64(); | ||
| let pos = match LongPositions::<T>::get(netuid, &coldkey) { | ||
| Some(mut existing) => { | ||
| ensure!(existing.hotkey == hotkey, Error::<T>::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::<T>::get(netuid); | ||
| ensure!(count < LongMaxPositions::<T>::get(), Error::<T>::LongPositionLimit); |
There was a problem hiding this comment.
[HIGH] Long open burns stake before later fallible checks
do_open_long removes the caller's staked Alpha, updates SubnetAlphaOut, and decreases the provided Alpha reserve before checking LongHotkeyMismatch and LongPositionLimit. Because the dispatch path is not #[transactional], those mutations can persist when the later ensure! returns an error, leaving no corresponding long position/aggregate update. Once longs are enabled, failed opens can burn or strand Alpha and corrupt subnet reserve accounting. Pre-check the existing-position hotkey and position-count limit before any stake/reserve mutation, or wrap the whole dispatch path in an explicit storage transaction.
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short still accepts only position_input and delegates to do_open_short, which computes Q, retained proceeds, and escrow from live mutable pool reserves at inclusion time. A user can sign based on quote_open_short, then be sandwiched into materially worse liability/escrow terms before the extrinsic lands. Add caller-signed execution bounds, such as maximum alpha liability, minimum retained proceeds, maximum escrow, and preferably an expiry/deadline, and reject if the included state violates them.
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: AlphaBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on long opens
open_long has the same execution-bound gap: it accepts only position_input, while do_open_long derives the fixed TAO liability and escrow from live reserves at inclusion time. If longs are enabled, users cannot bind the execution terms they quoted, so a sandwich can force a materially worse liability/escrow profile. Add caller-signed bounds for the derived long terms and reject if the included pool state exceeds them.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Final thermos pass (no Critical/High): adds the SubnetAlphaOut >= amount guard to long open/top-up, mirroring the short-close guard, so a share-pool rounding edge can't under-decrement outstanding alpha and let close mint it back. +4 long-side tests (alpha-mint guard, top-up, merge mismatch + position cap, invalid fraction/min-input). Suite now 62 passing. Co-authored-by: Cursor <cursoragent@cursor.com>
Final review pass (thermos branch audit) — addressedVerdict: no Critical/High; long side confirmed conservation-sound and structurally safer than shorts (issuance-based custody, no fallible transfer before any write). Of its findings:
|
| let pos = match LongPositions::<T>::get(netuid, &coldkey) { | ||
| Some(mut existing) => { | ||
| ensure!(existing.hotkey == hotkey, Error::<T>::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::<T>::get(netuid); | ||
| ensure!(count < LongMaxPositions::<T>::get(), Error::<T>::LongPositionLimit); |
There was a problem hiding this comment.
[HIGH] Long open burns stake before later fallible checks
do_open_long has already decreased the caller's stake and pool alpha at lines 135-137 before this match runs. The LongHotkeyMismatch and LongPositionLimit checks can still return Err afterward; FRAME dispatch errors do not automatically roll back prior storage/currency mutations. A caller can therefore lose/burn alpha without receiving a position by opening against an existing position with a different hotkey, or when the per-subnet position cap is full. Move all fallible merge/cap validation before any stake/reserve mutation, or wrap the whole operation in a transactional boundary that rolls back on error.
| let pos = match ShortPositions::<T>::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::<T>::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::<T>::get(netuid); | ||
| ensure!( | ||
| count < ShortMaxPositions::<T>::get(), | ||
| Error::<T>::ShortPositionLimit | ||
| ); | ||
| ShortPositionCount::<T>::insert(netuid, count.saturating_add(1)); |
There was a problem hiding this comment.
[HIGH] Short open mutates funds before later fallible checks
do_open_short transfers the user's floor into custody, transfers N+E from the subnet account, and decrements reserves/TotalStake at lines 291-296 before this match runs. The later ShortHotkeyMismatch and ShortPositionLimit checks can still return Err; those errors do not roll back the already-applied balance and reserve mutations. This can confiscate funds and desync accounting on a failed open. Perform all hotkey/cap validation before the transfers/reserve writes, or make the entire open path transactional.
| pub fn open_short( | ||
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: TaoBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on short opens
open_short lets the signer provide only position_input, while do_open_short derives Q, N, E, and pool removal from live reserves at execution time. Any intervening pool move or same-block ordering can materially change the fixed liability and retained/escrow terms after the user signs, with no min_retained, max_liability, max_price, deadline, or quote hash to reject adverse execution. Add caller-supplied execution bounds and enforce them after quote calculation but before mutation.
| pub fn open_long( | ||
| origin: OriginFor<T>, | ||
| hotkey: T::AccountId, | ||
| netuid: NetUid, | ||
| position_input: AlphaBalance, |
There was a problem hiding this comment.
[HIGH] Missing slippage bound on long opens
open_long has the same issue: the signer supplies only alpha input, and the TAO liability/collateral terms are computed from live pool state at inclusion time. A trader can be forced into materially worse terms than the quote they observed, with no signed limit to abort the open. Add explicit bounds such as maximum TAO liability / minimum retained proceeds / maximum execution price and enforce them before any state mutation.
|
🔄 AI review updated — Skeptic: VULNERABLE |
From a 4-agent adversarial review (no free-money / no P0 found): - tao_f/alpha_f and the pEMA conversions use saturating_from_num (they run in the non-transactional on_initialize decay path; a from_num overflow panic would halt consensus — not reachable under supply caps, but defense-in-depth). - set_short/long_max_positions clamped to [1, 4096] so governance can't lift the deregistration-settlement blast radius to a chain-halting size. Suite 62 passing. Co-authored-by: Cursor <cursoragent@cursor.com>
Adversarial security review (4 parallel attacker agents) — "extract value for free / no risk?"Four independent black-hat passes (intra-system free-money, price/EMA/emissions manipulation, staking/lock/swap/dereg interactions, fixed-point/panic/liveness). Conclusion: no free-money / no-risk extraction path, and no attacker-reachable P0 chain-halt. Conservation holds on every open/close/decay/default/dereg path. Why value-for-free is blocked (the load-bearing invariants)
Real exposure (griefing / liveness / hardening — not theft)
Items 1–3 and 5's Balancer check join the existing pre-enablement gate (slippage bounds, benchmarked weights). None block the gated RFC; all must clear before |
Mirrors the short read layer for longs so the two sides are equivalent to any client (the UI-symmetry goal — internal custody plumbing stays as-is): - Types: LongOpenQuote, LongPositionInfo, LongMarketInfo, CloseLongQuote (Alpha collateral / TAO liability denominations). - Pallet views: quote_open_long, get_long_position(s), get_subnet_long_state, quote_close_long, long_blocks_to_dust. - Runtime API: 5 new DerivativesRuntimeApi methods + runtime impl. - 6 mirror tests (quote==position, enable gating, decay materialization, market capacity, close quote, cross-subnet listing). Suite now 68. Co-authored-by: Cursor <cursoragent@cursor.com>
Long-side RPC/view parity addedThe long side now exposes a symmetric client/RPC read layer, so shorts and longs are equivalent to any client (wallets/UI). This closes the asymmetry the reviews flagged — without changing the internal custody model (longs keep issuance-escrow, which is the Alpha-native equivalent of the short TAO custody account).
This removes "long RPC parity" from the pre-enablement follow-up list. |
Per spec §4.5, derivative TAO movements now write TaoFlow scaled by a
governance factor chi (DerivativeFlowFactor), so the core purpose — expressing
negative flow — works: a short removing TAO writes negative flow; TAO returned
on unwind/close/dereg (incl. a long close paying its D liability) writes
positive flow. chi=0 restores the previous flow-neutral behavior; default 1.0.
- Storage DerivativeFlowFactor + DefaultDerivativeFlowFactor (1.0).
- scale_flow/record_derivative_{in,out}flow helpers wired into every
derivative SubnetTAO movement (short open/decay/close/default/dereg; long close).
- Setter set_derivative_flow_factor_ppb (clamped [0,1]) + admin extrinsic 111.
- Test derivatives_write_subnet_flow (short→negative, long close→positive, chi=0 neutral).
Suite 69.
Co-authored-by: Cursor <cursoragent@cursor.com>
Design change: derivatives now write subnet TaoFlow (governance factor χ)Per maintainer direction, derivative operations are no longer flow-neutral — expressing negative flow is a core purpose of the feature. Implemented as the spec's §4.5 activation factor
Security note (important): this intentionally re-opens the attack surface the spec gated off — short-driven emissions-redirection / cross-subnet flow-suppression (spec §4.5.1, §10.8): an actor shorting many subnets to push their measured flow down and redirect emissions toward a favored subnet. The mechanism is still gated overall ( Tests: |
Flow is now a single TaoFlow write at open based on the TAO swapped through the pool, instead of tracking every reserve movement: - short open: sells alpha, extracts N TAO -> negative flow (chi*N). - long open: routes D TAO through the pool to buy alpha -> positive flow (chi*D). Unwinds (decay/close/default/dereg) no longer touch flow; the flow EMA decays the open-time pulse. chi=0 stays flow-neutral. Updated derivatives_write_subnet_flow (long flow now asserted at open). Suite 69. Co-authored-by: Cursor <cursoragent@cursor.com>
Open and close now write opposite TaoFlow (swap-direction symmetric): - short open sells alpha (-N); short close rebuys rhoQ alpha with TAO, valued at the EMA price (+, flash-resistant, reversing the open). - long open buys alpha with D TAO (+D); long close sells the exposure back (-rhoD). Decay/default/dereg still write no flow. Extended derivatives_write_subnet_flow to assert both reversals. Suite 69. Co-authored-by: Cursor <cursoragent@cursor.com>
Audit H1: short open debited `N` while close credited `Q·pEMA = E > N`, so a round-tripped short manufactured net-positive flow, inverting the bearish intent. Open now uses the same `Q·pEMA` basis as close. Audit M1 + symmetry: close AND default reverse the open-side flow on a single per-side basis (short `Q·pEMA`, long `D`), so standing flow tracks only live positions and an abandoned position can't leave a lasting bias. L1: use saturating_from_num for the moving price in the close path. Co-authored-by: Cursor <cursoragent@cursor.com>
Thermos round 2 — flow integration fixes (
|
Add close_short_self / close_long_self dispatches that settle the liability against the pool from the position's own floor+buffer, so no pre-held Alpha (short) or TAO (long) is required. Reject underwater closes via the new CloseCostExceedsClaim error. Co-authored-by: Cursor <cursoragent@cursor.com>
…rowing-spec Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # pallets/admin-utils/src/lib.rs # pallets/subtensor/src/macros/dispatches.rs # pallets/subtensor/src/macros/errors.rs
Slippage/limit-price protection (default off): open/top_up/close/close_self (+ long mirrors) take an optional `limit_price` (alpha price ppb). After the pool mutation the executable price is checked in the adverse direction (floor for price-lowering legs, ceiling for price-raising legs); violations revert with `SlippageExceeded`. Top-up has no pool interaction so the bound is a documented no-op. Also fixes an I64F64 overflow in short/long `spot_close_cost`: the rao-scale `t*q` product (~1e27) saturated I64F64 (~9.2e18), collapsing the buyback cost to ~0 — which made cash-settled close return only escrow (permanent ~N pool drain) and defeated the underwater guard. Reordered to ratio-first. Co-authored-by: Cursor <cursoragent@cursor.com>
…rowing-spec Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # pallets/subtensor/src/macros/dispatches.rs # pallets/subtensor/src/macros/events.rs # runtime/src/lib.rs
| let block = Self::get_current_block_as_u64(); | ||
| let pos = match LongPositions::<T>::get(netuid, &coldkey) { | ||
| Some(mut existing) => { | ||
| ensure!(existing.hotkey == hotkey, Error::<T>::LongHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Long open burns stake before later fallible checks
do_open_long decreases the caller's stake and pool reserves before this merge-hotkey check, and before the new-position limit check below. These dispatches are not wrapped in #[transactional] / with_transaction, so returning LongHotkeyMismatch or LongPositionLimit does not roll those mutations back. An existing long owner can submit a merge with a different hotkey, receive an error, and still have stake/reserve state changed without the position being updated. Move all fallible position checks before lines 136-141 or make the whole derivative dispatch transactional.
| 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::<T>::ShortHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Short open mutates funds before later fallible checks
do_open_short transfers the floor, moves pool TAO into custody, decreases reserves, and records flow before this merge-hotkey check, and before the new-position limit check below. Because this call is not transactional, a ShortHotkeyMismatch / ShortPositionLimit error leaves those mutations in storage while no matching position update is committed. Perform the hotkey/count checks before any transfer or reserve/flow mutation, or wrap the whole extrinsic in a storage transaction.
|
|
||
| // Slippage guard: a short lowers the price, so reject if it ended up | ||
| // below the caller's floor (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_least(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Short slippage checks run after non-transactional mutations
The short-side slippage guard executes after transfers, reserve updates, flow writes, and position/aggregate inserts. The close paths have the same pattern at the later ensure_price_at_most calls. In Substrate, a DispatchError does not automatically revert prior storage writes, so a caller-supplied failing limit can still leave the short opened or the close funds moved while the extrinsic reports SlippageExceeded. Either compute and enforce the bound before mutating state, or wrap these derivative dispatches in a transaction so the guard actually reverts the trade.
|
|
||
| // Slippage guard: a long raises the price, so reject if it ended up above | ||
| // the caller's ceiling (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_most(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Long slippage checks run after non-transactional mutations
This slippage guard runs after stake/reserve/flow mutations and position insertion. The long close and self-close paths also perform their ensure_price_at_least checks only after moving TAO/Alpha and minting returned stake. Without a transaction wrapper, SlippageExceeded leaves those mutations in place, so the limit is not an atomic execution bound. Move the price check ahead of mutation using a precomputed post-trade price, or make the whole operation transactional.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Adds the new optional limit_price (slippage guard) argument to all open/close/top_up short and long extrinsic calls in the derivatives tests so the pallet test build compiles after the slippage-guard change. Co-authored-by: Cursor <cursoragent@cursor.com>
| Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, position_input); | ||
| SubnetAlphaOut::<T>::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::<T>::get(netuid, &coldkey) { | ||
| Some(mut existing) => { | ||
| ensure!(existing.hotkey == hotkey, Error::<T>::LongHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Long open burns stake before later fallible checks
do_open_long mutates stake, SubnetAlphaOut, alpha reserves, and derivative flow before checking the existing-position hotkey and new-position cap. A caller with an existing long can submit a different hotkey, hit LongHotkeyMismatch, and still have stake/reserve accounting already changed unless this path is made transactional or all fallible checks are moved before mutation. The same issue applies to the LongPositionLimit branch below.
| // Slippage guard: a long raises the price, so reject if it ended up above | ||
| // the caller's ceiling (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_most(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Long slippage checks run after non-transactional mutations
The long-side limit-price checks run after the pool/stake/reserve/position mutations. If SlippageExceeded is returned, the dispatch fails after the economic effects have already been applied. This affects open_long here and the analogous close paths at do_close_long / do_close_long_self; either wrap the dispatch in a storage transaction or check an executable projected price before mutating state.
| 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::<T>::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::<T>::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::<T>::ShortHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Short open mutates funds before later fallible checks
do_open_short transfers TAO into custody, removes TAO from the subnet account, changes reserves / TotalStake, and records flow before checking the existing-position hotkey and new-position cap. A failing ShortHotkeyMismatch or ShortPositionLimit can therefore strand funds and corrupt reserve accounting unless the operation is transactional or those checks happen before any mutation.
| // Slippage guard: a short lowers the price, so reject if it ended up | ||
| // below the caller's floor (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_least(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Short slippage checks run after non-transactional mutations
The short-side limit-price checks are evaluated after transfers, reserve changes, aggregate updates, position insertion, and flow accounting. A rejected limit_price can return SlippageExceeded while leaving the trade effects applied. This affects open_short here and the analogous close paths at do_close_short / do_close_short_self; make the dispatch transactional or validate against a projected post-trade price before mutating state.
|
🔄 AI review updated — Skeptic: VULNERABLE |
…ht aware) Replace the hand-rolled constant-product close-cost math in short_spot_close_cost / long_spot_close_cost with exact-output quotes through the swap engine, so buyback/sell costs match real pool execution including fees and pool weights (and avoid the I64F64 overflow the old rao-scale product hit). - swap-interface: add SwapHandler::sim_tao_in_for_alpha_out / sim_alpha_in_for_tao_out exact-output quotes. - swap: implement them via a new Balancer::get_quote_needed_for_base (mirror of get_base_needed_for_quote) and a gross_up_fee helper. - longs: settle_longs_on_dereg now covers the D debt at the conservative max(spot, EMA) alpha cost, mirroring the short's K_D, so a stale-high EMA after a fast drop can't under-seize and leak equity to the long. Co-authored-by: Cursor <cursoragent@cursor.com>
| let block = Self::get_current_block_as_u64(); | ||
| let pos = match LongPositions::<T>::get(netuid, &coldkey) { | ||
| Some(mut existing) => { | ||
| ensure!(existing.hotkey == hotkey, Error::<T>::LongHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Long open burns stake before later fallible checks
do_open_long has already decreased the caller's stake, reduced SubnetAlphaOut, changed pool reserves, and recorded flow before this merge hotkey check runs. The new open_long dispatch wrapper is not marked transactional, so an Err(LongHotkeyMismatch) will not roll those prior mutations back. The same ordering also affects the new-position count check below. Move all fallible position-merge/count and limit checks ahead of the stake/reserve mutations, or wrap the whole dispatch body in an explicit storage transaction.
|
|
||
| // Slippage guard: a long raises the price, so reject if it ended up above | ||
| // the caller's ceiling (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_most(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Long slippage checks run after non-transactional mutations
The caller-controlled slippage guard runs after stake, reserve, aggregate, position-count, active-subnet, and flow mutations. A caller can submit a restrictive limit_price so this returns SlippageExceeded while the preceding state changes persist. That can burn or mint stake/reserves without the position update being treated as a successful extrinsic. Check the limit before committing mutations via a pure quote, or perform the whole open inside a storage transaction that rolls back on Err.
| 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::<T>::ShortHotkeyMismatch); |
There was a problem hiding this comment.
[HIGH] Short open mutates funds before later fallible checks
do_open_short transfers TAO from the caller and subnet account into custody, decreases reserves/TotalStake, and records flow before this hotkey-mismatch check. The dispatch wrapper is not transactional, so Err(ShortHotkeyMismatch) leaves those mutations behind. The same issue applies to the new-position limit check below. Move all fallible merge/count checks before the transfers/reserve mutations, or wrap the operation in a storage transaction.
|
|
||
| // Slippage guard: a short lowers the price, so reject if it ended up | ||
| // below the caller's floor (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_least(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Short slippage checks run after non-transactional mutations
The short-open slippage guard is evaluated after custody transfers, reserve/TotalStake updates, flow accounting, position count insertion, and aggregate writes. A failing limit_price returns SlippageExceeded without reverting those prior mutations. This creates a partial-open state with funds and reserves moved even though the dispatch failed. Use a pre-mutation quote for the limit check, or make the whole operation transactional.
| } | ||
| // Slippage guard: settling escrow raises the price, so reject if it ended | ||
| // up above the caller's ceiling (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_most(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Short close can return funds before failing slippage
do_close_short decreases the caller's staked Alpha, credits Alpha reserve/flow, returns escrow to the pool, and transfers P+R back to the caller before this limit check can fail. Since the position and aggregate are only reduced after the check, a rejected close can still move funds and reserves while leaving the position storage unchanged. The close path needs the same transaction wrapper or a pre-mutation executable-price check.
| } | ||
| // Slippage guard: a long unwind pushes the price down, so reject if it | ||
| // ended up below the caller's floor (sandwich/MEV protection). | ||
| Self::ensure_price_at_least(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Long close can mutate balances before failing slippage
do_close_long transfers the caller's TAO into the subnet account, increases pool TAO reserve/TotalStake, records flow, and mints returned Alpha stake before checking limit_price. If the guard fails, those mutations persist while the long position is not reduced. This is a direct balance/reserve inconsistency risk on an enabled runtime surface; check the limit before mutation or run the close in a storage transaction.
| } | ||
| // Slippage guard: the buyback raises the price, so reject if it ended up | ||
| // above the caller's ceiling (sandwich/MEV protection). `None` = no bound. | ||
| Self::ensure_price_at_most(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Self-covering short close mutates custody before slippage
do_close_short_self transfers custody TAO back to the pool and caller, updates reserves/TotalStake, and records flow before this slippage check. A deliberately failing limit_price leaves the position unchanged but custody and pool balances already moved, enabling partial settlement of a position that the runtime reports as failed. The self-close path must either pre-check the quoted post-trade price or be wrapped in a transaction.
| } | ||
| // Slippage guard: the self-cover sale pushes the price down, so reject if | ||
| // it ended up below the caller's floor (sandwich/MEV protection). | ||
| Self::ensure_price_at_least(netuid, limit_price)?; |
There was a problem hiding this comment.
[HIGH] Self-covering long close mutates stake before slippage
do_close_long_self restores Alpha reserve, records flow, and mints the remaining claim back as stake before the slippage guard. On SlippageExceeded, the long position is left unreduced while those reserve/stake changes remain. This makes the self-covering close non-atomic and can corrupt derivative accounting once longs are enabled. Check the limit before mutation or wrap the path in a storage transaction.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Try it on a live clone net
A disposable clone of mainnet is up for hands-on testing. It runs this branch with full mainnet state (so your own coldkey already has its real balance and stake on it), the short/long mechanism already enabled, and fast blocks (~0.2–1.6 s).
Endpoint:
ws://204.12.162.200:9944— the same port also serves HTTP-RPC athttp://204.12.162.200:9944.Use your own wallet and point
--networkat the endpoint — thebtcli derivcommands (latent-to/btcli#1007) drive the extrinsics andDerivativesRuntimeApi:Longs mirror this with
--side long(you post an Alpha floor and repay TAO to close). Closing a short repays theQAlpha out of the validator stake.Motivation
Adapted from the Fixed-Liability Covered Continuous-Unwind Model (
shorting.pdf, v3.6.1).Today a participant can only express a positive view on a subnet's Alpha by buying it; there is no native way to express a negative view without first owning Alpha and selling it. This proposal adds native pool-borrowing derivatives on the Alpha/TAO CPMM pools so traders can:
The design deliberately avoids the failure modes of classic on-chain leverage:
K_D = max(K_spot,last, Q·pEMA)so forcing a dereg confers no free terminal extraction.κcaps aggregate active footprint and per-block shock; it can start tiny and ramp via governance.See
docs/derivatives/DESIGN.mdanddocs/derivatives/IMPLEMENTATION_PLAN.mdfor the full mapping from the paper onto subtensor.What this implements (shorts-first launch scope)
open_short,top_up_short,close_short(partial + full), permissionlessdefault_short.N; fixed Alpha liabilityQ; linked TAO escrowE; pool fractionϕsolved from the spec's closed forms. Reproduces the paper's worked example exactly (C≈100, N≈37.5, ϕ≈0.039, Q≈3902, E≈39).R/E/Btogether (cumulativeΩaccumulator) and restores TAO to the pool. Per-block iteration is limited to subnets with live short state.do_dissolve_network(escrow rejoins distribution; equity paid; liability-cover recycled).TotalStake, and issuance move in lockstep; derivative legs are flow-neutral (never write TaoFlow).SubnetMovingPriceas the risk/terminal price EMA (no new TWAP/reserve EMA),recycle_tao, and the dereg hook.pallet-admin-utils, call_index 96–102): enable flag,κ, base LTV, decay bounds, dust, default grace, minimum input — all clamped/validated.DerivativesRuntimeApi):quote_open_short,quote_close_short,get_short_position(s) (materialized to the current block with health metrics — current buffer/escrow, time-to-dust, default window, incremental buyback), andget_subnet_short_state(capacity, open interest, params).safe-math: addschecked_exp; carry accumulation uses an accurate small-δseries so the aggregate and per-position decay stay exactly consistent.Anti-exploit / audit notes
transfer_tao(errors, never under-transfers); reserve credits in block-hook/dereg paths only fire on a successful transfer, so a short custody can never inflateSubnetTAO.(coldkey, netuid).Testing
pallets/subtensor/src/tests/derivatives.rs: 35 tests covering gating, the spec worked example, capacity/anti-split, low-liquidity rejection, decay + restoration + closed-form rate check, full/partial close with value-conservation assertions, default eligibility + grace, in-the-money / underwater terminal settlement, end-to-end dissolve, the audit fixes, and the RPC views. All pass.networks/recycle_alphasuites (60 tests).Not in scope / follow-ups
χ_S), kept off per the paper.DbWeightfor now).κramp or enablement (seeIMPLEMENTATION_PLAN.md).Made with Cursor