Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dAppStaking): Move actions for bonus rewards #1418

Merged
merged 36 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6050826
Concept adaptation for move functionality
Dinonard Feb 2, 2025
53c4632
Comments
Dinonard Feb 4, 2025
9af8d29
BonusStatus type, config param added & tests adjusted
ipapandinas Feb 5, 2025
5cd733d
move extrinsic
ipapandinas Feb 5, 2025
2e4a849
additional tests
ipapandinas Feb 6, 2025
09f1002
dynamic MAX_BONUS_SAFE_MOVES config param
ipapandinas Feb 7, 2025
0e61acc
test utils: assert_move_stake and composable helpers
ipapandinas Feb 7, 2025
1c45bf8
types: extra compare_stake_amounts method and StakeAmount stake takes…
ipapandinas Feb 7, 2025
437d9a7
some move tests
ipapandinas Feb 7, 2025
aaf2900
benchmarks for both unstake scenarios
ipapandinas Feb 7, 2025
c362eb6
fix: current_era usage
ipapandinas Feb 10, 2025
5cd0b23
fix: unstake from future stake does not chip current_stake_amount in …
ipapandinas Feb 11, 2025
c0fe6cd
fix: avoid copying in subtracted_stake_amount
ipapandinas Feb 11, 2025
1c7030a
fix: convert bonus stake into regular stake for just forfeited bonus
ipapandinas Feb 11, 2025
4bf6ec9
task: renaming and cleanups
ipapandinas Feb 11, 2025
9ded185
fix: move from unregistered
ipapandinas Feb 11, 2025
2ca99a7
extra test
ipapandinas Feb 12, 2025
e108c54
task: on runtime upgrade bonus update migration
ipapandinas Feb 13, 2025
78ae139
fix: tmp weights for compilation success
ipapandinas Feb 13, 2025
8f58c3f
README updated
ipapandinas Feb 13, 2025
e149694
feat: add 'move_stake' to dAppStaking precompiles
ipapandinas Feb 13, 2025
cf5ba43
feat: test lazy_update_bonus_status
ipapandinas Feb 13, 2025
433f5d0
misc: nomenclature consistency & comment cleanup
ipapandinas Feb 13, 2025
b7adf47
fix: pre/post runtime upgrade checks
ipapandinas Feb 13, 2025
63eccf4
fix: clippy CI
ipapandinas Feb 13, 2025
7139686
task: add integrity tests for BonusStatus update
ipapandinas Feb 13, 2025
7897bfd
review comments & renames
ipapandinas Feb 17, 2025
69aeb79
feat: merge existing bonus statuses
ipapandinas Feb 17, 2025
499b5f4
fix: previous_staked is only retained for previous eras
ipapandinas Feb 17, 2025
dfb0a75
fix benchmark compilation
ipapandinas Feb 17, 2025
7df699a
migration fully tested
ipapandinas Feb 17, 2025
ddeab1a
fix: rework unstake_amount
ipapandinas Feb 17, 2025
c1fc9bb
fix: remove "assert_eq!" from runtime
ipapandinas Feb 17, 2025
03df158
fix: add BonusUpdateState for migration
ipapandinas Feb 18, 2025
f371a77
add V8ToV9 migration to parachain runtimes
ipapandinas Feb 18, 2025
662bc6a
minor cleanups
ipapandinas Feb 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading