Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 1 addition & 10 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ mod dispatches {
use crate::MAX_CRV3_COMMIT_SIZE_BYTES;
use crate::MAX_NUM_ROOT_CLAIMS;
use crate::MAX_ROOT_CLAIM_THRESHOLD;
use crate::MAX_SUBNET_CLAIMS;

/// Dispatchable functions allow users to interact with the pallet and invoke state changes.
/// These functions materialize as "extrinsics", which are often compared to transactions.
Expand Down Expand Up @@ -2169,15 +2168,7 @@ mod dispatches {
) -> DispatchResultWithPostInfo {
let coldkey: T::AccountId = ensure_signed(origin)?;

ensure!(!subnets.is_empty(), Error::<T>::InvalidSubnetNumber);
ensure!(
subnets.len() <= MAX_SUBNET_CLAIMS,
Error::<T>::InvalidSubnetNumber
);

Self::maybe_add_coldkey_index(&coldkey);

let weight = Self::do_root_claim(coldkey, Some(subnets))?;
let weight = Self::do_root_claim_checked(coldkey, subnets)?;
Ok((Some(weight), Pays::Yes).into())
}

Expand Down
15 changes: 15 additions & 0 deletions pallets/subtensor/src/staking/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,21 @@ impl<T: Config> Pallet<T> {
}
}

pub fn do_root_claim_checked(
coldkey: T::AccountId,
subnets: BTreeSet<NetUid>,
) -> Result<Weight, DispatchError> {
ensure!(!subnets.is_empty(), Error::<T>::InvalidSubnetNumber);
ensure!(
subnets.len() <= crate::MAX_SUBNET_CLAIMS,
Error::<T>::InvalidSubnetNumber
);

Self::maybe_add_coldkey_index(&coldkey);

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] Arbitrary coldkeys can pollute the root auto-claim index

claim_root_for accepts any signed origin and passes an arbitrary coldkey into this shared helper. The helper indexes that coldkey before do_root_claim proves the coldkey has any root stake; for a coldkey with no staking hotkeys, try_do_root_claim simply iterates an empty vector, deposits RootClaimed, and commits. A caller can therefore pay only transaction fees to persistently grow StakingColdkeys, StakingColdkeysByIndex, and NumStakingColdkeys with arbitrary accounts, diluting run_auto_claim_root_divs selection and creating unbounded state bloat. Gate the index insertion on actual root stake, or make claim_root_for reject targets without root stake before calling maybe_add_coldkey_index.

Suggested change
Self::maybe_add_coldkey_index(&coldkey);
ensure!(
Self::coldkey_has_root_stake(&coldkey),
Error::<T>::NotEnoughStakeToWithdraw
);
Self::maybe_add_coldkey_index(&coldkey);

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] Arbitrary coldkeys can pollute the root auto-claim index

claim_root_for lets any signed account choose coldkey, and this helper adds that target to StakingColdkeys before proving the target has any root stake. Because try_do_root_claim succeeds even for a coldkey with no staking hotkeys, an attacker can pay fees to persistently add arbitrary coldkeys to the auto-claim sampling set, inflating NumStakingColdkeys and reducing the probability that legitimate root stakers are selected each block. Gate this index insertion on the target currently holding root stake, or only add coldkeys at the root-stake creation paths.


Self::do_root_claim(coldkey, Some(subnets))
}

pub fn do_root_claim(
coldkey: T::AccountId,
subnets: Option<BTreeSet<NetUid>>,
Expand Down
15 changes: 15 additions & 0 deletions precompiles/src/solidity/stakingV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,21 @@ interface IStaking {
uint256 netuid
) external payable;

/**
* @dev Claims the caller's own accrued root (netuid 0) emissions.
*
* The claim is settled for the coldkey derived from the caller's address, so a keyless
* smart contract can claim its own root dividends without an off-chain private key. There
* is no "claim on behalf of another coldkey" variant: a caller can only ever claim for
* itself.
*
* @param subnets The subnets to claim on (at most MAX_SUBNET_CLAIMS entries).
*
* Requirements:
* - `subnets` must be non-empty and contain at most MAX_SUBNET_CLAIMS unique entries.
*/
function claimRoot(uint16[] memory subnets) external;

/**
* @dev Set how much the caller approves the spender to use the provided amount of subnet tokens
* on its behalf in a later call.
Expand Down
46 changes: 46 additions & 0 deletions precompiles/src/staking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ use precompile_utils::EvmResult;
use precompile_utils::prelude::{Address, revert};
use sp_core::{H160, H256, U256};
use sp_runtime::traits::{AsSystemOriginSigner, Dispatchable, StaticLookup, UniqueSaturatedInto};
use sp_std::collections::btree_set::BTreeSet;
use sp_std::vec;
use subtensor_runtime_common::{NetUid, ProxyType, Token};

Expand Down Expand Up @@ -293,6 +294,18 @@ where
handle.try_dispatch_runtime_call::<R, _>(call, RawOrigin::Signed(account_id))
}

#[precompile::public("claimRoot(uint16[])")]
fn claim_root(handle: &mut impl PrecompileHandle, subnets: Vec<u16>) -> EvmResult<()> {
if subnets.len() > pallet_subtensor::MAX_SUBNET_CLAIMS {
return Err(revert("too many subnets"));
}
let account_id = handle.caller_account_id::<R>();
let subnets: BTreeSet<NetUid> = subnets.into_iter().map(NetUid::from).collect();
let call = pallet_subtensor::Call::<R>::claim_root { subnets };

handle.try_dispatch_runtime_call::<R, _>(call, RawOrigin::Signed(account_id))
}

#[precompile::public("getTotalColdkeyStake(bytes32)")]
#[precompile::view]
fn get_total_coldkey_stake(
Expand Down Expand Up @@ -1332,6 +1345,39 @@ mod tests {
});
}

#[test]
fn staking_precompile_v2_claim_root_dispatches_and_bounds_subnets() {
new_test_ext().execute_with(|| {
let caller = addr_from_index(0x1099);
let precompiles = precompiles::<StakingPrecompileV2<Runtime>>();
let precompile_addr = addr_from_index(StakingPrecompileV2::<Runtime>::INDEX);

// Happy path
precompiles
.prepare_test(
caller,
precompile_addr,
encode_with_selector(
selector_u32("claimRoot(uint16[])"),
(vec![1u16, 2u16, 3u16],),
),
)
.execute_returns(());

// Guard: more than MAX_SUBNET_CLAIMS (5) subnets is rejected up front
precompiles
.prepare_test(
caller,
precompile_addr,
encode_with_selector(
selector_u32("claimRoot(uint16[])"),
(vec![1u16, 2u16, 3u16, 4u16, 5u16, 6u16],),
),
)
.execute_reverts(|output| output == b"too many subnets");
});
}

#[test]
fn staking_precompile_v2_add_stake_limit_increases_stake() {
new_test_ext().execute_with(|| {
Expand Down
47 changes: 47 additions & 0 deletions ts-tests/suites/zombienet_evm/06-claim-root-precompile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { beforeAll, describeSuite, expect } from "@moonwall/cli";
import { subtensor } from "@polkadot-api/descriptors";
import { ethers } from "ethers";
import type { TypedApi } from "polkadot-api";
import {
convertH160ToSS58,
createEthersWallet,
disableWhiteListCheck,
forceSetBalance,
ISTAKING_V2_ADDRESS,
IStakingV2ABI,
waitForFinalizedBlocks,
} from "../../utils";

describeSuite({
id: "claim-root-precompile",
title: "Staking V2 precompile: claimRoot",
foundationMethods: "zombie",
testCases: ({ it, context }) => {
let api: TypedApi<typeof subtensor>;
let ethWallet: ethers.Wallet;

beforeAll(async () => {
api = context.papi("Node").getTypedApi(subtensor);
const provider = context.ethers("EVM").provider as ethers.JsonRpcProvider;
ethWallet = createEthersWallet(provider);

await forceSetBalance(api, convertH160ToSS58(ethWallet.address));
await disableWhiteListCheck(api, true);
await waitForFinalizedBlocks(api, 1);
}, 300000);

it({
id: "T01",
title: "claimRoot self-claim dispatches successfully (no-op for an unstaked caller)",
test: async () => {
const staking = new ethers.Contract(ISTAKING_V2_ADDRESS, IStakingV2ABI, ethWallet);

// The precompile dispatches the self `claim_root` under the caller's derived
// coldkey.
const tx = await staking.claimRoot([1, 2, 3]);
const receipt = await tx.wait();
expect(receipt?.status).toBe(1);
},
});
},
});
13 changes: 13 additions & 0 deletions ts-tests/utils/evm-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ export const IStakingV2ABI = [
stateMutability: "payable",
type: "function",
},
{
inputs: [
{
internalType: "uint16[]",
name: "subnets",
type: "uint16[]",
},
],
name: "claimRoot",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{
Expand Down
Loading