dAppStaking - Bonus rewards mechanism rework
Opened this issue · 6 comments
Context
This issue proposes improvements to the bonus rewards mechanism in dAppStaking to enhance user flexibility and align with community feedback.
- "The user is not free to move their stake during the Build&Earn subperiod: stake and forget, making it difficult to arbitrate, especially with the dynamic thresholds." – source
- "The current bonus system is completely incompatible with the dynamic threshold system introduced recently." – source
Suggested Solutions
Introduce a new move
extrinsic that enables users to reallocate their staked voting bonus between dApps while preserving the bonus reward.
This new extrinsic will support two main use cases:
- a configurable number
MaxBonusMovesPerPeriod
of maximum allowed regular moves per period (2 by default) to allow limited bonus reallocation during the Build&Earn subperiod. Usingmove
during the Voting subperiod, reallocates the voting stake without increasing the move counter. - a free reallocation if a dApp is unregistered (no impact on user’s move count),
Nov 6 EDIT: From discussion below
Dec 11 EDIT: Partial unstacking should be treated as a move to no dApp (reduce SafeMovesRemaining counter - check scenario 3)
Interface Proposal
The new move
extrinsic
pub fn move(
origin: OriginFor<T>,
from_smart_contract: T::SmartContract,
to_smart_contract: T::SmartContract,
amount: Balance
) -> DispatchResult
The new config params
TempBonusMovesForOngoingPeriod
- used in the ongoing periodMaxBonusMovesPerPeriod
- use in the next period
The updated SingularStakingInfo
struct
struct SingularStakingInfo {
previous_staked: StakeAmount,
staked: StakeAmount,
bonus_status: BonusStatus,
}
The bonus status definition
enum BonusStatus {
BonusForfeited,
SafeMovesRemaining(u8),
}
- The initial value of
SafeMovesRemaining
will be set toMaxBonusMovesPerPeriod
in each period, with an initial default set toTempBonusMovesForOngoingPeriod
of 1 for the ongoing period. - The
BonusForfeited
andSafeMovesRemaining(_)
are respectively encoded to false and true to avoid a storage migration.
Logic Steps and Checks
- Validate Origin
- Confirm that
from_smart_contract
andto_smart_contract
are distinct. - Verify
to_smart_contract
registration - Check
amount
to move:- Check if it is not too small/large
- Ensure the specified amount does not exceed
from_smart_contract
’s staked balance
- Handle
bonus_status
:- If
from_smart_contract
is unregistered, maintain thebonus_status
. - If
to_smart_contract
is registered andSafeMovesRemaining
> 0, decrementSafeMovesRemaining
. - If
SafeMovesRemaining
reaches zero, setbonus_status
toBonusForfeited
.
- If
- Staking Changes
- Unstake
from from_smart_contract
using existing unstake logic. - Stake on
to_smart_contract
using existing stake logic. - Check correct storage updates.
- Unstake
- Emit
Move
event with:account
/from_smart_contract
/to_smart_contract
/amount
/bonus_status
💡 Note: Previous
nomination_transfer
from dAppStaking v2 can be used as reference
⚠️ Note: a partialunstake
must also be adapted to act as ifmove
to no dApp was used
This decrease theSafeMovesRemaining
counter, preserving the bonus for the remaining stake.
However, by using themove
extrinsic you actually move your voting stake to a new (or an already staked) dApp, and your bonus is still preserved on both stakes.
Some expected outcomes scenarios
Stake reallocation to a new or previously unstaked dApp
// dApp 1
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(1),
}
// MOVE 1: dApp1 -> dApp2
// dApp 2
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(0),
}
// MOVE 2: dApp2 -> dApp3
// dApp 3
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: BonusForfeited,
}
Bonus migration between already staked dApps
// dApp 1
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(1),
}
// MOVE 1: dApp1 -> dApp2 (where Y is original stake on dApp2 & Z is all or some stake of dApp1)
// dApp 2
SingularStakingInfo {
previous_staked: Y + Z,
staked: Y + Z,
bonus_status: SafeMovesRemaining(0),
}
Partial unstacking should be treated as a move to no dApp
// dApp 1
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(1),
}
// UNSTAKE 1: Y = X - unstake1_amount
// dApp 1
SingularStakingInfo {
previous_staked: Y,
staked: Y,
bonus_status: SafeMovesRemaining(0),
}
// UNSTAKE 2: Z = Y - unstake2_amount
// dApp 1
SingularStakingInfo {
previous_staked: Z,
staked: Z,
bonus_status: BonusForfeited,
}
Consistency Steps
- Prepare and test a multi-block storage migration to support new
bonus_status
and removeloyal_staker
fromSingularStakingInfo
- Some unit tests ideas - Cap Logic Checks:
- Verify that the move counter resets appropriately at the start of each new period
- Prevents moves beyond the configurable cap
- Verify that the move counter is not increased for unregistered dApps
- Verify that the move counter is not increased for moves during the Voting subperiod - More testing scenarios:
- A staker reallocates bonuses multiple times
- A staker migrates bonuses from an unregistered dApp
- A staker moves partial stake
- Documentation
Ensure that the actual bonus reward calculation (also for unregistered dApps) & staking / unstaking / claiming mechanisms still work.
@ipapandinas I would suggest a bit different solution which should be technically simpler & better for the user IMO.
The bonus reward or the loyalty flag is tied to a particular stake, not the account.
E.g. if Alice
stakes on 1 dApp, she must keep the Build&Earn
stake to get the bonus. If she goes below that amount at any point, she looses 100% of the reward.
On the other side, Bob
stakes on 3 dApps, using the same amount for the sake of simplicity. If he at any points e.g. decides to unstake from 1 dApp, he will loose only 1/3 of the bonus, retaining the other 2/3. So Bob's stake is more "robust".
Therefore I'd suggest to track whether the stake can be moved on the level of singular stake.
It is more fair to the users who support multiple dApps compared to just one.
Tech Solution Suggestion
AccountLedger
should be kept as-is.
Instead, we should just modify the SingularStakingInfo
. This is how it looks today.
#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)]
pub struct SingularStakingInfo {
/// Amount staked before, if anything.
pub(crate) previous_staked: StakeAmount,
/// Staked amount
pub(crate) staked: StakeAmount,
/// Indicates whether a staker is a loyal staker or not.
pub(crate) loyal_staker: bool,
}
We can modify it to something like:
#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)]
pub struct SingularStakingInfo {
/// Amount staked before, if anything.
pub(crate) previous_staked: StakeAmount,
/// Staked amount
pub(crate) staked: StakeAmount,
/// Indicates whether a staker is a loyal staker or not.
pub(crate) period_stake: PeriodStake
}
enum PeriodStake {
/// Bonus for this stake has been forfeited, staker won't receive anything
BonusForfeited,
/// Staker can move some or all stake one more time without loosing the reward.
/// This value would be deprecated later.
OneMoveRemaining,
/// The amount of safe 'moves' remaning, where staker can move all or part of this stake to another dApp
SafeMovesRemaining(u8),
}
The benefit of this approach is that we don't need to do any storage migration since false
will encode the the BonusForfeited
and true
will encode to OneMoveRemaining
.
Since we'd be deploying this in the mid of the ongoing period, having only one move remaining shouldn't be an issue.
Later we'd use SafeMovesRemaining(u8)
which would use the constant you mentioned.
Thank you for the insights! I agree that tracking moves at the singular stake level makes sense for improving user fairness and control, especially for those supporting multiple dApps.
Your suggested design is also very clever—I really like it! I propose simplifying the enum slightly:
enum BonusStatus {
BonusForfeited,
SafeMovesRemaining(u8),
}
With SafeMovesRemaining(_)
encoded to true
and BonusForfeited
encoded to false
as you suggested; I've removed OneMoveRemaining
to keep it straightforward.
The initial value for SafeMovesRemaining(u8)
will be set by MaxBonusMovesPerPeriod
from the config, with a starting at 1 for the ongoing period. We can then upgrade the runtime to allow 2 moves with the next period later.
Here are the expected outcomes for the given scenarios:
Stake reallocation to a newly registered or previously unstaked dApp
The bonus decreases or is forfeited if moves are exhausted. For example:
// dApp 1
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(1),
}
// MOVE 1: dApp1 -> dApp2
// dApp 2
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(0),
}
// MOVE 2: dApp2 -> dApp3
// dApp 3
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: BonusForfeited,
}
Bonus migration within already staked dApps:
Combine existing stake and reduce move count accordingly.
// dApp 1
SingularStakingInfo {
previous_staked: X,
staked: X,
bonus_status: SafeMovesRemaining(1),
}
// MOVE 1: dApp1 -> dApp2 (Y represents the original stake on dApp2)
// dApp 2
SingularStakingInfo {
previous_staked: Y + X,
staked: Y + X,
bonus_status: SafeMovesRemaining(0),
}
WDYT? I’ll continue updating the original issue body to clarify the implementation as we discuss.
For the enum simplification, I'd prefer if it was as you suggested, if it works & makes life simpler.
My idea was to just initially go with this enum value, without using it in the future (deprecating it).
Best to also check with the UI team if it causes them problems/complications.
The initial value for SafeMovesRemaining(u8) will be set by MaxBonusMovesPerPeriod from the config, with a starting at 1 for the ongoing period. We can then upgrade the runtime to allow 2 moves with the next period later.
We shouldn't need to do runtime upgrade to change this value though - it's unnecessarily complex and will be something we need to keep track of & prepare. Running it through the governance will also consume time.
If we want to have a different value to start with, best to define it via an additional pallet config type.
All of that being said - we have multiblock migration now, it's simple to write & test, so might as well as do it I guess 🤷♂️.
It's not the worst thing to do :)
For the move
call, I believe users will expect that they can specify the move amount.
This will make the logic a bit more complex.
If we want to have a different value to start with, best to define it via an additional pallet config type.
Just to confirm I've captured your idea, are you suggesting of having two new config params? Something like:
TempBonusMovesForPeriod3
- used in the ongoing periodMaxBonusMovesPerPeriod
- use in the next period
Here is a first interface proposal and logic outline for the move
extrinsic:
pub fn move(
origin: OriginFor<T>,
from_smart_contract: T::SmartContract,
to_smart_contract: T::SmartContract,
amount: Option<Balance>
) -> DispatchResult
Logic: steps and checks
- Validate Origin
- Check
to_smart_contract
registration - Check
amount
to move:- Check if it is not too small/large
- Ensure the specified amount does not exceed
from_smart_contract
’s staked balance
- Prepare
bonus_status
:- If
from_smart_contract
is not registered, maintain thebonus_status
value when transferring singular stake. - If
to_smart_contract
is registered andSafeMovesRemaining
> 0, decrementSafeMovesRemaining
forto_smart_contract
. - If
SafeMovesRemaining
reaches zero, setbonus_status
toBonusForfeited
.
- If
- Perform stake transfer
- If
amount
is None, transfer everything fromfrom_smart_contract
toto_smart_contract
, removingSingularStakingInfo
forfrom_smart_contract
- If the remaining stake amount in
from_smart_contract
is lower than theMinimumStakeAmount
, move everything and removeSingularStakingInfo
too. - Add the amount to
to_smart_contract
's staked balance - Update, Create or Remove
SingularStakingInfo
for both contracts
- If
- Emit
Move
event:account
/from_smart_contract
/to_smart_contract
/amount
/bonus_status
Just to confirm I've captured your idea, are you suggesting of having two new config params? Something like:
TempBonusMovesForPeriod3 - used in the ongoing period
MaxBonusMovesPerPeriod - use in the next period
In case we decide to take the route without migration & without the dummy value OneMoveRemaining
, then yes.
We should avoid relying on us doing another timely runtime upgrade in the future just to change a config params.
Few comments:
-
If amount is None,
- I understand the idea, but no need to complicate the interface - let the UI handle that.
- The logic overall looks good! Technical detail - we've had
nomination_transfer
call in the old dApp staking v2. The way I implemented it was by reusing the stake/unstake logic. The move was essentially divided intounstake
, followed bystake
. That way there's very little new code added (small tip 🙂).
Perfect! I've edited the original issue's body accordingly