Root Reborn#2759
Conversation
Replace the per-block auto-sell of root dividends with a compounding, redeemable beta basket. Root validators set a distribution vector over subnets via `set_root_weights`; each validator's root dividends are sold to TAO and re-bought as alpha across those subnets, staked under a global escrow coldkey (so the basket counts toward the validator's stake and compounds), and redeemed to TAO on demand through the existing claim path using an E/P growth multiplier. Auto-claim/auto-sell removed. Adds a dedicated `RootBasketWeights` map, `BasketPrincipal` accounting, hotkey-swap and subnet-dissolve handling, a legacy-state seed migration, and RPC views (staker pending TAO, validator NAV + basket, network-wide NAV). Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
This runtime-upgrade migration collects every RootClaimable hotkey and then, for each claimable slot, scans RootClaimed::iter_prefix. Returning an accumulated Weight does not bound execution; the upgrade block still performs all reads/writes in one shot. On a large state this can exceed the block budget or timeout during runtime upgrade. Convert this to a versioned multi-block migration, or gate deployment on a measured state bound that proves the full scan fits comfortably in one block.
| // Credit the validator's root nominators proportionally to their root stake. | ||
| Self::increase_stake_for_hotkey_on_subnet( |
There was a problem hiding this comment.
[HIGH] Subnet dissolve pays basket NAV to current root shares, not owed principals
During dissolve, the entire basket is credited with increase_stake_for_hotkey_on_subnet, which distributes value across the validator's current root share pool. That ignores per-coldkey owed principal and RootClaimed watermarks, then finalize_all_subnet_root_dividends clears the claim data for the dissolved subnet. A staker who enters or changes root stake after basket accrual but before a root-initiated dissolve can receive value that belongs to prior owed principals, while legitimate claimants lose their basket claim. Liquidate pro rata by each coldkey's computed owed principal, or force/settle claims against BasketPrincipal before clearing the subnet state.
🛡️ AI Review — Skeptic (security review)VERDICT: VULNERABLE BASELINE scrutiny: established write-access opentensor contributor; no trusted Gittensor allowlist hit; branch root-reborn -> devnet-ready. No Findings
Prior-comment reconciliation
ConclusionThe PR appears legitimate, but it still introduces unbounded runtime work in paths that can execute during upgrade, block processing, subnet teardown, or signed dispatch. These HIGH severity availability risks should block merge until the work is bounded, metered, or moved into chunked processing. 📜 Previous run (superseded)
# 🔍 AI Review — Auditor (domain review) has not yet run on this PR. |
|
🔄 AI review updated — Skeptic: VULNERABLE |
Revive the existing root-weights plumbing: store the basket vector under Weights[ROOT][uid] (uid-keyed, so it follows the validator through hotkey swaps automatically and reuses existing weight terms/limits) rather than a separate RootBasketWeights map. Keep the dedicated `set_root_weights` extrinsic since the generic set_weights rejects netuid 0 and root needs different checks. Retain the dust-recycle fix (Σ owed == BasketPrincipal). Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
This migration runs inside on_runtime_upgrade, but it materializes every RootClaimable hotkey and then scans RootClaimed for every (netuid, hotkey) slot. That work is proportional to live chain state and can exceed the upgrade block budget, which risks an overweight runtime upgrade or a chain-stalling migration. The note acknowledges the risk, but the PR still wires it into the one-shot upgrade path. Convert this to a bounded/multi-block migration or otherwise cap the per-block work before enabling it.
| for (i, (dest_netuid, weight)) in valid.iter().enumerate() { | ||
| // Last slot absorbs the rounding remainder so Σ tao_s == tao_total exactly. | ||
| let tao_s: u64 = if i == last_idx { | ||
| tao_total_u64.saturating_sub(spent) | ||
| } else { | ||
| U96F32::saturating_from_num(tao_total_u64) | ||
| .saturating_mul(U96F32::saturating_from_num(*weight)) | ||
| .checked_div(U96F32::saturating_from_num(weight_sum)) | ||
| .unwrap_or(U96F32::saturating_from_num(0)) | ||
| .saturating_to_num::<u64>() | ||
| }; | ||
| spent = spent.saturating_add(tao_s); | ||
| if tao_s == 0 { | ||
| continue; | ||
| } | ||
|
|
||
| let bought: AlphaBalance = match Self::swap_tao_for_alpha( | ||
| *dest_netuid, | ||
| tao_s.into(), | ||
| T::SwapInterface::max_price(), | ||
| true, | ||
| ) { | ||
| Ok(res) => res.amount_paid_out, | ||
| Err(err) => return TransactionOutcome::Rollback(Err(err)), | ||
| }; |
There was a problem hiding this comment.
[HIGH] Unbounded basket fan-out runs AMM work during coinbase
distribute_root_alpha_to_basket runs from coinbase for each validator with root dividends, and this loop performs AMM buys/stake accounting for every valid destination in the validator's root-weight vector. set_root_weights accepts any existing non-root subnet and does not enforce a small fan-out cap or include this dynamic AMM work in block weight, so validators can make normal block production scale with validators_with_root_divs * destination_subnets. That is a runtime DoS risk in the steady-state block path. Cap the basket vector length and account for the worst-case work, or move the fan-out into a bounded deferred process.
| // Credit the validator's root nominators proportionally to their root stake. | ||
| Self::increase_stake_for_hotkey_on_subnet( | ||
| hotkey, | ||
| NetUid::ROOT, | ||
| owed_tao.amount_paid_out.to_u64().into(), |
There was a problem hiding this comment.
[HIGH] Subnet dissolve pays basket NAV to current root shares, not owed principals
The basket ledger tracks each staker's outstanding principal through RootClaimable, RootClaimed, and BasketPrincipal, but dissolve liquidation ignores that ledger and credits the entire liquidated NAV to whoever currently holds root stake under the validator. A staker with accrued basket principal can lose value after root stake changes, while a newer/current root holder receives a windfall; then finalize_all_subnet_root_dividends removes BasketPrincipal and clears claim state. Liquidation must pay or convert value pro-rata by owed principal, not by current root-share ownership.
|
🔄 AI review updated — Skeptic: VULNERABLE |
On subnet dissolve, liquidate_basket_to_root_stakers previously credited the swapped basket value to the validator's current root nominators in proportion to their *current root stake* (increase_stake_for_hotkey_on_subnet), ignoring the per-coldkey owed entitlement. That windfalls recent/large-current-stake nominators and short-changes stakers who actually accrued the basket, then wipes the ledger — a provable intra-staker fairness bug. Now: swap the whole basket once, then distribute the realized TAO pro-rata by each staker's owed (rate*root_stake - claimed == owed*E/P), crediting each coldkey individually and rebasing its claimed watermark (mirrors a normal claim). Degenerate zero-owed case falls back to stake-proportional so value is never orphaned. Adds a regression test proving a zero-owed fresh staker receives nothing while the accruing staker receives the basket. Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
This runtime-upgrade migration collects every RootClaimable hotkey and then scans RootClaimed for each (netuid, hotkey) slot. The returned Weight is computed while doing the work, but nothing bounds the amount of state touched before the upgrade block must finish. On a large chain state this can exceed block limits and stall the runtime upgrade. Convert this to a bounded multi-block migration or otherwise cap/chunk the scanned keys before enabling it on a live chain.
| let tao_total_u64: u64 = tao_total.to_u64(); | ||
| let mut spent: u64 = 0; | ||
| let last_idx = valid.len().saturating_sub(1); | ||
| for (i, (dest_netuid, weight)) in valid.iter().enumerate() { |
There was a problem hiding this comment.
[HIGH] Unbounded basket fan-out runs AMM work during coinbase
valid comes from Weights[ROOT], and set_root_weights only checks matching lengths, duplicates, and that each destination subnet exists. A validator can therefore set a basket over every non-root subnet, and every root dividend for that validator will execute this loop in the coinbase path, including AMM swaps and stake/storage updates per destination. This makes block execution scale with validator-chosen fan-out without a cap or corresponding weight accounting. Add a small maximum basket destination count, enforce it in set_root_weights, and keep the coinbase path bounded.
| for hotkey in hotkeys.iter() { | ||
| // Liquidate the validator's beta basket on this subnet back to root stakers before | ||
| // clearing rates, so subnet teardown does not orphan basket value in the escrow. | ||
| Self::liquidate_basket_to_root_stakers(hotkey, &escrow, netuid); |
There was a problem hiding this comment.
[HIGH] Subnet dissolve adds unbounded basket liquidation under fixed weight
Subnet dissolve now calls liquidate_basket_to_root_stakers for every hotkey with root-claim state. That helper performs a swap and then iterates the validator's current root stakers to distribute by owed amount, but both dissolve_network and root_dissolve_network still declare a fixed weight. This path is also reachable from subnet registration when the subnet limit prunes an old subnet. A subnet with many basket-backed validators or many root stakers can make dissolve/pruning exceed its declared block weight. Move liquidation to a bounded multi-block cleanup or strictly cap the amount of basket/staker work performed per call.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Mint basket principal *shares* at the live escrow NAV (E/P) instead of at par. A deposit into an already-compounded basket now mints fewer shares than the alpha bought, leaving E/P unchanged: existing holders are not diluted and a late staker cannot skim past compounding. Makes the staker-facing guarantee strict — a new staker only ever earns their fair share of distributions from the point they join forward. Adds tests proving claims 1-4 (principal never lost; accrued beta unchanged by others staking; beta compounds; no dilution/skim on late stake, incl. E/P invariance across a deposit). Also includes the dissolve liquidation distributing pro-rata by owed entitlement rather than current root share. Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
This runtime upgrade collects every RootClaimable hotkey, then for each slot scans RootClaimed::iter_prefix. That work is proportional to all historical root-claim state and happens in one on_runtime_upgrade; returning accumulated weight after doing the scan does not bound execution. On a large state this can exceed the upgrade block and stall the chain. Convert this to a bounded/chunked migration or otherwise prove the production state is capped below block limits before merge.
| let tao_total_u64: u64 = tao_total.to_u64(); | ||
| let mut spent: u64 = 0; | ||
| let last_idx = valid.len().saturating_sub(1); | ||
| for (i, (dest_netuid, weight)) in valid.iter().enumerate() { |
There was a problem hiding this comment.
[HIGH] Unbounded basket fan-out runs AMM work during coinbase
valid is the validator-controlled root weight vector and this loop performs AMM swaps plus stake/storage mutations for every destination during coinbase. set_root_weights does not cap fan-out, and coinbase execution is not charged per validator basket destination here, so a large set of root validators with broad vectors can push block processing beyond limits. Enforce a small maximum basket fan-out at weight-setting time, or move this work into a metered/chunked path with explicit block-weight accounting.
| // Liquidate the validator's beta basket on this subnet back to root stakers before | ||
| // clearing rates, so subnet teardown does not orphan basket value in the escrow. | ||
| Self::liquidate_basket_to_root_stakers(hotkey, &escrow, netuid); | ||
| BasketPrincipal::<T>::remove(hotkey, netuid); |
There was a problem hiding this comment.
[HIGH] Subnet dissolve adds unbounded basket liquidation under fixed weight
Subnet dissolve already runs under a fixed dispatch weight, but this added call liquidates each validator basket and, inside liquidate_basket_to_root_stakers, scans the validator's root stakers and distributes per coldkey. finalize_all_subnet_root_dividends iterates all RootClaimable hotkeys, so dissolve/prune can become validators * stakers work plus swaps under a fixed-weight path. Make teardown bounded/chunked, or cap the affected basket/staker set so dissolve cannot exceed block limits.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Add observability for off-chain indexing / future tokenization cost-basis: - Events: BasketDeposited (alpha bought + shares minted at NAV, per validator/subnet), BasketClaimed (TAO realized by a staker), and BasketLiquidated (TAO returned to root stakers on subnet dissolve). - RPC/runtime-API: betaBasket_getValidatorWeights returns a validator's basket weight vector (its curation strategy) so dashboards can display it. Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
on_runtime_upgrade now collects every RootClaimable hotkey and then, for each (hotkey, netuid) slot, scans RootClaimed::iter_prefix. This is unbounded historical state work in a single upgrade block, so a large root-claim ledger can overweight the runtime upgrade and halt block production. Make this a bounded multi-block migration or prove and enforce a hard upper bound before mainnet deployment.
| let tao_total_u64: u64 = tao_total.to_u64(); | ||
| let mut spent: u64 = 0; | ||
| let last_idx = valid.len().saturating_sub(1); | ||
| for (i, (dest_netuid, weight)) in valid.iter().enumerate() { |
There was a problem hiding this comment.
[HIGH] Unbounded basket fan-out runs AMM work during coinbase
Coinbase now loops over every valid destination in the validator's root-weight vector and performs swap/stake/accounting work for each. This executes during block processing, not under an extrinsic weight, and the PR body already notes the basket fan-out is currently unmetered. Cap the vector length to a small constant or move/distribute the AMM work through a metered path before enabling this in runtime.
| // so the full amount is allocated. | ||
| let mut distributed: u64 = 0; | ||
| let last_idx = owed_list.len().saturating_sub(1); | ||
| for (i, (coldkey, owed)) in owed_list.iter().enumerate() { |
There was a problem hiding this comment.
[HIGH] Subnet dissolve adds unbounded basket liquidation under fixed weight
Subnet teardown now liquidates each validator basket, scans current root stakers via alpha_iter_single_prefix, builds owed_list, and then loops over every owed staker to credit TAO. finalize_all_subnet_root_dividends already iterates all RootClaimable hotkeys, so this nests unbounded validators × root stakers × accounting/swap work under the dissolve/prune flow. Bound or chunk liquidation and account for the weight before clearing the subnet state.
| ); | ||
|
|
||
| // --- 7. No duplicate destination subnets. | ||
| ensure!(!Self::has_duplicate_uids(&dests), Error::<T>::DuplicateUids); |
There was a problem hiding this comment.
[HIGH] set_root_weights accepts an unbounded vector under fixed weight
set_root_weights reuses the fixed set_weights() dispatch weight and Pays::No, but this new path never caps dests before the O(n²) duplicate check and later stores the whole vector. Normal set_weights checks the vector length before duplicate validation; this path should similarly reject anything above the number of active non-root subnets or another hard basket fan-out cap. Otherwise a registered root validator can submit an oversized vector that is far more expensive than its declared weight, and successful large vectors also amplify the coinbase fan-out issue.
| ensure!(!Self::has_duplicate_uids(&dests), Error::<T>::DuplicateUids); | |
| ensure!( | |
| dests.len() <= TotalNetworks::<T>::get().saturating_sub(1) as usize, | |
| Error::<T>::UidsLengthExceedUidsInSubNet | |
| ); | |
| ensure!(!Self::has_duplicate_uids(&dests), Error::<T>::DuplicateUids); |
|
🔄 AI review updated — Skeptic: VULNERABLE |
… claim type - Record protocol outflow on the origin sell and inflow on each redistribution buy in distribute_root_alpha_to_basket, so a deposit->claim round-trip nets ~0 on the dest pools (symmetric with the claim/liquidation outflow that was already recorded). Records sit inside with_transaction so they roll back with the swaps. - Exclude the beta-escrow coldkey from clear_small_nomination_if_required: basket positions are not nominations, and sweeping one stranded TAO in the keyless escrow account while leaving BasketPrincipal untouched (breaking Sum(owed) == BasketPrincipal and zeroing every staker's owed * E/P payout). - Deprecate the claim-type surface: set_root_claim_type now rejects the no-op Keep/KeepSubnets variants (new RootClaimTypeNotSupported error); fixed the false "(Keep was removed)" comment and documented the variants as no-ops. - Remove dead auto-claim machinery (run_auto_claim_root_divs, block_hash_to_indices, block_hash_to_indices_weight) and its false-coverage test; update affected tests and benchmarks. Co-authored-by: Cursor <cursoragent@cursor.com>
| let escrow = Pallet::<T>::get_beta_escrow_account_id(); | ||
| weight.saturating_accrue(T::DbWeight::get().reads(1)); | ||
|
|
||
| let hotkeys: Vec<T::AccountId> = RootClaimable::<T>::iter_keys().collect(); |
There was a problem hiding this comment.
[HIGH] One-shot migration scans unbounded root-claim state
This migration runs in on_runtime_upgrade and collects every RootClaimable hotkey, then scans each hotkey's claimable map and each (netuid, hotkey) RootClaimed prefix. That work is proportional to live chain state and has no batch cursor or hard cap, so a large root-claim state can make the upgrade block exceed its weight budget and halt block production. Move this to a bounded multi-block migration or otherwise prove and enforce a state-size cap before running it in the runtime upgrade path.
| let tao_total_u64: u64 = tao_total.to_u64(); | ||
| let mut spent: u64 = 0; | ||
| let last_idx = valid.len().saturating_sub(1); | ||
| for (i, (dest_netuid, weight)) in valid.iter().enumerate() { |
There was a problem hiding this comment.
[HIGH] Unbounded basket fan-out runs AMM work during coinbase
valid is derived from the validator's root weights with no enforced fan-out cap, then coinbase performs a swap, stake update, storage mutation, and event for every destination. This runs during block production rather than a user-paid dispatch, so a validator can store a large vector and make normal coinbase execution perform unbounded AMM/storage work. Enforce a small max destination count at set_root_weights time and meter the coinbase path against that bound.
| for hotkey in hotkeys.iter() { | ||
| // Liquidate the validator's beta basket on this subnet back to root stakers before | ||
| // clearing rates, so subnet teardown does not orphan basket value in the escrow. | ||
| Self::liquidate_basket_to_root_stakers(hotkey, &escrow, netuid); |
There was a problem hiding this comment.
[HIGH] Subnet dissolve adds unbounded basket liquidation under fixed weight
Subnet finalization already iterates all RootClaimable hotkeys; this added call now also liquidates each validator basket and, inside liquidation, scans that validator's root stakers to build owed_list before crediting each one. Subnet teardown/pruning must remain bounded by its dispatch weight, but this adds work proportional to validators times root stakers, plus swaps and stake mutations. Make liquidation chunked or explicitly bounded and account for the bound in the dissolve/prune weight.
| ); | ||
|
|
||
| // --- 7. No duplicate destination subnets. | ||
| ensure!(!Self::has_duplicate_uids(&dests), Error::<T>::DuplicateUids); |
There was a problem hiding this comment.
[HIGH] set_root_weights accepts an unbounded vector under fixed weight
Before this duplicate check and the following per-destination validation/storage, there is no length cap on dests/values. The dispatch is charged WeightInfo::set_weights() with Pays::No, but it loops over the whole vector, upscales it, zips it, and stores the full result. A signed root validator can submit an oversized vector and force unmetered runtime work/storage. Reuse the normal max-weight-vector limit or add a root-specific bounded vector type and benchmarked weight.
|
🔄 AI review updated — Skeptic: VULNERABLE |
|
Except I am missing something, it seems like a full (and/or partial) unstake/move stake from root forfeits a staker's unclaimed basket shares unintentionally. @unconst What one is owed computes as
Example 1 (Partial unstake of 50 TAO):
Example 2 (Full unstake of 100 TAO):
So the 50 shares aren't claimable anymore (right?). Actually one would also forfeit shares when it's a new basket so |
A validator can now weight root (uid 0) in its basket vector to opt out of subnet exposure: that slice is held as root stake (TAO at 1:1) under the escrow instead of being swapped into subnet alpha, and it compounds and is claimable through the same E/P machinery as the alpha slots. Root has no AMM pool, so the swap is elided (valuation is already 1:1) and the reserve bookkeeping is mirrored directly; the escrow custody account is excluded from the claimant base and from dissolution payouts since it is not a claimant. Adds 4 tests covering deposit, claim (reassign, no swap), compounding, and the escrow-denominator exclusion. Co-authored-by: Cursor <cursoragent@cursor.com>
…ttlement Align the set_root_weights producer with the basket consumer so a validator can actually populate a root (uid 0) slot on-chain (previously the extrinsic rejected root, leaving the new path unreachable in production). Code-quality cleanups: extract credit_root_reserves (the SubnetTAO/ SubnetAlphaOut/TotalStake triple, previously hand-mirrored in 3 places) and hoist the shared post-claim watermark advance so root and subnet claims share one settlement tail instead of duplicating it. Co-authored-by: Cursor <cursoragent@cursor.com>
…ble-ready) Restructure the basket's unit of account so entitlements are shares of a single fund per validator, never claims on a specific subnet's alpha. This decouples what stakers are owed (fund shares) from what the fund holds (escrow positions), which is the prerequisite for validator-directed rebalancing and share tokenization later: holdings can change without touching any staker's claim. How it works now: * Storage: BasketShares(hot) = outstanding fund shares P (TAO-denominated); BasketRate(hot) = single shares-per-root-stake accumulator; and BasketClaimed(hot, cold) = signed i128 claimed watermark. The watermark is signed on purpose: stake-change rebasing (claimed +/- rate * delta) must be exact in both directions or unstake-before-claim forfeits accrued entitlement (the old unsigned per-subnet RootClaimed had this bug). * Deposit: each root dividend is sold for TAO and deployed across subnets per the validator's Weights[ROOT] vector into the keyless escrow. Fund NAV N is snapshotted after the origin sell (the fund may hold origin-subnet alpha) and shares = tao_deployed * P / N mint at the pre-deposit NAV, so existing holders are never diluted and late deposits cannot skim past compounding. Mint/payout math is u128 (U96F32 saturates at chain scale). Dust deposits (rate increment below I96F32 resolution) roll back and recycle so sum(owed) == P is never broken. * Claim (claim_root, now arg-less): fund-level pro-rata redemption. Owed shares define fraction f = owed / P; exactly f of every holding is redeemed (subnet alpha sold to TAO, the root cash slot reassigned without a swap) and staked on root. Composition is preserved by every claim. A claim that realizes zero TAO (all alpha takes floor to zero) rolls back rather than burning shares. Only the ROOT RootClaimableThreshold entry gates dust claims; the sudo setter rejects other netuids. * Dissolution: a dying subnet's holdings convert into each fund's root (TAO) slot. NAV is continuous, entitlements untouched; the old liquidate-to-stakers machinery is deleted. * Key swaps: root hotkey swaps move the whole fund (shares, rate, watermarks, holdings) by value; coldkey swaps carry the watermark even at zero live root stake (negative watermark = owed with no stake). * Migration is migrate_seed_beta_basket_v2 under a fresh key: the v1 name was already consumed on chains that ran the abandoned per-slot seed, which would have silently skipped conversion and stranded every basket. v2 converts legacy per-subnet state at fixed moving prices (spot fallback), preserving each staker's owed TAO value exactly (sum(owed) == P), tolerates pre-existing v1 escrow/root-slot state without double-staking or minting unbacked shares, and clears orphaned BasketPrincipal entries. * Retired dead surface: set_root_claim_type (call 122), sudo_set_num_root_ claims (call 123), RootClaimType/RootClaimTypeEnum/NumRootClaim storage, and their events/errors/benchmarks/ts-tests. Redemption is always a full swap to root TAO; there is no auto-claim scheduler. Covered by 50+ basket/migration tests including conservation under interleaved deposits/claims, chain-scale magnitudes, self-referential origin deposits, v1-already-ran migration state, coldkey-swap entitlement carry, and zero-realized-claim no-ops. Co-authored-by: Cursor <cursoragent@cursor.com>

Root Reborn
Root staking is TAO's risk-free rate — the yield paid to the network's safest capital. Today the protocol pays that yield by selling: every block, root dividends are auto-swapped out of subnets into TAO. The rate that's supposed to anchor the network is funded by continuously dumping the very assets that give TAO its value.
Root Reborn flips the sign of that flow. Root validators set a distribution vector over subnets (a normal
set_weights-style call on subnet 0). Each validator's root dividends are no longer auto-sold — they're reinvested across subnets per that vector into a compounding beta basket, staked back under the validator, and redeemable to TAO on demand by stakers.Why this matters
How the basket works now: one fund per validator
Each validator's basket is a single fund, not a collection of per-subnet claims. This is the load-bearing design decision, made deliberately so the basket is future-proof for active management: because staker entitlements are denominated in fund shares rather than any particular subnet's alpha, the fund's holdings can later be rebalanced (validator-directed trading, share tokenization, direct buy-in) without touching a single staker's claim.
(validator_hotkey, escrow, netuid)in the normal share pool — including a root slot that holds TAO 1:1 as the fund's cash position (a validator can weight uid 0 to opt out of subnet exposure). NAVNis the mark-to-market TAO value of those holdings.BasketShares[hot]is the outstanding TAO-denominated share supplyP. Deposits minttao_deployed · P / Nat the pre-deposit NAV (snapshotted after the origin sell, since the fund may itself hold origin-subnet alpha) — existing holders are never diluted and a late deposit can't skim past compounding. Mint/payout math is u128;U96F32intermediates saturate at chain-scale magnitudes.BasketRate[hot](shares per unit root stake) plus a signed watermarkBasketClaimed[hot, cold](i128). Signed on purpose: stake-change rebasing (claimed ± rate·Δ) must be exact in both directions, otherwise unstake-before-claim silently forfeits accrued entitlement.claim_root(), now argument-less) is fund-level and purely proportional: owed shares define a fractionf = owed/P, and exactlyfof every holding is redeemed (subnet alpha sold to TAO; the root slot reassigned without a swap) and staked on root. Every claim preserves fund composition, so claims and future rebalancing never interfere. A claim that would realize zero TAO rolls back rather than burning shares.What changed (technical)
Weights[ROOT][uid]root-weights map (uid-keyed, so it follows the validator through hotkey swaps automatically), set via the dedicatedset_root_weightsextrinsic. Weights steer inflows; holdings float with the market.distribute_root_alpha_to_basket— sell origin alpha → TAO → buy perwinto the escrow → mint fund shares at NAV, one rate bump. Recycles if no weights / no root stake; dust deposits (rate increment below I96F32 resolution) roll back and recycle soΣ owed == Pis never broken. Fully transactional.BasketShares/BasketRate/BasketClaimedreplace the per-subnetRootClaimable/RootClaimed/BasketPrincipal(legacy maps remain declared only for the migration to drain).migrate_seed_beta_basket_v2— converts legacy per-subnet claim state into the unified fund at fixed per-subnet moving prices (spot fallback for cold EMAs), preserving every staker's owed TAO value exactly (Σ owed == Pat the seed,N/P = 1). It runs under a fresh migration key because the v1 name was already consumed on chains that ran the abandoned per-slot seed — reusing it would have silently skipped conversion and stranded every basket. v2 tolerates v1 state: it never double-stakes existing escrow, caps root-slot conversion at real backing (haircutting the rate soΣ owed == Pstill holds), and clears orphanedBasketPrincipalentries.set_root_claim_type(call 122),sudo_set_num_root_claims(call 123),RootClaimType/RootClaimTypeEnum/NumRootClaimand their events/errors/benchmarks. Redemption is always a full swap to root TAO; there is no auto-claim scheduler.sudo_set_root_claim_thresholdnow rejects non-ROOT netuids (claims only consult the ROOT entry).betaBasket_getStakerOwed,betaBasket_getValidatorNav,betaBasket_getValidatorBasket,betaBasket_getTotalNav(mark-to-market), unchanged signatures, now fund-backed.Breaking changes (release coordination)
claim_root(call 121) dropped itssubnetsargument — fund-level redemption has no per-subnet selection. Calls 122/123 are retired (indices reserved, not reused). SDKs/wallets must regenerate metadata.BasketDeposited/BasketClaimedare fund-level (nonetuid);BasketLiquidated→BasketHoldingConverted.Review & correctness
Three independent review passes were run on this branch (accounting/security, maintainability, and an adversarial edge-case hunt). Core accounting verified sound: no share-inflation, double-claim, or escrow-drain path; deposits and redemptions are
TotalStake-neutral;Σ owed == Pholds with rounding always stranding dust in favor of remaining holders. Findings fixed in this PR:i128. Regression test proves owed survives unstake-all and remains claimable.U96F32's 96 integer bits saturate around 7.9e28, reachable at supply-scale funds, which would have silently underpaid by orders of magnitude.Known follow-ups (not blocking devnet)
migrate_seed_beta_basket_v2to a multi-block migration before mainnet scale (single-block scan ofvalidators × subnets × coldkeys).w(cap buy size vs pool depth).claim_rootbenchmarks (cost now scales with the number of fund holdings).rebalance_basket(swap holdings A→B; no staker accounting touched), harvest-to-explicit-shares, transfer, and direct deposit — each an additive extrinsic on the sameN/Pmath.Test plan
cargo test -p pallet-subtensor --lib— 1198 pass, including 40+ basket tests: conservation under interleaved deposits/claims across multi-subnet + root-slot compositions, compounding, full-drain, proportional/disproportional payouts, composition preservation, claim idempotency, chain-scale magnitudes (saturation regression), self-referential origin deposits, dust-deposit recycling, threshold no-ops, drain-and-revive, unstake-preserves-accrued, coldkey-swap entitlement carry, zero-realized-claim no-op, hotkey-swap fund migration, dissolve conversion + fairness, end-to-end via the real coinbase, RPC views; plus 4 seed-migration tests (fresh chain, overclaimed slots, multi-subnet owed preservation, v1-already-ran).cargo clippy -p pallet-subtensor --all-targetsclean; full workspace type-checks.