Skip to content

feat: swap_basket — validator basket trading (consolidated) - #3155

Merged
unarbos merged 54 commits into
mainfrom
cursor/swap-basket-consolidated-b2ac
Sep 16, 2026
Merged

unarbos merged 54 commits into
mainfrom
cursor/swap-basket-consolidated-b2ac

Conversation

@unarbos

@unarbos unarbos commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Single PR that carries all swap_basket work onto main. It replaces the stacked chain #3150#3152#3153#3154 (closed, branches kept; every superseded tip is an ancestor of this branch).

Enables root validators to rebalance their beta basket via swap_basket, with guardrails, a caller-signed minimum output, a bounded pending-deposit flush model, measured WeightInfo, spec_version 456, SDK/docs regen, and release notes.

What ships

Feature (swap_basket)

  • New extrinsic swap_basket(hotkey, origin_netuid, destination_netuid, amount, min_amount_out): sell one escrow holding for TAO and buy another (netuid 0 = fund TAO cash slot, moved as one unit).
  • Fund shares / staker entitlements unchanged; only composition changes.
  • min_amount_out: caller-signed floor on the destination leg (alpha of destination_netuid, TAO when it is 0). BasketMinOutNotMet when the executed output is below it; classified insufficient_liquidity in the SDK.
  • Post-trade NAV comes from two re-quotes (origin and destination), not a full sweep.
  • Ops model: coldkey grants a BasketTrading proxy (index 18) to a trader multisig; submit through MEV shield. swap_basket requires the BasketTrading proxy.
  • Root admin switches: BasketTradingEnabled (global, default off), BasketTradingFrozen (per hotkey), BasketDailyTurnoverCap, BasketLiquidityCap.

Guardrails (launch defaults)

Guardrail Default Bound
Caller floor min_amount_out Executed destination output must reach the signed floor
Per-leg slippage band 2% Each AMM leg vs EMA/spot
Turnover budget 10% of NAV Token bucket, refills over 7200 blocks; first window opens at the first trade
Liquidity cap 10% of pool Per-destination, relative to pool liquidity (SubnetAlphaIn); root exempt, selling never capped
Concentration cap 1/16 of NAV Per holding
Enable / freeze off / unfrozen Root admin switches

Adversarial loss bound (with liquidity cap): ≈ 13% of turnover ≈ 1.3% of NAV per bucket / day sustained, assuming pools near the 0.5 balancer-weight equilibrium.

Flush weight model

  • Pending-deposit flush work is split into BasketFlushWork { quotes, rows }: read-only NAV quotes are priced like NAV-sweep rows, executed rows (a swap plus stake/reserve/queue writes) like redeemed claim_root rows.
  • Every flushing extrinsic (swap_basket, stake_into_basket, claim_root, claim_root_with_hotkey) declares the same flat allowance basket_flush_weight_bound() = 8 * MAX_BASKET_ROWS quotes + 2 * MAX_BASKET_ROWS rows, derived in basket_flush_work_bound, and refunds the real work post-dispatch.
  • Per-credit failures are isolated inside one batch attempt (no multiplicative retry), so the declared allowance is a true upper bound. Coldkey-wide claims gain a flush-axis admission check (RootClaimTooHeavy).

Weights (reference-measured)

  • swap_basket(h) and the four basket admin setters were measured by the Validate-Benchmarks workflow on the reference Benchmarking runner (run 34758967315, run-benchmarks label, steps 50 / repeat 20, head 0208953e9).
  • swap_basket is taken verbatim from the workflow's bench-patch artifact via scripts/benchmark_action.py selective-patch: 794_300_412 + 86_269_632*h ref-time, 47 + 10h reads, 27 writes, 11_344 + 2_609h proof, h ∈ [1, 256] (was the hand estimate 60_000_000 + 20_000_000*h, 24 + 8h reads, 16 writes).
  • The four setters measured inside the 75% drift threshold, so the workflow left them unpatched; their reference-runner figures from the same run are committed in the format sudo_set_root_weights_cap already uses: trading_enabled 3_890_000, trading_frozen 4_922_000, daily_turnover_cap 3_841_000, liquidity_cap 3_893_000 (all r0 w1; were 4_916_000).
  • No other weight entry was changed. The 38 other drifted pallet_subtensor entries in that patch pre-date this branch and are left to the nightly drift process. The label was removed after the artifact was retrieved.

SDK / CLI

  • SwapBasket intent gains min_amount_out (Money, default 0); btcli root trade is renamed btcli root swap and adds --max-slippage (percent, default 1.0), quoting both legs and setting the floor to quote × (1 − pct/100).
  • basket_trading_status view, reads, error map (BasketMinOutNotMet, BasketDepositPending), _generated/ bindings regenerated (codegen.check --drift: no drift).
  • Golden metadata fixture re-recorded against a localnet built from this branch (metadata now carries the five-argument call and BasketMinOutNotMet); metadata digest and Ledger proof vector re-pinned; shape corpus re-recorded (unchanged).

Release prep

  • spec_version 455 → 456.
  • BasketSwapped appended at the event enum tail (no mid-enum renumbering).
  • Validator, governance, and staker guides + V456 release page + reference docs/catalogs regenerated.
  • Accepted RUSTSEC-2026-0269 (wasmtime pin, same class as existing ignores); docs-preview lock overrides.

Supersedes (closed, branches kept)

Breaking change

Runtime upgrade to spec 456. swap_basket call shape is five arguments (min_amount_out added before any mainnet release of the call).

Test plan

  • cargo test -p pallet-subtensor swap_basket (49), basket (135), claim_root (61); cargo test -p pallet-admin-utils basket (4); cargo test -p bittensor-core digest (4)
  • sdk/python: 1793 offline unit tests pass against the re-recorded fixtures; ruff check / ruff format --check clean
  • cargo fmt --check --all, git diff --check
  • Reference-measured benchmark weights applied (see above)
  • CI green on this branch vs main (skeptic advisory)
  • Smoke: enable trading, proxy-filtered swap_basket, band / floor / liquidity / turnover / concentration rejects
Open in Web Open in Cursor 

@vercel

vercel Bot commented Sep 11, 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 1:21am UTC

Request Review

@unarbos
unarbos marked this pull request as ready for review September 11, 2026 16:23

@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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

credits + 4 * 256 budgets for a single deposit attempt, but deposit_root_alpha_batch (basket_flush.rs:311–323) retries every credit separately when a multi-credit batch fails. Each retry can repeat full holding valuations and curated deployment, making work scale with credits × holdings, even when both counts are below 256. The existing test_flush_failure_path_requeues_and_splits demonstrates a reachable soft-failure case.

Once trading is enabled, an authorized caller can trigger this flush before the trade’s balance and turnover checks; a subsequent rejection does not undo the computation. Returning a larger actual weight cannot repair underdeclared admission weight because FRAME caps post-dispatch charging at the declaration. The new benchmark uses an empty queue and misses this path.

Bound flush execution explicitly or include the batch attempt plus every singleton retry in the declared weight. Add a regression with many holdings and queued credits that forces batch failure and verifies the complete work remains within the admission bound.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI Review — Skeptic (security review)

VERDICT: VULNERABLE

LOW contributor risk / baseline scrutiny: write access and substantive merged contributions; no supplied Gittensor match; cursor/swap-basket-consolidated-b2ac → main.

No evidence of malicious intent or changes to AI-review trust files was identified.

Checks: git diff --check passed; git status --short was clean. actionlint was unavailable. Formatting, builds, tests, and generators were skipped under the Skeptic static-only restriction.

Findings

Sev File Finding
MEDIUM sdk/python/bittensor/cli/commands/root_swap.py:198 Quote failures disable the requested slippage protection inline

Prior-comment reconciliation

  • cabb2f0b: not addressed — Both exception paths still discard the quote and allow submission with a zero minimum output.

Conclusion

The CLI still permits submission without the user's requested minimum-output protection after a read failure. Require a successful quote unless the user explicitly disables that protection.


📜 Previous run (superseded)
Sev File Finding Status
MEDIUM sdk/python/bittensor/cli/commands/root_swap.py:198 Quote failures disable the requested slippage protection ➡️ Carried forward to current findings
Both exception paths still discard the quote and allow submission with a zero minimum output.

🔍 AI Review — Auditor (domain review)

VERDICT: 👍

Established contributor with write permission; Gittensor association UNKNOWN. Supplied overlap context identifies no competing basket-trading implementation.

Both prior findings are addressed: the migration now runs through the runtime upgrade tuple with pre/post validation, and the Ledger fixture contains distinct JavaScript and Rust proofs with matching 512-byte tails.

Description discrepancies: update spec 456 to 460, document removal of root-weight curation, and correct the flush allowance to 10 × MAX_BASKET_ROWS quotes. Direct owner signatures and Any proxies also permit trading; BasketTrading is not mandatory.

Validation: static review, Ledger fixture comparison, and frontmatter presence checks on 212 documentation files. cargo fmt --check --all was blocked by rustup’s read-only directory. Python Ruff and generated-doc checks were skipped because uv and the existing environment were unavailable; TypeScript formatting was skipped because pnpm and its environment were unavailable. actionlint was unavailable. No builds or tests ran. git diff --check passed; git status --short was clean. No files modified.

Findings

No findings.

Prior-comment reconciliation

  • 68683c94: addressed — The migration has pre_upgrade/post_upgrade validation and is registered in the runtime Migrations tuple.
  • a60f2883: addressed — The fixture now records a separately generated JavaScript proof; it differs from the Rust proof while matching the checked 512-byte envelope tail.

Conclusion

The previous blockers are resolved, and no new substantive issue was identified in the reviewed paths. Approval remains subject to CI validation.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/subtensor/src/migrations/migrate_remove_root_weights.rs:36 Add try-runtime validation for the storage migration ✅ Addressed
The migration has pre_upgrade/post_upgrade validation and is registered in the runtime Migrations tuple.
MEDIUM sdk/bittensor-core/fixtures/ledger_proof_vector.json:2 Restore an independently generated JavaScript proof ✅ Addressed
The fixture now records a separately generated JavaScript proof; it differs from the Rust proof while matching the checked 512-byte envelope tail.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:366 budgets only credits + 1024 flush units. However, deposit_root_alpha_batch (pallets/subtensor/src/staking/basket_flush.rs:316-329) retries every credit separately after a multi-credit batch fails. Each retry repeats basket valuation and, for curated funds, destination deployment work, making the cost scale with credits × holdings rather than their sum. Failed singleton deposits are requeued, so subsequent calls can repeat this work. The flush runs before the trade’s balance and turnover checks, and returning a larger post-dispatch weight cannot increase the weight reserved at admission.

Bound and charge the initial batch plus all possible singleton retries before dispatch, or limit the flush to explicitly budgeted work. Add a regression covering a failing multi-credit batch with many holdings and assert that declared weight covers all executed work.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:366 reserves only credits + 1024 flush units. However, deposit_root_alpha_batch (basket_flush.rs:316–327) retries every origin separately after a multi-credit batch fails. Each curated retry can repeat three holding sweeps and destination deployment, making work grow with credits × holdings, rather than the declared additive bound.

An authorized trader can trigger this work through swap_basket once trading is enabled. Returning a larger actual weight cannot increase the pre-dispatch reservation, and a later trade failure still incurs the flush work. Bound or meter the retries before execution, include their worst-case cost in admission, and add a regression covering a failed multi-credit batch with many holdings.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:366 budgets only credits + 1024 quote units. However, deposit_root_alpha_batch (pallets/subtensor/src/staking/basket_flush.rs:317–326) retries each credit separately after a soft batch failure. Each retry repeats the holding valuations and, for curated funds, destination deployment work. The resulting work grows with credits × holdings, beyond this single-batch allowance.

An authorized trader can trigger this flush repeatedly while failing credits remain queued, including through trades that subsequently fail. Returning a larger actual weight cannot repair pre-dispatch admission: post-dispatch weight is capped by the declared weight.

Bound and charge the complete batch-plus-singleton retry path before execution, or limit flush work to the admitted budget. Add a failure-path regression asserting declared weight covers the complete flush and trade.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:395 still bounds flushing as credits + 1024, but deposit_root_alpha_batch (basket_flush.rs:319–328) retries every credit separately after a multi-credit batch fails. Each retry repeats the fund NAV sweep and, for curated funds, deployment work. This makes the reachable flush cost scale with credits × holdings, rather than the additive bound declared here.

An authorized trader can trigger this work with queued deposits even when the subsequent trade fails. Returning a larger post-dispatch weight cannot repair an insufficient block-admission reservation. Bound all batch and singleton retry work before dispatch, or cap flushing to the reserved work and defer the remainder; cover the failing-batch retry path in the weight regression test.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:395 budgets only credits + 1024 flush units. However, deposit_root_alpha_batch retries every credit separately after a multi-credit batch fails (pallets/subtensor/src/staking/basket_flush.rs:316–327). Each retry repeats basket valuations; curated retries account for 3 × holdings + destinations + 1 units apiece. Work therefore grows with credits × holdings, beyond this fixed sweep allowance.

An authorized trader can trigger this path with pending credits once trading is enabled, including on calls that subsequently fail. Reporting higher post-dispatch weight cannot increase the pre-dispatch admission limit. Bound all batch and singleton retry work before admission, or cap flush execution to the declared budget, and cover a failed multi-credit batch in a weight-bound regression test.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

pallets/subtensor/src/staking/basket_trade.rs:395 budgets only one batch's sweeps. However, deposit_root_alpha_batch in pallets/subtensor/src/staking/basket_flush.rs:318-329 retries every credit separately after a multi-credit batch fails. Each retry repeats the holding valuations, so flush work can grow as credits × holdings instead of credits + a fixed sweep allowance.

An authorized trader can invoke this path with pending credits once trading is enabled, including through trades that subsequently fail. The larger weight returned on success cannot retroactively increase the pre-dispatch reservation; post-dispatch accounting is capped by the declared weight. Bound the initial batch plus all singleton retries, or enforce a metered flush limit before executing them. Add a regression covering a failed multi-credit batch with many holdings and verify that declared weight covers all executed work.

@github-actions

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.

if credits == 0 {
0
} else {
credits.saturating_add(4 * 256)

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] Flush weight omits per-credit retry sweeps

This is still not an upper bound on the work performed. deposit_root_alpha_batch (basket_flush.rs:315–327) retries every credit individually after a multi-credit batch fails. Each retry can repeat the full holding valuations and destination deployment, so flush work can scale as credits × holdings rather than credits + 4 * 256. Recoverable deposit failures can leave credits queued for subsequent calls.

Once trading is enabled, an authorized trader can trigger this work before the trade's balance and budget checks, exceeding the weight reserved for block admission. Reporting a larger post-dispatch weight cannot repair an underestimated pre-dispatch bound. Include the initial batch and every possible singleton retry in the declared bound, or cap flush execution to a reserved work budget; cover a late-failing multi-credit batch in the weight regression test.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

`codegen.check --names` requires every chain error to carry a semantic
code and a description. Main's runtime 456 fixes added
Crowdloan.FundsNotSettled (invalid_argument, alongside
InvalidFinalizationConfig) and LimitOrders.OrderSignerFrozen (disabled,
like ColdkeySwapAnnounced/ColdkeySwapDisputed which it wraps).

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Source-line anchors moved with main's changes, and the two new chain
errors gain their generated pages.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Mainnet took v456-v458 as the security and accounting releases while
this branch was open, so the basket release page and the guides that
cite it now name v459.

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

Root validators no longer publish a target vector. Dividends accumulate
in place on the subnet they were earned on, direct deposits mirror the
fund's current holdings by realizable value, and a fund with no holdings
holds its first deposit as the root cash slot. Only swap_basket changes
composition. Weights never decided what a validator earned, so no
validator's income moves.

Chain:
- Delete set_root_weights (call 146), do_set_root_weights, its
  rate-limit arms, get_valid_basket_weights, the curated tao x w/sum(w)
  deploy branch, and the sell-and-redeploy dividend flow. The
  accumulate-in-place path is the only dividend path; the deposit
  engine is user-only and mirrors holdings.
- Drop RootWeightSettingEnabled and sudo_set_root_weight_setting_enabled.
- Repurpose the 1/16 concentration cap as BasketConcentrationCap (a
  StorageValue guarding swap_basket buys only); rename the setter to
  sudo_set_basket_concentration_cap (index 105) and the error to
  BasketConcentrationCapExceeded.
- Dust sweep keys off actual holdings alone: every sub-threshold row is
  cashed (a swap_basket position is far above the threshold at entry).
- Remove get_validator_root_weights, the get_validator_weights runtime
  API / RPC, and BasketSummary.weights (freeze hash updated).
- Migration remove_root_weights_v1 clears Weights[ROOT], kills the gate,
  carries RootWeightsCap[ROOT] into BasketConcentrationCap, and drops the
  retired map. The three superseded weight migrations are removed; the
  NetworkUpdateWeightsOnRoot dissolution phase is a pass-through no-op.
- Retired-but-kept for SCALE index stability: errors BasketHasNoWeights
  and RootWeightSettingDisabled, event RootWeightsSet.
- Flush allowance re-derived for the single path (10x rows quotes);
  stake_into_basket declared weight sizes 256 mirrored slots.
- spec_version 460 (459 is taken by the SharePool emergency runtime).

SDK/docs:
- Remove SetRootWeights, btcli root weights, the validator_root_weights
  read, and weights from the basket summary. Regenerate _generated
  (spec 460), golden.json, the shape corpus, the Ledger proof vector,
  and the metadata digest against a fast-runtime localnet built from
  this branch; codegen drift and static gates clean.
- Rewrite the Root Reborn and basket-trading guides for the no-weights
  model; regenerate reference pages; move the release page to
  v460-upgrade; drop setRootWeights from ts-tests.

Tests: 1557 pallet-subtensor + 86 admin-utils pass; clippy -D warnings
clean; SDK pytest 1775 pass.

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

Address the auditor findings on the previous commit:

- Run migrate_remove_root_weights from the runtime `Migrations` tuple as
  an `OnRuntimeUpgrade` (like stamp_beta_baselines) instead of the pallet
  hook. `pre_upgrade` snapshots the retired cap, the new cap, and the
  non-root Weights row count; `post_upgrade` checks the run marker, an
  empty Weights[ROOT], the killed gate, the emptied retired map, the exact
  cap carry-over (default kept when governance never set one, untouched
  on a re-run), and that non-root weights survived.
- Record polkadot_js_proof_hex from @polkadot-api/merkleize-metadata
  1.3.1 on the same metadata, parts, and chain info instead of mirroring
  the Rust proof. The JS digest matches the Rust digest and the proof
  tails agree, so the vector cross-checks implementations again.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Registering the weights-removal migration in runtime/src/lib.rs shifted the
runtime API implementation lines the generated query pages link to.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
The swap_basket band referenced only the slow emission EMA (a ~monthly
half-life) and spot, and re-anchored to spot on every leg. After any
real price move the slow EMA sits stale, so a compromised trader key
could lift or dump spot inside a block and have the fund fill leg after
leg at the manipulated price: 1.1 / 4.1 / 6.9% of NAV per day on the buy
leg and 1.1 / 3.1 / 4.7% on the sell leg at slow-EMA/spot divergences of
1.35 / 2 / 4 (swarm E8: BK-11 F1, BK-03 F1/F2), far above the accepted
~1.3%/day ceiling. The concentration cap and turnover budget were also
measured against a realizable NAV a same-block pump of a held thin pool
could mark up to the pump size (BK-15 F2, BK-03 F3).

- Add SubnetFastMovingPrice: an unclamped U64F64 EMA of spot with a
  600-block (~2h) half-life, advanced right after the slow EMA in
  update_moving_price from the previous block's closing spot, seeded at
  spot on first update. No extrinsic can move it within a block.
- Bound both legs to it: buy ceiling 1.02 x min(slow, fast, spot), sell
  floor 0.98 x max(slow, fast, spot). A subnet missing either EMA is
  refused (SlippageTooHigh), as before for the slow one.
- Measure the turnover budget and the concentration cap against a
  guarded NAV that marks each holding at min(realizable, alpha x slow
  EMA); root cash passes through. One-sided by design: an over-EMA
  holding is under-marked, which only tightens the guards.
  basket_trading_status reports the budget from the same mark.

Regression tests (tests/swap_basket_stale_ema.rs) replay the PoC
sequences with the fast anchor held at the block's opening spot, and a
control that re-pins it to live spot before every leg (the old band):
buy leg control 113 / 415 / 691 bps of NAV -> fixed 0 bps, one in-band
leg; sell leg control 114 / 330 / 507 bps -> fixed 0 bps; a 100k and a
1M TAO same-block pump of a held 10k/10k pool marks realizable NAV
10,825 -> 919,402 TAO while guarded NAV stays 10,900, and the 1,500 TAO
(cap) and 9,500 TAO (budget) buys stay refused. Honest trades at spot
still fill; a dip the fast EMA has followed is tradeable. Existing
trading tests pin the fast EMA alongside the slow one; two "documents
current behaviour" tests (one-block walk to the EMA ceiling, profit
taking walking to the EMA floor) now assert the anchored behaviour.

Docs: the band, cap, budget, and extraction-ceiling sections of the
basket-trading guides and the v460 release page restate the anchors
and re-derive the ceiling (same-block attacks ~0; the 1.3%/day bound
applies only to a pump held ~10h against arbitrage). SDK bindings
regenerated (new storage item); golden fixture and Ledger vector
re-recorded (metadata digest unchanged).

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
…ings and docs

Mainnet is on spec 459 (#3162) and hotfix 2 takes 460, so the basket
runtime ships as 461. After the rebase:

- runtime/src/lib.rs: spec_version 461; the Migrations tuple carries
  both #3162's root pot reconciliation and the root-weights removal.
- Release page and guides: v460 -> v461.
- SDK: bindings, golden fixture, shape corpus, Ledger proof vector, and
  the metadata digest (affbcc25...) re-recorded against a fast-runtime
  localnet built from this branch (spec 461); duplicate error-map and
  description entries from the auto-merge removed (main had already
  classified FundsNotSettled / OrderSignerFrozen); reference docs
  regenerated.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Golden fixture and Ledger proof vector recorded against a localnet built
from the rebased head (spec 461; digest unchanged). Generated bindings,
namespaces stub, and reference pages verified in sync (no drift).

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
@unarbos
unarbos force-pushed the cursor/swap-basket-consolidated-b2ac branch from 3c19ee5 to 5ce614d Compare September 15, 2026 23:51

@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 +196 to +198
except Exception:
# Display-only context; a quoting hiccup (or a pre-v4 node) must not block the swap.
holdings, status, expected_out = [], None, None

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 protection

root_swap.py:196–198 converts any context-read failure into an absent quote; the inner quote handler does the same. Submission then proceeds with the original intent's min_amount_out = 0, even with an explicit --max-slippage 0 or the default 1% limit. A transient failure in the status read alone therefore removes the user's protection. The runtime's 2% per-leg band does not enforce that requested output floor, and a warning on the review card does not protect unattended submission.

Abort when a required quote fails or produces no usable output, unless the user explicitly selected --max-slippage 100. Keep optional status-read failures separate from mandatory quoting.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

swap_basket now refuses a subnet without a fast price anchor, so the
benchmark pools need it pinned alongside the slow EMA.

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 +196 to +198
except Exception:
# Display-only context; a quoting hiccup (or a pre-v4 node) must not block the swap.
holdings, status, expected_out = [], None, None

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 protection

sdk/python/bittensor/cli/commands/root_swap.py:196-198: A failure reading holdings, trading status, or either swap quote leaves the original intent's min_amount_out at zero and still reaches app_ctx.submit. This also happens when the user explicitly requests a tight --max-slippage. The protocol's per-leg price bands do not enforce that requested bound relative to the user's quote, so a trade can execute at a worse output than authorized by that option. The review-card warning does not protect unattended submissions. Abort when a positive quote cannot be obtained unless the user explicitly selected --max-slippage 100; apply this to both exception paths.

@github-actions

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 on lines +196 to +198
except Exception:
# Display-only context; a quoting hiccup (or a pre-v4 node) must not block the swap.
holdings, status, expected_out = [], None, None

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 protection

A failure reading holdings, trading status, or either quote leaves expected_out=None, so submission proceeds with the intent's default min_amount_out=0, even when the caller requested --max-slippage 1 (or a stricter limit). The inner exception handler at lines 188–190 does the same. The protocol's per-leg price bands do not enforce the user's quoted-output floor, so the trade can execute worse than the requested tolerance. A review-card warning does not preserve that constraint, particularly for unattended submissions. Abort when a usable quote is unavailable unless the caller explicitly selects --max-slippage 100; keep optional status-read failures separate from required quoting.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@unarbos
unarbos force-pushed the cursor/swap-basket-consolidated-b2ac branch from 495b72b to a7a3f19 Compare September 16, 2026 01:56
@unarbos
unarbos merged commit 7c9d45e into main Sep 16, 2026
145 of 148 checks passed

@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 +196 to +198
except Exception:
# Display-only context; a quoting hiccup (or a pre-v4 node) must not block the swap.
holdings, status, expected_out = [], None, None

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 protection

root_swap.py:186–198 catches quote and context-read failures and continues with the original intent, whose min_amount_out defaults to zero. Thus a transient RPC failure allows submission even with an explicit --max-slippage 1, without enforcing that requested floor. The protocol's per-leg price band does not enforce the user's quoted-output limit, and the review-card warning does not restore it. Abort when a usable quote is unavailable unless the user explicitly selected --max-slippage 100; apply this to both exception paths.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

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.

2 participants