Skip to content

Commit

Permalink
feat(dAppStaking): Move actions for bonus rewards (#1418)
Browse files Browse the repository at this point in the history
* Concept adaptation for move functionality

* Comments

* BonusStatus type, config param added & tests adjusted

* move extrinsic

* additional tests

* dynamic MAX_BONUS_SAFE_MOVES config param

* test utils: assert_move_stake and composable helpers

* types: extra compare_stake_amounts method and StakeAmount stake takes bonus_status

* some move tests

* benchmarks for both unstake scenarios

* fix: current_era usage

* fix: unstake from future stake does not chip current_stake_amount in EraInfo

* fix: avoid copying in subtracted_stake_amount

* fix: convert bonus stake into regular stake for just forfeited bonus

* task: renaming and cleanups

* fix: move from unregistered

* extra test

* task: on runtime upgrade bonus update migration

* fix: tmp weights for compilation success

* README updated

* feat: add 'move_stake' to dAppStaking precompiles

* feat: test lazy_update_bonus_status

* misc: nomenclature consistency & comment cleanup

* fix: pre/post runtime upgrade checks

* fix: clippy CI

* task: add integrity tests for BonusStatus update

* review comments & renames

* feat: merge existing bonus statuses

* fix: previous_staked is only retained for previous eras

* fix benchmark compilation

* migration fully tested

* fix: rework unstake_amount

* fix: remove "assert_eq!" from runtime

* fix: add BonusUpdateState for migration

* add V8ToV9 migration to parachain runtimes

* minor cleanups

---------

Co-authored-by: Igor Papandinas <[email protected]>
  • Loading branch information
Dinonard and ipapandinas authored Feb 18, 2025
1 parent d078b43 commit ff28544
Show file tree
Hide file tree
Showing 23 changed files with 3,661 additions and 697 deletions.
44 changes: 41 additions & 3 deletions pallets/dapp-staking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ The protocol keeps track of how much was staked by the user in `voting` and `bui

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 _safe move actions_ for the ongoing period.

#### Unstaking Tokens

Expand All @@ -157,7 +157,32 @@ If unstake would reduce the staked amount below `MinimumStakeAmount`, everything

Once period finishes, all stakes are reset back to zero. This means that no unstake operation is needed after period ends to _unstake_ funds - it's done automatically.

If dApp has been unregistered, a special operation to unstake from unregistered contract must be used.
During the `Build&Earn` subperiod, if unstaking reduces the voting stake, the bonus status will be updated, and the number of allowed _move actions_ for the ongoing period will be reduced.

Any forfeited bonus is converted into `Build&Earn` stake, ensuring that voting amounts are not lost but instead reallocated appropriately.

If dApp has been unregistered, a special operation to unstake from unregistered contract must be used that preserves bonus elegibility.

#### Moving Stake Between Contracts

The moving stake feature allows users to transfer their staked amount between two smart contracts without undergoing the unstake and stake process separately. This feature ensures that the transferred stake remains aligned with the current staking period (effective in the next era), and any bonus eligibility is preserved as long as the conditions for the bonus reward are not violated (move actions are limited by `MaxBonusSafeMovesPerPeriod`).

Key details about moving stake:

- The destination contract must be different from the source contract.
- The user must ensure that unclaimed rewards are claimed before initiating a stake move.
- Only a limited number of move actions (defined by `MaxBonusSafeMovesPerPeriod`) are allowed during a single period to preserve bonus reward eligibility (check "Claiming Bonus Reward" section below).
- If the destination contract is newly staked, the user's total staked contracts must not exceed the maximum allowed number of staked contracts.
- The destination contract must not be unregistered, but moving stake away from an unregistered contract is allowed without affecting bonus eligibility.

This feature is particularly useful for stakers who wish to rebalance their stake across multiple contracts (including new registrations) or move their stake to better-performing dApps while retaining the potential for rewards and maintaining bonus eligibility.

#### Bonus Status Handling in Moves

When moving stake, if the destination contract has no existing bonus eligibility, it inherits the incoming bonus status from the source contract. If both the source and destination have nonzero bonus statuses, they are merged by averaging their values. This prevents unintended bonus gains or losses while ensuring fairness in bonus distribution.

For example, if the configuration allows **2** safe moves, the default bonus status starts at **3**. If the source contract's bonus status decreases from **3** to **1** after an unstake and the move operation, and the destination contract retains the default **3**, the new bonus status is calculated as: **(1 + 3) / 2**, resulting into **2**.
This ensures a smooth and fair adjustment while keeping stake amounts properly aligned.

#### Claiming Staker Rewards

Expand All @@ -175,7 +200,20 @@ Rewards are calculated using a simple formula: `staker_reward_pool * staker_stak

#### Claiming Bonus Reward

If staker staked on a dApp during the voting subperiod, and didn't reduce their staked amount below what was staked at the end of the voting subperiod, this makes them eligible for the bonus reward.
If a staker has staked on a dApp during the voting subperiod, and the bonus status for the associated staked amount has not been forfeited due to excessive move actions, they remain eligible for the bonus reward.

Only a limited number of _safe move actions_ are allowed during the `build&earn` subperiod to preserve bonus reward eligibility. Move actions refer to either:

- A 'partial unstake that decreases the voting stake',
- A 'stake transfer between two contracts'. (check previous "Moving Stake Between Contracts" section)

The number of authorized safe move actions is defined by `MaxBonusSafeMovesPerPeriod`. For example:
If 2 safe bonus move actions are allowed for one period, and a user has staked **100** on contract A during the `voting` subperiod and **50** during the `build&earn` subperiod, they can safely:

1. Unstake **70**, reducing the `voting` stake to **80**.
2. Transfer **50** to contract B.

After these actions, the user will still be eligible for bonus rewards (**20** on contract A and **50** on contract B). However, if an additional move action is performed on contract A, the bonus eligibility will be forfeited.

Bonus rewards need to be claimed per contract, unlike staker rewards.

Expand Down
158 changes: 155 additions & 3 deletions pallets/dapp-staking/src/benchmarking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use frame_support::{assert_ok, migrations::SteppedMigration, weights::WeightMete
use frame_system::{Pallet as System, RawOrigin};
use sp_std::prelude::*;

use ::assert_matches::assert_matches;
use assert_matches::assert_matches;

mod utils;
use utils::*;
Expand Down Expand Up @@ -259,7 +259,7 @@ mod benchmarks {
amount,
));

// Move over to the build&earn subperiod to ensure 'non-loyal' staking.
// Move over to the build&earn subperiod to ensure staking without a bonus status.
// This is needed so we can achieve staker entry cleanup after claiming unlocked tokens.
force_advance_to_next_subperiod::<T>();
assert_eq!(
Expand Down Expand Up @@ -782,7 +782,7 @@ mod benchmarks {
fn cleanup_expired_entries(x: Linear<1, { T::MaxNumberOfStakedContracts::get() }>) {
initial_config::<T>();

// Move over to the build&earn subperiod to ensure 'non-loyal' staking.
// Move over to the build&earn subperiod to ensure staking without a bonus status.
force_advance_to_next_subperiod::<T>();

// Prepare staker & lock some amount
Expand Down Expand Up @@ -839,6 +839,116 @@ mod benchmarks {
assert_last_event::<T>(Event::<T>::Force { forcing_type }.into());
}

#[benchmark]
fn move_stake_from_registered_source() {
initial_config::<T>();

let staker: T::AccountId = whitelisted_caller();
let owner: T::AccountId = account("dapp_owner", 0, SEED);
let source_contract = T::BenchmarkHelper::get_smart_contract(1);
let destination_contract = T::BenchmarkHelper::get_smart_contract(2);
assert_ok!(DappStaking::<T>::register(
RawOrigin::Root.into(),
owner.clone().into(),
source_contract.clone(),
));
assert_ok!(DappStaking::<T>::register(
RawOrigin::Root.into(),
owner.clone().into(),
destination_contract.clone(),
));

// To preserve source staking and create destination staking
let amount = T::MinimumLockedAmount::get() + T::MinimumLockedAmount::get();
T::BenchmarkHelper::set_balance(&staker, amount);
assert_ok!(DappStaking::<T>::lock(
RawOrigin::Signed(staker.clone()).into(),
amount,
));

assert_ok!(DappStaking::<T>::stake(
RawOrigin::Signed(staker.clone()).into(),
source_contract.clone(),
amount
));

let amount_to_move = T::MinimumLockedAmount::get();

#[extrinsic_call]
move_stake(
RawOrigin::Signed(staker.clone()),
source_contract.clone(),
destination_contract.clone(),
amount_to_move.clone(),
);

assert_last_event::<T>(
Event::<T>::StakeMoved {
account: staker,
source_contract,
destination_contract,
amount: amount_to_move,
}
.into(),
);
}

#[benchmark]
fn move_stake_unregistered_source() {
initial_config::<T>();

let staker: T::AccountId = whitelisted_caller();
let owner: T::AccountId = account("dapp_owner", 0, SEED);
let source_contract = T::BenchmarkHelper::get_smart_contract(1);
let destination_contract = T::BenchmarkHelper::get_smart_contract(2);
assert_ok!(DappStaking::<T>::register(
RawOrigin::Root.into(),
owner.clone().into(),
source_contract.clone(),
));
assert_ok!(DappStaking::<T>::register(
RawOrigin::Root.into(),
owner.clone().into(),
destination_contract.clone(),
));

let amount = T::MinimumLockedAmount::get();
T::BenchmarkHelper::set_balance(&staker, amount);
assert_ok!(DappStaking::<T>::lock(
RawOrigin::Signed(staker.clone()).into(),
amount,
));

assert_ok!(DappStaking::<T>::stake(
RawOrigin::Signed(staker.clone()).into(),
source_contract.clone(),
amount
));

assert_ok!(DappStaking::<T>::unregister(
RawOrigin::Root.into(),
source_contract.clone(),
));

#[extrinsic_call]
move_stake(
RawOrigin::Signed(staker.clone()),
source_contract.clone(),
destination_contract.clone(),
amount.clone(),
);

assert_last_event::<T>(
Event::<T>::StakeMoved {
account: staker,
source_contract,
destination_contract,
amount,
}
.into(),
);
}

#[benchmark]
fn on_initialize_voting_to_build_and_earn() {
initial_config::<T>();
Expand Down Expand Up @@ -1137,6 +1247,48 @@ mod benchmarks {
);
}

/// TODO: remove this benchmark once BonusStatus update is done
#[benchmark]
fn update_bonus_step_success() {
initial_config::<T>();

let staker: T::AccountId = whitelisted_caller();
let owner: T::AccountId = account("dapp_owner", 0, SEED);
let source_contract = T::BenchmarkHelper::get_smart_contract(1);
assert_ok!(DappStaking::<T>::register(
RawOrigin::Root.into(),
owner.clone().into(),
source_contract.clone(),
));

let amount = T::MinimumLockedAmount::get();
T::BenchmarkHelper::set_balance(&staker, amount);
assert_ok!(DappStaking::<T>::lock(
RawOrigin::Signed(staker.clone()).into(),
amount,
));

assert_ok!(DappStaking::<T>::stake(
RawOrigin::Signed(staker.clone()).into(),
source_contract.clone(),
amount
));

#[block]
{
assert!(DappStaking::<T>::update_bonus_step(&mut StakerInfo::<T>::iter()).is_ok());
}
}

/// TODO: remove this benchmark once BonusStatus update is done
#[benchmark]
fn update_bonus_step_noop() {
#[block]
{
assert!(DappStaking::<T>::update_bonus_step(&mut StakerInfo::<T>::iter()).is_err());
}
}

impl_benchmark_test_suite!(
Pallet,
crate::benchmarking::tests::new_test_ext(),
Expand Down
Loading

0 comments on commit ff28544

Please sign in to comment.