diff --git a/frame/dapps-staking/src/lib.rs b/frame/dapps-staking/src/lib.rs index 4c939aff..00390baf 100644 --- a/frame/dapps-staking/src/lib.rs +++ b/frame/dapps-staking/src/lib.rs @@ -36,6 +36,9 @@ //! - `maintenance_mode` - enables or disables pallet maintenance mode //! - `set_reward_destination` - sets reward destination for the staker rewards //! - `set_contract_stake_info` - root-only call to set storage value (used for fixing corrupted data) +//! - `set_rewards_beneficiary` - set the beneficary of a staker's rewards +//! - `remove_rewards_beneficiary` - remove the beneficary of a staker's rewards +//! - `update_rewards_beneficiary` - called by the beneficary and update the beneficary of a staker's rewards to a new account //! //! User is encouraged to refer to specific function implementations for more comprehensive documentation. //! @@ -484,7 +487,7 @@ where } } -/// Instruction on how to handle reward payout for stakers. +/// Instruction on how to handle reward payout for stakers when beneficiary is not set. /// In order to make staking more competitive, majority of stakers will want to /// automatically restake anything they earn. #[derive(Encode, Decode, Clone, Copy, PartialEq, Eq, RuntimeDebug, TypeInfo)] diff --git a/frame/dapps-staking/src/pallet/mod.rs b/frame/dapps-staking/src/pallet/mod.rs index baca18fb..07115e80 100644 --- a/frame/dapps-staking/src/pallet/mod.rs +++ b/frame/dapps-staking/src/pallet/mod.rs @@ -180,6 +180,19 @@ pub mod pallet { ValueQuery, >; + /// Beneficiary of staking rewards on perticular contract. + /// `(staker, contract_id) -> beneficiary_account_id` + #[pallet::storage] + #[pallet::getter(fn staking_beneficiary)] + pub type RewardsBeneficiary = StorageDoubleMap< + _, + Twox64Concat, + T::AccountId, + Blake2_128Concat, + T::SmartContract, + T::AccountId, + >; + /// Stores the current pallet storage version. #[pallet::storage] #[pallet::getter(fn storage_version)] @@ -233,6 +246,12 @@ pub mod pallet { BalanceOf, T::SmartContract, ), + /// Staking rewards beneficiary is set + BeneficiarySet(T::AccountId, T::SmartContract, T::AccountId), + /// Staking rewards beneficiary is removed + BeneficiaryRemoved(T::AccountId, T::SmartContract), + /// Staking rewards beneficiary is updated + BeneficiaryUpdated(T::AccountId, T::SmartContract, T::AccountId), } #[pallet::error] @@ -291,6 +310,12 @@ pub mod pallet { NotActiveStaker, /// Transfering nomination to the same contract NominationTransferToSameContract, + /// There is no beneficiary set for the staker per contract + BeneficiaryNotSet, + /// Not allow non-beneficiary to update + UpdateBeneficiaryNotAllowed, + /// Beneficiary used is not valid + InvalidBeneficiary, } #[pallet::hooks] @@ -709,8 +734,10 @@ pub mod pallet { /// Claim earned staker rewards for the oldest unclaimed era. /// In order to claim multiple eras, this call has to be called multiple times. /// - /// The rewards are always added to the staker's free balance (account) but depending on the reward destination configuration, - /// they might be immediately re-staked. + /// When [`RewardsBeneficiary`] is set, the rewards are added to the beneficiary's free balance and unlocked. + /// When RewardsBeneficiary is not set, the rewards are added to, + /// - staker's free balance but locked with [`RewardDestination`] is StakeBalance + /// - staker's free balance and unlocked with [`RewardDestination`] is FreeBalance #[pallet::weight(T::WeightInfo::claim_staker_with_restake().max(T::WeightInfo::claim_staker_without_restake()))] pub fn claim_staker( origin: OriginFor, @@ -751,7 +778,10 @@ pub mod pallet { staker_info.latest_staked_value(), ); - if should_restake_reward { + let beneficiary = RewardsBeneficiary::::get(&staker, &contract_id); + let mut weight_info = T::WeightInfo::claim_staker_without_restake(); + + if beneficiary.is_none() && should_restake_reward { staker_info .stake(current_era, staker_reward) .map_err(|_| Error::::UnexpectedStakeInfoEra)?; @@ -762,17 +792,7 @@ pub mod pallet { staker_info.len() <= T::MaxEraStakeValues::get(), Error::::TooManyEraStakeValues ); - } - - // Withdraw reward funds from the dapps staking pot - let reward_imbalance = T::Currency::withdraw( - &Self::account_id(), - staker_reward, - WithdrawReasons::TRANSFER, - ExistenceRequirement::AllowDeath, - )?; - if should_restake_reward { ledger.locked = ledger.locked.saturating_add(staker_reward); Self::update_ledger(&staker, ledger); @@ -795,18 +815,24 @@ pub mod pallet { contract_id.clone(), staker_reward, )); + + weight_info = T::WeightInfo::claim_staker_with_restake(); } - T::Currency::resolve_creating(&staker, reward_imbalance); + // Withdraw reward funds from the dapps staking pot + let reward_imbalance = T::Currency::withdraw( + &Self::account_id(), + staker_reward, + WithdrawReasons::TRANSFER, + ExistenceRequirement::AllowDeath, + )?; + + let reward_account = beneficiary.clone().unwrap_or(staker.clone()); + T::Currency::resolve_creating(&reward_account, reward_imbalance); Self::update_staker_info(&staker, &contract_id, staker_info); - Self::deposit_event(Event::::Reward(staker, contract_id, era, staker_reward)); + Self::deposit_event(Event::::Reward(reward_account, contract_id, era, staker_reward)); - Ok(Some(if should_restake_reward { - T::WeightInfo::claim_staker_with_restake() - } else { - T::WeightInfo::claim_staker_without_restake() - }) - .into()) + Ok(Some(weight_info).into()) } /// Claim earned dapp rewards for the specified era. @@ -961,6 +987,79 @@ pub mod pallet { Ok(().into()) } + /// Sets the beneficiary of staking rewards. + /// + /// The dispatch origin is staker. + #[pallet::weight(10_000)] + pub fn set_rewards_beneficiary( + origin: OriginFor, + contract_id: T::SmartContract, + beneficiary: T::AccountId, + ) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let staker = ensure_signed(origin)?; + + ensure!( + Self::is_active(&contract_id), + Error::::NotOperatedContract + ); + ensure!(beneficiary != staker, Error::::InvalidBeneficiary); + + RewardsBeneficiary::::insert(&staker, &contract_id, &beneficiary); + + Self::deposit_event(Event::::BeneficiarySet(staker, contract_id, beneficiary)); + Ok(().into()) + } + + /// Removes the beneficiary of staking rewards. + /// + /// The dispatch origin is staker. + #[pallet::weight(10_000)] + pub fn remove_rewards_beneficiary( + origin: OriginFor, + contract_id: T::SmartContract, + ) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let staker = ensure_signed(origin)?; + + RewardsBeneficiary::::remove(&staker, &contract_id); + + Self::deposit_event(Event::::BeneficiaryRemoved(staker, contract_id)); + Ok(().into()) + } + + /// Updates the beneficiary to a new account. + /// + /// The dispatch origin is the current beneficiary. + #[pallet::weight(10_000)] + pub fn update_rewards_beneficiary( + origin: OriginFor, + staker: T::AccountId, + contract_id: T::SmartContract, + new_beneficiary: T::AccountId, + ) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let sender = ensure_signed(origin)?; + + ensure!( + Self::is_active(&contract_id), + Error::::NotOperatedContract + ); + + RewardsBeneficiary::::try_mutate(&staker, &contract_id, |maybe_beneficiary| -> DispatchResultWithPostInfo { + let beneficiary = maybe_beneficiary.as_mut().ok_or(Error::::BeneficiaryNotSet)?; + ensure!(sender == *beneficiary, Error::::UpdateBeneficiaryNotAllowed); + ensure!(new_beneficiary != staker, Error::::InvalidBeneficiary); + + *beneficiary = new_beneficiary.clone(); + + Ok(().into()) + })?; + + Self::deposit_event(Event::::BeneficiaryUpdated(staker, contract_id, new_beneficiary)); + Ok(().into()) + } + /// Used to force set `ContractEraStake` storage values. /// The purpose of this call is only for fixing one of the issues detected with dapps-staking. /// diff --git a/frame/dapps-staking/src/testing_utils.rs b/frame/dapps-staking/src/testing_utils.rs index 88951543..89eb6da2 100644 --- a/frame/dapps-staking/src/testing_utils.rs +++ b/frame/dapps-staking/src/testing_utils.rs @@ -10,6 +10,8 @@ pub(crate) struct MemorySnapshot { staker_info: StakerInfo, contract_info: ContractStakeInfo, free_balance: Balance, + beneficiary: Option, + beneficiary_free_balance: Balance, ledger: AccountLedger, } @@ -27,6 +29,10 @@ impl MemorySnapshot { contract_info: DappsStaking::contract_stake_info(contract_id, era).unwrap_or_default(), ledger: DappsStaking::ledger(&account), free_balance: ::Currency::free_balance(&account), + beneficiary: RewardsBeneficiary::::get(&account, &contract_id), + beneficiary_free_balance: RewardsBeneficiary::::get(&account, &contract_id) + .map(|acc| ::Currency::free_balance(&acc)) + .unwrap_or_default(), } } @@ -40,6 +46,8 @@ impl MemorySnapshot { contract_info: DappsStaking::contract_stake_info(contract_id, era).unwrap_or_default(), ledger: Default::default(), free_balance: Default::default(), + beneficiary: None, + beneficiary_free_balance: Default::default(), } } } @@ -528,7 +536,7 @@ pub(crate) fn assert_claim_staker(claimer: AccountId, contract_id: &MockSmartCon ); // check for stake event if restaking is performed - if DappsStaking::should_restake_reward( + if init_state_current_era.beneficiary.is_none() && DappsStaking::should_restake_reward( init_state_current_era.ledger.reward_destination, init_state_current_era.dapp_info.state, init_state_current_era.staker_info.latest_staked_value(), @@ -544,8 +552,9 @@ pub(crate) fn assert_claim_staker(claimer: AccountId, contract_id: &MockSmartCon } // last event should be Reward, regardless of restaking + let reward_account = init_state_current_era.beneficiary.unwrap_or(claimer); System::assert_last_event(mock::Event::DappsStaking(Event::Reward( - claimer, + reward_account, contract_id.clone(), claim_era, calculated_reward, @@ -582,46 +591,71 @@ fn assert_restake_reward( final_state_current_era: &MemorySnapshot, reward: Balance, ) { - if DappsStaking::should_restake_reward( + let beneficiary = final_state_current_era.beneficiary; + let restake = DappsStaking::should_restake_reward( init_state_current_era.ledger.reward_destination, init_state_current_era.dapp_info.state, init_state_current_era.staker_info.latest_staked_value(), - ) { - // staked values should increase - assert_eq!( - init_state_current_era.staker_info.latest_staked_value() + reward, - final_state_current_era.staker_info.latest_staked_value() - ); - assert_eq!( - init_state_current_era.era_info.staked + reward, - final_state_current_era.era_info.staked - ); - assert_eq!( - init_state_current_era.era_info.locked + reward, - final_state_current_era.era_info.locked - ); - assert_eq!( - init_state_current_era.contract_info.total + reward, - final_state_current_era.contract_info.total - ); - } else { - // staked values should remain the same, and free balance increase - assert_eq!( - init_state_current_era.free_balance + reward, - final_state_current_era.free_balance - ); - assert_eq!( - init_state_current_era.era_info.staked, - final_state_current_era.era_info.staked - ); - assert_eq!( - init_state_current_era.era_info.locked, - final_state_current_era.era_info.locked - ); - assert_eq!( - init_state_current_era.contract_info, - final_state_current_era.contract_info - ); + ); + + match (beneficiary, restake) { + (Some(_), _) => { + // staked values should remain the same, and free balance increase + assert_eq!( + init_state_current_era.beneficiary_free_balance + reward, + final_state_current_era.beneficiary_free_balance + ); + assert_eq!( + init_state_current_era.era_info.staked, + final_state_current_era.era_info.staked + ); + assert_eq!( + init_state_current_era.era_info.locked, + final_state_current_era.era_info.locked + ); + assert_eq!( + init_state_current_era.contract_info, + final_state_current_era.contract_info + ); + }, + (None, true) => { + // staked values should increase + assert_eq!( + init_state_current_era.staker_info.latest_staked_value() + reward, + final_state_current_era.staker_info.latest_staked_value() + ); + assert_eq!( + init_state_current_era.era_info.staked + reward, + final_state_current_era.era_info.staked + ); + assert_eq!( + init_state_current_era.era_info.locked + reward, + final_state_current_era.era_info.locked + ); + assert_eq!( + init_state_current_era.contract_info.total + reward, + final_state_current_era.contract_info.total + ); + }, + (None, false) => { + // staked values should remain the same, and the staker's free balance increase + assert_eq!( + init_state_current_era.free_balance + reward, + final_state_current_era.free_balance + ); + assert_eq!( + init_state_current_era.era_info.staked, + final_state_current_era.era_info.staked + ); + assert_eq!( + init_state_current_era.era_info.locked, + final_state_current_era.era_info.locked + ); + assert_eq!( + init_state_current_era.contract_info, + final_state_current_era.contract_info + ); + } } } diff --git a/frame/dapps-staking/src/tests.rs b/frame/dapps-staking/src/tests.rs index 8debe575..2d692c53 100644 --- a/frame/dapps-staking/src/tests.rs +++ b/frame/dapps-staking/src/tests.rs @@ -1782,6 +1782,58 @@ fn claim_only_payout_is_ok() { }) } +#[test] +fn claim_staker_works_when_beneficiary_set_and_reward_destination_is_free_balance() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let developer = 1; + let staker = 2; + let beneficiary = 3; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + // stake some tokens + let start_era = DappsStaking::current_era(); + assert_register(developer, &contract_id); + let stake_value = 100; + assert_bond_and_stake(staker, &contract_id, stake_value); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + // disable reward restaking + advance_to_era(start_era + 1); + assert_set_reward_destination(staker, RewardDestination::FreeBalance); + + // ensure it's claimed correctly + assert_claim_staker(staker, &contract_id); + }) +} + +#[test] +fn claim_staker_works_when_beneficiary_set_and_reward_destination_is_stake_balance() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let developer = 1; + let staker = 2; + let beneficiary = 3; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + // stake some tokens + let start_era = DappsStaking::current_era(); + assert_register(developer, &contract_id); + let stake_value = 100; + assert_bond_and_stake(staker, &contract_id, stake_value); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + // disable reward restaking + advance_to_era(start_era + 1); + assert_set_reward_destination(staker, RewardDestination::StakeBalance); + + // ensure it's claimed correctly + assert_claim_staker(staker, &contract_id); + }) +} + #[test] fn claim_with_zero_staked_is_ok() { ExternalityBuilder::build().execute_with(|| { @@ -2172,3 +2224,129 @@ pub fn set_contract_stake_info() { ); }) } + +#[test] +pub fn set_rewards_beneficiary_works() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + + assert_ok!(DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary)); + assert_eq!(RewardsBeneficiary::::get(staker, contract_id), Some(beneficiary)); + }) +} + +#[test] +pub fn set_rewards_beneficiary_failed_when_contract_is_not_registered() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_noop!( + DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary), + Error::::NotOperatedContract + ); + }) +} + +#[test] +pub fn set_rewards_beneficiary_failed_when_beneficiary_is_staker() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + + assert_noop!( + DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, staker), + Error::::InvalidBeneficiary + ); + }) +} + +#[test] +pub fn remove_rewards_beneficiary_works() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + assert_ok!(DappsStaking::remove_rewards_beneficiary(Origin::signed(staker), contract_id)); + assert_eq!(RewardsBeneficiary::::get(staker, contract_id), None); + }) +} + +#[test] +pub fn update_rewards_beneficiary_works() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let new_beneficiary = 3; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + assert_ok!(DappsStaking::update_rewards_beneficiary(Origin::signed(beneficiary), staker, contract_id, new_beneficiary)); + assert_eq!(RewardsBeneficiary::::get(staker, contract_id), Some(3)); + }) +} + +#[test] +pub fn update_rewards_beneficiary_failed_when_beneficiary_is_invalid() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let new_beneficiary = 3; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + assert_noop!( + DappsStaking::update_rewards_beneficiary(Origin::signed(new_beneficiary), staker, contract_id, new_beneficiary), + Error::::UpdateBeneficiaryNotAllowed + ); + assert_eq!(RewardsBeneficiary::::get(staker, contract_id), Some(2)); + }) +} + +#[test] +pub fn update_rewards_beneficiary_failed_when_beneficiary_is_staker() { + ExternalityBuilder::build().execute_with(|| { + initialize_first_block(); + + let staker = 1; + let beneficiary = 2; + let contract_id = MockSmartContract::Evm(H160::repeat_byte(0x01)); + + assert_register(10, &contract_id); + let _ = DappsStaking::set_rewards_beneficiary(Origin::signed(staker), contract_id, beneficiary); + + assert_noop!( + DappsStaking::update_rewards_beneficiary(Origin::signed(beneficiary), staker, contract_id, staker), + Error::::InvalidBeneficiary + ); + assert_eq!(RewardsBeneficiary::::get(staker, contract_id), Some(2)); + }) +} +