diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs index ed21777f..801bff65 100644 --- a/contracts/governance/src/lib.rs +++ b/contracts/governance/src/lib.rs @@ -199,6 +199,7 @@ mod governance { created_at: now, executed_at: 0, timelock_until: 0, + is_emergency: false, }; self.proposals.insert(proposal_id, &proposal); @@ -214,6 +215,139 @@ mod governance { Ok(proposal_id) } + /// Creates a new emergency proposal. Only signers may propose. + /// Emergency proposals require unanimous signer approval but bypass the timelock. + #[ink(message)] + pub fn create_emergency_proposal( + &mut self, + description_hash: Hash, + action_type: GovernanceAction, + target: Option, + ) -> Result { + let caller = self.env().caller(); + self.ensure_signer(caller)?; + + if self.active_proposal_count >= constants::GOVERNANCE_MAX_ACTIVE_PROPOSALS { + return Err(Error::MaxProposals); + } + + let proposal_id = self.proposal_counter; + self.proposal_counter = self.proposal_counter.saturating_add(1); + let now = self.env().block_number() as u64; + + // Unanimous approval required for emergency + let emergency_threshold = self.signers.len() as u32; + + let proposal = GovernanceProposal { + id: proposal_id, + proposer: caller, + description_hash, + action_type: action_type.clone(), + target, + threshold: emergency_threshold, + votes_for: 0, + votes_against: 0, + status: ProposalStatus::Active, + created_at: now, + executed_at: 0, + timelock_until: 0, + is_emergency: true, + }; + + self.proposals.insert(proposal_id, &proposal); + self.active_proposal_count = self.active_proposal_count.saturating_add(1); + + self.env().emit_event(ProposalCreated { + proposal_id, + proposer: caller, + action_type, + threshold: emergency_threshold, + }); + + Ok(proposal_id) + } + + /// Returns the governance analytics. + #[ink(message)] + pub fn get_analytics(&self) -> GovernanceAnalytics { + let total = self.proposal_counter; + let mut executed = 0; + let mut rejected = 0; + let mut cancelled = 0; + let mut active = 0; + + let mut total_participation_bps: u64 = 0; + let mut closed_count = 0; + + let signer_count = self.signers.len() as u64; + + for id in 0..total { + if let Some(proposal) = self.proposals.get(id) { + match proposal.status { + ProposalStatus::Active => active += 1, + ProposalStatus::Approved => active += 1, + ProposalStatus::Executed => { + executed += 1; + closed_count += 1; + if signer_count > 0 { + let total_votes = (proposal.votes_for.saturating_add(proposal.votes_against)) as u64; + let bps = total_votes.saturating_mul(10_000) / signer_count; + total_participation_bps = total_participation_bps.saturating_add(bps); + } + } + ProposalStatus::Rejected => { + rejected += 1; + closed_count += 1; + if signer_count > 0 { + let total_votes = (proposal.votes_for.saturating_add(proposal.votes_against)) as u64; + let bps = total_votes.saturating_mul(10_000) / signer_count; + total_participation_bps = total_participation_bps.saturating_add(bps); + } + } + ProposalStatus::Cancelled => { + cancelled += 1; + } + ProposalStatus::Expired => { + closed_count += 1; + if signer_count > 0 { + let total_votes = (proposal.votes_for.saturating_add(proposal.votes_against)) as u64; + let bps = total_votes.saturating_mul(10_000) / signer_count; + total_participation_bps = total_participation_bps.saturating_add(bps); + } + } + } + } + } + + let avg_participation_bps = if closed_count > 0 { + (total_participation_bps / closed_count) as u32 + } else { + 0 + }; + + GovernanceAnalytics { + total_proposals: total, + executed_proposals: executed, + rejected_proposals: rejected, + cancelled_proposals: cancelled, + active_proposals: active, + avg_participation_bps, + } + } + + /// Returns the participation rate for a specific proposal in basis points. + #[ink(message)] + pub fn get_proposal_participation(&self, proposal_id: u64) -> Result { + let proposal = self.proposals.get(proposal_id).ok_or(Error::ProposalNotFound)?; + let signer_count = self.signers.len() as u32; + if signer_count == 0 { + return Ok(0); + } + let total_votes = proposal.votes_for.saturating_add(proposal.votes_against); + let bps = (total_votes as u64).saturating_mul(10_000) / (signer_count as u64); + Ok(bps as u32) + } + /// Casts a vote on an active proposal. Only signers may vote. #[ink(message)] pub fn vote(&mut self, proposal_id: u64, support: bool) -> Result<(), Error> { @@ -244,7 +378,11 @@ mod governance { if proposal.votes_for >= proposal.threshold { let now = self.env().block_number() as u64; proposal.status = ProposalStatus::Approved; - proposal.timelock_until = now.saturating_add(self.timelock_blocks); + if proposal.is_emergency { + proposal.timelock_until = now; // Bypass timelock + } else { + proposal.timelock_until = now.saturating_add(self.timelock_blocks); + } self.active_proposal_count = self.active_proposal_count.saturating_sub(1); } @@ -574,4 +712,5 @@ mod governance { // ========================================================================= // Tests // ========================================================================= + include!("tests.rs"); } diff --git a/contracts/governance/src/tests.rs b/contracts/governance/src/tests.rs index de75e986..a8d5f0b2 100644 --- a/contracts/governance/src/tests.rs +++ b/contracts/governance/src/tests.rs @@ -199,4 +199,88 @@ mod tests { assert_eq!(proposal.status, ProposalStatus::Cancelled); assert_eq!(gov.get_active_proposal_count(), 0); } + + #[ink::test] + fn emergency_proposal_succeeds_without_timelock() { + let mut gov = create_governance(); + let accounts = default_accounts(); + + // Create emergency proposal + set_caller(accounts.alice); + let id = gov.create_emergency_proposal(dummy_hash(), GovernanceAction::ModifyProperty, None) + .unwrap(); + + let proposal = gov.get_proposal(id).unwrap(); + assert_eq!(proposal.is_emergency, true); + assert_eq!(proposal.threshold, 3); // Unanimous: all 3 signers + + // Vote on proposal + gov.vote(id, true).unwrap(); + + set_caller(accounts.bob); + gov.vote(id, true).unwrap(); + + set_caller(accounts.charlie); + gov.vote(id, true).unwrap(); + + // Once approved, emergency proposals bypass timelock and can be executed immediately! + let proposal = gov.get_proposal(id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Approved); + assert_eq!( + proposal.timelock_until, + ink::env::block_number::() as u64 + ); + + // Execute immediately + gov.execute_proposal(id).unwrap(); + let proposal = gov.get_proposal(id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + } + + #[ink::test] + fn governance_analytics_and_participation_rates() { + let mut gov = create_governance(); + let accounts = default_accounts(); + + // 1. Check initial empty analytics + let stats = gov.get_analytics(); + assert_eq!(stats.total_proposals, 0); + assert_eq!(stats.executed_proposals, 0); + assert_eq!(stats.avg_participation_bps, 0); + + // 2. Create and execute proposal + set_caller(accounts.alice); + gov.create_proposal(dummy_hash(), GovernanceAction::ModifyProperty, None) + .unwrap(); + + // Bob and Charlie vote (2 out of 3 signers vote) -> 66% (6666 bps) + set_caller(accounts.bob); + gov.vote(0, true).unwrap(); + set_caller(accounts.charlie); + gov.vote(0, true).unwrap(); + + // Timelock and execute + advance_block(11); + set_caller(accounts.alice); + gov.execute_proposal(0).unwrap(); + + // 3. Create another proposal that gets rejected + let id2 = gov.create_proposal(dummy_hash(), GovernanceAction::SaleApproval, None).unwrap(); + // Alice votes against, Bob votes against -> 2 out of 3 vote (66.6%) + set_caller(accounts.alice); + gov.vote(id2, false).unwrap(); + set_caller(accounts.bob); + gov.vote(id2, false).unwrap(); + + let stats = gov.get_analytics(); + assert_eq!(stats.total_proposals, 2); + assert_eq!(stats.executed_proposals, 1); + assert_eq!(stats.rejected_proposals, 1); + // Average participation rate: (6666 + 6666) / 2 = 6666 bps + assert_eq!(stats.avg_participation_bps, 6666); + + // Proposal participation rate query + assert_eq!(gov.get_proposal_participation(0).unwrap(), 6666); + } } + diff --git a/contracts/governance/src/types.rs b/contracts/governance/src/types.rs index 574beaaa..6c4f577f 100644 --- a/contracts/governance/src/types.rs +++ b/contracts/governance/src/types.rs @@ -61,4 +61,24 @@ pub struct GovernanceProposal { pub created_at: u64, pub executed_at: u64, pub timelock_until: u64, + pub is_emergency: bool, } + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct GovernanceAnalytics { + pub total_proposals: u64, + pub executed_proposals: u64, + pub rejected_proposals: u64, + pub cancelled_proposals: u64, + pub active_proposals: u64, + pub avg_participation_bps: u32, +} + diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index 82f2e3ae..f1fb6495 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -74,6 +74,20 @@ mod staking { pub reward_rate_bps: u128, } + #[ink(event)] + pub struct AutoCompoundUpdated { + #[ink(topic)] + pub staker: AccountId, + pub auto_compound: bool, + } + + #[ink(event)] + pub struct RewardsReinvested { + #[ink(topic)] + pub staker: AccountId, + pub amount: u128, + } + #[ink(event)] pub struct ParamProposalCreated { #[ink(topic)] @@ -256,6 +270,7 @@ mod staking { lock_period, reward_debt: self.acc_reward_per_share, governance_delegate: None, + auto_compound: false, }; self.stakes.insert(caller, &stake_info); @@ -326,19 +341,65 @@ mod staking { return Err(Error::InsufficientPool); } + let now = self.env().block_number() as u64; self.reward_pool = self.reward_pool.saturating_sub(rewards); - stake.reward_debt = self.acc_reward_per_share; - self.stakes.insert(caller, &stake); - self.env().emit_event(RewardsClaimed { - staker: caller, - amount: rewards, - }); + if stake.auto_compound { + stake.amount = stake.amount.saturating_add(rewards); + self.total_staked = self.total_staked.saturating_add(rewards); + + // Update governance power + let power_holder = stake.governance_delegate.unwrap_or(stake.staker); + let current_power = self.governance_power.get(power_holder).unwrap_or(0); + self.governance_power.insert(power_holder, ¤t_power.saturating_add(rewards)); + + stake.staked_at = now; + stake.reward_debt = self.acc_reward_per_share; + self.stakes.insert(caller, &stake); + + self.env().emit_event(RewardsReinvested { + staker: caller, + amount: rewards, + }); + } else { + stake.staked_at = now; + stake.reward_debt = self.acc_reward_per_share; + self.stakes.insert(caller, &stake); + + self.env().emit_event(RewardsClaimed { + staker: caller, + amount: rewards, + }); + } Ok(rewards) }) } + /// Opt-in or opt-out of automatic compounding. + #[ink(message)] + pub fn set_auto_compound(&mut self, auto_compound: bool) -> Result<(), Error> { + let caller = self.env().caller(); + let mut stake = self.stakes.get(caller).ok_or(Error::StakeNotFound)?; + stake.auto_compound = auto_compound; + self.stakes.insert(caller, &stake); + self.env().emit_event(AutoCompoundUpdated { + staker: caller, + auto_compound, + }); + Ok(()) + } + + /// Returns the staking tier for a staker. + #[ink(message)] + pub fn get_staker_tier(&self, staker: AccountId) -> StakingTier { + if let Some(stake) = self.stakes.get(staker) { + self.get_tier_internal(stake.amount) + } else { + StakingTier::Bronze + } + } + /// Delegate governance power to another address. #[ink(message)] pub fn delegate_governance(&mut self, delegate: AccountId) -> Result<(), Error> { @@ -638,7 +699,26 @@ mod staking { / 5_256_000; // blocks per year let multiplier = stake.lock_period.multiplier(); - base_reward.saturating_mul(multiplier) / 100 + let reward = base_reward.saturating_mul(multiplier) / 100; + + // Apply staking tier bonus multiplier + let tier = self.get_tier_internal(stake.amount); + let tier_multiplier = tier.reward_multiplier(); + reward.saturating_mul(tier_multiplier) / 100 + } + + fn get_tier_internal(&self, amount: u128) -> StakingTier { + if amount >= 500_000 { + StakingTier::Diamond + } else if amount >= 100_000 { + StakingTier::Platinum + } else if amount >= 50_000 { + StakingTier::Gold + } else if amount >= 10_000 { + StakingTier::Silver + } else { + StakingTier::Bronze + } } fn validate_param(kind: &ParamKind) -> Result<(), Error> { diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index f240bcf9..476d9072 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -125,7 +125,7 @@ mod tests { let accounts = default_accounts(); set_caller(accounts.alice); - staking.fund_reward_pool(1_000_000_000_000).unwrap(); + staking.fund_reward_pool(10_000_000_000_000).unwrap(); set_caller(accounts.bob); staking @@ -520,4 +520,74 @@ mod tests { assert_eq!(staking.get_min_stake(), 2_000); } + + #[ink::test] + fn set_auto_compound_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + set_caller(accounts.bob); + staking.stake(10_000, LockPeriod::Flexible).unwrap(); + + let stake_info = staking.get_stake(accounts.bob).unwrap(); + assert_eq!(stake_info.auto_compound, false); + + staking.set_auto_compound(true).unwrap(); + let stake_info = staking.get_stake(accounts.bob).unwrap(); + assert_eq!(stake_info.auto_compound, true); + } + + #[ink::test] + fn auto_compounding_reinvests_rewards() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(10_000_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking.stake(1_000_000_000_000_000, LockPeriod::Flexible).unwrap(); + staking.set_auto_compound(true).unwrap(); + + advance_block(100_000); + + let initial_stake = staking.get_stake(accounts.bob).unwrap().amount; + let pending = staking.get_pending_rewards(accounts.bob); + assert!(pending > 0); + + staking.claim_rewards().unwrap(); + + let final_stake = staking.get_stake(accounts.bob).unwrap().amount; + assert_eq!(final_stake, initial_stake + pending); + } + + #[ink::test] + fn staking_tiers_applied_correctly() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + // Bob stakes Bronze amount (< 10_000) + set_caller(accounts.bob); + staking.stake(5_000, LockPeriod::Flexible).unwrap(); + assert_eq!(staking.get_staker_tier(accounts.bob), StakingTier::Bronze); + + // Charlie stakes Silver amount (>= 10_000) + set_caller(accounts.charlie); + staking.stake(15_000, LockPeriod::Flexible).unwrap(); + assert_eq!(staking.get_staker_tier(accounts.charlie), StakingTier::Silver); + + // Django stakes Gold amount (>= 50_000) + let django = accounts.django; + set_caller(django); + staking.stake(55_000, LockPeriod::Flexible).unwrap(); + assert_eq!(staking.get_staker_tier(django), StakingTier::Gold); + + // Verify tier name and multiplier + assert_eq!(StakingTier::Bronze.name(), "Bronze"); + assert_eq!(StakingTier::Bronze.reward_multiplier(), 100); + assert_eq!(StakingTier::Silver.reward_multiplier(), 110); + assert_eq!(StakingTier::Gold.reward_multiplier(), 120); + assert_eq!(StakingTier::Platinum.reward_multiplier(), 135); + assert_eq!(StakingTier::Diamond.reward_multiplier(), 150); + } } + diff --git a/contracts/staking/src/types.rs b/contracts/staking/src/types.rs index cf30460a..bc8247f7 100644 --- a/contracts/staking/src/types.rs +++ b/contracts/staking/src/types.rs @@ -56,8 +56,51 @@ pub struct StakeInfo { pub lock_period: LockPeriod, pub reward_debt: u128, pub governance_delegate: Option, + pub auto_compound: bool, } +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum StakingTier { + Bronze, + Silver, + Gold, + Platinum, + Diamond, +} + +impl StakingTier { + pub fn name(&self) -> &'static str { + match self { + StakingTier::Bronze => "Bronze", + StakingTier::Silver => "Silver", + StakingTier::Gold => "Gold", + StakingTier::Platinum => "Platinum", + StakingTier::Diamond => "Diamond", + } + } + + pub fn reward_multiplier(&self) -> u128 { + match self { + StakingTier::Bronze => 100, // 1.0x + StakingTier::Silver => 110, // 1.1x + StakingTier::Gold => 120, // 1.2x + StakingTier::Platinum => 135, // 1.35x + StakingTier::Diamond => 150, // 1.5x + } + } +} + + /// A staking parameter that stakers can vote to change. #[derive( Debug,