diff --git a/chain-extensions/src/lib.rs b/chain-extensions/src/lib.rs index 14ea23d9c8..a213cafa63 100644 --- a/chain-extensions/src/lib.rs +++ b/chain-extensions/src/lib.rs @@ -16,6 +16,7 @@ use pallet_contracts::chain_extension::{ }; use pallet_subtensor_proxy as pallet_proxy; use pallet_subtensor_proxy::WeightInfo; +use sp_runtime::traits::Zero; use sp_runtime::{DispatchError, Weight, traits::StaticLookup}; use sp_std::marker::PhantomData; use substrate_fixed::types::U96F32; @@ -53,6 +54,28 @@ where } } +// Verify that delegate is an authorized zero-delay proxy for on_behalf_of +fn verify_proxy( + on_behalf_of: &T::AccountId, + delegate: &T::AccountId, + required_proxy_type: ProxyType, +) -> Result<(), DispatchError> +where + T: pallet_proxy::Config, +{ + let (proxies, _) = pallet_proxy::Proxies::::get(on_behalf_of); + proxies + .iter() + .find(|p| { + p.delegate == *delegate + && p.delay.is_zero() + && (p.proxy_type == required_proxy_type + || pallet_proxy::Pallet::::is_superset(p.proxy_type, required_proxy_type)) + }) + .ok_or(DispatchError::Other("Not authorized proxy"))?; + Ok(()) +} + impl SubtensorChainExtension where T: pallet_subtensor::Config @@ -523,6 +546,392 @@ where Ok(RetVal::Converging(Output::Success as u32)) } + FunctionId::ProxyAddStakeV1 => { + let weight = Weight::from_parts(340_800_000, 0) + .saturating_add(T::DbWeight::get().reads(25_u64)) + .saturating_add(T::DbWeight::get().writes(15)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, netuid, amount_staked): ( + T::AccountId, + T::AccountId, + NetUid, + TaoBalance, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::add_stake( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + netuid, + amount_staked, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyRemoveStakeV1 => { + let weight = Weight::from_parts(196_800_000, 0) + .saturating_add(T::DbWeight::get().reads(20)) + .saturating_add(T::DbWeight::get().writes(10)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, netuid, amount_unstaked): ( + T::AccountId, + T::AccountId, + NetUid, + AlphaBalance, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::remove_stake( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + netuid, + amount_unstaked, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyUnstakeAllV1 => { + let weight = Weight::from_parts(28_830_000, 0) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().writes(0)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey): (T::AccountId, T::AccountId) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::unstake_all( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyUnstakeAllAlphaV1 => { + let weight = Weight::from_parts(358_500_000, 0) + .saturating_add(T::DbWeight::get().reads(37_u64)) + .saturating_add(T::DbWeight::get().writes(21_u64)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey): (T::AccountId, T::AccountId) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::unstake_all_alpha( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyMoveStakeV1 => { + let weight = Weight::from_parts(164_300_000, 0) + .saturating_add(T::DbWeight::get().reads(16_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)); + env.charge_weight(weight)?; + + let ( + on_behalf_of, + origin_hotkey, + destination_hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + ): ( + T::AccountId, + T::AccountId, + T::AccountId, + NetUid, + NetUid, + AlphaBalance, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::move_stake( + RawOrigin::Signed(on_behalf_of).into(), + origin_hotkey, + destination_hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyTransferStakeV1 => { + let weight = Weight::from_parts(160_300_000, 0) + .saturating_add(T::DbWeight::get().reads(14_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)); + env.charge_weight(weight)?; + + let ( + on_behalf_of, + destination_coldkey, + hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + ): ( + T::AccountId, + T::AccountId, + T::AccountId, + NetUid, + NetUid, + AlphaBalance, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Transfer)?; + + let call_result = pallet_subtensor::Pallet::::transfer_stake( + RawOrigin::Signed(on_behalf_of).into(), + destination_coldkey, + hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxySwapStakeV1 => { + let weight = Weight::from_parts(351_300_000, 0) + .saturating_add(T::DbWeight::get().reads(36_u64)) + .saturating_add(T::DbWeight::get().writes(22_u64)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, origin_netuid, destination_netuid, alpha_amount): ( + T::AccountId, + T::AccountId, + NetUid, + NetUid, + AlphaBalance, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::swap_stake( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyAddStakeLimitV1 => { + let weight = Weight::from_parts(402_900_000, 0) + .saturating_add(T::DbWeight::get().reads(25_u64)) + .saturating_add(T::DbWeight::get().writes(15)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, netuid, amount_staked, limit_price, allow_partial): ( + T::AccountId, + T::AccountId, + NetUid, + TaoBalance, + TaoBalance, + bool, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::add_stake_limit( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + netuid, + amount_staked, + limit_price, + allow_partial, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyRemoveStakeLimitV1 => { + let weight = Weight::from_parts(377_400_000, 0) + .saturating_add(T::DbWeight::get().reads(29_u64)) + .saturating_add(T::DbWeight::get().writes(14)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, netuid, amount_unstaked, limit_price, allow_partial): ( + T::AccountId, + T::AccountId, + NetUid, + AlphaBalance, + TaoBalance, + bool, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::remove_stake_limit( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + netuid, + amount_unstaked, + limit_price, + allow_partial, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxySwapStakeLimitV1 => { + let weight = Weight::from_parts(411_500_000, 0) + .saturating_add(T::DbWeight::get().reads(36_u64)) + .saturating_add(T::DbWeight::get().writes(22_u64)); + env.charge_weight(weight)?; + + let ( + on_behalf_of, + hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + limit_price, + allow_partial, + ): ( + T::AccountId, + T::AccountId, + NetUid, + NetUid, + AlphaBalance, + TaoBalance, + bool, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::swap_stake_limit( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + origin_netuid, + destination_netuid, + alpha_amount, + limit_price, + allow_partial, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxyRemoveStakeFullLimitV1 => { + let weight = Weight::from_parts(395_300_000, 0) + .saturating_add(T::DbWeight::get().reads(29_u64)) + .saturating_add(T::DbWeight::get().writes(14_u64)); + env.charge_weight(weight)?; + + let (on_behalf_of, hotkey, netuid, limit_price): ( + T::AccountId, + T::AccountId, + NetUid, + Option, + ) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::remove_stake_full_limit( + RawOrigin::Signed(on_behalf_of).into(), + hotkey, + netuid, + limit_price, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } + FunctionId::ProxySetColdkeyAutoStakeHotkeyV1 => { + let weight = Weight::from_parts(29_930_000, 0) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)); + env.charge_weight(weight)?; + + let (on_behalf_of, netuid, hotkey): (T::AccountId, NetUid, T::AccountId) = env + .read_as() + .map_err(|_| DispatchError::Other("Failed to decode input parameters"))?; + + let caller = env.caller(); + verify_proxy::(&on_behalf_of, &caller, ProxyType::Staking)?; + + let call_result = pallet_subtensor::Pallet::::set_coldkey_auto_stake_hotkey( + RawOrigin::Signed(on_behalf_of).into(), + netuid, + hotkey, + ); + + match call_result { + Ok(_) => Ok(RetVal::Converging(Output::Success as u32)), + Err(e) => Ok(RetVal::Converging(Output::from(e) as u32)), + } + } } } } diff --git a/chain-extensions/src/tests.rs b/chain-extensions/src/tests.rs index b8956e8659..6d040e7124 100644 --- a/chain-extensions/src/tests.rs +++ b/chain-extensions/src/tests.rs @@ -1005,3 +1005,611 @@ fn get_alpha_price_returns_encoded_price() { ); }); } + +mod proxy_dispatch_tests { + use super::*; + + fn setup_proxy( + delegator: &AccountId, + delegate: &AccountId, + proxy_type: subtensor_runtime_common::ProxyType, + delay: u64, + ) { + pallet_subtensor::Pallet::::add_balance_to_coldkey_account( + delegator, + TaoBalance::from(1_000_000_000), + ); + assert_ok!(pallet_subtensor_proxy::Pallet::::add_proxy( + RawOrigin::Signed(*delegator).into(), + *delegate, + proxy_type, + delay, + )); + } + + fn setup_staked_env( + stake_multiplier: u64, + ) -> (AccountId, AccountId, AccountId, AccountId, u64, u16) { + let owner_hotkey = U256::from(10001); + let owner_coldkey = U256::from(10002); + let real_coldkey = U256::from(10101); + let contract = U256::from(10102); + let hotkey = U256::from(10202); + let min_stake = DefaultMinStake::::get(); + let amount_raw = min_stake.to_u64().saturating_mul(stake_multiplier); + + let netuid = mock::add_dynamic_network(&owner_hotkey, &owner_coldkey); + mock::setup_reserves( + netuid, + amount_raw.saturating_mul(1_000).into(), + AlphaBalance::from(amount_raw.saturating_mul(10_000)), + ); + mock::register_ok_neuron(netuid, hotkey, real_coldkey, 0); + + pallet_subtensor::Pallet::::add_balance_to_coldkey_account( + &real_coldkey, + TaoBalance::from(amount_raw.saturating_add(1_000_000_000)), + ); + + ( + real_coldkey, + contract, + hotkey, + U256::from(10303), + amount_raw, + netuid.into(), + ) + } + + fn stake_and_setup( + real_coldkey: &AccountId, + contract: &AccountId, + hotkey: &AccountId, + netuid: u16, + amount_raw: u64, + ) { + assert_ok!(pallet_subtensor::Pallet::::add_stake( + RawOrigin::Signed(*real_coldkey).into(), + *hotkey, + netuid.into(), + amount_raw.into(), + )); + mock::remove_stake_rate_limit_for_tests(hotkey, real_coldkey, netuid.into()); + setup_proxy( + real_coldkey, + contract, + subtensor_runtime_common::ProxyType::Staking, + 0, + ); + } + + // ---- Proxy verification tests ---- + + #[test] + fn proxy_add_stake_with_staking_proxy_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Staking, + 0, + ); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + + let total_stake = + pallet_subtensor::Pallet::::get_total_stake_for_hotkey(&hotkey); + assert!(total_stake > TaoBalance::ZERO); + }); + } + + #[test] + fn proxy_add_stake_with_any_proxy_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Any, + 0, + ); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_add_stake_without_proxy_fails() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env); + assert!(matches!( + ret, + Err(DispatchError::Other("Not authorized proxy")) + )); + }); + } + + #[test] + fn proxy_add_stake_with_wrong_proxy_type_fails() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Senate, + 0, + ); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env); + assert!(matches!( + ret, + Err(DispatchError::Other("Not authorized proxy")) + )); + }); + } + + #[test] + fn proxy_add_stake_with_delayed_proxy_fails() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Staking, + 10, + ); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env); + assert!(matches!( + ret, + Err(DispatchError::Other("Not authorized proxy")) + )); + }); + } + + // ---- Per-operation success tests ---- + + #[test] + fn proxy_remove_stake_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let alpha_before = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let unstake: AlphaBalance = (alpha_before.to_u64() / 2).into(); + + let input = (real_coldkey, hotkey, NetUid::from(netuid), unstake).encode(); + let mut env = MockEnv::new(FunctionId::ProxyRemoveStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + + let alpha_after = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + assert!(alpha_after < alpha_before); + }); + } + + #[test] + fn proxy_unstake_all_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let input = (real_coldkey, hotkey).encode(); + let mut env = MockEnv::new(FunctionId::ProxyUnstakeAllV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_unstake_all_alpha_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let input = (real_coldkey, hotkey).encode(); + let mut env = MockEnv::new(FunctionId::ProxyUnstakeAllAlphaV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_move_stake_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let owner2_hotkey = U256::from(20001); + let owner2_coldkey = U256::from(20002); + let netuid2: u16 = mock::add_dynamic_network(&owner2_hotkey, &owner2_coldkey).into(); + let dest_hotkey = U256::from(20003); + mock::register_ok_neuron(netuid2.into(), dest_hotkey, real_coldkey, 0); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let move_amount: AlphaBalance = (alpha.to_u64() / 3).into(); + + let input = ( + real_coldkey, + hotkey, + dest_hotkey, + NetUid::from(netuid), + NetUid::from(netuid2), + move_amount, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyMoveStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_transfer_stake_with_transfer_proxy_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, dest_coldkey, amount_raw, netuid) = + setup_staked_env(250); + + assert_ok!(pallet_subtensor::Pallet::::add_stake( + RawOrigin::Signed(real_coldkey).into(), + hotkey, + netuid.into(), + amount_raw.into(), + )); + mock::remove_stake_rate_limit_for_tests(&hotkey, &real_coldkey, netuid.into()); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Transfer, + 0, + ); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let transfer_amount: AlphaBalance = (alpha.to_u64() / 3).into(); + + let input = ( + real_coldkey, + dest_coldkey, + hotkey, + NetUid::from(netuid), + NetUid::from(netuid), + transfer_amount, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyTransferStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + + let dest_alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &dest_coldkey, + netuid.into(), + ); + assert_eq!(dest_alpha, transfer_amount); + }); + } + + #[test] + fn proxy_transfer_stake_with_staking_proxy_fails() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, dest_coldkey, amount_raw, netuid) = + setup_staked_env(250); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + + let input = ( + real_coldkey, + dest_coldkey, + hotkey, + NetUid::from(netuid), + NetUid::from(netuid), + AlphaBalance::from(alpha.to_u64() / 3), + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyTransferStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env); + assert!(matches!( + ret, + Err(DispatchError::Other("Not authorized proxy")) + )); + }); + } + + #[test] + fn proxy_swap_stake_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let owner2_hotkey = U256::from(20001); + let owner2_coldkey = U256::from(20002); + let netuid2: u16 = mock::add_dynamic_network(&owner2_hotkey, &owner2_coldkey).into(); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let swap_amount: AlphaBalance = (alpha.to_u64() / 3).into(); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + NetUid::from(netuid2), + swap_amount, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxySwapStakeV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_add_stake_limit_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Staking, + 0, + ); + + let limit_price = TaoBalance::from(u64::MAX); + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + TaoBalance::from(amount_raw), + limit_price, + true, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyAddStakeLimitV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_remove_stake_limit_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let limit_price = TaoBalance::from(0u64); + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + AlphaBalance::from(alpha.to_u64() / 2), + limit_price, + true, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyRemoveStakeLimitV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_set_coldkey_auto_stake_hotkey_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, _, netuid) = setup_staked_env(10); + setup_proxy( + &real_coldkey, + &contract, + subtensor_runtime_common::ProxyType::Staking, + 0, + ); + + pallet_subtensor::Owner::::insert(hotkey, real_coldkey); + pallet_subtensor::OwnedHotkeys::::insert(real_coldkey, vec![hotkey]); + pallet_subtensor::Uids::::insert(NetUid::from(netuid), hotkey, 0u16); + + let input = (real_coldkey, NetUid::from(netuid), hotkey).encode(); + let mut env = MockEnv::new( + FunctionId::ProxySetColdkeyAutoStakeHotkeyV1, + contract, + input, + ); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + + assert_eq!( + pallet_subtensor::AutoStakeDestination::::get( + real_coldkey, + NetUid::from(netuid) + ), + Some(hotkey) + ); + }); + } + + #[test] + fn proxy_swap_stake_limit_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let owner2_hotkey = U256::from(20001); + let owner2_coldkey = U256::from(20002); + let netuid2: u16 = mock::add_dynamic_network(&owner2_hotkey, &owner2_coldkey).into(); + + let alpha = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + let swap_amount: AlphaBalance = (alpha.to_u64() / 3).into(); + let limit_price = TaoBalance::from(0u64); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + NetUid::from(netuid2), + swap_amount, + limit_price, + true, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxySwapStakeLimitV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + }); + } + + #[test] + fn proxy_remove_stake_full_limit_succeeds() { + mock::new_test_ext(1).execute_with(|| { + let (real_coldkey, contract, hotkey, _, amount_raw, netuid) = setup_staked_env(200); + stake_and_setup(&real_coldkey, &contract, &hotkey, netuid, amount_raw); + + let input = ( + real_coldkey, + hotkey, + NetUid::from(netuid), + Option::::None, + ) + .encode(); + let mut env = MockEnv::new(FunctionId::ProxyRemoveStakeFullLimitV1, contract, input); + + let ret = SubtensorChainExtension::::dispatch(&mut env).unwrap(); + assert_success(ret); + + let alpha_after = + pallet_subtensor::Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &real_coldkey, + netuid.into(), + ); + assert!(alpha_after.is_zero()); + }); + } +} + +#[test] +fn proxy_variants_have_stable_discriminants() { + assert_eq!(FunctionId::ProxyAddStakeV1 as u16, 16); + assert_eq!(FunctionId::ProxyRemoveStakeV1 as u16, 17); + assert_eq!(FunctionId::ProxyUnstakeAllV1 as u16, 18); + assert_eq!(FunctionId::ProxyUnstakeAllAlphaV1 as u16, 19); + assert_eq!(FunctionId::ProxyMoveStakeV1 as u16, 20); + assert_eq!(FunctionId::ProxyTransferStakeV1 as u16, 21); + assert_eq!(FunctionId::ProxySwapStakeV1 as u16, 22); + assert_eq!(FunctionId::ProxyAddStakeLimitV1 as u16, 23); + assert_eq!(FunctionId::ProxyRemoveStakeLimitV1 as u16, 24); + assert_eq!(FunctionId::ProxySwapStakeLimitV1 as u16, 25); + assert_eq!(FunctionId::ProxyRemoveStakeFullLimitV1 as u16, 26); + assert_eq!(FunctionId::ProxySetColdkeyAutoStakeHotkeyV1 as u16, 27); +} + +#[test] +fn proxy_ids_roundtrip_try_from_primitive() { + for id in 16u16..=27u16 { + let func_id = FunctionId::try_from(id).unwrap_or_else(|_| { + panic!("FunctionId::try_from({id}) should succeed for proxy variant") + }); + assert_eq!(func_id as u16, id); + } +} diff --git a/chain-extensions/src/types.rs b/chain-extensions/src/types.rs index ee6298ad5b..7aa8c3375b 100644 --- a/chain-extensions/src/types.rs +++ b/chain-extensions/src/types.rs @@ -21,6 +21,18 @@ pub enum FunctionId { AddProxyV1 = 13, RemoveProxyV1 = 14, GetAlphaPriceV1 = 15, + ProxyAddStakeV1 = 16, + ProxyRemoveStakeV1 = 17, + ProxyUnstakeAllV1 = 18, + ProxyUnstakeAllAlphaV1 = 19, + ProxyMoveStakeV1 = 20, + ProxyTransferStakeV1 = 21, + ProxySwapStakeV1 = 22, + ProxyAddStakeLimitV1 = 23, + ProxyRemoveStakeLimitV1 = 24, + ProxySwapStakeLimitV1 = 25, + ProxyRemoveStakeFullLimitV1 = 26, + ProxySetColdkeyAutoStakeHotkeyV1 = 27, } #[derive(PartialEq, Eq, Copy, Clone, Encode, Decode, Debug)] @@ -66,6 +78,8 @@ pub enum Output { ProxyNoSelfProxy = 18, /// Proxy relationship not found ProxyNotFound = 19, + /// Caller is not an authorized proxy for the specified account + NotAuthorizedProxy = 20, } impl From for Output { @@ -93,6 +107,7 @@ impl From for Output { Some("Duplicate") => Output::ProxyDuplicate, Some("NoSelfProxy") => Output::ProxyNoSelfProxy, Some("NotFound") => Output::ProxyNotFound, + Some("NotProxy") => Output::NotAuthorizedProxy, _ => Output::RuntimeError, } } diff --git a/contract-tests/bittensor/lib.rs b/contract-tests/bittensor/lib.rs index 8867d017d8..047f5a0435 100755 --- a/contract-tests/bittensor/lib.rs +++ b/contract-tests/bittensor/lib.rs @@ -22,6 +22,18 @@ pub enum FunctionId { AddProxyV1 = 13, RemoveProxyV1 = 14, GetAlphaPriceV1 = 15, + ProxyAddStakeV1 = 16, + ProxyRemoveStakeV1 = 17, + ProxyUnstakeAllV1 = 18, + ProxyUnstakeAllAlphaV1 = 19, + ProxyMoveStakeV1 = 20, + ProxyTransferStakeV1 = 21, + ProxySwapStakeV1 = 22, + ProxyAddStakeLimitV1 = 23, + ProxyRemoveStakeLimitV1 = 24, + ProxySwapStakeLimitV1 = 25, + ProxyRemoveStakeFullLimitV1 = 26, + ProxySetColdkeyAutoStakeHotkeyV1 = 27, } #[ink::chain_extension(extension = 0x1000)] @@ -130,6 +142,109 @@ pub trait RuntimeReadWrite { #[ink(function = 15)] fn get_alpha_price(netuid: u16) -> u64; + + #[ink(function = 16)] + fn proxy_add_stake( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + netuid: u16, + amount: u64, + ); + + #[ink(function = 17)] + fn proxy_remove_stake( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + netuid: u16, + amount: u64, + ); + + #[ink(function = 18)] + fn proxy_unstake_all( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + ); + + #[ink(function = 19)] + fn proxy_unstake_all_alpha( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + ); + + #[ink(function = 20)] + fn proxy_move_stake( + on_behalf_of: ::AccountId, + origin_hotkey: ::AccountId, + destination_hotkey: ::AccountId, + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ); + + #[ink(function = 21)] + fn proxy_transfer_stake( + on_behalf_of: ::AccountId, + destination_coldkey: ::AccountId, + hotkey: ::AccountId, + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ); + + #[ink(function = 22)] + fn proxy_swap_stake( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ); + + #[ink(function = 23)] + fn proxy_add_stake_limit( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ); + + #[ink(function = 24)] + fn proxy_remove_stake_limit( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ); + + #[ink(function = 25)] + fn proxy_swap_stake_limit( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ); + + #[ink(function = 26)] + fn proxy_remove_stake_full_limit( + on_behalf_of: ::AccountId, + hotkey: ::AccountId, + netuid: u16, + limit_price: u64, + ); + + #[ink(function = 27)] + fn proxy_set_coldkey_auto_stake_hotkey( + on_behalf_of: ::AccountId, + netuid: u16, + hotkey: ::AccountId, + ); } #[ink::scale_derive(Encode, Decode, TypeInfo)] @@ -412,5 +527,229 @@ mod bittensor { .get_alpha_price(netuid) .map_err(|_e| ReadWriteErrorCode::ReadFailed) } + + // Proxy-delegated variants + + #[ink(message)] + pub fn proxy_add_stake( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + netuid: u16, + amount: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_add_stake(on_behalf_of.into(), hotkey.into(), netuid, amount) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_remove_stake( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + netuid: u16, + amount: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_remove_stake(on_behalf_of.into(), hotkey.into(), netuid, amount) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_unstake_all( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_unstake_all(on_behalf_of.into(), hotkey.into()) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_unstake_all_alpha( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_unstake_all_alpha(on_behalf_of.into(), hotkey.into()) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_move_stake( + &self, + on_behalf_of: [u8; 32], + origin_hotkey: [u8; 32], + destination_hotkey: [u8; 32], + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_move_stake( + on_behalf_of.into(), + origin_hotkey.into(), + destination_hotkey.into(), + origin_netuid, + destination_netuid, + amount, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_transfer_stake( + &self, + on_behalf_of: [u8; 32], + destination_coldkey: [u8; 32], + hotkey: [u8; 32], + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_transfer_stake( + on_behalf_of.into(), + destination_coldkey.into(), + hotkey.into(), + origin_netuid, + destination_netuid, + amount, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_swap_stake( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_swap_stake( + on_behalf_of.into(), + hotkey.into(), + origin_netuid, + destination_netuid, + amount, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_add_stake_limit( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_add_stake_limit( + on_behalf_of.into(), + hotkey.into(), + netuid, + amount, + limit_price, + allow_partial, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_remove_stake_limit( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_remove_stake_limit( + on_behalf_of.into(), + hotkey.into(), + netuid, + amount, + limit_price, + allow_partial, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_swap_stake_limit( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + limit_price: u64, + allow_partial: bool, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_swap_stake_limit( + on_behalf_of.into(), + hotkey.into(), + origin_netuid, + destination_netuid, + amount, + limit_price, + allow_partial, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_remove_stake_full_limit( + &self, + on_behalf_of: [u8; 32], + hotkey: [u8; 32], + netuid: u16, + limit_price: u64, + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_remove_stake_full_limit( + on_behalf_of.into(), + hotkey.into(), + netuid, + limit_price, + ) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } + + #[ink(message)] + pub fn proxy_set_coldkey_auto_stake_hotkey( + &self, + on_behalf_of: [u8; 32], + netuid: u16, + hotkey: [u8; 32], + ) -> Result<(), ReadWriteErrorCode> { + self.env() + .extension() + .proxy_set_coldkey_auto_stake_hotkey(on_behalf_of.into(), netuid, hotkey.into()) + .map_err(|_e| ReadWriteErrorCode::WriteFailed) + } } } diff --git a/docs/wasm-contracts.md b/docs/wasm-contracts.md index d3a6b5637f..c9e2702662 100644 --- a/docs/wasm-contracts.md +++ b/docs/wasm-contracts.md @@ -43,6 +43,18 @@ Subtensor provides a custom chain extension that allows smart contracts to inter | 12 | `set_coldkey_auto_stake_hotkey` | Configure automatic stake destination | `(NetUid, AccountId)` | Error code | | 13 | `add_proxy` | Add a staking proxy for the caller | `(AccountId)` | Error code | | 14 | `remove_proxy` | Remove a staking proxy for the caller | `(AccountId)` | Error code | +| 16 | `proxy_add_stake` | Delegate stake on behalf of a proxied account | `(AccountId, AccountId, NetUid, TaoBalance)` | Error code | +| 17 | `proxy_remove_stake` | Withdraw stake on behalf of a proxied account | `(AccountId, AccountId, NetUid, AlphaBalance)` | Error code | +| 18 | `proxy_unstake_all` | Unstake all on behalf of a proxied account | `(AccountId, AccountId)` | Error code | +| 19 | `proxy_unstake_all_alpha` | Unstake all alpha on behalf of a proxied account | `(AccountId, AccountId)` | Error code | +| 20 | `proxy_move_stake` | Move stake between hotkeys on behalf of a proxied account | `(AccountId, AccountId, AccountId, NetUid, NetUid, AlphaBalance)` | Error code | +| 21 | `proxy_transfer_stake` | Transfer stake between coldkeys on behalf of a proxied account | `(AccountId, AccountId, AccountId, NetUid, NetUid, AlphaBalance)` | Error code | +| 22 | `proxy_swap_stake` | Swap stake between subnets on behalf of a proxied account | `(AccountId, AccountId, NetUid, NetUid, AlphaBalance)` | Error code | +| 23 | `proxy_add_stake_limit` | Delegate stake with price limit on behalf of a proxied account | `(AccountId, AccountId, NetUid, TaoBalance, TaoBalance, bool)` | Error code | +| 24 | `proxy_remove_stake_limit` | Withdraw stake with price limit on behalf of a proxied account | `(AccountId, AccountId, NetUid, AlphaBalance, TaoBalance, bool)` | Error code | +| 25 | `proxy_swap_stake_limit` | Swap stake with price limit on behalf of a proxied account | `(AccountId, AccountId, NetUid, NetUid, AlphaBalance, TaoBalance, bool)` | Error code | +| 26 | `proxy_remove_stake_full_limit` | Fully withdraw stake on behalf of a proxied account | `(AccountId, AccountId, NetUid, Option)` | Error code | +| 27 | `proxy_set_coldkey_auto_stake_hotkey` | Configure auto-stake on behalf of a proxied account | `(AccountId, NetUid, AccountId)` | Error code | Example usage in your ink! contract: ```rust @@ -85,6 +97,18 @@ Chain extension functions that modify state return error codes as `u32` values. | 17 | `ProxyDuplicate` | Proxy already exists | | 18 | `ProxyNoSelfProxy` | Cannot add self as proxy | | 19 | `ProxyNotFound` | Proxy relationship not found | +| 20 | `NotAuthorizedProxy` | Caller is not an authorized proxy for the account | + +#### Proxy-Delegated Extensions (IDs 16–27) + +Functions 16–27 are proxy-delegated variants of the base staking operations (IDs 1–12). They allow a smart contract that holds a `Staking` (or `Any`) proxy over a coldkey to manage stake on that coldkey's behalf. + +Each proxy function prepends an `on_behalf_of: AccountId` parameter. The chain extension verifies: +1. The contract (caller) is registered as a zero-delay proxy for `on_behalf_of` +2. The proxy type is sufficient for the operation (`Staking` for most, `Transfer` for `proxy_transfer_stake`) +3. `ProxyType::Any` is accepted as a superset of all types + +If proxy authorization fails, the extension returns `NotAuthorizedProxy` (error code 20). ### Call Filter