diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs index c0215862ea..82853f402f 100644 --- a/pallets/dapp-staking-v3/src/lib.rs +++ b/pallets/dapp-staking-v3/src/lib.rs @@ -1223,87 +1223,7 @@ pub mod pallet { Self::ensure_pallet_enabled()?; let account = ensure_signed(origin)?; - let mut ledger = Ledger::::get(&account); - let staked_period = ledger - .staked_period() - .ok_or(Error::::NoClaimableRewards)?; - - // Check if the rewards have expired - let protocol_state = ActiveProtocolState::::get(); - ensure!( - staked_period >= Self::oldest_claimable_period(protocol_state.period_number()), - Error::::RewardExpired - ); - - // Calculate the reward claim span - let earliest_staked_era = ledger - .earliest_staked_era() - .ok_or(Error::::InternalClaimStakerError)?; - let era_rewards = - EraRewards::::get(Self::era_reward_span_index(earliest_staked_era)) - .ok_or(Error::::NoClaimableRewards)?; - - // The last era for which we can theoretically claim rewards. - // And indicator if we know the period's ending era. - let (last_period_era, period_end) = if staked_period == protocol_state.period_number() { - (protocol_state.era.saturating_sub(1), None) - } else { - PeriodEnd::::get(&staked_period) - .map(|info| (info.final_era, Some(info.final_era))) - .ok_or(Error::::InternalClaimStakerError)? - }; - - // The last era for which we can claim rewards for this account. - let last_claim_era = era_rewards.last_era().min(last_period_era); - - // Get chunks for reward claiming - let rewards_iter = - ledger - .claim_up_to_era(last_claim_era, period_end) - .map_err(|err| match err { - AccountLedgerError::NothingToClaim => Error::::NoClaimableRewards, - _ => Error::::InternalClaimStakerError, - })?; - - // Calculate rewards - let mut rewards: Vec<_> = Vec::new(); - let mut reward_sum = Balance::zero(); - for (era, amount) in rewards_iter { - let era_reward = era_rewards - .get(era) - .ok_or(Error::::InternalClaimStakerError)?; - - // Optimization, and zero-division protection - if amount.is_zero() || era_reward.staked.is_zero() { - continue; - } - let staker_reward = Perbill::from_rational(amount, era_reward.staked) - * era_reward.staker_reward_pool; - - rewards.push((era, staker_reward)); - reward_sum.saturating_accrue(staker_reward); - } - let rewards_len: u32 = rewards.len().unique_saturated_into(); - - T::StakingRewardHandler::payout_reward(&account, reward_sum) - .map_err(|_| Error::::RewardPayoutFailed)?; - - Self::update_ledger(&account, ledger)?; - - rewards.into_iter().for_each(|(era, reward)| { - Self::deposit_event(Event::::Reward { - account: account.clone(), - era, - amount: reward, - }); - }); - - Ok(Some(if period_end.is_some() { - T::WeightInfo::claim_staker_rewards_past_period(rewards_len) - } else { - T::WeightInfo::claim_staker_rewards_ongoing_period(rewards_len) - }) - .into()) + Self::internal_claim_staker_rewards_for(account) } /// Used to claim bonus reward for a smart contract, if eligible. @@ -1316,59 +1236,7 @@ pub mod pallet { Self::ensure_pallet_enabled()?; let account = ensure_signed(origin)?; - let staker_info = StakerInfo::::get(&account, &smart_contract) - .ok_or(Error::::NoClaimableRewards)?; - let protocol_state = ActiveProtocolState::::get(); - - // Ensure: - // 1. Period for which rewards are being claimed has ended. - // 2. Account has been a loyal staker. - // 3. Rewards haven't expired. - let staked_period = staker_info.period_number(); - ensure!( - staked_period < protocol_state.period_number(), - Error::::NoClaimableRewards - ); - ensure!( - staker_info.is_loyal(), - Error::::NotEligibleForBonusReward - ); - ensure!( - staker_info.period_number() - >= Self::oldest_claimable_period(protocol_state.period_number()), - Error::::RewardExpired - ); - - let period_end_info = - PeriodEnd::::get(&staked_period).ok_or(Error::::InternalClaimBonusError)?; - // Defensive check - we should never get this far in function if no voting period stake exists. - ensure!( - !period_end_info.total_vp_stake.is_zero(), - Error::::InternalClaimBonusError - ); - - let eligible_amount = staker_info.staked_amount(Subperiod::Voting); - let bonus_reward = - Perbill::from_rational(eligible_amount, period_end_info.total_vp_stake) - * period_end_info.bonus_reward_pool; - - T::StakingRewardHandler::payout_reward(&account, bonus_reward) - .map_err(|_| Error::::RewardPayoutFailed)?; - - // Cleanup entry since the reward has been claimed - StakerInfo::::remove(&account, &smart_contract); - Ledger::::mutate(&account, |ledger| { - ledger.contract_stake_count.saturating_dec(); - }); - - Self::deposit_event(Event::::BonusReward { - account: account.clone(), - smart_contract, - period: staked_period, - amount: bonus_reward, - }); - - Ok(()) + Self::internal_claim_bonus_reward_for(account, smart_contract) } /// Used to claim dApp reward for the specified era. @@ -1591,6 +1459,38 @@ pub mod pallet { Ok(()) } + /// Claims some staker rewards for the specified account, if they have any. + /// In the case of a successful call, at least one era will be claimed, with the possibility of multiple claims happening. + #[pallet::call_index(19)] + #[pallet::weight({ + let max_span_length = T::EraRewardSpanLength::get(); + T::WeightInfo::claim_staker_rewards_ongoing_period(max_span_length) + .max(T::WeightInfo::claim_staker_rewards_past_period(max_span_length)) + })] + pub fn claim_staker_rewards_for( + origin: OriginFor, + account: T::AccountId, + ) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + ensure_signed(origin)?; + + Self::internal_claim_staker_rewards_for(account) + } + + /// Used to claim bonus reward for a smart contract on behalf of the specified account, if eligible. + #[pallet::call_index(20)] + #[pallet::weight(T::WeightInfo::claim_bonus_reward())] + pub fn claim_bonus_reward_for( + origin: OriginFor, + account: T::AccountId, + smart_contract: T::SmartContract, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + ensure_signed(origin)?; + + Self::internal_claim_bonus_reward_for(account, smart_contract) + } + /// A call used to fix accounts with inconsistent state, where frozen balance is actually higher than what's available. /// /// The approach is as simple as possible: @@ -2144,6 +2044,7 @@ pub mod pallet { T::WeightInfo::on_idle_cleanup() } + /// Internal function that executes teh `claim_unlocked` logic for the specified account. fn internal_claim_unlocked(account: T::AccountId) -> DispatchResultWithPostInfo { let mut ledger = Ledger::::get(&account); @@ -2168,5 +2069,150 @@ pub mod pallet { Ok(Some(T::WeightInfo::claim_unlocked(removed_entries)).into()) } + + /// Internal function that executes the `claim_staker_rewards_` logic for the specified account. + fn internal_claim_staker_rewards_for(account: T::AccountId) -> DispatchResultWithPostInfo { + let mut ledger = Ledger::::get(&account); + let staked_period = ledger + .staked_period() + .ok_or(Error::::NoClaimableRewards)?; + + // Check if the rewards have expired + let protocol_state = ActiveProtocolState::::get(); + ensure!( + staked_period >= Self::oldest_claimable_period(protocol_state.period_number()), + Error::::RewardExpired + ); + + // Calculate the reward claim span + let earliest_staked_era = ledger + .earliest_staked_era() + .ok_or(Error::::InternalClaimStakerError)?; + let era_rewards = + EraRewards::::get(Self::era_reward_span_index(earliest_staked_era)) + .ok_or(Error::::NoClaimableRewards)?; + + // The last era for which we can theoretically claim rewards. + // And indicator if we know the period's ending era. + let (last_period_era, period_end) = if staked_period == protocol_state.period_number() { + (protocol_state.era.saturating_sub(1), None) + } else { + PeriodEnd::::get(&staked_period) + .map(|info| (info.final_era, Some(info.final_era))) + .ok_or(Error::::InternalClaimStakerError)? + }; + + // The last era for which we can claim rewards for this account. + let last_claim_era = era_rewards.last_era().min(last_period_era); + + // Get chunks for reward claiming + let rewards_iter = + ledger + .claim_up_to_era(last_claim_era, period_end) + .map_err(|err| match err { + AccountLedgerError::NothingToClaim => Error::::NoClaimableRewards, + _ => Error::::InternalClaimStakerError, + })?; + + // Calculate rewards + let mut rewards: Vec<_> = Vec::new(); + let mut reward_sum = Balance::zero(); + for (era, amount) in rewards_iter { + let era_reward = era_rewards + .get(era) + .ok_or(Error::::InternalClaimStakerError)?; + + // Optimization, and zero-division protection + if amount.is_zero() || era_reward.staked.is_zero() { + continue; + } + let staker_reward = Perbill::from_rational(amount, era_reward.staked) + * era_reward.staker_reward_pool; + + rewards.push((era, staker_reward)); + reward_sum.saturating_accrue(staker_reward); + } + let rewards_len: u32 = rewards.len().unique_saturated_into(); + + T::StakingRewardHandler::payout_reward(&account, reward_sum) + .map_err(|_| Error::::RewardPayoutFailed)?; + + Self::update_ledger(&account, ledger)?; + + rewards.into_iter().for_each(|(era, reward)| { + Self::deposit_event(Event::::Reward { + account: account.clone(), + era, + amount: reward, + }); + }); + + Ok(Some(if period_end.is_some() { + T::WeightInfo::claim_staker_rewards_past_period(rewards_len) + } else { + T::WeightInfo::claim_staker_rewards_ongoing_period(rewards_len) + }) + .into()) + } + + /// Internal function that executes the `claim_bonus_reward` logic for the specified account & smart contract. + fn internal_claim_bonus_reward_for( + account: T::AccountId, + smart_contract: T::SmartContract, + ) -> DispatchResult { + let staker_info = StakerInfo::::get(&account, &smart_contract) + .ok_or(Error::::NoClaimableRewards)?; + let protocol_state = ActiveProtocolState::::get(); + + // Ensure: + // 1. Period for which rewards are being claimed has ended. + // 2. Account has been a loyal staker. + // 3. Rewards haven't expired. + let staked_period = staker_info.period_number(); + ensure!( + staked_period < protocol_state.period_number(), + Error::::NoClaimableRewards + ); + ensure!( + staker_info.is_loyal(), + Error::::NotEligibleForBonusReward + ); + ensure!( + staker_info.period_number() + >= Self::oldest_claimable_period(protocol_state.period_number()), + Error::::RewardExpired + ); + + let period_end_info = + PeriodEnd::::get(&staked_period).ok_or(Error::::InternalClaimBonusError)?; + // Defensive check - we should never get this far in function if no voting period stake exists. + ensure!( + !period_end_info.total_vp_stake.is_zero(), + Error::::InternalClaimBonusError + ); + + let eligible_amount = staker_info.staked_amount(Subperiod::Voting); + let bonus_reward = + Perbill::from_rational(eligible_amount, period_end_info.total_vp_stake) + * period_end_info.bonus_reward_pool; + + T::StakingRewardHandler::payout_reward(&account, bonus_reward) + .map_err(|_| Error::::RewardPayoutFailed)?; + + // Cleanup entry since the reward has been claimed + StakerInfo::::remove(&account, &smart_contract); + Ledger::::mutate(&account, |ledger| { + ledger.contract_stake_count.saturating_dec(); + }); + + Self::deposit_event(Event::::BonusReward { + account: account.clone(), + smart_contract, + period: staked_period, + amount: bonus_reward, + }); + + Ok(()) + } } } diff --git a/pallets/dapp-staking-v3/src/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs index 9a75c366b9..09c6de2864 100644 --- a/pallets/dapp-staking-v3/src/test/mock.rs +++ b/pallets/dapp-staking-v3/src/test/mock.rs @@ -24,7 +24,7 @@ use crate::{ use frame_support::{ construct_runtime, ord_parameter_types, parameter_types, - traits::{fungible::Mutate as FunMutate, ConstU128, ConstU32, EitherOfDiverse}, + traits::{fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, EitherOfDiverse}, weights::Weight, }; use sp_arithmetic::fixed_point::FixedU128; diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs index a44fb4a22d..2c221fe625 100644 --- a/pallets/dapp-staking-v3/src/test/tests.rs +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -38,7 +38,10 @@ use sp_runtime::{ }; use astar_primitives::{ - dapp_staking::{CycleConfiguration, EraNumber, RankedTier, SmartContractHandle, TierSlots}, + dapp_staking::{ + CycleConfiguration, EraNumber, RankedTier, SmartContractHandle, StakingRewardHandler, + TierSlots, + }, Balance, BlockNumber, }; @@ -3424,3 +3427,67 @@ fn fix_account_scenarios_work() { ); }) } + +#[test] +fn claim_staker_rewards_for_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let staker_account = 2; + let lock_amount = 300; + assert_lock(staker_account, lock_amount); + let stake_amount = 93; + assert_stake(staker_account, &smart_contract, stake_amount); + + // Advance into Build&Earn period, and allow one era to pass. Claim reward for 1 era. + advance_to_era(ActiveProtocolState::::get().era + 2); + + // Basic checks, since the entire claim logic is already covered by other tests + let claimer_account = 3; + assert_ok!(DappStaking::claim_staker_rewards_for( + RuntimeOrigin::signed(claimer_account), + staker_account + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Reward { + account: staker_account, + era: ActiveProtocolState::::get().era - 1, + // for this simple test, entire staker reward pool goes to the staker + amount: ::StakingRewardHandler::staker_and_dapp_reward_pools(0).0, + })); + }) +} + +#[test] +fn claim_bonus_reward_for_works() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let staker_account = 2; + let lock_amount = 300; + assert_lock(staker_account, lock_amount); + let stake_amount = 93; + assert_stake(staker_account, &smart_contract, stake_amount); + + // Advance to the next period, and claim the bonus + advance_to_next_period(); + let claimer_account = 3; + assert_ok!(DappStaking::claim_bonus_reward_for( + RuntimeOrigin::signed(claimer_account), + staker_account, + smart_contract.clone() + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::BonusReward { + account: staker_account, + period: ActiveProtocolState::::get().period_number() - 1, + smart_contract, + // for this simple test, entire bonus reward pool goes to the staker + amount: ::StakingRewardHandler::bonus_reward_pool(), + })); + }) +}