diff --git a/docs/guides/root-reborn.mdx b/docs/guides/root-reborn.mdx index 0bb2722ec4..ccef1bcbdc 100644 --- a/docs/guides/root-reborn.mdx +++ b/docs/guides/root-reborn.mdx @@ -213,11 +213,11 @@ root-stakes to. the entitlement keeps accruing and pays out once it clears. There is no deadline and nothing expires. - **Claim fee.** The inclusion fee scales with how many ALPHA types the - basket holds. Both root-claim calls reserve a conservative 256-unit work - envelope at inclusion, independent of how many networks currently exist, - and refund the unused part after the claim. The amount you actually spend - follows the holdings scanned and redeemed (around τ0.057 on a full - 128-holding basket). + basket holds. The chain reserves the declared-work fee at inclusion + (a 129-unit envelope, around τ0.058, for a single-validator claim) and + refunds the unused part after the claim. The legacy coldkey-wide call + reserves the full 256-unit envelope. The amount you actually spend is + around τ0.057 on a full 128-holding basket. `btcli root claim --dry-run` shows reserved versus spent, compares the spent fee to accrued yield, warns if the claim loses money, and refuses if free TAO cannot cover the reserved amount. diff --git a/docs/guides/staking.mdx b/docs/guides/staking.mdx index 2372e6f59f..27768235af 100644 --- a/docs/guides/staking.mdx +++ b/docs/guides/staking.mdx @@ -190,13 +190,13 @@ btcli root claim --hotkey 5F... --amount all # withdraw full position btcli root claim --hotkey 5F... # claim accrued into stake only ``` -The claim fee scales with how many ALPHA types are in the basket. Both -root-claim calls reserve a conservative 256-unit work envelope at inclusion, -independent of the current network count, and refund the unused part after. -You actually spend according to the holdings scanned and redeemed (around -τ0.057 on a full 128-holding basket). `--dry-run` and the confirm step show -reserved versus spent, warn if that spent fee exceeds accrued yield, and -refuse if free TAO cannot cover the reserve. +The claim fee scales with how many ALPHA types are in the basket. The +chain reserves a 129-unit envelope (around τ0.058) for a single-validator +claim and refunds the unused part after. The legacy coldkey-wide call still +reserves the full 256-unit envelope. You actually spend around τ0.057 on a +full 128-holding basket. `--dry-run` and the confirm step show reserved versus +spent, warn if that spent fee exceeds accrued yield, and refuse if free TAO +cannot cover the reserve. If `RootStakeUnlockInterval` is nonzero, a claim refreshes the root-stake hold and cannot be followed by an unstake in the same atomic batch. Claim first, diff --git a/docs/tx/claim-root-with-hotkey.mdx b/docs/tx/claim-root-with-hotkey.mdx index e35c30f725..944fca40e3 100644 --- a/docs/tx/claim-root-with-hotkey.mdx +++ b/docs/tx/claim-root-with-hotkey.mdx @@ -17,9 +17,8 @@ consolidated into the fund's root (TAO) slot as a side effect, so the per-holding claim fee shrinks over time; curated positions are left to compound. The transaction fee is charged by work actually done: holdings redeemed pay full weight, holdings merely scanned pay a small -per-row cost. The chain reserves a fixed 256-unit declared-work envelope at -inclusion, independent of the current network count, and refunds the unused -part after. +per-row cost. The chain reserves the declared-work fee at inclusion +(a 129-unit single-basket envelope) and refunds the unused part after. `plan` and `btcli root claim --dry-run` show reserved versus spent, warn when the spent fee exceeds accrued yield, and refuse when free TAO cannot cover the reserve. @@ -81,7 +80,7 @@ result = sub.execute_tool("claim_root_with_hotkey", {...}, wallet) ```rust #[pallet::call_index(148)] #[pallet::weight( - ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK) + ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK) )] pub fn claim_root_with_hotkey( origin: OriginFor, diff --git a/docs/tx/unstake-all.mdx b/docs/tx/unstake-all.mdx index df9a623b4d..569a8357ee 100644 --- a/docs/tx/unstake-all.mdx +++ b/docs/tx/unstake-all.mdx @@ -88,7 +88,7 @@ Delegates to [`do_unstake_all`](/code/pallets/subtensor/src/staking/remove_stake ```rust #[pallet::call_index(148)] #[pallet::weight( - ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK) + ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK) )] pub fn claim_root_with_hotkey( origin: OriginFor, diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 6a417488bd..e9b40b42c5 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -71,12 +71,12 @@ pub const MIN_ALPHA_LOW: u16 = 1_639; pub const MAX_ROOT_CLAIM_THRESHOLD: u64 = 10_000_000; -/// Benchmark upper bound and admission envelope for `claim_root` / -/// `claim_root_scan` (Linear<1, N>). Weight calculation cannot walk storage, -/// so both claim paths reserve this many units and refuse work that would -/// exceed the envelope. Post-dispatch weight is refunded to the work -/// actually performed. +/// Benchmark and admission ceiling for root claims and scans. +/// Coldkey-wide claims reserve this full envelope. pub const MAX_ROOT_CLAIM_WORK: u32 = 256; +/// Single-hotkey quote: root plus the current maximum 128 subnet slots. +/// Raise this in the same runtime upgrade that raises the subnet limit. +pub const MAX_ROOT_CLAIM_HOTKEY_WORK: u32 = 129; /// Minimum number of positive destination weights required by `set_root_weights`. Softened /// to the number of available destinations when fewer networks exist than this floor. diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 3b6d8e753d..e1d4ef9f72 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1988,7 +1988,7 @@ mod dispatches { /// * `RootClaimed`: On successfully claiming the root emissions for this coldkey+hotkey. #[pallet::call_index(148)] #[pallet::weight( - ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK) + ::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK) )] pub fn claim_root_with_hotkey( origin: OriginFor, diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 9621f64c71..8325630c22 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -749,9 +749,9 @@ impl Pallet { (Self::get_all_subnet_netuids().len() as u32).max(1) } - /// Pre-dispatch work units for both claim paths. Weight calculation must - /// stay storage-independent (no `NetworksAdded` or basket walks here); - /// execution then refuses work that would exceed this envelope. + /// Pre-dispatch work units for a coldkey-wide claim. Weight calculation + /// cannot inspect the signer, so this path needs the full hard envelope + /// and refunds unused work after dispatch. pub(crate) fn root_claim_declared_work() -> u32 { crate::MAX_ROOT_CLAIM_WORK } diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index a0d14d8cf9..2b57a3a0b6 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -246,6 +246,18 @@ fn test_claim_root_declared_weight_covers_bounded_work() { NetworksAdded::::insert(ghost, false); assert_eq!(SubtensorModule::root_claim_existing_networks(), existing); assert!(existing < crate::MAX_ROOT_CLAIM_WORK); + + // The single-hotkey declaration reserves the configured 128 subnet + // slots plus root, not the coldkey-wide 256-unit envelope. + let single_work = crate::MAX_ROOT_CLAIM_HOTKEY_WORK; + assert_eq!(single_work, 129); + let single_call = + RuntimeCall::SubtensorModule(crate::Call::claim_root_with_hotkey { hotkey }); + let single_declared = single_call.get_dispatch_info().call_weight; + let single_envelope = ::WeightInfo::claim_root(single_work); + assert!(single_declared.all_gte(single_envelope)); + assert!(single_declared.all_lt(declared_weight)); + let actual_weight = SubtensorModule::claim_root(RuntimeOrigin::signed(coldkey), subnets) .expect("claim succeeds") .actual_weight diff --git a/sdk/python/bittensor/intents/_root_claim_fee.py b/sdk/python/bittensor/intents/_root_claim_fee.py index e20f50b28d..f99af63128 100644 --- a/sdk/python/bittensor/intents/_root_claim_fee.py +++ b/sdk/python/bittensor/intents/_root_claim_fee.py @@ -1,9 +1,8 @@ """Claim-fee preview for ``claim_root`` / ``claim_root_with_hotkey``. -Both runtime calls reserve ``MAX_ROOT_CLAIM_WORK`` (256) weight units at -inclusion, then refund down to the work actually done. That reserve is what -people see leave their free balance, and it is larger than the fee that -finally settles. That gap is the usual claim-fee surprise. +Coldkey-wide claims reserve ``MAX_ROOT_CLAIM_WORK`` (256) weight units. +Single-hotkey claims reserve one basket's 129-unit envelope. Both refund down +to the work actually done. This module estimates both numbers, compares the spent fee to accrued yield, and tells the caller when a claim loses money or cannot even be included. @@ -26,15 +25,22 @@ _REDEEM_REF_TIME = 70 # One full ``claim_root`` weight unit under LinearWeightToFee (~τ0.0004475). -# Both claim paths reserve ``MAX_ROOT_CLAIM_WORK`` of these, plus any -# non-weight base/length fee returned by ``payment_info``. +# The coldkey-wide path reserves ``MAX_ROOT_CLAIM_WORK`` of these; the +# single-hotkey path reserves ``MAX_ROOT_CLAIM_HOTKEY_WORK``. Both also pay +# any non-weight base/length fee returned by ``payment_info``. _APPROX_REDEEM_FEE_RAO = 447_500 _MAX_ROOT_CLAIM_WORK = 256 +_MAX_ROOT_CLAIM_HOTKEY_WORK = 129 # Default ``RootClaimableThreshold`` (500_000 rao) when storage is empty. _DEFAULT_THRESHOLD_RAO = 500_000 +def root_claim_declared_work(hotkeys: Optional[list[str]]) -> int: + """Return the runtime's declared work envelope for the selected call.""" + return _MAX_ROOT_CLAIM_WORK if hotkeys is None else _MAX_ROOT_CLAIM_HOTKEY_WORK + + class _FeeView: """Public-only keypair shape for ``estimate_fee`` (zeroed signature).""" @@ -300,6 +306,7 @@ async def _quote( reserve: Optional[RootClaimReserve], ) -> Optional[RootClaimFeeQuote]: coldkey_wide = hotkeys is None + declared_work = root_claim_declared_work(hotkeys) if admission is None: admission = await root_claim_admission( substrate, @@ -316,6 +323,7 @@ async def _quote( fee_payer_address, compose=compose, call=call, + declared_work=declared_work, ) if coldkey_wide: @@ -361,6 +369,7 @@ async def _quote( redeem_holdings=redeem_holdings, scan_holdings=scan_holdings, ), + declared_work=declared_work, ) return RootClaimFeeQuote( @@ -403,9 +412,18 @@ async def _reserved_fee( signer_address: str, compose: Callable[[], Awaitable[Any]], *, + declared_work: int = _MAX_ROOT_CLAIM_WORK, call: Any = None, ) -> Balance: - return (await _reserved_fee_with_status(substrate, signer_address, compose, call=call))[0] + return ( + await _reserved_fee_with_status( + substrate, + signer_address, + compose, + call=call, + declared_work=declared_work, + ) + )[0] async def _reserved_fee_with_status( @@ -414,13 +432,14 @@ async def _reserved_fee_with_status( compose: Callable[[], Awaitable[Any]], *, call: Any = None, + declared_work: int = _MAX_ROOT_CLAIM_WORK, ) -> tuple[Balance, bool]: try: if call is None: call = await compose() return await substrate.estimate_fee(call, _FeeView(signer_address)), True except Exception: - return Balance.from_rao(_APPROX_REDEEM_FEE_RAO * _MAX_ROOT_CLAIM_WORK), False + return Balance.from_rao(_APPROX_REDEEM_FEE_RAO * max(declared_work, 1)), False async def root_claim_reserve( @@ -429,6 +448,7 @@ async def root_claim_reserve( *, compose: Callable[[], Awaitable[Any]], call: Any = None, + declared_work: int = _MAX_ROOT_CLAIM_WORK, ) -> RootClaimReserve: """Read mandatory reserve/free state even when yield preview is unavailable.""" free_rao = await _free_rao(substrate, fee_payer_address) @@ -437,6 +457,7 @@ async def root_claim_reserve( fee_payer_address, compose, call=call, + declared_work=declared_work, ) return RootClaimReserve( reserved=reserved, @@ -448,26 +469,29 @@ async def root_claim_reserve( def _spent_fee( reserved: Balance, work: RootClaimWork, + *, + declared_work: int = _MAX_ROOT_CLAIM_WORK, ) -> Balance: """Refund unused declared units; keep non-weight base/length fees intact. Runtime active units are ``max(hotkey_count, realized + swept, 1)``. The quote floors by the selected hotkey count so empty-basket validators still - cost a full unit. ``estimate_fee`` prices the 256-unit declaration plus - extrinsic base/length; only the weight slice scales. + cost a full unit. ``estimate_fee`` prices ``declared_work`` plus extrinsic + base/length; only the weight slice scales. """ if reserved.rao <= 0: return reserved - declared_weight = _APPROX_REDEEM_FEE_RAO * _MAX_ROOT_CLAIM_WORK + declared_work = max(declared_work, 1) + declared_weight = _APPROX_REDEEM_FEE_RAO * declared_work weight_part = min(reserved.rao, declared_weight) base_part = max(0, reserved.rao - declared_weight) active = max(work.redeem_holdings, work.hotkeys, 1) - active_weight = weight_part * active // _MAX_ROOT_CLAIM_WORK + active_weight = weight_part * active // declared_work scan_weight = ( weight_part * max(work.scan_holdings, 0) * _SCAN_REF_TIME - // (_MAX_ROOT_CLAIM_WORK * _REDEEM_REF_TIME) + // (declared_work * _REDEEM_REF_TIME) ) spent_weight = active_weight + scan_weight return Balance.from_rao(min(reserved.rao, base_part + max(spent_weight, 0))) diff --git a/sdk/python/bittensor/intents/registration.py b/sdk/python/bittensor/intents/registration.py index a52c214aeb..f688515dec 100644 --- a/sdk/python/bittensor/intents/registration.py +++ b/sdk/python/bittensor/intents/registration.py @@ -13,6 +13,7 @@ from ._root_claim_fee import ( quote_root_claim_fee, root_claim_admission, + root_claim_declared_work, root_claim_reserve, ) from .base import Intent, IntentPreflight @@ -260,6 +261,7 @@ async def _claim_preflight( call: Any = None, ) -> IntentPreflight: hotkeys = self._claim_hotkeys() + declared_work = root_claim_declared_work(hotkeys) try: admission = await root_claim_admission( substrate, @@ -293,6 +295,7 @@ async def compose(): fee_payer, compose=compose, call=call, + declared_work=declared_work, ) except Exception as error: return IntentPreflight( @@ -415,9 +418,8 @@ class ClaimRootWithHotkey(_RootClaimIntent): per-holding claim fee shrinks over time; curated positions are left to compound. The transaction fee is charged by work actually done: holdings redeemed pay full weight, holdings merely scanned pay a small - per-row cost. The chain reserves a fixed 256-unit declared-work envelope at - inclusion, independent of the current network count, and refunds the unused - part after. + per-row cost. The chain reserves the declared-work fee at inclusion + (a 129-unit single-basket envelope) and refunds the unused part after. ``plan`` and ``btcli root claim --dry-run`` show reserved versus spent, warn when the spent fee exceeds accrued yield, and refuse when free TAO cannot cover the reserve. diff --git a/sdk/python/tests/unit/test_root_claim_fee.py b/sdk/python/tests/unit/test_root_claim_fee.py index 809ad8f2a2..0e7257f6a9 100644 --- a/sdk/python/tests/unit/test_root_claim_fee.py +++ b/sdk/python/tests/unit/test_root_claim_fee.py @@ -1,4 +1,4 @@ -"""Reserved/spent root-claim fees follow the 256-unit runtime envelope.""" +"""Reserved/spent root-claim fees follow each call's runtime envelope.""" from __future__ import annotations @@ -22,6 +22,20 @@ async def _boom(): assert reserved.rao == fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK +@pytest.mark.asyncio +async def test_single_hotkey_reserved_fallback_uses_declared_work(): + async def _boom(): + raise RuntimeError("no payment_info") + + reserved = await fees._reserved_fee( + object(), + "5F3sa2TJAW", + _boom, + declared_work=fees._MAX_ROOT_CLAIM_HOTKEY_WORK, + ) + assert reserved.rao == fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_HOTKEY_WORK + + def test_spent_scales_against_256_not_network_count(): reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK) spent = fees._spent_fee( @@ -62,6 +76,17 @@ def test_coldkey_wide_empty_baskets_floor_to_hotkey_count(): assert spent.rao == fees._APPROX_REDEEM_FEE_RAO * 100 +def test_single_hotkey_spent_scales_against_its_smaller_declaration(): + declared_work = fees._MAX_ROOT_CLAIM_HOTKEY_WORK + reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * declared_work) + spent = fees._spent_fee( + reserved, + fees.RootClaimWork(hotkeys=1, redeem_holdings=32, scan_holdings=0), + declared_work=declared_work, + ) + assert spent.rao == fees._APPROX_REDEEM_FEE_RAO * 32 + + def _seed_claim_quote( substrate: FakeSubstrate, *, diff --git a/website/apps/bittensor-website/public/catalog/intents.json b/website/apps/bittensor-website/public/catalog/intents.json index 01d9d444f8..34b4a804d4 100644 --- a/website/apps/bittensor-website/public/catalog/intents.json +++ b/website/apps/bittensor-website/public/catalog/intents.json @@ -556,7 +556,7 @@ { "name": "claim_root_with_hotkey", "summary": "Redeem accrued root dividends (basket shares) for one validator.", - "description": "Redeem accrued root dividends (basket shares) for one validator.\n\nRedeems the signing coldkey's owed shares on the given validator only:\nthat basket pays out pro-rata (subnet alpha holdings are sold to TAO at\nthe current pool price) and the proceeds are staked back to root on the\nsame validator. Other validators' accrued yield is left untouched.\nClaims whose estimated payout is below the chain's claim threshold\n(see `root_claim_threshold`) are silently skipped and keep accruing.\nOrphaned dust holdings in the basket (subnets outside the validator's\ncurrent weight vector, worth less than the same threshold) are\nconsolidated into the fund's root (TAO) slot as a side effect, so the\nper-holding claim fee shrinks over time; curated positions are left to\ncompound. The transaction fee is charged by work actually done:\nholdings redeemed pay full weight, holdings merely scanned pay a small\nper-row cost. The chain reserves a fixed 256-unit declared-work envelope at\ninclusion, independent of the current network count, and refunds the unused\npart after.\n`plan` and `btcli root claim --dry-run` show reserved versus spent,\nwarn when the spent fee exceeds accrued yield, and refuse when free\nTAO cannot cover the reserve.\nPreview per-validator payouts with `root_basket_owed_breakdown`.", + "description": "Redeem accrued root dividends (basket shares) for one validator.\n\nRedeems the signing coldkey's owed shares on the given validator only:\nthat basket pays out pro-rata (subnet alpha holdings are sold to TAO at\nthe current pool price) and the proceeds are staked back to root on the\nsame validator. Other validators' accrued yield is left untouched.\nClaims whose estimated payout is below the chain's claim threshold\n(see `root_claim_threshold`) are silently skipped and keep accruing.\nOrphaned dust holdings in the basket (subnets outside the validator's\ncurrent weight vector, worth less than the same threshold) are\nconsolidated into the fund's root (TAO) slot as a side effect, so the\nper-holding claim fee shrinks over time; curated positions are left to\ncompound. The transaction fee is charged by work actually done:\nholdings redeemed pay full weight, holdings merely scanned pay a small\nper-row cost. The chain reserves the declared-work fee at inclusion\n(a 129-unit single-basket envelope) and refunds the unused part after.\n`plan` and `btcli root claim --dry-run` show reserved versus spent,\nwarn when the spent fee exceeds accrued yield, and refuse when free\nTAO cannot cover the reserve.\nPreview per-validator payouts with `root_basket_owed_breakdown`.", "signer": "coldkey", "origin": "signed", "verify": null,