Skip to content
Merged
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
141 changes: 140 additions & 1 deletion contracts/governance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ mod governance {
created_at: now,
executed_at: 0,
timelock_until: 0,
is_emergency: false,
};

self.proposals.insert(proposal_id, &proposal);
Expand All @@ -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<AccountId>,
) -> Result<u64, Error> {
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<u32, Error> {
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> {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -574,4 +712,5 @@ mod governance {
// =========================================================================
// Tests
// =========================================================================
include!("tests.rs");
}
84 changes: 84 additions & 0 deletions contracts/governance/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ink::env::DefaultEnvironment>() 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);
}
}

20 changes: 20 additions & 0 deletions contracts/governance/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Loading
Loading