diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index afa7825e..6ba3fde1 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -139,6 +139,93 @@ mod staking { // Storage // ========================================================================= + // ========================================================================= + // Delegation Events + // ========================================================================= + + #[ink(event)] + pub struct ValidatorRegistered { + #[ink(topic)] + pub validator: AccountId, + pub self_stake: u128, + pub commission_rate: u32, + } + + #[ink(event)] + pub struct CommissionRateUpdated { + #[ink(topic)] + pub validator: AccountId, + pub old_rate: u32, + pub new_rate: u32, + } + + #[ink(event)] + pub struct StakeDelegated { + #[ink(topic)] + pub delegator: AccountId, + #[ink(topic)] + pub validator: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct UndelegationInitiated { + #[ink(topic)] + pub delegator: AccountId, + #[ink(topic)] + pub validator: AccountId, + pub amount: u128, + pub claimable_at: u64, + } + + #[ink(event)] + pub struct UndelegatedTokensClaimed { + #[ink(topic)] + pub delegator: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct DelegationRewardsClaimed { + #[ink(topic)] + pub delegator: AccountId, + #[ink(topic)] + pub validator: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct ValidatorCommissionClaimed { + #[ink(topic)] + pub validator: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct ValidatorSlashed { + #[ink(topic)] + pub validator: AccountId, + pub slash_amount: u128, + pub delegated_reduction: u128, + } + + #[ink(event)] + pub struct ValidatorDeactivated { + #[ink(topic)] + pub validator: AccountId, + pub reason: DeactivationReason, + } + + #[ink(event)] + pub struct ValidatorReactivated { + #[ink(topic)] + pub validator: AccountId, + } + + // ========================================================================= + // Storage + // ========================================================================= + #[ink(storage)] pub struct Staking { admin: AccountId, @@ -833,6 +920,505 @@ pub fn get_early_withdrawal_penalty_bps(&self) -> u128 { self.governance_power.insert(power_holder, &new_power); } } + + // ========================================================================= + // Delegated Staking — Internal Helpers + // ========================================================================= + + /// Sync the per-validator reward accumulator up to the current block. + /// Must be called before any state mutation that touches a validator's + /// reward state. + fn update_validator_rewards(&mut self, validator: AccountId) { + let mut info = match self.validators.get(validator) { + Some(v) => v, + None => return, + }; + let now = self.env().block_number() as u64; + let blocks = (now as u128).saturating_sub(info.last_reward_block as u128); + if blocks == 0 || info.total_delegated == 0 { + info.last_reward_block = now; + self.validators.insert(validator, &info); + return; + } + // gross reward for the delegated pool over elapsed blocks + let gross_reward = info + .total_delegated + .saturating_mul(self.reward_rate_bps) + .saturating_mul(blocks) + / constants::REWARD_RATE_PRECISION + / 5_256_000; // blocks per year + + let commission = gross_reward + .saturating_mul(info.commission_rate as u128) + / BPS_DENOMINATOR as u128; + let net_reward = gross_reward.saturating_sub(commission); + + info.accumulated_commission = info.accumulated_commission.saturating_add(commission); + info.acc_reward_per_share = info.acc_reward_per_share.saturating_add( + net_reward + .saturating_mul(REWARD_PRECISION) + / info.total_delegated, + ); + info.last_reward_block = now; + self.validators.insert(validator, &info); + } + + /// Project the pending delegation reward for a record without writing state. + fn pending_delegation_reward( + &self, + record: &DelegationRecord, + info: &ValidatorInfo, + ) -> u128 { + let now = self.env().block_number() as u64; + let blocks = (now as u128).saturating_sub(info.last_reward_block as u128); + + let projected_acc = if info.total_delegated > 0 && blocks > 0 { + let gross = info + .total_delegated + .saturating_mul(self.reward_rate_bps) + .saturating_mul(blocks) + / constants::REWARD_RATE_PRECISION + / 5_256_000; + let commission = gross + .saturating_mul(info.commission_rate as u128) + / BPS_DENOMINATOR as u128; + let net = gross.saturating_sub(commission); + info.acc_reward_per_share.saturating_add( + net.saturating_mul(REWARD_PRECISION) / info.total_delegated, + ) + } else { + info.acc_reward_per_share + }; + + (record + .amount + .saturating_mul(projected_acc) + / REWARD_PRECISION) + .saturating_sub(record.reward_debt) + } + + // ========================================================================= + // Delegated Staking — Validator Lifecycle + // ========================================================================= + + /// Register the caller as a validator with a self-stake and commission rate. + #[ink(message)] + pub fn register_validator( + &mut self, + self_stake: u128, + commission_rate: u32, + ) -> Result<(), Error> { + let caller = self.env().caller(); + if self_stake < MIN_VALIDATOR_STAKE { + return Err(Error::InsufficientValidatorStake); + } + if commission_rate > MAX_COMMISSION_RATE { + return Err(Error::InvalidCommissionRate); + } + if self.validators.contains(caller) { + return Err(Error::AlreadyValidator); + } + let now = self.env().block_number() as u64; + let info = ValidatorInfo { + self_stake, + commission_rate, + total_delegated: 0, + accumulated_commission: 0, + is_active: true, + acc_reward_per_share: 0, + last_reward_block: now, + }; + self.validators.insert(caller, &info); + self.validator_list.push(caller); + self.env().emit_event(ValidatorRegistered { + validator: caller, + self_stake, + commission_rate, + }); + Ok(()) + } + + /// Update the caller's commission rate. + #[ink(message)] + pub fn update_commission_rate(&mut self, new_rate: u32) -> Result<(), Error> { + let caller = self.env().caller(); + if !self.validators.contains(caller) { + return Err(Error::Unauthorized); + } + if new_rate > MAX_COMMISSION_RATE { + return Err(Error::InvalidCommissionRate); + } + self.update_validator_rewards(caller); + let mut info = self.validators.get(caller).unwrap(); + let old_rate = info.commission_rate; + info.commission_rate = new_rate; + self.validators.insert(caller, &info); + self.env().emit_event(CommissionRateUpdated { + validator: caller, + old_rate, + new_rate, + }); + Ok(()) + } + + /// Voluntarily deactivate the caller's validator. + #[ink(message)] + pub fn deactivate_validator(&mut self) -> Result<(), Error> { + let caller = self.env().caller(); + let mut info = self.validators.get(caller).ok_or(Error::Unauthorized)?; + info.is_active = false; + self.validators.insert(caller, &info); + self.env().emit_event(ValidatorDeactivated { + validator: caller, + reason: DeactivationReason::Voluntary, + }); + Ok(()) + } + + /// Reactivate an inactive validator. + #[ink(message)] + pub fn reactivate_validator(&mut self) -> Result<(), Error> { + let caller = self.env().caller(); + let mut info = self.validators.get(caller).ok_or(Error::Unauthorized)?; + if info.self_stake < MIN_VALIDATOR_STAKE { + return Err(Error::InsufficientValidatorStake); + } + info.is_active = true; + self.validators.insert(caller, &info); + self.env().emit_event(ValidatorReactivated { validator: caller }); + Ok(()) + } + + /// Admin-only: slash a validator and propagate to all delegators. + #[ink(message)] + pub fn slash_validator(&mut self, validator: AccountId) -> Result<(), Error> { + propchain_traits::non_reentrant!(self, { + self.ensure_admin()?; + if !self.validators.contains(validator) { + return Err(Error::ValidatorNotFound); + } + + self.update_validator_rewards(validator); + let mut info = self.validators.get(validator).unwrap(); + + // Slash validator self-stake + let self_slash = info.self_stake.saturating_mul(SLASH_PERCENT) / 100; + info.self_stake = info.self_stake.saturating_sub(self_slash); + + // Slash each delegator + let delegators = self + .validator_delegators + .get(validator) + .unwrap_or_default(); + let mut total_delegated_reduction: u128 = 0; + for delegator in &delegators { + let key = (*delegator, validator); + if let Some(mut record) = self.delegations.get(key) { + let d_slash = record.amount.saturating_mul(SLASH_PERCENT) / 100; + record.amount = record.amount.saturating_sub(d_slash); + total_delegated_reduction = + total_delegated_reduction.saturating_add(d_slash); + self.delegations.insert(key, &record); + } + } + + info.total_delegated = info + .total_delegated + .saturating_sub(total_delegated_reduction); + self.total_delegated_stake = self + .total_delegated_stake + .saturating_sub(total_delegated_reduction); + + let was_active = info.is_active; + if info.self_stake < MIN_VALIDATOR_STAKE { + info.is_active = false; + } + self.validators.insert(validator, &info); + + self.env().emit_event(ValidatorSlashed { + validator, + slash_amount: self_slash, + delegated_reduction: total_delegated_reduction, + }); + + if was_active && !info.is_active { + self.env().emit_event(ValidatorDeactivated { + validator, + reason: DeactivationReason::Slashed, + }); + } + + Ok(()) + }) + } + + // ========================================================================= + // Delegated Staking — Delegation Lifecycle + // ========================================================================= + + /// Delegate `amount` tokens to `validator`. + #[ink(message)] + pub fn delegate( + &mut self, + validator: AccountId, + amount: u128, + ) -> Result<(), Error> { + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + let info = self + .validators + .get(validator) + .ok_or(Error::ValidatorNotActive)?; + if !info.is_active { + return Err(Error::ValidatorNotActive); + } + if amount < self.min_stake { + return Err(Error::InsufficientAmount); + } + if self.delegations.contains((caller, validator)) { + return Err(Error::AlreadyDelegated); + } + + self.update_validator_rewards(validator); + let info = self.validators.get(validator).unwrap(); + + let reward_debt = info + .acc_reward_per_share + .saturating_mul(amount) + / REWARD_PRECISION; + + let record = DelegationRecord { + delegator: caller, + validator, + amount, + reward_debt, + unbonding_start: None, + }; + self.delegations.insert((caller, validator), &record); + + // Update secondary indices + let mut delegators = self + .validator_delegators + .get(validator) + .unwrap_or_default(); + delegators.push(caller); + self.validator_delegators.insert(validator, &delegators); + self.delegator_validator.insert(caller, &validator); + + // Update validator totals + let mut info = self.validators.get(validator).unwrap(); + info.total_delegated = info.total_delegated.saturating_add(amount); + self.validators.insert(validator, &info); + self.total_delegated_stake = + self.total_delegated_stake.saturating_add(amount); + + self.env().emit_event(StakeDelegated { + delegator: caller, + validator, + amount, + }); + Ok(()) + }) + } + + /// Initiate unbonding for the caller's delegation. + #[ink(message)] + pub fn undelegate(&mut self, validator: AccountId) -> Result<(), Error> { + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + let mut record = self + .delegations + .get((caller, validator)) + .ok_or(Error::DelegationNotFound)?; + + if record.unbonding_start.is_some() { + return Err(Error::AlreadyUnbonding); + } + + self.update_validator_rewards(validator); + let mut info = self.validators.get(validator).unwrap(); + + let now = self.env().block_number() as u64; + record.unbonding_start = Some(now); + self.delegations.insert((caller, validator), &record); + + info.total_delegated = info.total_delegated.saturating_sub(record.amount); + self.validators.insert(validator, &info); + self.total_delegated_stake = self + .total_delegated_stake + .saturating_sub(record.amount); + + self.env().emit_event(UndelegationInitiated { + delegator: caller, + validator, + amount: record.amount, + claimable_at: now.saturating_add(UNBONDING_PERIOD_BLOCKS), + }); + Ok(()) + }) + } + + /// Claim tokens after the unbonding period has elapsed. + #[ink(message)] + pub fn claim_undelegated(&mut self, validator: AccountId) -> Result { + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + let record = self + .delegations + .get((caller, validator)) + .ok_or(Error::DelegationNotFound)?; + + let start = record.unbonding_start.ok_or(Error::DelegationNotFound)?; + let now = self.env().block_number() as u64; + if now < start.saturating_add(UNBONDING_PERIOD_BLOCKS) { + return Err(Error::UnbondingPeriodActive); + } + + let amount = record.amount; + self.delegations.remove((caller, validator)); + self.delegator_validator.remove(caller); + + // Remove from validator_delegators list + let mut delegators = self + .validator_delegators + .get(validator) + .unwrap_or_default(); + if let Some(pos) = delegators.iter().position(|d| *d == caller) { + delegators.swap_remove(pos); + } + self.validator_delegators.insert(validator, &delegators); + + self.env().emit_event(UndelegatedTokensClaimed { + delegator: caller, + amount, + }); + Ok(amount) + }) + } + + /// Claim pending delegation rewards (net of validator commission). + #[ink(message)] + pub fn claim_delegation_rewards( + &mut self, + validator: AccountId, + ) -> Result { + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + let record = self + .delegations + .get((caller, validator)) + .ok_or(Error::DelegationNotFound)?; + + self.update_validator_rewards(validator); + let info = self.validators.get(validator).unwrap(); + + let reward = self.pending_delegation_reward(&record, &info); + if reward == 0 { + return Err(Error::NoRewards); + } + if reward > self.reward_pool { + return Err(Error::InsufficientPool); + } + + self.reward_pool = self.reward_pool.saturating_sub(reward); + + let mut record = self.delegations.get((caller, validator)).unwrap(); + record.reward_debt = info + .acc_reward_per_share + .saturating_mul(record.amount) + / REWARD_PRECISION; + self.delegations.insert((caller, validator), &record); + + self.env().emit_event(DelegationRewardsClaimed { + delegator: caller, + validator, + amount: reward, + }); + Ok(reward) + }) + } + + /// Validator claims their accumulated commission. + #[ink(message)] + pub fn claim_validator_commission(&mut self) -> Result { + propchain_traits::non_reentrant!(self, { + let caller = self.env().caller(); + if !self.validators.contains(caller) { + return Err(Error::Unauthorized); + } + + self.update_validator_rewards(caller); + let mut info = self.validators.get(caller).unwrap(); + + if info.accumulated_commission == 0 { + return Err(Error::NoRewards); + } + if info.accumulated_commission > self.reward_pool { + return Err(Error::InsufficientPool); + } + + let commission = info.accumulated_commission; + self.reward_pool = self.reward_pool.saturating_sub(commission); + info.accumulated_commission = 0; + self.validators.insert(caller, &info); + + self.env().emit_event(ValidatorCommissionClaimed { + validator: caller, + amount: commission, + }); + Ok(commission) + }) + } + + // ========================================================================= + // Delegated Staking — Queries + // ========================================================================= + + /// Returns the DelegationRecord for a (delegator, validator) pair. + #[ink(message)] + pub fn get_delegation( + &self, + delegator: AccountId, + validator: AccountId, + ) -> Option { + self.delegations.get((delegator, validator)) + } + + /// Returns the ValidatorInfo for a validator account. + #[ink(message)] + pub fn get_validator_info(&self, validator: AccountId) -> Option { + self.validators.get(validator) + } + + /// Returns the pending (unclaimed) delegation reward for a delegator. + #[ink(message)] + pub fn get_pending_delegation_rewards( + &self, + delegator: AccountId, + validator: AccountId, + ) -> u128 { + let record = match self.delegations.get((delegator, validator)) { + Some(r) => r, + None => return 0, + }; + let info = match self.validators.get(validator) { + Some(i) => i, + None => return 0, + }; + self.pending_delegation_reward(&record, &info) + } + + /// Returns the list of all registered validator accounts. + #[ink(message)] + pub fn get_validator_list(&self) -> Vec { + self.validator_list.clone() + } + + /// Returns the total delegated stake across all validators. + #[ink(message)] + pub fn get_total_delegated_stake(&self) -> u128 { + self.total_delegated_stake + } } // ========================================================================= diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index fb053b6e..4c161bbd 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -2,6 +2,619 @@ #[cfg(test)] mod tests { + // ========================================================================= + // Delegated Staking Tests + // ========================================================================= + + use super::*; + + fn default_accounts() -> ink::env::test::DefaultAccounts { + ink::env::test::default_accounts::() + } + + fn set_caller(caller: AccountId) { + ink::env::test::set_caller::(caller); + } + + fn advance_block(n: u32) { + for _ in 0..n { + ink::env::test::advance_block::(); + } + } + + fn create_staking() -> Staking { + let accounts = default_accounts(); + set_caller(accounts.alice); + Staking::new(500, 1_000) + } + + // ---- Validator Registration ---- + + #[ink::test] + fn register_validator_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert!(staking.register_validator(MIN_VALIDATOR_STAKE, 500).is_ok()); + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert_eq!(info.self_stake, MIN_VALIDATOR_STAKE); + assert_eq!(info.commission_rate, 500); + assert_eq!(info.total_delegated, 0); + assert_eq!(info.accumulated_commission, 0); + assert!(info.is_active); + } + + #[ink::test] + fn register_validator_below_min_stake_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!( + staking.register_validator(MIN_VALIDATOR_STAKE - 1, 500), + Err(Error::InsufficientValidatorStake) + ); + } + + #[ink::test] + fn register_validator_invalid_commission_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!( + staking.register_validator(MIN_VALIDATOR_STAKE, MAX_COMMISSION_RATE + 1), + Err(Error::InvalidCommissionRate) + ); + } + + #[ink::test] + fn register_validator_max_commission_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert!(staking.register_validator(MIN_VALIDATOR_STAKE, MAX_COMMISSION_RATE).is_ok()); + } + + #[ink::test] + fn register_validator_double_registration_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + assert_eq!( + staking.register_validator(MIN_VALIDATOR_STAKE, 500), + Err(Error::AlreadyValidator) + ); + } + + #[ink::test] + fn get_validator_list_returns_registered() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + let list = staking.get_validator_list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0], accounts.bob); + } + + // ---- Commission Rate Update ---- + + #[ink::test] + fn update_commission_rate_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + staking.update_commission_rate(1_000).unwrap(); + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert_eq!(info.commission_rate, 1_000); + } + + #[ink::test] + fn update_commission_rate_non_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!( + staking.update_commission_rate(500), + Err(Error::Unauthorized) + ); + } + + #[ink::test] + fn update_commission_rate_exceeds_max_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + assert_eq!( + staking.update_commission_rate(MAX_COMMISSION_RATE + 1), + Err(Error::InvalidCommissionRate) + ); + } + + // ---- Deactivation / Reactivation ---- + + #[ink::test] + fn deactivate_validator_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + staking.deactivate_validator().unwrap(); + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert!(!info.is_active); + } + + #[ink::test] + fn deactivate_non_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!(staking.deactivate_validator(), Err(Error::Unauthorized)); + } + + #[ink::test] + fn reactivate_validator_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + staking.deactivate_validator().unwrap(); + staking.reactivate_validator().unwrap(); + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert!(info.is_active); + } + + #[ink::test] + fn reactivate_non_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!(staking.reactivate_validator(), Err(Error::Unauthorized)); + } + + // ---- Delegate ---- + + #[ink::test] + fn delegate_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + + let record = staking.get_delegation(accounts.charlie, accounts.bob).unwrap(); + assert_eq!(record.amount, 5_000); + assert!(record.unbonding_start.is_none()); + + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert_eq!(info.total_delegated, 5_000); + assert_eq!(staking.get_total_delegated_stake(), 5_000); + } + + #[ink::test] + fn delegate_to_inactive_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + staking.deactivate_validator().unwrap(); + + set_caller(accounts.charlie); + assert_eq!( + staking.delegate(accounts.bob, 5_000), + Err(Error::ValidatorNotActive) + ); + } + + #[ink::test] + fn delegate_to_unregistered_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.charlie); + assert_eq!( + staking.delegate(accounts.bob, 5_000), + Err(Error::ValidatorNotActive) + ); + } + + #[ink::test] + fn delegate_below_min_stake_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + assert_eq!( + staking.delegate(accounts.bob, 500), // below min_stake of 1_000 + Err(Error::InsufficientAmount) + ); + } + + #[ink::test] + fn delegate_double_delegation_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + assert_eq!( + staking.delegate(accounts.bob, 5_000), + Err(Error::AlreadyDelegated) + ); + } + + // ---- Undelegate ---- + + #[ink::test] + fn undelegate_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + staking.undelegate(accounts.bob).unwrap(); + + let record = staking.get_delegation(accounts.charlie, accounts.bob).unwrap(); + assert!(record.unbonding_start.is_some()); + + let info = staking.get_validator_info(accounts.bob).unwrap(); + assert_eq!(info.total_delegated, 0); + assert_eq!(staking.get_total_delegated_stake(), 0); + } + + #[ink::test] + fn undelegate_no_delegation_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + assert_eq!( + staking.undelegate(accounts.bob), + Err(Error::DelegationNotFound) + ); + } + + #[ink::test] + fn undelegate_already_unbonding_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + staking.undelegate(accounts.bob).unwrap(); + assert_eq!( + staking.undelegate(accounts.bob), + Err(Error::AlreadyUnbonding) + ); + } + + // ---- Claim Undelegated ---- + + #[ink::test] + fn claim_undelegated_before_period_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + staking.undelegate(accounts.bob).unwrap(); + assert_eq!( + staking.claim_undelegated(accounts.bob), + Err(Error::UnbondingPeriodActive) + ); + } + + #[ink::test] + fn claim_undelegated_after_period_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + staking.undelegate(accounts.bob).unwrap(); + + advance_block(UNBONDING_PERIOD_BLOCKS as u32 + 1); + + let amount = staking.claim_undelegated(accounts.bob).unwrap(); + assert_eq!(amount, 5_000); + assert!(staking.get_delegation(accounts.charlie, accounts.bob).is_none()); + } + + // ---- Claim Delegation Rewards ---- + + #[ink::test] + fn claim_delegation_rewards_no_delegation_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + assert_eq!( + staking.claim_delegation_rewards(accounts.bob), + Err(Error::DelegationNotFound) + ); + } + + #[ink::test] + fn claim_delegation_rewards_empty_pool_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 1_000_000_000_000_000).unwrap(); + + advance_block(100_000); + + // reward_pool is 0 — should fail with InsufficientPool (or NoRewards if 0) + let result = staking.claim_delegation_rewards(accounts.bob); + assert!( + result == Err(Error::NoRewards) || result == Err(Error::InsufficientPool), + "expected NoRewards or InsufficientPool, got {:?}", + result + ); + } + + #[ink::test] + fn claim_delegation_rewards_with_pool_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 0).unwrap(); // 0% commission + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 1_000_000_000_000_000).unwrap(); + + advance_block(100_000); + + let pending = staking.get_pending_delegation_rewards(accounts.charlie, accounts.bob); + assert!(pending > 0, "expected pending rewards > 0, got {}", pending); + + let claimed = staking.claim_delegation_rewards(accounts.bob).unwrap(); + assert!(claimed > 0); + + // After claiming, pending should be ~0 + let pending_after = staking.get_pending_delegation_rewards(accounts.charlie, accounts.bob); + assert_eq!(pending_after, 0); + } + + // ---- Claim Validator Commission ---- + + #[ink::test] + fn claim_validator_commission_no_commission_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + assert_eq!( + staking.claim_validator_commission(), + Err(Error::NoRewards) + ); + } + + #[ink::test] + fn claim_validator_commission_non_validator_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + assert_eq!( + staking.claim_validator_commission(), + Err(Error::Unauthorized) + ); + } + + #[ink::test] + fn claim_validator_commission_with_pool_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 1_000).unwrap(); // 10% commission + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 1_000_000_000_000_000).unwrap(); + + advance_block(100_000); + + // Trigger accumulator update by calling claim_delegation_rewards + // (or directly call claim_validator_commission which calls update internally) + set_caller(accounts.bob); + let commission = staking.claim_validator_commission().unwrap(); + assert!(commission > 0, "expected commission > 0, got {}", commission); + } + + // ---- Slash Validator ---- + + #[ink::test] + fn slash_validator_non_admin_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + assert_eq!( + staking.slash_validator(accounts.bob), + Err(Error::Unauthorized) + ); + } + + #[ink::test] + fn slash_validator_not_found_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.alice); + assert_eq!( + staking.slash_validator(accounts.bob), + Err(Error::ValidatorNotFound) + ); + } + + #[ink::test] + fn slash_validator_reduces_self_stake() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.alice); + staking.slash_validator(accounts.bob).unwrap(); + + let info = staking.get_validator_info(accounts.bob).unwrap(); + let expected = MIN_VALIDATOR_STAKE * (100 - SLASH_PERCENT) / 100; + assert_eq!(info.self_stake, expected); + } + + #[ink::test] + fn slash_validator_reduces_delegator_amounts() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE * 10, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + + set_caller(accounts.alice); + staking.slash_validator(accounts.bob).unwrap(); + + let record = staking.get_delegation(accounts.charlie, accounts.bob).unwrap(); + let expected = 5_000u128 * (100 - SLASH_PERCENT) / 100; + assert_eq!(record.amount, expected); + } + + #[ink::test] + fn slash_validator_below_min_deactivates() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + // Register with exactly MIN_VALIDATOR_STAKE so slash drops below minimum + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.alice); + staking.slash_validator(accounts.bob).unwrap(); + + let info = staking.get_validator_info(accounts.bob).unwrap(); + // After 20% slash: 10_000_000 * 0.8 = 8_000_000 < MIN_VALIDATOR_STAKE + assert!(!info.is_active); + + // New delegations should be rejected + set_caller(accounts.charlie); + assert_eq!( + staking.delegate(accounts.bob, 5_000), + Err(Error::ValidatorNotActive) + ); + } + + // ---- End-to-End Flow ---- + + #[ink::test] + fn full_delegation_lifecycle() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + // Fund pool + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000_000_000).unwrap(); + + // Register validator with 0% commission + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 0).unwrap(); + + // Delegate + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 1_000_000_000_000_000).unwrap(); + + // Advance blocks to accrue rewards + advance_block(100_000); + + // Claim rewards + let reward = staking.claim_delegation_rewards(accounts.bob).unwrap(); + assert!(reward > 0); + + // Undelegate + staking.undelegate(accounts.bob).unwrap(); + + // Advance past unbonding period + advance_block(UNBONDING_PERIOD_BLOCKS as u32 + 1); + + // Claim undelegated + let amount = staking.claim_undelegated(accounts.bob).unwrap(); + assert_eq!(amount, 1_000_000_000_000_000); + assert!(staking.get_delegation(accounts.charlie, accounts.bob).is_none()); + } + + #[ink::test] + fn slash_multiple_delegators() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE * 10, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 10_000).unwrap(); + + set_caller(accounts.django); + staking.delegate(accounts.bob, 20_000).unwrap(); + + set_caller(accounts.alice); + staking.slash_validator(accounts.bob).unwrap(); + + let r1 = staking.get_delegation(accounts.charlie, accounts.bob).unwrap(); + let r2 = staking.get_delegation(accounts.django, accounts.bob).unwrap(); + assert_eq!(r1.amount, 10_000u128 * (100 - SLASH_PERCENT) / 100); + assert_eq!(r2.amount, 20_000u128 * (100 - SLASH_PERCENT) / 100); + } + + #[ink::test] + fn total_delegated_stake_consistency() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.bob); + staking.register_validator(MIN_VALIDATOR_STAKE, 500).unwrap(); + + set_caller(accounts.charlie); + staking.delegate(accounts.bob, 5_000).unwrap(); + + assert_eq!(staking.get_total_delegated_stake(), 5_000); + + let list = staking.get_validator_list(); + let sum: u128 = list + .iter() + .filter_map(|v| staking.get_validator_info(*v)) + .map(|i| i.total_delegated) + .sum(); + assert_eq!(sum, staking.get_total_delegated_stake()); + } +} use super::*; fn default_accounts() -> ink::env::test::DefaultAccounts { diff --git a/contracts/staking/src/types.rs b/contracts/staking/src/types.rs index bc8247f7..5352659c 100644 --- a/contracts/staking/src/types.rs +++ b/contracts/staking/src/types.rs @@ -160,3 +160,89 @@ pub struct ParamProposal { pub status: ProposalStatus, pub created_at: u64, } + +// ─── Delegated Staking Types ──────────────────────────────────────────────── + +/// Maximum commission rate a validator may set (100% in basis points). +pub const MAX_COMMISSION_RATE: u32 = 10_000; + +/// Minimum self-stake required for a validator to register or remain active. +pub const MIN_VALIDATOR_STAKE: u128 = 10_000_000; + +/// Percentage of stake slashed on a misbehaving validator (and their delegators). +pub const SLASH_PERCENT: u128 = 20; + +/// Unbonding period in blocks (~3.5 days at 6-second blocks). +pub const UNBONDING_PERIOD_BLOCKS: u64 = 50_400; + +/// Scaling factor used in the per-validator reward accumulator (10^12). +pub const REWARD_PRECISION: u128 = 1_000_000_000_000; + +/// On-chain record for a registered validator. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct ValidatorInfo { + /// The validator's own self-stake (subject to slashing). + pub self_stake: u128, + /// Commission rate in basis points (0–10_000). + pub commission_rate: u32, + /// Sum of all active delegated amounts to this validator. + pub total_delegated: u128, + /// Accumulated commission not yet claimed. + pub accumulated_commission: u128, + /// Whether the validator is currently accepting delegations. + pub is_active: bool, + /// Cumulative reward-per-share for delegators (scaled by REWARD_PRECISION). + pub acc_reward_per_share: u128, + /// Block number of the last reward accumulation update. + pub last_reward_block: u64, +} + +/// On-chain record for a single delegator → validator binding. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct DelegationRecord { + /// The delegator account that owns this record. + pub delegator: AccountId, + /// The validator this delegation is bound to. + pub validator: AccountId, + /// The delegated token amount (reduced by slashing). + pub amount: u128, + /// Snapshot of validator's acc_reward_per_share at last claim/delegation. + pub reward_debt: u128, + /// None = active; Some(block) = unbonding started at that block. + pub unbonding_start: Option, +} + +/// Reason a validator was deactivated (used in ValidatorDeactivated event). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum DeactivationReason { + Voluntary, + Slashed, +}