Skip to content

fix(swap_basket): liquidity-relative destination cap, token-bucket turnover, flush weight - #3153

Closed
unarbos wants to merge 4 commits into
test/swap-basket-coveragefrom
fix/swap-basket-liquidity-cap
Closed

unarbos wants to merge 4 commits into
test/swap-basket-coveragefrom
fix/swap-basket-liquidity-cap

Conversation

@unarbos

@unarbos unarbos commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Description

Stacked on #3150#3152 (base is test/swap-basket-coverage at 60982234e). Four commits, each self-contained:

1. fix(swap_basket): liquidity-relative destination cap

The exploit. A stolen trader key buys a thin subnet with fund cash in 2%-band slices while a counterparty (a colluding subnet owner, or plain arbitrageurs) sells alpha back to the EMA between slices; every slice passes the slippage, turnover, and concentration rules. Reproduced against #3150: the fund lost 8.3% of NAV in one turnover window (≈90% of the TAO it spent), because the concentration cap marks holdings at realizable value, which is bounded by the pool's TAO reserve and never grows no matter how much of the pool's supply the fund accumulates (it held 9× the pool's alpha reserve and passed the 1/16 cap at 0.98%).

The fix. After the buy leg, swap_basket refuses a trade that would leave the fund holding more than BasketLiquidityCap of the destination subnet's alpha reserve (SubnetAlphaIn). Root (the cash slot) has no pool and is exempt. Selling is never capped.

The bound. With cap L and a destination pool with TAO reserve R, a drain can extract about R × L² / (1 + L) from that pool — ≈1% of R at the 10% default (≈10 τ on a 1,000 τ pool, vs. the whole turnover budget before). Today's largest funds (~12.5k τ) are barely constrained: 10% of a median mainnet pool (~2.4M α ≈ 7k τ) is ≈700 τ, about the fund's 1/16 slice.

Default and tuning. BasketLiquidityCap is u16-normalized, default u16::MAX / 10 (10%). AdminUtils::sudo_set_basket_liquidity_cap(cap) (call index 109, root-only, zero rejected); e.g. 16383 = 25%. Global, not per subnet.

Also flips #3152's §2.1 pin (finding_2_1_thin_pool_drain_is_stopped_by_liquidity_cap) and isolates its §2.3 pin from the new cap.

2. test(swap_basket): winners over either cap are never clipped

Appreciation via direct pool state. A holding over RootWeightsCap or BasketLiquidityCap only refuses further buys into that subnet. Partial sells to cash or another subnet, trades between other holdings, curated dividend redeploy, dust consolidation, pro-rata claims and hotkey swap all leave the winner alone. Profit-taking after a run-up is not blocked by the EMA band (sell floor anchors to spot when spot > EMA; chained slices may walk price down to 0.98 × EMA).

3. fix(swap_basket): charge pending-deposit flush work

do_swap_basket flushes queued dividend credits before trading but did not price that work. Declared weight now comes from the pending-deposit count (swap_basket_declared_weight(hotkey): the 256-row cap plus one unit per queued credit plus 4 × 256 for the deposit's own sweeps), refunded post-dispatch to the flush work actually reported, priced per quote unit like claim_root. An empty queue costs nothing extra, so the existing weight pin is unchanged.

4. feat(swap_basket): token-bucket turnover budget

Replaces the fixed 7200-block window. Per hotkey BasketTradeBucket = (tao_available, last_refill_block); a missing row is a full bucket. On each trade: refill blocks_elapsed × budget / BASKET_TRADE_REFILL_BLOCKS, clamp to one budget (NAV at that moment), require tao_available ≥ tao_mid, subtract. Empty → BasketTurnoverBudgetExceeded. Hotkey swap carries the bucket conservatively (min level, max refill block). Bound: at most 1× the budget at any instant and ≈1× per day sustained; the §2.2 two-blocks-two-budgets burst is gone (pin flipped: finding_2_2_bucket_denies_a_second_budget_in_the_adjacent_block). BasketTradeWindow is removed (never on a live chain; no migration).

BasketTradingStatus keeps its SCALE encoding, so no runtime API bump: the u64 slot is now refill_blocks (7200) and tao_used is now tao_available; refill rate = budget_tao / refill_blocks. freeze_struct hash updated. SDK basket_trading_status returns remaining_tao, budget_tao, used_tao, refill_blocks, refill_per_block_tao; CLI text updated.

Related Issue(s)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (token-bucket turnover budget)

Breaking Change

None on chain (new storage with defaults; error/event appended; the removed window storage never shipped). Clients decoding BasketTradingStatus by field name must switch window_start_block/tao_usedrefill_blocks/tao_available (encoding unchanged; the SDK read is updated here).

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh (ran cargo fmt --check --all and cargo clippy -p pallet-subtensor -p pallet-admin-utils --tests -- -D warnings instead; both clean)
  • I have made corresponding changes to the documentation (SDK registries/docstrings updated; generated docs need a regeneration run, see notes)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Additional Notes

Tests run (final stack)

  • cargo test -p pallet-subtensor -- swap_basket claim_root stake_into_basket basket_flush swap_hotkey staking:: — 347 passed (includes test: swap_basket coverage #3152's swap_basket.rs with the rewritten bucket tests and both flipped pins, and the 9 tests in swap_basket_liquidity_cap.rs).
  • cargo test -p pallet-admin-utils -- basket root_weights_cap — 5 passed.
  • Runtime proxy_filters — 15 passed (before the restack; unchanged files).
  • cargo fmt --check --all, git diff --check, cargo clippy --tests -D warnings on both pallets: clean.

Not in this PR (per AGENTS.md)

  • sudo_set_basket_liquidity_cap reuses the measured sudo_set_root_weights_cap weight; a benchmark/WeightInfo entry is still owed for it and for the four feat: swap_basket — validator-directed beta basket rebalancing #3150 dispatchables. swap_basket keeps the hand formula + refund pattern.
  • No spec_version change or labels.
  • Generated docs (docs/errors/chain/*, docs/hyperparameters/index.mdx, docs/query/basket-trading-status.mdx) not regenerated here (no uv environment); the SDK registries and docstrings they derive from are updated.
  • sdk/python/bittensor/_generated/ still needs regeneration against a node running this runtime.
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 11, 2026 3:08pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI Review — Skeptic (security review)

VERDICT: SAFE

LOW contributor risk / baseline scrutiny: write-access maintainer with substantive merged history; disclosed Cursor co-authorship; no listed Gittensor association. Targets feat/basket-trading-swap-basket.

Reviewed all 14 changed files and traced cap arithmetic, transaction rollback, root authorization, and proxy classification. No review trust-boundary or dependency changes.

Static analysis only; formatting, builds, and tests were not run under Skeptic restrictions. git diff --check passed; git status --short was clean. Release integration still needs spec-version coordination and dedicated benchmark/WeightInfo wiring for the new setter.

Findings

No findings.

Conclusion

No malicious behavior or newly introduced security vulnerability found. The cap uses overflow-safe arithmetic on post-buy holdings and reserves, and rejection rolls back the trade.


🔍 AI Review — Auditor (domain review)

VERDICT: 👍

Established contributor with repository write access; Gittensor association UNKNOWN. No duplicate implementation identified in supplied PR metadata.

The cap implements the documented inequality using overflow-safe u128 arithmetic, checks post-buy reserves inside the rollback boundary, and exempts root. Tests cover thin-pool accumulation, rejection rollback, selling to root, governance tuning, and setter authorization.

cargo fmt --check --all could not run because rustup attempted to write to its read-only directory. Python Ruff and generated-doc checks were skipped because uv and sdk/python/.venv are unavailable. No builds or tests were run. git diff --check passed; git status --short was clean. No files changed. This feature-branch target requires no spec-version bump under the supplied Auditor policy.

Findings

Sev File Finding
MEDIUM pallets/admin-utils/src/lib.rs:2599 Add benchmark coverage and dedicated weight wiring inline

Conclusion

The liquidity-cap implementation is sound on static review. Add the setter’s benchmark and dedicated weight wiring to complete the repository’s dispatchable requirements.

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

/// reserve `R` is about `R × L² / (1 + L)`. Root-only. One storage write; reuses the
/// `sudo_set_root_weights_cap` weight.
#[pallet::call_index(109)]
#[pallet::weight(<T as pallet::Config>::WeightInfo::sudo_set_root_weights_cap())]

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] Add benchmark coverage and dedicated weight wiring

pallets/admin-utils/src/lib.rs:2599 borrows sudo_set_root_weights_cap's weight, but the new dispatchable has no matching benchmark or WeightInfo entry. The existing benchmark only measures the concentration setter, so future reference runs cannot validate this call independently. Add a sudo_set_basket_liquidity_cap benchmark and dedicated weight wiring; leave reference measurements to CI as required by AGENTS.md. The borrowed single-write weight does not establish a concrete undercharge here, so this is non-blocking.

@github-actions

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👍

@cursor cursor Bot mentioned this pull request Sep 11, 2026
13 tasks
cursoragent and others added 4 commits September 11, 2026 14:52
After the buy leg, refuse a swap_basket trade that would leave the fund
holding more than BasketLiquidityCap (default 10%) of the destination
subnet's alpha reserve. The concentration cap marks holdings at
realizable value, which is bounded by the pool's TAO reserve, so on a
thin pool a stolen key could keep buying in 2%-band slices while
counterparties sold back into its price support: every slice passed
every guardrail and the fund's realizable NAV fell by ~90% of the
turnover spent. The new cap bounds exposure per pool to about
R x L^2 / (1 + L) of that pool's TAO reserve.

- BasketLiquidityCap storage (u16-normalized, default u16::MAX / 10)
- BasketLiquidityCapExceeded error (appended)
- AdminUtils::sudo_set_basket_liquidity_cap (call index 109) and
  BasketLiquidityCapSet event (appended), in RootConfigCalls
- SDK error map/description and codegen allowlist entries
- Regression tests: thin-pool drain now stops with the new error, band
  slices up to the boundary, cap follows storage, sudo setter

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Appreciation via direct pool state: a holding over RootWeightsCap or
BasketLiquidityCap only refuses further buys into that subnet. Partial
sells to cash or another subnet, trades between other holdings, curated
dividend redeploy, dust consolidation, pro-rata claims, and hotkey swap
all leave the winner alone. Profit-taking after a run-up is not blocked
by the EMA band (sell floor anchors to spot when spot > EMA).

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
do_swap_basket flushes the hotkey's queued dividend credits before the
trade but swap_basket_weight did not price that work. Declare the weight
from the pending-deposit count (one unit per queued credit plus the
deposit's own sweeps, bounded by the same 256-row cap the trade assumes)
and refund to the flush work actually reported, priced per quote unit
like claim_root prices its flush. An empty queue costs nothing extra, so
the existing post-dispatch weight pin is unchanged.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
Replace the fixed 7200-block turnover window with a per-hotkey token
bucket: BasketTradeBucket stores (tao_available, last_refill_block), a
missing row is a full bucket, each trade refills blocks_elapsed x
budget / BASKET_TRADE_REFILL_BLOCKS, clamps to one budget at current
NAV, then takes tao_mid out. An empty bucket refuses with
BasketTurnoverBudgetExceeded. This removes the window-boundary burst
(two budgets in adjacent blocks): at any instant at most one budget can
be spent, and about one per day sustained.

- BasketTradeWindow removed (never on a live chain), BasketTradeBucket
  added; BASKET_TRADE_WINDOW_BLOCKS renamed BASKET_TRADE_REFILL_BLOCKS
- hotkey swap carries the bucket conservatively (min level, max block)
- BasketTradingStatus keeps its encoding (no API bump): the u64 slot is
  now refill_blocks, tao_used is now tao_available; freeze_struct hash
  updated; SDK read exposes remaining, capacity and refill per block
- window tests rewritten for bucket semantics; the 2.2 burst pin now
  asserts adjacent blocks cannot exceed one budget

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
@cursor
cursor Bot force-pushed the fix/swap-basket-liquidity-cap branch from 32b2891 to e276f4f Compare September 11, 2026 15:06
@cursor cursor Bot changed the title fix(swap_basket): liquidity-relative destination cap fix(swap_basket): liquidity-relative destination cap, token-bucket turnover, flush weight Sep 11, 2026
@cursor
cursor Bot changed the base branch from feat/basket-trading-swap-basket to test/swap-basket-coverage September 11, 2026 15:06
@cursor

cursor Bot commented Sep 11, 2026

Copy link
Copy Markdown

Superseded by #3155, which consolidates the whole swap_basket stack (#3150#3152#3153#3154) onto main. This branch's tip (e276f4f6f) is already an ancestor of cursor/swap-basket-consolidated-b2ac; nothing here is lost. Closing; branch kept.

@unarbos unarbos closed this Sep 11, 2026
unarbos added a commit that referenced this pull request Sep 15, 2026
Describe the swap_basket stack (#3150, #3152, #3153): what validators can
do, the BasketTrading proxy model, every guardrail with its launch default
(2% per-leg band, token-bucket turnover budget of 10% of NAV refilling over
7200 blocks, 10% per-pool liquidity cap, 1/16 concentration cap, enable and
freeze switches), the corrected worst-case loss bound, the new calls,
events, errors, storage, admin setters, runtime API, and SDK/btcli surface.
Tag the shipped v450 entry with its version so only the new release is
'next'.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
unarbos added a commit that referenced this pull request Sep 15, 2026
Describe the swap_basket stack (#3150, #3152, #3153): what validators can
do, the BasketTrading proxy model, every guardrail with its launch default
(2% per-leg band, token-bucket turnover budget of 10% of NAV refilling over
7200 blocks, 10% per-pool liquidity cap, 1/16 concentration cap, enable and
freeze switches), the corrected worst-case loss bound, the new calls,
events, errors, storage, admin setters, runtime API, and SDK/btcli surface.
Tag the shipped v450 entry with its version so only the new release is
'next'.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
unarbos added a commit that referenced this pull request Sep 15, 2026
Describe the swap_basket stack (#3150, #3152, #3153): what validators can
do, the BasketTrading proxy model, every guardrail with its launch default
(2% per-leg band, token-bucket turnover budget of 10% of NAV refilling over
7200 blocks, 10% per-pool liquidity cap, 1/16 concentration cap, enable and
freeze switches), the corrected worst-case loss bound, the new calls,
events, errors, storage, admin setters, runtime API, and SDK/btcli surface.
Tag the shipped v450 entry with its version so only the new release is
'next'.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
unarbos added a commit that referenced this pull request Sep 16, 2026
Describe the swap_basket stack (#3150, #3152, #3153): what validators can
do, the BasketTrading proxy model, every guardrail with its launch default
(2% per-leg band, token-bucket turnover budget of 10% of NAV refilling over
7200 blocks, 10% per-pool liquidity cap, 1/16 concentration cap, enable and
freeze switches), the corrected worst-case loss bound, the new calls,
events, errors, storage, admin setters, runtime API, and SDK/btcli surface.
Tag the shipped v450 entry with its version so only the new release is
'next'.

Co-authored-by: Arbos <unarbos@users.noreply.github.com>
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