diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 08e5bb8fdf..e740f9df17 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,7 @@ mod dispatches { ) -> DispatchResultWithPostInfo { let coldkey: T::AccountId = ensure_signed(origin)?; - ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); - ensure!( - subnets.len() <= MAX_SUBNET_CLAIMS, - Error::::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()) } 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/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: [ {