Skip to content

Add pallet-derivatives: longs and shorts on subnet alpha (spec 455) - #3135

Open
unarbos wants to merge 59 commits into
mainfrom
feat/derivatives
Open

unarbos wants to merge 59 commits into
mainfrom
feat/derivatives

Conversation

@unarbos

@unarbos unarbos commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Shorts-only launch. pallet-derivatives ships both sides, and only shorts are enabled at launch. Longs are designed, implemented, tested, and benchmarked, and are disabled behind their own governance switch pending a decision on their collateral and leverage. TAO-only collateral is unchanged.

  • Two switches, both off at launch. DerivativesEnabled (default false) gates add and only add: while it is off every open, grow, reduce, and flip fails with DerivativesDisabled, checked first in do_add. LongsEnabled (default false) gates the long side: checked right after DerivativesEnabled and before the leverage, on what the call would leave open, not on the side asked for. Opening a long, growing one, or a long-side add on a short that would flip it through zero (with at least MinDeposit past the flip point) fails with LongsDisabled; a long-side add that only reduces or closes a short goes through, as does every short-side add. Neither switch stops a close: owners can always exit, on a short or on a long opened while longs were on, and the weekly interest collection, forfeits, parked-liquidity releases, and dissolution settlement of open positions run unchanged. Root flips them with sudo_set_derivatives_enabled(enabled) (call index 3; DerivativesToggled) and sudo_set_longs_enabled(enabled) (call index 4; LongsToggled); both are in the DerivativesCalls proxy group. derivatives_params / btcli deriv params report them as enabled and longs_enabled. The launch plan is the network-wide switch on, the long switch off. long_interest_rate is inert until longs are enabled.
  • New pallet-derivatives (index 33): longs and shorts on a subnet's alpha, borrowed from the subnet's own pool. A position lifts a slice phi of both reserves without moving price, trades one half through the ordinary swap, and reverses the trade at settlement. Nothing is minted or burned; the pool only ever gets its own liquidity back.
  • One position per coldkey and subnet, one call to move it. add(netuid, side, deposit, leverage_percent) opens, grows, reduces, and flips. Adding on the held side folds a new tranche in; adding on the other side settles that share at the current price; asking for more than is held closes the position and opens the rest (if it reaches MinDeposit) on the new side. close settles in full. Every quantity in a position is a plain sum over its tranches; do_add runs in one storage layer. The flip arithmetic is one function, flip_surplus, used both to predict what an add would leave open (for the long switch) and to settle it.
  • Three root-set parameters are the whole design (DerivativesParams, sudo_set_params): pool_share (25%), the most all open positions of one side on one subnet may borrow of the lent reserve; short_interest_rate (52% a year) and long_interest_rate (26% a year), each a flat rate charged on a tranche's TAO exposure and fixed per tranche when it is added at its side's rate. Shorts pay more because the pool carries open-ended exposure to a short that nobody but its owner can close, while a long is the buy pressure the design wants. The starting values are the DEFAULT_SHORT_INTEREST_RATE_PERCENT / DEFAULT_LONG_INTEREST_RATE_PERCENT constants in position.rs. sudo_set_params refuses a zero rate on either side (ZeroInterestRate); a pool_share of zero pauses new adds and open positions still settle. No utilization or floating rates, and no per-subnet overrides. No migration: the pallet has never shipped, so Params has never been written on any network.
  • Cushions are TAO only. deposit is taken from the caller's free balance and comes back as TAO.
  • Leverage is the owner's choice under a per-side ceiling: runtime constants MaxShortLeverage 100 (1x) and MaxLongLeverage 150 (1.5x; binds nothing until longs are enabled). At 1x a long can never cost the pool anything. The long ceiling is bounded by one attack: the largest long the cap admits can dump alpha it holds outside into its own lifted price and abandon the debt, which pays iff L > 1 + sqrt(1 - pool_share) (1.87x at 25%; 1.57x at a TAO weight of 0.7). 1.5x is below the bound for every share up to 25% and every balancer weight.
  • Interest accrues per block and is collected weekly as buy pressure. interest_per_year = rate(side) × exposure_tao per tranche (params.interest_for(side, lifted_tao) in lift_tranche), accrued pro rata per block (BLOCKS_PER_YEAR = 365 × 7,200). Each position has a due block one INTEREST_PERIOD (50,400 blocks, 7 days) ahead, listed in the Due queue. on_initialize → collect_due walks the queue from NextDue, at most COLLECTIONS_PER_BLOCK = 20 collections a block, and defers a collection that cannot go through by one block. Each collection takes the interest due out of the cushion, buys alpha with it, and recycles the alpha (burn_interest); the pool keeps the TAO. Same-side adds and reductions do not move due. Settlements pay the interest owed through the same path.
  • No expiry, no liquidation, no price-based forced close. close is owner-only (ensure_signed, Positions::take(owner, …)); there is no third-party closer and no health check. Closer is Owner | Underwater | Starved | Dissolution. A position lives until its owner closes it, its cushion cannot pay at a weekly collection, or the subnet is dissolved. A price move alone never ends it: a doubling only puts a 1x short underwater, where a close returns nothing and the pool keeps the pot, and the owner may keep holding while the cushion pays its weeks. Only the starved case forfeits: forfeit hands everything the pallet holds for it back to the pool in kind, with no swap, and the owner gets nothing (Closer::Starved).
  • Settlement. A short's pot (cushion + proceeds) buys back exactly its alpha debt with an exact-output swap (buy_alpha_for), pays interest, and the rest is the owner's; a long sells its proceeds alpha, repays its TAO debt from the pot, pays interest, and the rest is the owner's. Escrow returns untouched via price-neutral return_liquidity.
  • Underwater closes never trade. Before the closing swap, do_settle asks the pool's own quote (quote_buy for a short's debt, quote_sell for a long's proceeds). If the share's pot cannot cover the debt plus the interest due, nothing is swapped: the cushion share, proceeds, and escrow go to the pool in kind, the owner is paid nothing, PositionClosed says closed_by: Underwater and reports the whole debt as the shortfall. Once the owner has lost the cushion a swap could only cost the pool more, and a limit-less market order announced in advance is what a sandwich trades against. The swap remains the last word for a quote that was a rao off. A partial settlement is the same fraction of every leg, with the whole position's interest paid first from the settling share, then from the cushion that stays.
  • Parked liquidity. return_to_pool compares the spot price with the subnet's moving price before re-adding a returned pair. If they differ by more than PARK_THRESHOLD_PERCENT (5%), the pair is parked in the new Parked map (TAO on the pallet account, alpha at the pallet hotkey; LiquidityParked) instead of deepening the pool at a pushed price. on_idle releases parked pairs once the spot is back within the band (LiquidityReleased, weight-metered at WeightInfo::release_parked each); the dissolution hook returns any parked pair to the reserves before settling positions; parked alpha counts in long_alpha_outstanding for the emission price. An honest close on a quiet pool parks nothing.
  • The cap is checked on the real footprint. pool_share is enforced on proceeds + escrow after the opening swap, not on the equal-weight projection phi(2 - phi), so a pool whose balancer weights drifted cannot lend one side more than its share (at TAO weight 0.7 the projection admitted a long that took 38% of the alpha reserve).
  • Emission ignores longs. The price the emission EMA tracks is computed with every open long's alpha counted back into the pool (get_emission_alpha_price), so a team cannot long its own subnet for emission. Shorts are left in.
  • Dissolution settles every position first, in the DerivativesSettle cleanup phase, before stakers are paid. The hook reads the pool's spot price and the subnet's moving price once, stores DissolutionPrices { short: max(spot, moving), long: min(spot, moving) } in DissolutionPrice, emits DissolutionPriced { short, long }, and returns any parked pair to the reserves. Every position is then cash-settled independently with no swap and no netting. A short's alpha debt is converted to TAO at the short price rounded up and repaid from cushion + proceeds; a short that pushed the spot down in the dissolving block is charged the moving price it could not push. A long pays debt + interest from its cushion first, then in alpha at the long price rounded up; the pool buys the long's remaining alpha for TAO at the same price as far as SubnetTAO goes, and any alpha the reserve cannot buy is handed to the owner as stake at the pallet hotkey (hand_alpha) to be paid out with every other stake. No long is paid nothing because another drew the reserve first; order changes only how much of a surplus arrives as TAO now versus alpha later. Weekly interest collection skips positions on a dissolving subnet, so a tick in the window cannot forfeit a position the settlement is about to pay. Settlement is weight-metered and never blocks dissolution; transfer failures are logged.
  • Plumbing: DerivativesPoolInterface in swap-interface with price-neutral lift_liquidity / return_liquidity, internal buy and sell through the balancer, exact-output buy_alpha_for, no-swap quote_buy / quote_sell, recycle_alpha, spot_price and moving_price on the same 32.32 scale, draw_tao, hand_alpha; exp_scaled in the balancer saturates instead of returning 0; SubnetDissolveHook / DerivativesHook in common; pallet hotkey claimed in on_runtime_upgrade from the parent block hash; derivatives calls NonCriticalAllowed only.
  • SDK: AddPosition(netuid, side, amount, leverage) and ClosePosition(netuid) intents; derivative_position, derivative_positions, derivative_positions_on_subnet, derivatives_params (both rates and both switches as enabled / longs_enabled) reads with interest_due, runway_days, and estimated equity_tao; btcli deriv short|long|list|close|params; LongsDisabled has a description and a remediation that points at deriv short / deriv close. Bindings regenerated from the built node.
  • Docs: docs/guides/derivatives.mdx, framed as a shorts-only launch (long sections kept and marked "not enabled at launch"; both switches in the launch-state note and switch section), with the animated lifecycle deck, payoff figure, numbered close flows for shorts and longs, the weekly-interest section, and the dissolution steps; generated query/tx/error pages; the v456 release page and releases index, same framing. Wording rule throughout: a price move alone never ends a position; it can only make it underwater (a close returns nothing, the pool keeps the pot, the owner may keep holding); forfeiture happens only when the cushion cannot pay a weekly collection, or at dissolution.
  • spec_version 455 → 456 (rebased on main's release 455).
  • CI hygiene folded in: cargo audit accepts RUSTSEC-2026-0269 (wasmtime 8.0.1, same polkadot-sdk pin as the existing wasmtime ignores; the runtime WASM has no filesystem), and the docs-preview lock pins fast-uri 3.1.7.

Weights

pallets/derivatives/src/weights.rs holds measured values from the repo's own benchmark pipeline: the run-benchmarks label triggered Validate-Benchmarks on the Benchmarking runner (run 34763489612, head b8d2dff, steps 50, repeat 20, standard invocation and .maintain/frame-weight-template.hbs). add, close, collect_interest and release_parked are verbatim from the run's bench-patch artifact. sudo_set_params and sudo_set_derivatives_enabled were within the 75% drift threshold, so the patch left them; their base weights are the same run's weight-compare readings and the file says so inline. sudo_set_longs_enabled was added after that run and carries a placeholder (the sudo_set_derivatives_enabled reading; same one-bool write); add now reads LongsEnabled on a long-side call. The next reference run picks both up. The patch also reported drift in pallet_subtensor, pallet_admin_utils, pallet_limit_orders and pallet_commitments; that drift predates this branch (the same set shows on PR #3155) and was not applied.

benchmark placeholder measured (reference) reads/writes proof
add 600 ms + close (1.5 s), 30+35 r / 20+25 w 1_571_933_000 ps (min 1_555_306_000) 32 / 23 8727
close 900 ms, 35 r / 25 w 625_155_000 ps (min 616_400_000) 29 / 21 8727
collect_interest 150 ms, 12 r / 10 w 258_409_000 ps (min 252_091_000) 24 / 15 6148
release_parked 120 ms, 12 r / 8 w 189_446_000 ps (min 183_450_000) 23 / 14 6148
sudo_set_params 6 ms, 1 w 3_847_000 ps 0 / 1 0
sudo_set_derivatives_enabled 6 ms, 1 w 3_681_000 ps 0 / 1 0
sudo_set_longs_enabled 3_681_000 ps carried over from sudo_set_derivatives_enabled (same shape: one write of one bool), marked as a placeholder in the file pending the next reference run 0 / 1 0

Benchmark setups were re-pointed at the current worst cases (commit b8d2dff). The underwater rule made the old setups the cheap path: a short pumped underwater is handed back in kind with no swap. Measured on the same machine, the old close setup came to 145 ms / 15 r / 11 w against 556 ms / 29 r / 21 w for the setup now in tree. add and close now settle a covered short the price moved against (300 TAO pumped into a 1000 TAO pool, a week of interest owed; the setup asserts the same coverage test do_settle applies), so the exact-output buyback, the interest swap and recycle, the payout and the liquidity return all run. collect_interest measures a paid collection (a year of interest, swapped and recycled) rather than a forfeit: a short forfeit measured 101 ms / 15 r / 11 w and a long forfeit 172 ms / 23 r / 17 w against 231 ms / 24 r / 15 w for the paid path, on the same machine. release_parked was already at its worst case (both price reads, both tokens returned through the live-pool path). Dissolution pacing at close() per position was checked against dedicated one-off measurements of settle_at_dissolution: 113 ms / 13 r / 12 w for a short and 216 ms / 21 r / 18 w for a long, both under close(). Those comparison numbers are from a cloud VM (4 vCPU Intel Xeon) whose readings for the committed benchmarks sit about 10% under the runner's; they are there for the ratios, not as committed weights.

Test plan

  • cargo test -p pallet-derivatives (80 tests, 87 with runtime-benchmarks; the long switch: launch default and root origin, a long cannot be opened or grown while off and the switch is checked before the leverage, a short can be reduced and closed with a long-side add but not flipped and a refused flip leaves the short, the balance, and the pool untouched, shorts open, grow, and close as usual, a long opened while on keeps paying interest, can be reduced, and can be closed once the switch is off; the network-wide switch: launch default and root origin, every kind of add refused while off, close allowed while off, interest collected and a starved position forfeited while off; per-side rates: short and long tranches booked at their own rate, a flip books the new side's rate, sudo_set_params rejects zero on either side, Params returns both; including tests/safety.rs: self-sandwich of a close at 0.25x..4x of the reserve is non-positive on both sides, a front-run cannot take from the pool, long-then-dump loses at 1.5x, the cap holds at TAO weight 0.7, a self-impacting short loses at dissolution, two winning longs are both paid, an interest tick during dissolution forfeits nothing, parked liquidity is released and included in dissolution) and the benchmark test suite with runtime-benchmarks; runtime and pallet-subtensor compile; cargo clippy -p pallet-derivatives --all-targets -D warnings
  • cargo fmt --check --all
  • SDK: ruff, pytest (1821 passed), codegen.check --drift|--coverage|--names|--units|--namespaces against a dev node built from this branch; btcli deriv params against it shows enabled: false, longs_enabled: false; btcli explain LongsDisabled
  • Website: tsc (this revision: the two edited release pages parse under tsc, full build in CI), generate.py --check
  • CI: benchmarks (Validate-Benchmarks run 34763489612; weights committed in f680750)
  • CI: try-runtime against the mainnet snapshot, WASM build

Made with Cursor

@vercel

vercel Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
subtensor Ready Ready Preview Sep 16, 2026 10:22pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

eco-tests changed — indexer review required

This PR modifies files under eco-tests/. and may affect downstream indexing.
cc @evgeny-s — please review manually

Changed files
  • eco-tests/src/mock.rs

@github-actions
github-actions Bot requested a review from evgeny-s September 2, 2026 19:47

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/lib.rs Outdated
Comment on lines +203 to +205
fn on_runtime_upgrade() -> Weight {
let _ =
T::Pool::register_pallet_hotkey(&Self::pallet_account(), &T::PalletHotkey::get());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Custody hotkey can be preclaimed before the upgrade

This migration ignores the registration result and never verifies that PalletHotkey belongs to the pallet account. The hotkey address is public and deterministic, while create_account_if_non_existent is a no-op when it already exists. An attacker can therefore claim it before this runtime upgrade; subsequent derivative alpha is staked under an attacker-owned hotkey, which the owner can migrate through swap_hotkey. Abort the upgrade on an ownership collision or use a custody identity that cannot be externally claimed, and verify ownership before accepting positions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 98f0797. PalletHotkey is no longer a compile-time constant. on_runtime_upgrade now calls claim_hotkey, which hashes (PalletId, "hotkey", parent_hash, nonce) into an address, skips any address that already exists, registers it to the pallet account, and only stores it after pallet_hotkey_registered confirms ownership. The address depends on the parent hash of the upgrade block, so it cannot be known before that block, and hooks run before any extrinsic in it. Until the storage is set every open fails with PalletHotkeyUnset. Tests: upgrade_claims_a_fresh_hotkey_for_the_pallet_account, claim_skips_a_hotkey_someone_registered_first, nothing_opens_until_the_hotkey_is_claimed.

Comment thread pallets/derivatives/src/settle.rs Outdated
Comment on lines +138 to +186
match &deposit {
Deposit::Tao(amount) => T::Pool::transfer_tao(&owner, &pallet_account, *amount)?,
Deposit::Alpha { hotkey, amount } => T::Pool::transfer_staked_alpha(
&owner,
hotkey,
&pallet_account,
&pallet_hotkey,
netuid,
*amount,
true,
false,
)?,
}

let (lifted_tao, lifted_alpha) =
T::Pool::lift_liquidity(netuid, phi, &pallet_account, &pallet_hotkey)?;
let legs = match side {
Side::Short => {
let proceeds = T::Pool::sell_alpha_internal(
&pallet_account,
&pallet_hotkey,
netuid,
lifted_alpha,
)?;
ensure!(!proceeds.is_zero(), Error::<T>::SwapReturnedZero);
Legs::Short {
proceeds,
debt: lifted_alpha,
escrow: lifted_tao,
}
}
Side::Long => {
let proceeds = T::Pool::buy_alpha_internal(
&pallet_account,
&pallet_hotkey,
netuid,
lifted_tao,
)?;
ensure!(!proceeds.is_zero(), Error::<T>::SwapReturnedZero);
Legs::Long {
proceeds,
debt: lifted_tao,
escrow: lifted_alpha,
}
}
};

let now = frame_system::Pallet::<T>::block_number();
let expires_at = Self::schedule_expiry(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Failed opens commit partial transfers and pool mutations

do_open transfers the cushion, lifts liquidity, and executes a swap before this fallible expiry-queue insertion, but neither open nor do_open establishes an outer storage transaction. If this or another later check fails, the extrinsic returns an error while those earlier mutations remain and no Position is recorded. Queue saturation makes this failure adversarially reachable. The same atomicity gap affects roll: settlement can commit before reopening fails. Wrap each complete open and roll operation in one transaction and add regression tests asserting all balances, reserves, stake, queues, and position state are unchanged on every late failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 98f0797. do_open now wraps its body in with_storage_layer, the same way do_settle already did, so the cushion transfer, the lift, and the opening swap roll back if the expiry-queue insert (or anything else) fails, regardless of caller. The open and roll extrinsics were already transactional as #[pallet::call] dispatchables, but the guarantee is now local to the function.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

🛡️ AI Review — Skeptic (security review)

VERDICT: VULNERABLE

LOW contributor risk / baseline scrutiny: repository write access, substantive merged contributions, disclosed co-authorship, no listed Gittensor association; feat/derivatives → main.

All four prior findings remain unresolved. Financial paths become reachable after governance enables derivatives. No AI-review trust-boundary files changed.

Static checks passed: git diff --check c1816d49f742993601a9a320a94112bbf13ff685 HEAD, git diff --check, and git status --short (clean). Builds, tests, and formatters were excluded by the static-only instructions. actionlint was unavailable; external dependency verification was unavailable offline.

Findings

Sev File Finding
HIGH pallets/derivatives/src/position.rs:87 Zero moving price restores manipulable dissolution payouts inline
HIGH pallets/subtensor/src/staking/derivatives_pool.rs:273 Partial swaps leave refunded assets without position accounting inline
MEDIUM pallets/derivatives/src/settle.rs:763 Parking hook scans all entries before checking their weight inline
MEDIUM sdk/python/bittensor/cli/commands/deriv.py:210 Quote failures disable the requested slippage limit inline

Prior-comment reconciliation

  • 86714403: not addressed — Opening still accepts pools without an initialized moving price; dissolution and parking retain their spot-only fallbacks.
  • 29496c33: not addressed — Swap refunds remain hidden from position, settlement, and interest accounting.
  • 3dc323b2: not addressed — The parking hook still collects every key before applying its per-entry weight budget.
  • beceee35: not addressed — Add and close still submit with a zero payout floor after quote failures.

Conclusion

Pricing and swap-accounting flaws can lose pool or user funds after activation; parking scans exceed their weight accounting, and CLI quote failures disable requested slippage protection. No evidence of malicious intent was found.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/derivatives/src/position.rs:87 Zero moving price restores manipulable dissolution payouts ➡️ Carried forward to current findings
Opening still accepts pools without an initialized moving price; dissolution and parking retain their spot-only fallbacks.
HIGH pallets/subtensor/src/staking/derivatives_pool.rs:273 Partial swaps leave refunded assets without position accounting ➡️ Carried forward to current findings
Swap refunds remain hidden from position, settlement, and interest accounting.
MEDIUM pallets/derivatives/src/settle.rs:763 Parking hook scans all entries before checking their weight ➡️ Carried forward to current findings
The parking hook still collects every key before applying its per-entry weight budget.
MEDIUM sdk/python/bittensor/cli/commands/deriv.py:210 Quote failures disable the requested slippage limit ➡️ Carried forward to current findings
Add and close still submit with a zero payout floor after quote failures.

🔍 AI Review — Auditor (domain review)

VERDICT: 👎

Gittensor association UNKNOWN; established high-volume contributor with repository write access. No substantive duplicate PR identified.

The implementation and substantive PR description agree, and spec_version is bumped to 455. The PR title still references spec 453 and should be updated.

git diff --check passed and the working tree is clean. No build was run because the blocking weight issue is statically confirmed.

Findings

Sev File Finding
HIGH pallets/derivatives/src/weights.rs:4 Replace placeholder weights before merge inline

Prior-comment reconciliation

  • f5046818: not addressed — The file still explicitly identifies its weights as hand-written placeholders.

Conclusion

Block merge until benchmark-generated weights replace the placeholders. Accurate accounting is required for dispatchables and automatic settlement processing.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/derivatives/src/weights.rs:4 Replace placeholder weights before merge ➡️ Carried forward to current findings
The file still explicitly identifies its weights as hand-written placeholders.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/settle.rs Outdated
.cushion
.alpha_hotkey()
.cloned();
let (tao_back, alpha_back) = Self::do_settle(&owner, netuid, side, Closer::Roll)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Failed rolls permanently settle the old position

do_settle commits its own storage layer before the top-up is validated or do_open runs. Dispatch errors do not automatically roll back earlier writes, so TopUpMismatch, a disabled side, a full expiry queue, a changed pool cap, or another reopen failure returns an error after closing the user's position and executing its settlement swaps. Wrap the entire settle-and-reopen sequence in one outer storage layer so any later error restores the original position and financial state.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/settle.rs Outdated
.cushion
.alpha_hotkey()
.cloned();
let (tao_back, alpha_back) = Self::do_settle(&owner, netuid, side, Closer::Roll)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Failed rolls permanently settle the old position

do_settle commits its own storage layer before do_open runs. If deposit construction or reopening subsequently fails—for example with TopUpMismatch, DepositTooLow, PoolCapExceeded, or ExpiryQueueFull—roll returns an error but the old position has already been removed and paid out. Wrap the entire settle-and-reopen sequence in one outer storage layer so every later error rolls back settlement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0111d05. do_roll now runs settle and reopen inside one with_storage_layer, so any error from deposit construction or do_open rolls the settlement back too. failed_roll_leaves_the_old_position_untouched asserts the position, footprint, owner balance, and pool reserves are unchanged after a TopUpMismatch roll; roll_settles_and_reopens_with_the_payout covers the happy path.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/weights.rs Outdated
Comment on lines +3 to +4
//! These are hand-written placeholders sized from storage reads and writes so the pallet can be
//! wired up. CI's reference benchmark run replaces them with measured values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Replace placeholder weights before merge

These unmeasured constants govern three swap-heavy dispatchables and are also used to meter on_idle expiry settlement and dissolution cleanup. If close() underestimates execution, automatic processing can consume more block resources than charged or metered. Run the reference benchmarks and commit their generated WeightInfo output before enabling this pallet; a future CI benchmark run is not sufficient protection for the code merged here.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/weights.rs Outdated
Comment on lines +3 to +4
//! These are hand-written placeholders sized from storage reads and writes so the pallet can be
//! wired up. CI's reference benchmark run replaces them with measured values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Replace placeholder weights before merge

These estimates are used by open, close, roll, and the on_idle weight meter despite covering multi-swap and extensive storage paths. Unmeasured undercharging can admit more execution than a block's weight limit and let expiry processing overrun its budget. Run the pallet benchmarks on reference hardware and commit the generated WeightInfo implementation before enabling this pallet.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/weights.rs Outdated
Comment on lines +3 to +4
//! These are hand-written placeholders sized from storage reads and writes so the pallet can be
//! wired up. CI's reference benchmark run replaces them with measured values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Replace placeholder weights before merge

These weights govern three stateful dispatchables and automatic expiry settlement, but are explicitly hand-written estimates. Underestimated execution or proof-size costs can let blocks exceed their resource limits. Run the pallet benchmarks on the reference hardware and commit the generated WeightInfo before merging; a future CI benchmark patch is not sufficient for release-ready runtime code.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/weights.rs Outdated
Comment on lines +3 to +4
//! These are hand-written placeholders sized from storage reads and writes so the pallet can be
//! wired up. CI's reference benchmark run replaces them with measured values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Replace placeholder weights before merge

This remains explicitly placeholder resource accounting for new economic dispatchables and automatic on_idle/dissolution work. Storage read/write estimates do not establish execution time or proof size, and underestimated weights can admit excessive block work. Run the reference benchmarks and commit their generated WeightInfo output before merging; merely scheduling a future CI benchmark is insufficient for a merge-ready runtime.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/derivatives/src/weights.rs Outdated
Comment on lines +3 to +4
//! These are hand-written placeholders sized from storage reads and writes so the pallet can be
//! wired up. CI's reference benchmark run replaces them with measured values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Replace placeholder weights before merge

These weights are explicitly handwritten estimates. They meter the new dispatchables as well as on_idle expiry sweeping and resumable subnet dissolution, so an underestimate can permit substantially more pool and storage work than the block budget accounts for. Run the existing benchmarks on reference hardware and commit their generated WeightInfo output before enabling this pallet.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

Brings in #3162 (SharePool root-cause fix, childkey threshold re-checks,
rustls audit fix, lock-dust test fix). Conflicts resolved:

- runtime/src/proxy_filters/mod.rs: the subtractive filter tests keep
  DerivativesCalls in the denied set for NonTransfer/NonFungible and take
  main's SudoCalls / SubtensorValueCalls exclusions.
- sdk error map and descriptions: main's classification and prose for
  FundsNotSettled and OrderSignerFrozen; the Derivatives block kept.
- generated bindings, docs, and catalogs: taken from main for now and
  regenerated in the next commit from a node built from this tree.

spec_version is 462 (460 = hotfix 2, 461 = baskets).

derivatives_pool.rs follows the SharePool fix's contract: every decrease of
the pallet's alpha checks that the share pool debited exactly what was asked
(NotEnoughStakeToWithdraw otherwise), as remove_stake and move_stake now do,
so a short debit can never be sold, returned, recycled, or handed on in
full. recycle_alpha is transactional like its siblings.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
The release page moves from /releases/v456-upgrade to /releases/v462-upgrade
(460 is hotfix 2, 461 is baskets); the guide, the releases index, and the
sitemap follow.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
From a release node built from this tree (spec_version 462), run as
--dev --tmp; codegen.check --drift/--names/--coverage/--units/--namespaces
and export_beta_baselines_rs.py --check pass. The merge had duplicated
BasketDepositPending in error_map.py and error_descriptions/subtensor.py;
main's entries are kept. Reference docs regenerated with
scripts/generate.py.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Brings in #3164 (share-pool epoch on open and on both denominator
transitions). spec_version is 463: 460 is main, 461 is hotfix 2, 462 is
baskets. The shorts release page moves to /releases/v463-upgrade and the
guide, releases index, and sitemap follow. Generated bindings are taken
from main here and regenerated for 463 in the next commit.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
From a release node built from this tree (spec_version 463), run as
--dev --tmp; codegen.check --drift/--names/--coverage/--units/--namespaces
pass; generate.py --check is clean.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +83 to +87
if moving.0.is_zero() {
return Self {
short: spot,
long: spot,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Zero moving price restores manipulable dissolution payouts

pallets/derivatives/src/position.rs:83-87 cash-settles both sides at spot when the moving price is zero, while lift_tranche permits positions without an initialized moving price. Once derivatives are enabled, a short opened before EMA initialization can receive a dissolution payout that includes the price decline caused by its own opening sale, at the pool's expense. The added test a_lone_short_that_moved_the_price_keeps_that_move_only_where_there_is_no_moving_price explicitly asserts this excess payout. Require an initialized reference before opening positions and use a manipulation-resistant settlement policy if that reference is unavailable; the zero-price fallback must not select spot.

Comment on lines +269 to +273
let unused = tao.saturating_sub(swap.amount_paid_in.saturating_add(swap.fee_paid));
if !unused.is_zero() {
Self::transfer_tao_from_subnet(netuid, coldkey, unused)?;
}
Ok(bought)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Partial swaps leave refunded assets without position accounting

pallets/subtensor/src/staking/derivatives_pool.rs:269-273 refunds unused TAO but returns only purchased alpha. The swap engine permits partial fills at its finite price limits. swap_until nevertheless counts the entire requested input as spent, and burn_interest treats a successful partial purchase as payment of all interest. The sell helper similarly restores unused alpha without reporting it to its callers. Consequently, settlement can underpay an owner, interest can be marked paid without reaching the pool, and refunded assets remain in shared custody without a position claim. The new exact stake-debit checks do not address this. Return and account for actual input consumed and all remainders throughout the callers, or atomically reject partial fills.

if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
return meter.consumed();
}
let parked: sp_std::vec::Vec<NetUid> = Parked::<T>::iter_keys().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Parking hook scans all entries before checking their weight

pallets/derivatives/src/settle.rs:763 reads and allocates every parked subnet key after charging only one database read. The per-entry budget check happens afterward, so even an idle budget too small to process one entry still scans the entire map. Accumulated parked entries therefore cause repeated work outside the reported block weight. Iterate incrementally, reserve weight before advancing the iterator, and retain a cursor so entries that remain parked cannot indefinitely prevent later entries from being examined.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Brings in the childkey_threshold_suspended read and the refreshed
docs-preview lockfile. The one conflict, the js-yaml pin in
.github/docs-preview-vercel/package.json, takes main's 4.3.2 to match
main's lockfile. spec_version stays 463. The new read's docs page and the
reads catalog are re-anchored to this branch's pallets/subtensor line
numbers with scripts/generate.py.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +83 to +87
if moving.0.is_zero() {
return Self {
short: spot,
long: spot,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Zero moving price restores manipulable dissolution payouts

pallets/derivatives/src/position.rs:83–87 settles both sides at manipulable spot when the moving price is zero. This remains reachable: is_dynamic does not require emissions to have started, smoothed_reserves falls back to live reserves, and lift_tranche accepts such pools. A short on an unstarted subnet can therefore lower spot before dissolution and have its alpha debt cash-settled below the protected reference value, retaining proceeds at the pool's expense. The zero-price fallback also disables liquidity parking. Require a valid initialized reference price before opening positions and preserve that protection through dissolution.

Comment on lines +269 to +273
let unused = tao.saturating_sub(swap.amount_paid_in.saturating_add(swap.fee_paid));
if !unused.is_zero() {
Self::transfer_tao_from_subnet(netuid, coldkey, unused)?;
}
Ok(bought)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Partial swaps leave refunded assets without position accounting

pallets/subtensor/src/staking/derivatives_pool.rs:269–273 refunds unused TAO but returns only alpha output; the sell helper similarly hides refunded alpha. The swap engine can successfully partially fill at its finite price limits. Callers consequently book the entire requested input as spent: swap_until overstates spending, burn_interest treats refunded TAO as paid interest, and opening/closing legs omit unused assets. Funds remain on the shared pallet account without a position or pool claim, reducing payouts or pool repayments. Return actual input consumption and account for every refund throughout settlement, or reject partial fills atomically.

if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
return meter.consumed();
}
let parked: sp_std::vec::Vec<NetUid> = Parked::<T>::iter_keys().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Parking hook scans all entries before checking their weight

pallets/derivatives/src/settle.rs:763 reads and allocates every parked key after charging only one database read. The per-release budget is checked afterward, so even an idle allowance too small for one release triggers the entire scan. Parking entries across subnets therefore causes repeated work outside the reported block weight. Iterate incrementally, charge before advancing the iterator, and retain a cursor so persistently parked entries do not starve later entries.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Test-only and btcli-only changes from main (flaky unit test, wallet balance
decimals). No conflicts; spec_version stays 463.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +84 to +87
return Self {
short: spot,
long: spot,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Zero moving price restores manipulable dissolution payouts

pallets/derivatives/src/position.rs:84-87 settles both sides at spot when the moving price is zero. This state is reachable: do_start_call enables trading immediately, while moving-price initialization occurs in a later block, and lift_tranche does not require an initialized moving price. A short opened before initialization can therefore be cash-settled at its own depressed spot price if the subnet is dissolved in that window, underpaying its alpha debt at the pool's expense. The zero-price fallback in spot_is_off_moving also disables parking protection. Require a valid initialized reference price before borrowing and avoid spot-only settlement or liquidity-release fallbacks.

Comment on lines +269 to +273
let unused = tao.saturating_sub(swap.amount_paid_in.saturating_add(swap.fee_paid));
if !unused.is_zero() {
Self::transfer_tao_from_subnet(netuid, coldkey, unused)?;
}
Ok(bought)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Partial swaps leave refunded assets without position accounting

pallets/subtensor/src/staking/derivatives_pool.rs:269-273 refunds unused input but returns only output. The swap engine can successfully fill partially at its finite price limits. Callers consequently cannot distinguish the requested input from actual spending: swap_until increments spent by the requested amount, burn_interest treats success as spending all interest, and opening/closing legs omit refunded assets. The sell wrapper has the same problem with refunded alpha. This can strand user or pool assets in the shared pallet account and overstate settlement costs. Return actual input consumption and account for every refund, or atomically reject partial fills.

if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
return meter.consumed();
}
let parked: sp_std::vec::Vec<NetUid> = Parked::<T>::iter_keys().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Parking hook scans all entries before checking their weight

pallets/derivatives/src/settle.rs:763 reads and allocates every parked key after charging only one database read. The per-release budget is checked afterward, so even a block with insufficient weight for one release scans the entire map. Users can populate entries across subnets by closing positions outside the price band, causing repeated unaccounted work in on_idle. Iterate incrementally with weight charged before advancing, and persist a cursor if needed to avoid repeatedly scanning the same entries.

Comment on lines +207 to +210
except Exception:
# The floor is a convenience; a quoting hiccup must not block the add.
quoted = None
floor = _floor(quoted, max_slippage) if quoted is not None else Balance.from_rao(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Quote failures disable the requested slippage limit

sdk/python/bittensor/cli/commands/deriv.py:207-210 catches any quote failure and submits with min_amount_out=0, even when the user requested --max-slippage 1. For an opposite-side add, this permits an unrestricted reduction or close that can lose the entire payout to price manipulation. close_position uses the same fallback. The generic warning does not enforce the requested limit, particularly for unattended submissions. Abort when a required settlement quote fails; require an explicit --max-slippage 100 to submit without protection.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

btcli-only change from main (typer exit on prompt abort). No conflicts;
spec_version stays 463.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +83 to +87
if moving.0.is_zero() {
return Self {
short: spot,
long: spot,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Zero moving price restores manipulable dissolution payouts

When the moving price is zero, dissolution values both sides at manipulable spot. lift_tranche permits these pools, and smoothed_reserves also falls back to live reserves. A short opened before dissolution therefore lowers the price used to repay its own debt and can extract pool TAO through its own price impact. The related spot_is_off_moving fallback also disables parking in this state, allowing liquidity to return at a manipulated price.

Require an initialized, usable reference price before lending and preserve conservative settlement/parking behavior when that reference is unavailable. Cover zero-moving-price pools in the manipulation regression tests.

Comment on lines +269 to +273
let unused = tao.saturating_sub(swap.amount_paid_in.saturating_add(swap.fee_paid));
if !unused.is_zero() {
Self::transfer_tao_from_subnet(netuid, coldkey, unused)?;
}
Ok(bought)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Partial swaps leave refunded assets without position accounting

The swap engine can partially fill at its price limit. This helper refunds unused TAO but returns only alpha output; swap_until consequently counts the requested input as spent, while burn_interest treats the entire interest payment as consumed. The sell helper similarly restores unsold alpha without reporting it. Opening and settlement then omit these refunded assets from position accounting, leaving pool or user funds stranded on the shared pallet account and potentially overstating debt or shortfall.

Propagate actual input spent and unused input through the interface and account for both tokens in every caller, or reject partial fills atomically.

if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
return meter.consumed();
}
let parked: sp_std::vec::Vec<NetUid> = Parked::<T>::iter_keys().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Parking hook scans all entries before checking their weight

iter_keys().collect() reads and allocates the entire parked map after charging only one database read. Even when the remaining idle budget cannot cover one release, every parked subnet is scanned first. User-triggered parking can therefore cause recurring storage work beyond the hook's reported weight.

Iterate incrementally and reserve weight before fetching each key. Use a resumable cursor so entries beyond the first budget-sized batch can also progress.

Comment on lines +207 to +210
except Exception:
# The floor is a convenience; a quoting hiccup must not block the add.
quoted = None
floor = _floor(quoted, max_slippage) if quoted is not None else Balance.from_rao(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Quote failures disable the requested slippage limit

A quote exception replaces the requested slippage protection with min_amount_out = 0 and proceeds to submission. For an opposite-side add, settles also remains false, skipping the settlement review section. close_position has the same fail-open behavior. Thus an RPC or decoding failure can turn a default 1% slippage request into an unrestricted settlement vulnerable to adverse price movement or sandwiching.

Abort when a protected settlement cannot be quoted. Require an explicit opt-out such as --max-slippage 100 before submitting with no floor.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

test-balancer-edge-emission-issuance saw SubnetTAO grow by more than the
emission counters in the injection block twice in a row; the same runtime
passed it twice earlier with identical emission figures, and the extra TAO
varies (64k, 1.24M rao), which points at the per-block basket deposit drain
landing on the chosen subnet rather than at this branch.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

return Self {
short: spot,
long: spot,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Zero moving price restores manipulable dissolution payouts

pallets/derivatives/src/position.rs:87 falls back to spot for both dissolution prices when the moving price is zero; spot_is_off_moving likewise disables parking. This state is reachable: opening checks do not require an initialized moving price, smoothed_reserves falls back to live reserves, and the EMA updates only for emitting subnets. After derivatives are enabled, a position on an uninitialized pool can therefore settle against a manipulated spot price without either safeguard. A short can reduce its cash-settled debt by pushing spot down before dissolution, leaving less TAO for the pool's stakers. Require a valid initialized reference price before lending and preserve a manipulation-resistant reference for settlement and liquidity returns.

Comment on lines +269 to +273
let unused = tao.saturating_sub(swap.amount_paid_in.saturating_add(swap.fee_paid));
if !unused.is_zero() {
Self::transfer_tao_from_subnet(netuid, coldkey, unused)?;
}
Ok(bought)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Partial swaps leave refunded assets without position accounting

pallets/subtensor/src/staking/derivatives_pool.rs:269-273 refunds unused input to the shared pallet account but returns only the output. The swap engine permits price-limit-clamped partial fills. Consequently, swap_until counts the requested input as spent, settlement subtracts that amount from the owner's pot, and burn_interest treats a successful partial swap as consuming all interest. The refunded TAO remains without a position or pool claim. The sell helper has the equivalent problem for refunded alpha. Return actual input consumption and account for every refund throughout opening, settlement, and interest collection, or atomically reject partial fills.

if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
return meter.consumed();
}
let parked: sp_std::vec::Vec<NetUid> = Parked::<T>::iter_keys().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Parking hook scans all entries before checking their weight

pallets/derivatives/src/settle.rs:763 collects every parked subnet after charging only one database read. Even when the remaining budget cannot afford a single release, the hook scans and allocates the entire map before reaching the per-entry weight check. Positions can create parked entries across subnets, causing repeated work beyond the declared idle budget. Iterate incrementally and reserve weight before fetching each entry, including the terminal lookup.

except Exception:
# The floor is a convenience; a quoting hiccup must not block the add.
quoted = None
floor = _floor(quoted, max_slippage) if quoted is not None else Balance.from_rao(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Quote failures disable the requested slippage limit

sdk/python/bittensor/cli/commands/deriv.py:210 catches quote failures and continues with min_amount_out=0, even when the user requested the default 1% slippage limit. The close command does the same. A transient RPC or decoding failure thus turns a protected settlement into an unrestricted one, permitting adverse price movement to consume the payout. Abort when a required quote fails; allow submission without a floor only when the user explicitly selects --max-slippage 100.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

This branch was successfully deployed

1 active deployment
Preview — 9126bdb9 Deployed Sep 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants