From 1ee0ac087010b940f3dffa78181f7fec67d45c2e Mon Sep 17 00:00:00 2001 From: Evgeny Svirsky Date: Tue, 23 Jun 2026 15:56:42 +0200 Subject: [PATCH 1/3] Added root_claim_for + precompiles for root_claim/root_claim_for. --- pallets/subtensor/src/macros/dispatches.rs | 29 +++++++--- pallets/subtensor/src/staking/claim_root.rs | 15 +++++ pallets/subtensor/src/tests/claim_root.rs | 63 +++++++++++++++++++++ precompiles/src/solidity/stakingV2.sol | 31 ++++++++++ precompiles/src/staking.rs | 24 ++++++++ 5 files changed, 154 insertions(+), 8 deletions(-) diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 2a22915ee9..c13b3df5ef 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -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. @@ -2169,15 +2168,29 @@ mod dispatches { ) -> DispatchResultWithPostInfo { let coldkey: T::AccountId = ensure_signed(origin)?; - ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); - ensure!( - subnets.len() <= MAX_SUBNET_CLAIMS, - Error::::InvalidSubnetNumber - ); + let weight = Self::do_root_claim_checked(coldkey, subnets)?; + Ok((Some(weight), Pays::Yes).into()) + } - Self::maybe_add_coldkey_index(&coldkey); + /// --- Claims the root emissions on behalf of any coldkey. + /// # Args: + /// * 'origin': any signed account; only authorizes (and pays for) the call. + /// * 'coldkey': the coldkey whose claims are settled; all realized value is credited here. + /// * 'subnets': the subnets to claim on (1..=MAX_SUBNET_CLAIMS). + /// + /// # Event: + /// * RootClaimed; + /// - On successfully claiming the root emissions for `coldkey`. + #[pallet::call_index(142)] + #[pallet::weight(::WeightInfo::claim_root())] + pub fn claim_root_for( + origin: OriginFor, + coldkey: T::AccountId, + subnets: BTreeSet, + ) -> DispatchResultWithPostInfo { + let _who: T::AccountId = ensure_signed(origin)?; - let weight = Self::do_root_claim(coldkey, Some(subnets))?; + let weight = Self::do_root_claim_checked(coldkey, subnets)?; Ok((Some(weight), Pays::Yes).into()) } diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 7001369a63..19d95c237d 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -345,6 +345,21 @@ impl Pallet { } } + pub fn do_root_claim_checked( + coldkey: T::AccountId, + subnets: BTreeSet, + ) -> Result { + ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); + ensure!( + subnets.len() <= crate::MAX_SUBNET_CLAIMS, + Error::::InvalidSubnetNumber + ); + + Self::maybe_add_coldkey_index(&coldkey); + + Self::do_root_claim(coldkey, Some(subnets)) + } + pub fn do_root_claim( coldkey: T::AccountId, subnets: Option>, diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 12606c5266..0d2a167675 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -588,6 +588,69 @@ fn test_claim_root_with_changed_stake() { }); } +#[test] +fn test_claim_root_for_credits_target_not_caller() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let hotkey = U256::from(1002); + let contract_coldkey = U256::from(1003); + let keeper = U256::from(2099); + let netuid = add_dynamic_network(&hotkey, &owner_coldkey); + remove_owner_registration_stake(netuid); + + SubtensorModule::set_tao_weight(u64::MAX); + + let root_stake = 2_000_000u64; + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &contract_coldkey, + NetUid::ROOT, + root_stake.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 1_000_000u64.into(), + AlphaBalance::ZERO, + ); + assert!( + RootClaimable::::get(hotkey).contains_key(&netuid), + "claimable must have accrued" + ); + + let root_of = |coldkey: &U256| -> u64 { + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + coldkey, + NetUid::ROOT, + ) + .into() + }; + let contract_root_before = root_of(&contract_coldkey); + assert_eq!(contract_root_before, root_stake); + assert_eq!(root_of(&keeper), 0); + + assert_ok!(SubtensorModule::claim_root_for( + RuntimeOrigin::signed(keeper), + contract_coldkey, + BTreeSet::from([netuid]) + )); + + assert!(root_of(&contract_coldkey) > contract_root_before); + assert_eq!(root_of(&keeper), 0); + assert!(RootClaimed::::get((netuid, &hotkey, &contract_coldkey)) > 0); + assert_eq!(RootClaimed::::get((netuid, &hotkey, &keeper)), 0); + }); +} + #[test] fn test_claim_root_with_drain_emissions_and_swap_claim_type() { new_test_ext(1).execute_with(|| { diff --git a/precompiles/src/solidity/stakingV2.sol b/precompiles/src/solidity/stakingV2.sol index 7cc3c89a7c..23c3ebe73c 100644 --- a/precompiles/src/solidity/stakingV2.sol +++ b/precompiles/src/solidity/stakingV2.sol @@ -311,6 +311,37 @@ interface IStaking { uint256 netuid ) external payable; + /** + * @dev Claims the caller's accrued beta-basket value, realizing it as root stake. + * + * Settles the caller's beta-basket positions on the listed subnets, swapping each to TAO and + * staking the proceeds onto root for the caller. The caller is the EVM account invoking this + * method — for a contract this is the contract's own derived coldkey — so a keyless contract + * (e.g. wrapped staked alpha) can settle its own baskets even though it cannot sign a native + * `claim_root` extrinsic. + * + * @param subnets The subnets to claim on (1..=5 entries). + * + * Requirements: + * - `subnets` must contain between 1 and 5 entries. + */ + function claimRoot(uint16[] memory subnets) external; + + /** + * @dev Claims a target coldkey's accrued beta-basket value on its behalf (permissionless). + * + * Realized value is always credited to `coldkey`, never to the caller; the caller only pays + * the transaction fee. This lets a keeper settle a keyless coldkey that cannot claim for + * itself. + * + * @param coldkey The coldkey whose baskets are claimed; all realized value is credited here. + * @param subnets The subnets to claim on (1..=5 entries). + * + * Requirements: + * - `subnets` must contain between 1 and 5 entries. + */ + function claimRootFor(bytes32 coldkey, 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. diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 554115ddf0..860b3853b7 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -30,6 +30,7 @@ // The allowance is specific to a pair of `(spender, netuid)`, but doesn't specify the `hotkey` which is instead // provided only in `transferStakeFrom`. +use alloc::collections::BTreeSet; use alloc::vec::Vec; use core::marker::PhantomData; use frame_support::Blake2_128Concat; @@ -293,6 +294,29 @@ where handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) } + #[precompile::public("claimRoot(uint16[])")] + fn claim_root(handle: &mut impl PrecompileHandle, subnets: Vec) -> EvmResult<()> { + let account_id = handle.caller_account_id::(); + let subnets: BTreeSet = subnets.into_iter().map(NetUid::from).collect(); + let call = pallet_subtensor::Call::::claim_root { subnets }; + + handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) + } + + #[precompile::public("claimRootFor(bytes32,uint16[])")] + fn claim_root_for( + handle: &mut impl PrecompileHandle, + coldkey: H256, + subnets: Vec, + ) -> EvmResult<()> { + let account_id = handle.caller_account_id::(); + let coldkey = R::AccountId::from(coldkey.0); + let subnets: BTreeSet = subnets.into_iter().map(NetUid::from).collect(); + let call = pallet_subtensor::Call::::claim_root_for { coldkey, subnets }; + + handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) + } + #[precompile::public("getTotalColdkeyStake(bytes32)")] #[precompile::view] fn get_total_coldkey_stake( From 9ac0d2beec724765ceae87c13ec07aeaa8b0392f Mon Sep 17 00:00:00 2001 From: Evgeny Svirsky Date: Tue, 23 Jun 2026 16:36:15 +0200 Subject: [PATCH 2/3] Reverted precompiles --- precompiles/src/solidity/stakingV2.sol | 31 -------------------------- precompiles/src/staking.rs | 24 -------------------- 2 files changed, 55 deletions(-) diff --git a/precompiles/src/solidity/stakingV2.sol b/precompiles/src/solidity/stakingV2.sol index 23c3ebe73c..7cc3c89a7c 100644 --- a/precompiles/src/solidity/stakingV2.sol +++ b/precompiles/src/solidity/stakingV2.sol @@ -311,37 +311,6 @@ interface IStaking { uint256 netuid ) external payable; - /** - * @dev Claims the caller's accrued beta-basket value, realizing it as root stake. - * - * Settles the caller's beta-basket positions on the listed subnets, swapping each to TAO and - * staking the proceeds onto root for the caller. The caller is the EVM account invoking this - * method — for a contract this is the contract's own derived coldkey — so a keyless contract - * (e.g. wrapped staked alpha) can settle its own baskets even though it cannot sign a native - * `claim_root` extrinsic. - * - * @param subnets The subnets to claim on (1..=5 entries). - * - * Requirements: - * - `subnets` must contain between 1 and 5 entries. - */ - function claimRoot(uint16[] memory subnets) external; - - /** - * @dev Claims a target coldkey's accrued beta-basket value on its behalf (permissionless). - * - * Realized value is always credited to `coldkey`, never to the caller; the caller only pays - * the transaction fee. This lets a keeper settle a keyless coldkey that cannot claim for - * itself. - * - * @param coldkey The coldkey whose baskets are claimed; all realized value is credited here. - * @param subnets The subnets to claim on (1..=5 entries). - * - * Requirements: - * - `subnets` must contain between 1 and 5 entries. - */ - function claimRootFor(bytes32 coldkey, 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. diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 860b3853b7..554115ddf0 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -30,7 +30,6 @@ // The allowance is specific to a pair of `(spender, netuid)`, but doesn't specify the `hotkey` which is instead // provided only in `transferStakeFrom`. -use alloc::collections::BTreeSet; use alloc::vec::Vec; use core::marker::PhantomData; use frame_support::Blake2_128Concat; @@ -294,29 +293,6 @@ where handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) } - #[precompile::public("claimRoot(uint16[])")] - fn claim_root(handle: &mut impl PrecompileHandle, subnets: Vec) -> EvmResult<()> { - let account_id = handle.caller_account_id::(); - let subnets: BTreeSet = subnets.into_iter().map(NetUid::from).collect(); - let call = pallet_subtensor::Call::::claim_root { subnets }; - - handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) - } - - #[precompile::public("claimRootFor(bytes32,uint16[])")] - fn claim_root_for( - handle: &mut impl PrecompileHandle, - coldkey: H256, - subnets: Vec, - ) -> EvmResult<()> { - let account_id = handle.caller_account_id::(); - let coldkey = R::AccountId::from(coldkey.0); - let subnets: BTreeSet = subnets.into_iter().map(NetUid::from).collect(); - let call = pallet_subtensor::Call::::claim_root_for { coldkey, subnets }; - - handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) - } - #[precompile::public("getTotalColdkeyStake(bytes32)")] #[precompile::view] fn get_total_coldkey_stake( From b214f4f8b137c3f7e69d99b55a369ab87e6e07f2 Mon Sep 17 00:00:00 2001 From: Evgeny Svirsky Date: Thu, 25 Jun 2026 14:15:56 +0200 Subject: [PATCH 3/3] Added precompile and removed the root_claim_for --- pallets/subtensor/src/macros/dispatches.rs | 22 ------- pallets/subtensor/src/tests/claim_root.rs | 63 ------------------- precompiles/src/solidity/stakingV2.sol | 15 +++++ precompiles/src/staking.rs | 46 ++++++++++++++ .../06-claim-root-precompile.test.ts | 47 ++++++++++++++ ts-tests/utils/evm-config.ts | 13 ++++ 6 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 ts-tests/suites/zombienet_evm/06-claim-root-precompile.test.ts diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index aa1c255f42..e740f9df17 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2172,28 +2172,6 @@ mod dispatches { Ok((Some(weight), Pays::Yes).into()) } - /// --- Claims the root emissions on behalf of any coldkey. - /// # Args: - /// * 'origin': any signed account; only authorizes (and pays for) the call. - /// * 'coldkey': the coldkey whose claims are settled; all realized value is credited here. - /// * 'subnets': the subnets to claim on (1..=MAX_SUBNET_CLAIMS). - /// - /// # Event: - /// * RootClaimed; - /// - On successfully claiming the root emissions for `coldkey`. - #[pallet::call_index(142)] - #[pallet::weight(::WeightInfo::claim_root())] - pub fn claim_root_for( - origin: OriginFor, - coldkey: T::AccountId, - subnets: BTreeSet, - ) -> DispatchResultWithPostInfo { - let _who: T::AccountId = ensure_signed(origin)?; - - let weight = Self::do_root_claim_checked(coldkey, subnets)?; - Ok((Some(weight), Pays::Yes).into()) - } - /// --- Sets the root claim type for the coldkey. /// # Args: /// * 'origin': (Origin): diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 0d2a167675..12606c5266 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -588,69 +588,6 @@ fn test_claim_root_with_changed_stake() { }); } -#[test] -fn test_claim_root_for_credits_target_not_caller() { - new_test_ext(1).execute_with(|| { - let owner_coldkey = U256::from(1001); - let hotkey = U256::from(1002); - let contract_coldkey = U256::from(1003); - let keeper = U256::from(2099); - let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - remove_owner_registration_stake(netuid); - - SubtensorModule::set_tao_weight(u64::MAX); - - let root_stake = 2_000_000u64; - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &contract_coldkey, - NetUid::ROOT, - root_stake.into(), - ); - mock_increase_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - &owner_coldkey, - netuid, - 10_000_000u64.into(), - ); - - SubtensorModule::distribute_emission( - netuid, - AlphaBalance::ZERO, - AlphaBalance::ZERO, - 1_000_000u64.into(), - AlphaBalance::ZERO, - ); - assert!( - RootClaimable::::get(hotkey).contains_key(&netuid), - "claimable must have accrued" - ); - - let root_of = |coldkey: &U256| -> u64 { - SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( - &hotkey, - coldkey, - NetUid::ROOT, - ) - .into() - }; - let contract_root_before = root_of(&contract_coldkey); - assert_eq!(contract_root_before, root_stake); - assert_eq!(root_of(&keeper), 0); - - assert_ok!(SubtensorModule::claim_root_for( - RuntimeOrigin::signed(keeper), - contract_coldkey, - BTreeSet::from([netuid]) - )); - - assert!(root_of(&contract_coldkey) > contract_root_before); - assert_eq!(root_of(&keeper), 0); - assert!(RootClaimed::::get((netuid, &hotkey, &contract_coldkey)) > 0); - assert_eq!(RootClaimed::::get((netuid, &hotkey, &keeper)), 0); - }); -} - #[test] fn test_claim_root_with_drain_emissions_and_swap_claim_type() { new_test_ext(1).execute_with(|| { diff --git a/precompiles/src/solidity/stakingV2.sol b/precompiles/src/solidity/stakingV2.sol index 7cc3c89a7c..cabf4d968e 100644 --- a/precompiles/src/solidity/stakingV2.sol +++ b/precompiles/src/solidity/stakingV2.sol @@ -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. diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 554115ddf0..54fb861aad 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -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}; @@ -293,6 +294,18 @@ where handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) } + #[precompile::public("claimRoot(uint16[])")] + fn claim_root(handle: &mut impl PrecompileHandle, subnets: Vec) -> EvmResult<()> { + if subnets.len() > pallet_subtensor::MAX_SUBNET_CLAIMS { + return Err(revert("too many subnets")); + } + let account_id = handle.caller_account_id::(); + let subnets: BTreeSet = subnets.into_iter().map(NetUid::from).collect(); + let call = pallet_subtensor::Call::::claim_root { subnets }; + + handle.try_dispatch_runtime_call::(call, RawOrigin::Signed(account_id)) + } + #[precompile::public("getTotalColdkeyStake(bytes32)")] #[precompile::view] fn get_total_coldkey_stake( @@ -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::>(); + let precompile_addr = addr_from_index(StakingPrecompileV2::::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(|| { diff --git a/ts-tests/suites/zombienet_evm/06-claim-root-precompile.test.ts b/ts-tests/suites/zombienet_evm/06-claim-root-precompile.test.ts new file mode 100644 index 0000000000..ac86e16d90 --- /dev/null +++ b/ts-tests/suites/zombienet_evm/06-claim-root-precompile.test.ts @@ -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; + 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); + }, + }); + }, +}); diff --git a/ts-tests/utils/evm-config.ts b/ts-tests/utils/evm-config.ts index 7ea940d572..8e5039e5c0 100644 --- a/ts-tests/utils/evm-config.ts +++ b/ts-tests/utils/evm-config.ts @@ -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: [ {