Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/guides/root-reborn.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ root-stakes to.
deadline and nothing expires.
- **Claim fee.** The inclusion fee scales with how many ALPHA types the
basket holds. The chain reserves the declared-work fee at inclusion
(one unit per existing network, often around τ0.1) and refunds the
unused part after the claim. The amount you actually spend is smaller
(around τ0.057 on a full 128-holding basket).
(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.
Expand Down
11 changes: 6 additions & 5 deletions docs/guides/staking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,12 @@ btcli root claim --hotkey 5F... # claim accrued into stake onl
```

The claim fee scales with how many ALPHA types are in the basket. The
chain reserves the declared-work fee at inclusion (often around τ0.1)
and refunds the unused part after. 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.
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.

Per-validator payouts below the claim threshold (default 500,000 rao) are
skipped and keep accruing — there is no deadline. See
Expand Down
4 changes: 2 additions & 2 deletions docs/tx/claim-root-with-hotkey.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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 the declared-work fee at inclusion
(one unit per existing network) and refunds the unused part after.
(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.
Expand Down Expand Up @@ -80,7 +80,7 @@ result = sub.execute_tool("claim_root_with_hotkey", {...}, wallet)
```rust
#[pallet::call_index(148)]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK)
)]
pub fn claim_root_with_hotkey(
origin: OriginFor<T>,
Expand Down
2 changes: 1 addition & 1 deletion docs/tx/remove-stake-limit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Delegates to [`do_remove_stake_limit`](/code/pallets/subtensor/src/staking/remov
```rust
#[pallet::call_index(148)]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK)
)]
pub fn claim_root_with_hotkey(
origin: OriginFor<T>,
Expand Down
2 changes: 1 addition & 1 deletion docs/tx/unstake-all.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Delegates to [`do_unstake_all`](/code/pallets/subtensor/src/staking/remove_stake
```rust
#[pallet::call_index(148)]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK)
)]
pub fn claim_root_with_hotkey(
origin: OriginFor<T>,
Expand Down
10 changes: 5 additions & 5 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1988,7 +1988,7 @@ mod dispatches {
/// * `RootClaimed`: On successfully claiming the root emissions for this coldkey+hotkey.
#[pallet::call_index(148)]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK)

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] Single-hotkey claims can execute beyond their declared weight

This reduces the declaration to 129 units, but the call still uses root_claim_fits_declared_budget, whose budget remains MAX_ROOT_CLAIM_WORK (256). A single basket can contain historical/deregistered subnet rows beyond the current 128-subnet limit, so a claim with 130–256 rows passes admission and performs more work than declared. That permits under-accounted block execution and resource-exhaustion attacks. Give this call a 129-unit admission check, including its actual basket-row count, or retain the 256-unit declaration.

Suggested change
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_HOTKEY_WORK)
<T as crate::pallet::Config>::WeightInfo::claim_root(crate::MAX_ROOT_CLAIM_WORK)

Comment thread
UnArbosSix marked this conversation as resolved.
)]
pub fn claim_root_with_hotkey(
origin: OriginFor<T>,
Expand Down
6 changes: 3 additions & 3 deletions pallets/subtensor/src/staking/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,9 +749,9 @@ impl<T: Config> Pallet<T> {
(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
}
Expand Down
12 changes: 12 additions & 0 deletions pallets/subtensor/src/tests/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,18 @@ fn test_claim_root_declared_weight_covers_bounded_work() {
NetworksAdded::<Test>::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 = <Test as crate::Config>::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
Expand Down
38 changes: 23 additions & 15 deletions sdk/python/bittensor/intents/_root_claim_fee.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -29,6 +28,7 @@
# 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
Expand Down Expand Up @@ -173,14 +173,21 @@ async def _quote(
holdings += len(rows)

networks = await _existing_network_count(substrate)
declared_work = _MAX_ROOT_CLAIM_WORK if coldkey_wide else _MAX_ROOT_CLAIM_HOTKEY_WORK
threshold_rao = await _threshold_rao(substrate)
free_rao = await _free_rao(substrate, signer_address)
reserved = await _reserved_fee(substrate, signer_address, compose)
reserved = await _reserved_fee(
substrate,
signer_address,
compose,
declared_work=declared_work,
)
spent = _spent_fee(
reserved,
holdings,
accrued_rao < threshold_rao,
hotkey_count=max(len(hotkeys), 1),
declared_work=declared_work,
)

return RootClaimFeeQuote(
Expand Down Expand Up @@ -217,12 +224,14 @@ async def _reserved_fee(
substrate: Any,
signer_address: str,
compose: Callable[[], Awaitable[Any]],
*,
declared_work: int = _MAX_ROOT_CLAIM_WORK,
) -> Balance:
try:
call = await compose()
return await substrate.estimate_fee(call, _FeeView(signer_address))
except Exception:
return Balance.from_rao(_APPROX_REDEEM_FEE_RAO * _MAX_ROOT_CLAIM_WORK)
return Balance.from_rao(_APPROX_REDEEM_FEE_RAO * max(declared_work, 1))


def _spent_fee(
Expand All @@ -231,32 +240,31 @@ def _spent_fee(
scan_only: bool,
*,
hotkey_count: int = 1,
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)
hotkeys = max(hotkey_count, 1)
if scan_only:
scan_weight = (
weight_part
* max(holdings, 0)
* _SCAN_REF_TIME
// (_MAX_ROOT_CLAIM_WORK * _REDEEM_REF_TIME)
weight_part * max(holdings, 0) * _SCAN_REF_TIME // (declared_work * _REDEEM_REF_TIME)
)
walk_weight = weight_part * hotkeys // _MAX_ROOT_CLAIM_WORK
walk_weight = weight_part * hotkeys // declared_work
spent_weight = walk_weight + scan_weight
else:
units = max(holdings, hotkeys)
spent_weight = weight_part * units // _MAX_ROOT_CLAIM_WORK
spent_weight = weight_part * units // declared_work
return Balance.from_rao(min(reserved.rao, base_part + max(spent_weight, 0)))


Expand Down
2 changes: 1 addition & 1 deletion sdk/python/bittensor/intents/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ class ClaimRootWithHotkey(Intent):
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 the declared-work fee at inclusion
(one unit per existing network) and refunds the unused part after.
(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.
Expand Down
28 changes: 27 additions & 1 deletion sdk/python/tests/unit/test_root_claim_fee.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -17,6 +17,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(reserved, holdings=32, scan_only=False)
Expand All @@ -43,3 +57,15 @@ def test_coldkey_wide_empty_baskets_floor_to_hotkey_count():
reserved = Balance.from_rao(fees._APPROX_REDEEM_FEE_RAO * fees._MAX_ROOT_CLAIM_WORK)
spent = fees._spent_fee(reserved, holdings=0, scan_only=False, hotkey_count=100)
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,
holdings=32,
scan_only=False,
declared_work=declared_work,
)
assert spent.rao == fees._APPROX_REDEEM_FEE_RAO * 32
2 changes: 1 addition & 1 deletion website/apps/bittensor-website/public/catalog/intents.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 the declared-work fee at inclusion\n(one unit per existing network) 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`.",
"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,
Expand Down
Loading