Basket Trading · September 2026
Spec 461 lets a root validator actively trade its beta basket, and makes trading the onlyway a fund's composition changes. Until now a fund could also be steered through the dividend stream: a set_root_weightsvector decided where new yield was deployed. That design is removed — dividends now always accumulate in place on the subnet they were earned on, direct deposits mirror the fund's current holdings, and the new swap_basket call sells part of one holding for TAO and buys another with it. Netuid 0 on either side is the fund's TAO cash slot, so a validator can also move into or out of cash.
Fund shares (β) and every staker's entitlement are untouched by a trade. Only what the fund holds changes, and with it the fund's NAV over time. Stakers keep choosing a validator; validators now also choose how to manage the basket between dividends.
Trading launches gated off network-wide. After the upgrade swap_basket fails with BasketTradingDisabled until governance flips BasketTradingEnabled on. Everything else in this release (proxy type, budgets, caps, reads, btcli) is live from the upgrade block. The operator walkthrough is the Basket trading guide.
The intended setup is a validator coldkey that grants a BasketTrading proxy (new ProxyType, index 18) to a trader account, usually a multisig. That proxy type admits exactly one call: swap_basket. It cannot stake, unstake, transfer, claim, or change keys.
The grant is opt-in in both directions. No existing proxy gains trading rights at the upgrade: NonTransfer, NonCritical, Staking, and NonFungible delegates are all refused swap_basket. Only Any and BasketTrading admit it. Trades are submitted through the MEV shield by default (the SDK intent sets mev_shield_default = True), so a pending trade does not advertise its legs to the block's other traders.
# validator coldkey: delegate trading to the desk multisig btcli proxy add --delegate <desk multisig> --proxy-type BasketTrading -w validator_cold # the desk: sell 250 α of netuid 8 and buy netuid 64 in the validator's fund, # insisting on at least 99% of the quoted fill (--max-slippage defaults to 1) btcli root swap --from 8 --to 64 --amount 250 --hotkey <validator hotkey> \ --max-slippage 1 -w desk --proxy-for <validator coldkey> # move part of the fund's netuid 3 position into cash (netuid 0) btcli root swap --from 3 --to 0 --amount 1200 --hotkey <validator hotkey> \ -w desk --proxy-for <validator coldkey>
A trading key is a new way for a fund to lose value, so every trade must pass six checks. Two are price rules, one is a budget, two are shape rules, and the last is a set of switches. All numbers below are the launch defaults; the caps and budget are hyperparameters governance can move. On top of them the caller sets its own floor on the fill.
SubnetFastMovingPrice, a two-hour-half-life EMA of spot written each block from the previous block's close), and its spot price. A buy may not fill above 1.02 × min(slow, fast, spot); a sell may not fill below 0.98 × max(slow, fast, spot). The fast anchor is a price nobody can move inside a block, so a same-block pump or dump cannot make the fund fill at the manipulated price; the slow anchor caps how far a held pump can carry it; the spot anchor caps the trade's own price impact. Any miss is SlippageTooHigh, and the whole trade rolls back.min_amount_out. The last argument of swap_basketis the least the buy leg must credit to the destination holding, after fees, in the destination subnet's alpha (TAO when the destination is netuid 0). Below it the trade fails with BasketMinOutNotMet and rolls back. 0sets no floor. This is the caller's protection against a fill worse than the quote it signed on; the band is the protocol's protection of the fund, is a price rule per leg, and applies regardless. btcli root swap derives the floor from a quote and --max-slippage (percent, default 1).BasketDailyTurnoverCap of its current NAV (default u16::MAX / 10, 10%). The TAO that passes through the middle of each trade is taken out of the bucket; the bucket refills continuously at capacity / 7200 per block, so an empty bucket is full again after one day (7200 blocks at 12 s). A fund that has never traded starts full. The level is clamped to one capacity, so at most one budget can be spent at any instant and about one per day sustained. This replaces the fixed daily window of the first draft, which let two full budgets through in two adjacent blocks at the window edge. Refusal is BasketTurnoverBudgetExceeded.BasketLiquidityCap (default u16::MAX / 10, 10%) of the destination subnet's alpha reserve (SubnetAlphaIn). Root has no pool and is exempt; selling is never capped. This is the rule that bounds a thin-pool drain (below). Refusal is BasketLiquidityCapExceeded.BasketConcentrationCap of fund NAV — the 1/16 rule (and young-chain softening) that used to bound set_root_weights vectors, carried over as a pure trade guardrail. Selling out of an over-cap position is always allowed. Refusal is BasketConcentrationCapExceeded.BasketTradingEnabled is the network-wide gate (default off). BasketTradingFrozen[hotkey]lets governance freeze one fund's trading, for example after a suspected key compromise; a frozen fund still accepts deposits and pays claims. Both follow the fund through a hotkey swap, as does the bucket. A trade is also refused while the beta-basket seed migration runs or a coldkey swap is announced for the signer.BasketTradingproxy), the hotkey must be registered on root, both subnets must exist, and the destination's subtoken must be enabled.The question these rules answer is: if the trading key is stolen and the thief colludes with a counterparty, how much of the fund can they extract before governance freezes the hotkey? The original proposal stated the bound as cap × (2 × 2% + fees) ≈ 0.43% of NAV per day. That figure counted only the price band and was too low. The calibration pass on the first draft reproduced a loss of 8.3% of NAV in one window, with every guardrail passing on every trade, by buying a thin subnet in 2%-band slices while a counterparty sold alpha back to the EMA between slices. The concentration cap could not see it, because it marks holdings at realizablevalue, which is bounded by the pool's TAO reserve and so never grows no matter how much of the pool's supply the fund accumulates.
The liquidity cap closes that path. With cap L on a pool with TAO reserve R, the most a drain can extract from that pool is about R × L² / (1 + L): at the 10% default, about 0.9% of the pool's TAO reserve, or about 9% of the TAO the fund spent buying into it. The realizable haircut on any holding is capped at about L / (1 + L) ≈ 9%. On a 1,000 τ pool that is roughly 10 τ; before the cap it was the whole turnover budget.
Put together, the adversarial bound per unit of turnover is about 9% (liquidity drain) + 4% (two legs at the edge of the band) + fees, roughly 13% of the TAO traded. Because the bucket holds one budget at a time, the most that can be moved through the fund at any instant is 10% of NAV, so the worst case is about 1.3% of NAV in a burst and about 1.3% of NAV per day sustained until the fund is frozen, spread across as many pools as the budget and the 1/16 cap allow. Honest trading pays only AMM fees and the price impact of its own legs. These are upper bounds for a stolen key and a willing counterparty, not expected costs.
That 1.3% figure assumed the band's moving-price anchor sits near spot. The security review showed it did not have to: the only smoothed reference was the monthly emission EMA, which sits stale after any real move, and the band re-anchored to spot on every leg. A key holder could lift spot toward a stale-high EMA and have the fund chain-buy near it, or dump toward a stale-low EMA and have it chain-sell — 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 EMA-to-spot ratios of 1.35, 2 and 4, all inside one block. The fast moving price closes this: replaying those sequences against the fixed band, the attacker's profit is at most rounding on either leg and the fund fills one in-band leg at the pre-move price. Regaining the old extraction now means holding a 30–100%-of-reserve pump against arbitrage for about ten hours, at which point the 1.3%-per-day bound above applies — at that capital-time cost, not for free.
The same review found the concentration cap's and the turnover budget's NAV denominator pumpable: a same-block pump of a thin pool the fund holds marked that holding at roughly the pump size, so a 1,500 τ buy the cap had refused became admitted, and a 9,500 τ buy the budget had refused too. Both are now measured against a guarded NAV that marks every holding at the lower of its realizable value and its alpha times the slow moving price, which cannot be moved inside a block; the same buys stay refused under the pump.
For today's largest funds (about 12,500 τ) the liquidity cap barely binds: 10% of a median mainnet pool is about 700 τ, which is about the fund's 1/16 slice. It binds correctly as funds grow. Raise BasketLiquidityCap if funds legitimately need larger positions in mid-depth pools; lower it to tighten the per-pool value at risk.
start_call, or newly started) cannot be traded until its EMA has warmed up. The EMA is min(price, 1), so a subnet trading above 1 τ per α cannot be bought within the band either.SubtensorModule::swap_basket(hotkey, origin_netuid, destination_netuid, amount, min_amount_out) (call index 150). Declared weight is a cap sized for 256 holdings plus any pending-deposit flush work; the actual weight is computed from the real holding count and refunded post-dispatch.BasketSwapped { hotkey, origin_netuid, destination_netuid, alpha_sold, tao_mid, alpha_bought }, appended at the tail of the event enum (index 149) so existing event indices are unchanged.BasketTradingDisabled, BasketTradingFrozen, BasketTurnoverBudgetExceeded, BasketSameSubnet, BasketLiquidityCapExceeded, BasketMinOutNotMet, and BasketConcentrationCapExceeded. SlippageTooHigh is reused for the band.BasketTradingEnabled (bool, default off), BasketTradingFrozen (per hotkey), BasketDailyTurnoverCap (u16, default 6553 = 10%), BasketLiquidityCap (u16, default 6553 = 10%), BasketConcentrationCap (u16, default 4096 = 1/16; the value governance had set in RootWeightsCap is carried over), and BasketTradeBucket (per hotkey: (tao_available, last_refill_block); a missing row is a full bucket).set_root_weights extrinsic (call 146), its RootWeightSettingEnabled gate and sudo_set_root_weight_setting_enabled setter, the RootWeightsCap map, the validator_root_weights read / get_validator_weights runtime API, and the weights field of BasketSummary. A migration clears every stored root vector; no fund's holdings change. Weights only ever decided how an earned dividend was deployed, never what a validator earned, so no validator's income moves.AdminUtils): sudo_set_basket_trading_enabled(enabled) (106), sudo_set_basket_trading_frozen(hotkey, frozen) (107), sudo_set_basket_daily_turnover_cap(cap) (108), sudo_set_basket_liquidity_cap(cap) (109), and sudo_set_basket_concentration_cap(cap) (105, replacing sudo_set_root_weights_cap), with events BasketTradingToggled, BasketTradingFrozenSet, BasketDailyTurnoverCapSet, BasketLiquidityCapSet, and BasketConcentrationCapSet. A zero cap is rejected with ValueNotInBounds.ProxyType::BasketTrading (index 18), whose filter admits only swap_basket. The broad proxy types do not gain the call.BetaBasketRuntimeApi v4 adds get_basket_trading_status(hotkey) returning { enabled, frozen, refill_blocks, tao_available, budget_tao }: the gates, the bucket's refill period, what a trade at this block could push through, and the bucket's capacity at current NAV.swap_basket and the five setters have their own benchmarks and WeightInfo entries, measured on the reference benchmarking hardware.The SDK release that ships with the runtime adds the SwapBasket intent (amount="all" sells the whole origin holding), the basket_trading_status read (enabled, frozen, budget_tao, remaining_tao, used_tao, refill_per_block_tao, refill_blocks), the BasketTrading proxy type, and descriptions for every new error. The intent takes min_amount_out (destination alpha, or TAO for netuid 0; default 0, no floor). btcli root swap --from --to --amount [--max-slippage] [--hotkey] [--proxy-for] is the CLI surface; it quotes both legs, sets the floor to the quote less --max-slippage percent (default 1), and its review card shows the origin and destination holdings, the expected and minimum output, and how much of the bucket is left before you sign. If the quote is unavailable the floor is 0 and the card says so.
import bittensor as bt
from bittensor.wallet import Wallet
desk = Wallet(name="desk")
async with bt.Subtensor("finney") as client:
status = await client.read("basket_trading_status", hotkey_ss58=HOTKEY)
# {'enabled': True, 'frozen': False, 'budget_tao': τ1,250, 'remaining_tao': τ980, ...}
tao_mid = (await client.read("quote_unstake", netuid=8, amount_alpha=250)).tao
expected = (await client.read("quote_stake", netuid=64, amount_tao=tao_mid.tao)).alpha
floor = bt.Balance.from_rao(expected.rao * 99 // 100, 64) # 1% under the quote
intent = bt.SwapBasket(
hotkey_ss58=HOTKEY, origin_netuid=8, dest_netuid=64, amount=250, min_amount_out=floor
)
result = await client.execute(intent, desk, proxy_for=VALIDATOR_COLDKEY)
if not result.success:
print(result.error.code, result.error.remediation)Upgrade with pip install -U bittensor. Reference pages: swap-basket, basket-trading-status, hyperparameters. Guides: Basket trading (the operator walkthrough for this release), Basket trading for governance (enable, freeze, tune, and the rogue-key playbook), Basket trading for stakers (nothing to do, and how to watch), Root Reborn, Proxies.