Skip to content

Commit

Permalink
feat: BonusStatus implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
ipapandinas committed Jan 3, 2025
1 parent 765d44b commit 6b31233
Show file tree
Hide file tree
Showing 15 changed files with 604 additions and 107 deletions.
36 changes: 21 additions & 15 deletions pallets/dapp-staking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ After an era ends, it's usually possible to claim rewards for it, if user or dAp
Periods are another _time unit_ in dApp staking. They are expected to be more lengthy than eras.

Each period consists of two subperiods:
* `Voting`
* `Build&Earn`

- `Voting`
- `Build&Earn`

Each period is denoted by a number, which increments each time a new period begins.
Period beginning is marked by the `voting` subperiod, after which follows the `build&earn` period.
Expand All @@ -41,8 +42,9 @@ Casting a vote, or staking, during the `Voting` subperiod makes the staker eligi

`Voting` subperiod length is expressed in _standard_ era lengths, even though the entire voting subperiod is treated as a single _voting era_.
E.g. if `voting` subperiod lasts for **5 eras**, and each era lasts for **100** blocks, total length of the `voting` subperiod will be **500** blocks.
* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts
* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts

- Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts
- Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts

Neither stakers nor dApps earn rewards during this subperiod - no new rewards are generated after `voting` subperiod ends.

Expand All @@ -56,14 +58,15 @@ It is still possible to _stake_ during this period, and stakers are encouraged t
The only exemption is the **final era** of the `build&earn` subperiod - it's not possible to _stake_ then since the stake would be invalid anyhow (stake is only valid from the next era which would be in the next period).

To continue the previous example where era length is **100** blocks, let's assume that `Build&Earn` subperiod lasts for 10 eras:
* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts
* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts
* Block 601, Era 3 starts, Period 1 continues, `Build&Earn` subperiod continues
* Block 701, Era 4 starts, Period 1 continues, `Build&Earn` subperiod continues
* ...
* Block 1401, Era 11 starts, Period 1 continues, `Build&Earn` subperiod enters the final era
* Block 1501, Era 12 starts, Period 2 starts, `Voting` subperiod starts
* Block 2001, Era 13 starts, Period 2 continues, `Build&Earn` subperiod starts

- Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts
- Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts
- Block 601, Era 3 starts, Period 1 continues, `Build&Earn` subperiod continues
- Block 701, Era 4 starts, Period 1 continues, `Build&Earn` subperiod continues
- ...
- Block 1401, Era 11 starts, Period 1 continues, `Build&Earn` subperiod enters the final era
- Block 1501, Era 12 starts, Period 2 starts, `Voting` subperiod starts
- Block 2001, Era 13 starts, Period 2 continues, `Build&Earn` subperiod starts

### dApps & Smart Contracts

Expand Down Expand Up @@ -137,11 +140,14 @@ User's stake on a contract must be equal or greater than the `MinimumStakeAmount

Although user can stake on multiple smart contracts, the amount is limited. To be more precise, amount of database entries that can exist per user is limited.

The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation.
The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. Only a limited number of _move actions_ are allowed during the `build&earn` subperiod to preserve bonus reward elegibility. _Move actions_ refer either to:

- a 'partial unstake with voting stake decrease',
- a 'stake transfer between two contracts'.

It is not possible to stake on a dApp that has been unregistered.
However, if dApp is unregistered after user has staked on it, user will keep earning
rewards for the staked amount.
rewards for the staked amount, or can 'move' his stake without impacting his number of allowed 'move actions' for the ongoing period.

#### Unstaking Tokens

Expand Down Expand Up @@ -240,4 +246,4 @@ In case they don't, they will simply miss on the earnings.
However, this should not be a problem given how the system is designed.
There is no longer _stake&forger_ - users are expected to revisit dApp staking at least at the
beginning of each new period to pick out old or new dApps on which to stake on.
If they don't do that, they miss out on the bonus reward & won't earn staker rewards.
If they don't do that, they miss out on the bonus reward & won't earn staker rewards.
22 changes: 14 additions & 8 deletions pallets/dapp-staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub mod pallet {
#[pallet::constant]
type RankingEnabled: Get<bool>;

/// The maximum number of 'move actions' allowed within a single period while
/// retaining eligibility for bonus rewards. Exceeding this limit will result in the
/// forfeiture of the bonus rewards for the affected stake.
#[pallet::constant]
type MaxBonusMovesPerPeriod: Get<u8> + Default + Debug;

/// Weight info for various calls & operations in the pallet.
type WeightInfo: WeightInfo;

Expand Down Expand Up @@ -290,7 +296,7 @@ pub mod pallet {
era: EraNumber,
amount: Balance,
},
/// Bonus reward has been paid out to a loyal staker.
/// Bonus reward has been paid out to a 'loyal' staker.
BonusReward {
account: T::AccountId,
smart_contract: T::SmartContract,
Expand Down Expand Up @@ -429,7 +435,7 @@ pub mod pallet {
T::AccountId,
Blake2_128Concat,
T::SmartContract,
SingularStakingInfo,
SingularStakingInfoFor<T>,
OptionQuery,
>;

Expand Down Expand Up @@ -1069,7 +1075,7 @@ pub mod pallet {
// Entry exists but period doesn't match. Bonus reward might still be claimable.
Some(staking_info)
if staking_info.period_number() >= threshold_period
&& staking_info.is_loyal() =>
&& staking_info.has_bonus() =>
{
return Err(Error::<T>::UnclaimedRewards.into());
}
Expand Down Expand Up @@ -1391,8 +1397,8 @@ pub mod pallet {
/// Cleanup expired stake entries for the contract.
///
/// Entry is considered to be expired if:
/// 1. It's from a past period & the account wasn't a loyal staker, meaning there's no claimable bonus reward.
/// 2. It's from a period older than the oldest claimable period, regardless whether the account was loyal or not.
/// 1. It's from a past period & the account has NO BONUS reward.
/// 2. It's from a period older than the oldest claimable period, regardless whether the account was has bonus reward or not.
#[pallet::call_index(17)]
#[pallet::weight(T::WeightInfo::cleanup_expired_entries(
T::MaxNumberOfStakedContracts::get()
Expand All @@ -1409,7 +1415,7 @@ pub mod pallet {
// This is bounded by max allowed number of stake entries per account.
let to_be_deleted: Vec<T::SmartContract> = StakerInfo::<T>::iter_prefix(&account)
.filter_map(|(smart_contract, stake_info)| {
if stake_info.period_number() < current_period && !stake_info.is_loyal()
if stake_info.period_number() < current_period && !stake_info.has_bonus()
|| stake_info.period_number() < threshold_period
{
Some(smart_contract)
Expand Down Expand Up @@ -2159,15 +2165,15 @@ pub mod pallet {

// Ensure:
// 1. Period for which rewards are being claimed has ended.
// 2. Account has been a loyal staker.
// 2. Account is eligible to bonus rewards.
// 3. Rewards haven't expired.
let staked_period = staker_info.period_number();
ensure!(
staked_period < protocol_state.period_number(),
Error::<T>::NoClaimableRewards
);
ensure!(
staker_info.is_loyal(),
staker_info.has_bonus(),
Error::<T>::NotEligibleForBonusReward
);
ensure!(
Expand Down
5 changes: 4 additions & 1 deletion pallets/dapp-staking/src/test/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ use frame_support::{
construct_runtime, derive_impl,
migrations::MultiStepMigrator,
ord_parameter_types, parameter_types,
traits::{fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, EitherOfDiverse},
traits::{
fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, ConstU8, EitherOfDiverse,
},
weights::Weight,
};
use sp_arithmetic::fixed_point::FixedU128;
Expand Down Expand Up @@ -258,6 +260,7 @@ impl pallet_dapp_staking::Config for Test {
type MinimumStakeAmount = ConstU128<3>;
type NumberOfTiers = ConstU32<4>;
type RankingEnabled = ConstBool<true>;
type MaxBonusMovesPerPeriod = ConstU8<2>;
type WeightInfo = weights::SubstrateWeight<Test>;
#[cfg(feature = "runtime-benchmarks")]
type BenchmarkHelper = BenchmarkHelper<MockSmartContract, AccountId>;
Expand Down
81 changes: 61 additions & 20 deletions pallets/dapp-staking/src/test/testing_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
use crate::test::mock::*;
use crate::types::*;
use crate::{
pallet::Config, ActiveProtocolState, ContractStake, CurrentEraInfo, DAppId, DAppTiers,
EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, NextDAppId,
PeriodEnd, PeriodEndInfo, StakerInfo,
pallet::Config, ActiveProtocolState, BonusStatusFor, ContractStake, CurrentEraInfo, DAppId,
DAppTiers, EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger,
NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo,
};

use frame_support::{
Expand Down Expand Up @@ -54,7 +54,7 @@ pub(crate) struct MemorySnapshot {
<Test as frame_system::Config>::AccountId,
<Test as Config>::SmartContract,
),
SingularStakingInfo,
SingularStakingInfo<<Test as Config>::MaxBonusMovesPerPeriod>,
>,
contract_stake: HashMap<DAppId, ContractStakeAmount>,
era_rewards: HashMap<EraNumber, EraRewardSpan<<Test as Config>::EraRewardSpanLength>>,
Expand Down Expand Up @@ -550,9 +550,10 @@ pub(crate) fn assert_stake(
);
assert_eq!(post_staker_info.period_number(), stake_period);
assert_eq!(
post_staker_info.is_loyal(),
pre_staker_info.is_loyal(),
"Staking operation mustn't change loyalty flag."
post_staker_info.has_bonus(),
pre_staker_info.has_bonus(),
"Staking operation mustn't change bonus reward
eligibility."
);
}
// A new entry is created.
Expand All @@ -570,7 +571,7 @@ pub(crate) fn assert_stake(
);
assert_eq!(post_staker_info.period_number(), stake_period);
assert_eq!(
post_staker_info.is_loyal(),
post_staker_info.has_bonus(),
stake_subperiod == Subperiod::Voting
);
}
Expand Down Expand Up @@ -713,20 +714,42 @@ pub(crate) fn assert_unstake(
"Staked amount must decrease by the 'amount'"
);

let is_loyal = pre_staker_info.is_loyal()
&& match unstake_subperiod {
Subperiod::Voting => !post_staker_info.staked_amount(Subperiod::Voting).is_zero(),
Subperiod::BuildAndEarn => {
post_staker_info.staked_amount(Subperiod::Voting)
== pre_staker_info.staked_amount(Subperiod::Voting)
}
};
let should_keep_bonus = if pre_staker_info.has_bonus() {
match pre_staker_info.bonus_status {
BonusStatus::SafeMovesRemaining(remaining_moves) if remaining_moves > 0 => true,
_ => match unstake_subperiod {
Subperiod::Voting => {
!post_staker_info.staked_amount(Subperiod::Voting).is_zero()
}
Subperiod::BuildAndEarn => {
post_staker_info.staked_amount(Subperiod::Voting)
== pre_staker_info.staked_amount(Subperiod::Voting)
}
},
}
} else {
false
};

assert_eq!(
post_staker_info.is_loyal(),
is_loyal,
"If 'Voting' stake amount is reduced in B&E period, loyalty flag must be set to false."
post_staker_info.has_bonus(),
should_keep_bonus,
"If 'voting stake' amount is fully unstaked in Voting subperiod or reduced in B&E subperiod, 'BonusStatus' must reflect this."
);

if unstake_subperiod == Subperiod::BuildAndEarn
&& pre_staker_info.has_bonus()
&& post_staker_info.staked_amount(Subperiod::Voting)
< pre_staker_info.staked_amount(Subperiod::Voting)
{
let mut bonus_status_clone = pre_staker_info.bonus_status.clone();
bonus_status_clone.decrease_moves();

assert_eq!(
post_staker_info.bonus_status, bonus_status_clone,
"'BonusStatus' must correctly decrease moves when 'voting stake' is reduced in B&E subperiod."
);
}
}

let unstaked_amount_era_pairs =
Expand Down Expand Up @@ -828,6 +851,24 @@ pub(crate) fn assert_unstake(
}
}

/// Assert the bonus status of a staker for a specific smart contract.
pub(crate) fn assert_bonus_status(
account: AccountId,
smart_contract: &MockSmartContract,
expected_bonus_status: BonusStatusFor<Test>,
) {
let snapshot = MemorySnapshot::new();
let staker_info = snapshot
.staker_info
.get(&(account, *smart_contract))
.expect("Staker info entry must exist to verify bonus status.");

assert_eq!(
staker_info.bonus_status, expected_bonus_status,
"The staker's bonus status does not match the expected value."
);
}

/// Claim staker rewards.
pub(crate) fn assert_claim_staker_rewards(account: AccountId) {
let pre_snapshot = MemorySnapshot::new();
Expand Down Expand Up @@ -1195,7 +1236,7 @@ pub(crate) fn assert_cleanup_expired_entries(account: AccountId) {
.iter()
.for_each(|((inner_account, contract), entry)| {
if *inner_account == account {
if entry.period_number() < current_period && !entry.is_loyal()
if entry.period_number() < current_period && !entry.has_bonus()
|| entry.period_number() < threshold_period
{
to_be_deleted.push(contract);
Expand Down
63 changes: 54 additions & 9 deletions pallets/dapp-staking/src/test/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@

use crate::test::{mock::*, testing_utils::*};
use crate::{
pallet::Config, ActiveProtocolState, ContractStake, DAppId, DAppTierRewardsFor, DAppTiers,
EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger, NextDAppId,
Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod, TierConfig,
TierThreshold,
pallet::Config, ActiveProtocolState, BonusStatus, ContractStake, DAppId, DAppTierRewardsFor,
DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger,
NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod,
TierConfig, TierThreshold,
};

use frame_support::{
Expand Down Expand Up @@ -1246,7 +1246,7 @@ fn stake_fails_due_to_too_many_staked_contracts() {
let account = 1;
assert_lock(account, 100 as Balance * max_number_of_contracts as Balance);

// Advance to build&earn subperiod so we ensure non-loyal staking
// Advance to build&earn subperiod so we ensure 'non-loyal' staking
advance_to_next_subperiod();

// Register smart contracts up to the max allowed number
Expand Down Expand Up @@ -2147,10 +2147,10 @@ fn cleanup_expired_entries_is_ok() {

// Scenario:
// - 1st contract will be staked in the period that expires due to exceeded reward retention
// - 2nd contract will be staked in the period on the edge of expiry, with loyalty flag
// - 3rd contract will be be staked in the period on the edge of expiry, without loyalty flag
// - 4th contract will be staked in the period right before the current one, with loyalty flag
// - 5th contract will be staked in the period right before the current one, without loyalty flag
// - 2nd contract will be staked in the period on the edge of expiry, with bonus elegibility
// - 3rd contract will be be staked in the period on the edge of expiry, without bonus elegibility
// - 4th contract will be staked in the period right before the current one, with bonus elegibility
// - 5th contract will be staked in the period right before the current one, without bonus elegibility
//
// Expectation: 1, 3, 5 should be removed, 2 & 4 should remain

Expand Down Expand Up @@ -2926,6 +2926,51 @@ fn stake_after_period_ends_with_max_staked_contracts() {
})
}

#[test]
fn stake_after_period_ends_reset_bonus_status_is_ok() {
ExtBuilder::default().build_and_execute(|| {
let max_bonus_moves: u8 = <Test as Config>::MaxBonusMovesPerPeriod::get();

// Phase 1: 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 account = 2;
let amount = 400;
let partial_unstake_amount = 100;
assert_lock(account, amount);
assert_stake(account, &smart_contract, amount - partial_unstake_amount);

// Phase 2: Advance to B&E subperiod, we ensure 'bonus safe moves' remaining is decreased with a partial unstake (overflowing 'voting' stake)
advance_to_next_subperiod();
assert_unstake(account, &smart_contract, partial_unstake_amount);

if max_bonus_moves == 0 {
let expected_bonus_status = BonusStatus::BonusForfeited;
assert_bonus_status(account, &smart_contract, expected_bonus_status);
} else {
let expected_bonus_status = BonusStatus::SafeMovesRemaining(max_bonus_moves - 1);
assert_bonus_status(account, &smart_contract, expected_bonus_status);
}

// Phase 3: Advance to the next period, claim rewards
advance_to_next_period();
for _ in 0..required_number_of_reward_claims(account) {
assert_claim_staker_rewards(account);
}

if max_bonus_moves > 0 {
assert_claim_bonus_reward(account, &smart_contract);
}

// Phase 4: Restake and verify BonusStatus reset
assert_stake(account, &smart_contract, partial_unstake_amount);
let default_bonus_status = BonusStatus::default();
assert_bonus_status(account, &smart_contract, default_bonus_status);
})
}

#[test]
fn post_unlock_balance_cannot_be_transferred() {
ExtBuilder::default().build_and_execute(|| {
Expand Down
Loading

0 comments on commit 6b31233

Please sign in to comment.