diff --git a/pallets/subtensor/src/utils/misc.rs b/pallets/subtensor/src/utils/misc.rs index 7e62e4c7ec..18130ed8c5 100644 --- a/pallets/subtensor/src/utils/misc.rs +++ b/pallets/subtensor/src/utils/misc.rs @@ -780,6 +780,11 @@ impl Pallet { SubnetOwner::::iter_values().any(|owner| *address == owner) } + /// The minimum TAO-equivalent value a stake operation must reach to be accepted. + pub fn get_stake_operation_threshold() -> TaoBalance { + DefaultMinStake::::get() + } + /// The NominatorMinRequiredStake is the factor by which we multiply /// the DefaultMinStake to get nominator minimum stake. With DefaulMinStake /// of 0.001 TAO and NominatorMinRequiredStake of 100_000_000, the diff --git a/precompiles/src/solidity/stakingV2.abi b/precompiles/src/solidity/stakingV2.abi index 69c1e339f2..d81bd593ae 100644 --- a/precompiles/src/solidity/stakingV2.abi +++ b/precompiles/src/solidity/stakingV2.abi @@ -196,6 +196,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "getStakeOperationThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/precompiles/src/solidity/stakingV2.sol b/precompiles/src/solidity/stakingV2.sol index 7cc3c89a7c..56864a9e2d 100644 --- a/precompiles/src/solidity/stakingV2.sol +++ b/precompiles/src/solidity/stakingV2.sol @@ -205,6 +205,15 @@ interface IStaking { */ function getNominatorMinRequiredStake() external view returns (uint256); + /** + * @dev Returns the minimum TAO-equivalent value (in rao) a stake operation must + * reach to be accepted; operations below this threshold are rejected. + * It is a view function, meaning it does not modify the state of the contract and is free to call. + * + * @return The stake operation threshold in rao. + */ + function getStakeOperationThreshold() external view returns (uint256); + /** * @dev Adds a subtensor stake `amount` associated with the `hotkey` within a price limit. * diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 554115ddf0..199ebec082 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -389,6 +389,17 @@ where Ok(stake.into()) } + #[precompile::public("getStakeOperationThreshold()")] + #[precompile::view] + fn get_stake_operation_threshold(handle: &mut impl PrecompileHandle) -> EvmResult { + // The threshold is a runtime constant, but gas accounting conservatively + // charges it as one storage read. + handle.record_db_reads::(1)?; + let threshold = pallet_subtensor::Pallet::::get_stake_operation_threshold(); + + Ok(threshold.to_u64().into()) + } + #[precompile::public("addProxy(bytes32)")] #[precompile::payable] fn add_proxy(handle: &mut impl PrecompileHandle, delegate: H256) -> EvmResult<()> { @@ -1587,6 +1598,134 @@ mod tests { encode_with_selector(selector_u32("getNominatorMinRequiredStake()"), ()), U256::from(pallet_subtensor::Pallet::::get_nominator_min_required_stake()), ); + + let threshold = + pallet_subtensor::Pallet::::get_stake_operation_threshold().to_u64(); + assert!(threshold > 0); + assert_static_call( + &precompiles, + caller, + precompile_addr, + encode_with_selector(selector_u32("getStakeOperationThreshold()"), ()), + U256::from(threshold), + ); + }); + } + + #[test] + fn staking_precompile_v2_add_stake_below_threshold_is_rejected() { + new_test_ext().execute_with(|| { + let netuid = setup_staking_subnet(); + let caller = addr_from_index(0x1010); + let caller_account = mapped_account(caller); + let hotkey = hotkey(); + + fund_account(&caller_account, COLDKEY_BALANCE); + ensure_hotkey_exists(&hotkey); + + let threshold = + pallet_subtensor::Pallet::::get_stake_operation_threshold().to_u64(); + + let rejected = execute_precompile( + &precompiles::>(), + addr_from_index(StakingPrecompileV2::::INDEX), + caller, + encode_with_selector( + selector_u32("addStake(bytes32,uint256,uint256)"), + ( + H256::from_slice(hotkey.as_ref()), + U256::from(threshold - 1), + U256::from(TEST_NETUID_U16), + ), + ), + U256::zero(), + ) + .expect("staking v2 add stake should route to the precompile"); + + assert!(rejected.is_err()); + assert_eq!(stake_for(&hotkey, &caller_account, netuid), 0); + }); + } + + #[test] + fn staking_precompile_v2_transfer_stake_below_threshold_is_rejected() { + new_test_ext().execute_with(|| { + let netuid = setup_staking_subnet(); + let caller = addr_from_index(0x1011); + let caller_account = mapped_account(caller); + let destination_coldkey = AccountId::from([0x33; 32]); + let hotkey = hotkey(); + + fund_account(&caller_account, COLDKEY_BALANCE); + add_stake_v2(caller, &hotkey, TEST_NETUID_U16, INITIAL_STAKE_RAO); + let caller_stake_before = stake_for(&hotkey, &caller_account, netuid); + + let threshold = + pallet_subtensor::Pallet::::get_stake_operation_threshold().to_u64(); + // Alpha priced at ~2 TAO in this pool, so an alpha amount far below the + // threshold is guaranteed to fall short of it in TAO terms. + let below_threshold_alpha = threshold / 100; + assert!(below_threshold_alpha > 0); + + let rejected = execute_precompile( + &precompiles::>(), + addr_from_index(StakingPrecompileV2::::INDEX), + caller, + encode_with_selector( + selector_u32("transferStake(bytes32,bytes32,uint256,uint256,uint256)"), + ( + H256::from_slice(destination_coldkey.as_ref()), + H256::from_slice(hotkey.as_ref()), + U256::from(TEST_NETUID_U16), + U256::from(TEST_NETUID_U16), + U256::from(below_threshold_alpha), + ), + ), + U256::zero(), + ) + .expect("staking v2 transfer stake should route to the precompile"); + + assert!(rejected.is_err()); + assert_eq!(stake_for(&hotkey, &destination_coldkey, netuid), 0); + assert_eq!( + stake_for(&hotkey, &caller_account, netuid), + caller_stake_before + ); + }); + } + + #[test] + fn staking_precompile_v2_transfer_stake_above_threshold_moves_stake() { + new_test_ext().execute_with(|| { + let netuid = setup_staking_subnet(); + let caller = addr_from_index(0x1012); + let caller_account = mapped_account(caller); + let destination_coldkey = AccountId::from([0x33; 32]); + let hotkey = hotkey(); + + fund_account(&caller_account, COLDKEY_BALANCE); + add_stake_v2(caller, &hotkey, TEST_NETUID_U16, INITIAL_STAKE_RAO); + let caller_stake_before = stake_for(&hotkey, &caller_account, netuid); + + precompiles::>() + .prepare_test( + caller, + addr_from_index(StakingPrecompileV2::::INDEX), + encode_with_selector( + selector_u32("transferStake(bytes32,bytes32,uint256,uint256,uint256)"), + ( + H256::from_slice(destination_coldkey.as_ref()), + H256::from_slice(hotkey.as_ref()), + U256::from(TEST_NETUID_U16), + U256::from(TEST_NETUID_U16), + U256::from(TRANSFERRED_ALLOWANCE_RAO), + ), + ), + ) + .execute_returns(()); + + assert!(stake_for(&hotkey, &destination_coldkey, netuid) > 0); + assert!(stake_for(&hotkey, &caller_account, netuid) < caller_stake_before); }); } diff --git a/ts-tests/utils/evm-config.ts b/ts-tests/utils/evm-config.ts index 7ea940d572..b1316245c7 100644 --- a/ts-tests/utils/evm-config.ts +++ b/ts-tests/utils/evm-config.ts @@ -290,6 +290,19 @@ export const IStakingV2ABI = [ stateMutability: "view", type: "function", }, + { + inputs: [], + name: "getStakeOperationThreshold", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ {