From df5d42c59eb7c371cb61253de90945b633218a91 Mon Sep 17 00:00:00 2001 From: mark8-1 Date: Sat, 30 May 2026 12:41:01 +0200 Subject: [PATCH] feat(staking): implement early withdrawal penalty --- contracts/staking/src/errors.rs | 4 ++ contracts/staking/src/lib.rs | 109 ++++++++++++++++++++++-------- contracts/staking/src/tests.rs | 79 ++++++++++++++++++++++ contracts/traits/src/constants.rs | 5 ++ 4 files changed, 170 insertions(+), 27 deletions(-) diff --git a/contracts/staking/src/errors.rs b/contracts/staking/src/errors.rs index f81885b2..f845cde5 100644 --- a/contracts/staking/src/errors.rs +++ b/contracts/staking/src/errors.rs @@ -22,6 +22,7 @@ pub enum Error { VotingEnded, QuorumNotReached, TooManyProposals, + EarlyWithdrawalPenaltyApplied, } impl core::fmt::Display for Error { @@ -46,6 +47,7 @@ impl core::fmt::Display for Error { Error::VotingEnded => write!(f, "Voting period has ended"), Error::QuorumNotReached => write!(f, "Quorum not reached"), Error::TooManyProposals => write!(f, "Too many active proposals"), + Error::EarlyWithdrawalPenaltyApplied => write!(f, "Early withdrawal penalty applied"), } } } @@ -72,6 +74,7 @@ impl ContractError for Error { Error::VotingEnded => staking_codes::STAKING_VOTING_ENDED, Error::QuorumNotReached => staking_codes::STAKING_QUORUM_NOT_REACHED, Error::TooManyProposals => staking_codes::STAKING_TOO_MANY_PROPOSALS, + Error::EarlyWithdrawalPenaltyApplied => staking_codes::STAKING_LOCK_ACTIVE + 1, } } @@ -96,6 +99,7 @@ impl ContractError for Error { Error::VotingEnded => "Cannot vote after the voting window has closed", Error::QuorumNotReached => "Total turnout did not meet the quorum threshold", Error::TooManyProposals => "Active proposal limit reached", + Error::EarlyWithdrawalPenaltyApplied => "Stake was withdrawn early; a penalty was deducted", } } diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index f1fb6495..afa7825e 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -107,6 +107,13 @@ mod staking { pub support: bool, pub weight: u128, } + #[ink(event)] + pub struct EarlyWithdrawal { + #[ink(topic)] + pub staker: AccountId, + pub amount_returned: u128, + pub penalty: u128, +} #[ink(event)] pub struct ParamProposalExecuted { @@ -152,6 +159,7 @@ mod staking { param_votes: Mapping<(u64, AccountId), bool>, voting_period_blocks: u64, quorum_bps: u32, + early_withdrawal_penalty_bps: u128, } // ========================================================================= @@ -191,6 +199,7 @@ mod staking { param_votes: Mapping::default(), voting_period_blocks: DEFAULT_VOTING_PERIOD_BLOCKS, quorum_bps: DEFAULT_QUORUM_BPS, + early_withdrawal_penalty_bps: constants::DEFAULT_EARLY_WITHDRAWAL_PENALTY_BPS, } } @@ -292,39 +301,85 @@ mod staking { Ok(()) } - /// Unstake tokens. Fails if the lock period is still active. + /// Unstake tokens. If called before the lock period expires, a penalty +/// of `early_withdrawal_penalty_bps` is deducted from the returned amount. +/// The penalty amount is retained in the reward pool. #[ink(message)] pub fn unstake(&mut self) -> Result<(), Error> { - propchain_traits::non_reentrant!(self, { - let caller = self.env().caller(); - let stake = self.stakes.get(caller).ok_or(Error::StakeNotFound)?; - - let now = self.env().block_number() as u64; - if now < stake.lock_until { - return Err(Error::LockActive); - } - - let amount = stake.amount; - - // Remove governance power - self.remove_governance_power(&stake); - - self.stakes.remove(caller); - self.total_staked = self.total_staked.saturating_sub(amount); + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + let stake = self.stakes.get(caller).ok_or(Error::StakeNotFound)?; + + let now = self.env().block_number() as u64; + let amount = stake.amount; + let is_early = now < stake.lock_until; + + // Calculate penalty for early withdrawal (zero for on-time or flexible) + let penalty = if is_early && stake.lock_period != LockPeriod::Flexible { + amount + .saturating_mul(self.early_withdrawal_penalty_bps) + .saturating_div(constants::BASIS_POINTS_DENOMINATOR as u128) + } else { + 0 + }; + + let amount_returned = amount.saturating_sub(penalty); + + // Remove governance power + self.remove_governance_power(&stake); + + self.stakes.remove(caller); + self.total_staked = self.total_staked.saturating_sub(amount); + + // Penalty stays in the reward pool to benefit remaining stakers + if penalty > 0 { + self.reward_pool = self.reward_pool.saturating_add(penalty); + } - // Remove from staker list - if let Some(pos) = self.staker_list.iter().position(|s| *s == caller) { - self.staker_list.swap_remove(pos); - } + // Remove from staker list + if let Some(pos) = self.staker_list.iter().position(|s| *s == caller) { + self.staker_list.swap_remove(pos); + } - self.env().emit_event(Unstaked { - staker: caller, - amount, - }); + if is_early && stake.lock_period != LockPeriod::Flexible { + self.env().emit_event(EarlyWithdrawal { + staker: caller, + amount_returned, + penalty, + }); + } else { + self.env().emit_event(Unstaked { + staker: caller, + amount, + }); + } - Ok(()) - }) + Ok(()) + }) } + /// Update the early withdrawal penalty rate. Admin only. +/// `penalty_bps` must not exceed `MAX_EARLY_WITHDRAWAL_PENALTY_BPS`. +/// +#[ink(message)] +pub fn set_early_withdrawal_penalty( + &mut self, + penalty_bps: u128, +) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + if penalty_bps > constants::MAX_EARLY_WITHDRAWAL_PENALTY_BPS { + return Err(Error::InvalidConfig); + } + self.early_withdrawal_penalty_bps = penalty_bps; + Ok(()) +} + +/// Get the current early withdrawal penalty rate in basis points. +#[ink(message)] +pub fn get_early_withdrawal_penalty_bps(&self) -> u128 { + self.early_withdrawal_penalty_bps +} /// Claim accumulated rewards. #[ink(message)] diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index 476d9072..fb053b6e 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -561,6 +561,85 @@ mod tests { } #[ink::test] + +#[ink::test] +fn early_withdrawal_applies_penalty() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + + staking.stake(10_000_000_000_000, LockPeriod::ThirtyDays).unwrap(); + + // Unstake immediately — lock not expired, penalty should apply + assert!(staking.unstake().is_ok()); + + assert_eq!(staking.get_total_staked(), 0); + // 10% of 10_000_000_000_000 = 1_000_000_000_000 went to reward pool + assert!(staking.get_reward_pool() >= 1_000_000_000_000); +} + +#[ink::test] +fn flexible_lock_no_penalty_on_early_unstake() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + + staking.stake(10_000_000_000_000, LockPeriod::Flexible).unwrap(); + + let pool_before = staking.get_reward_pool(); + assert!(staking.unstake().is_ok()); + assert_eq!(staking.get_total_staked(), 0); + // No penalty for flexible + assert_eq!(staking.get_reward_pool(), pool_before); +} + +#[ink::test] +fn no_penalty_after_lock_expires() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + + staking.stake(10_000_000_000_000, LockPeriod::ThirtyDays).unwrap(); + + // Advance past the 30-day lock period + advance_block(constants::LOCK_PERIOD_30_DAYS as u32 + 1); + + let pool_before = staking.get_reward_pool(); + assert!(staking.unstake().is_ok()); + // Reward pool unchanged — no penalty after expiry + assert_eq!(staking.get_reward_pool(), pool_before); +} + +#[ink::test] +fn set_early_withdrawal_penalty_admin_only() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + // Admin (alice) can update + assert!(staking.set_early_withdrawal_penalty(500).is_ok()); + assert_eq!(staking.get_early_withdrawal_penalty_bps(), 500); + + // Non-admin cannot + set_caller(accounts.bob); + assert_eq!( + staking.set_early_withdrawal_penalty(200), + Err(Error::Unauthorized) + ); +} + +#[ink::test] +fn set_early_withdrawal_penalty_max_cap() { + let mut staking = create_staking(); + + // Above 50% cap is rejected + assert_eq!( + staking.set_early_withdrawal_penalty(6_000), + Err(Error::InvalidConfig) + ); + + // Exactly at cap is fine + assert!(staking.set_early_withdrawal_penalty(5_000).is_ok()); +} fn staking_tiers_applied_correctly() { let mut staking = create_staking(); let accounts = default_accounts(); diff --git a/contracts/traits/src/constants.rs b/contracts/traits/src/constants.rs index 943a2d0c..b78f1086 100644 --- a/contracts/traits/src/constants.rs +++ b/contracts/traits/src/constants.rs @@ -202,3 +202,8 @@ pub const MAX_PAUSE_DURATION: u64 = 2_592_000; /// Minimum pause duration in seconds (1 minute). pub const MIN_PAUSE_DURATION: u64 = 60; +/// Default early withdrawal penalty in basis points (10% = 1000 bps). +pub const DEFAULT_EARLY_WITHDRAWAL_PENALTY_BPS: u128 = 1_000; + +/// Maximum early withdrawal penalty allowed (50% = 5000 bps). +pub const MAX_EARLY_WITHDRAWAL_PENALTY_BPS: u128 = 5_000;