Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions contracts/staking/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum Error {
VotingEnded,
QuorumNotReached,
TooManyProposals,
EarlyWithdrawalPenaltyApplied,
}

impl core::fmt::Display for Error {
Expand All @@ -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"),
}
}
}
Expand All @@ -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,
}
}

Expand All @@ -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",
}
}

Expand Down
109 changes: 82 additions & 27 deletions contracts/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,6 +159,7 @@ mod staking {
param_votes: Mapping<(u64, AccountId), bool>,
voting_period_blocks: u64,
quorum_bps: u32,
early_withdrawal_penalty_bps: u128,
}

// =========================================================================
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)]
Expand Down
79 changes: 79 additions & 0 deletions contracts/staking/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions contracts/traits/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;