diff --git a/contracts/dao_governance/src/errors.rs b/contracts/dao_governance/src/errors.rs index 2f652b06..1ed182c2 100644 --- a/contracts/dao_governance/src/errors.rs +++ b/contracts/dao_governance/src/errors.rs @@ -1,8 +1,8 @@ //! # DAO Governance Error Definitions //! //! Standardized, typed, and documented contract errors for DAO governance, -//! voting, proposals, and voice credit operations. -//! Namespace: 400–499 (Governance). +//! voting, proposals, timelock, and voice credit operations. +//! Namespace: 400-499 (Governance). This is the single `DaoError` for the crate. use soroban_sdk::contracterror; @@ -11,24 +11,40 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum DaoError { - /// 401 — Contract has already been initialized. + /// 401 - Contract has already been initialized. AlreadyInitialized = 401, - /// 402 — Contract has not been initialized. + /// 402 - Contract has not been initialized. NotInitialized = 402, - /// 403 — Caller is not authorized for governance operation. + /// 403 - Caller is not authorized for governance operation. Unauthorized = 403, - /// 404 — Proposal was not found with specified identifier. + /// 404 - Proposal was not found with specified identifier. ProposalNotFound = 404, - /// 405 — Proposal is closed and not open for voting or execution. + /// 405 - Proposal is not in the status required by the operation. ProposalClosed = 405, - /// 406 — Voting deadline is still live; premature finalization rejected. + /// 406 - Voting deadline is still live; premature finalization rejected. VotingDeadlineLive = 406, - /// 407 — Voter has already cast votes on this proposal. + /// 407 - Voter has already cast votes on this proposal (or replayed a nonce). AlreadyVoted = 407, - /// 408 — Insufficient voice credits available to cast the requested votes. + /// 408 - Insufficient spendable voice credits for the requested votes. InsufficientCredits = 408, - /// 409 — Number of votes must be strictly positive. + /// 409 - Number of votes must be non-zero. ZeroVotes = 409, - /// 410 — Proposal deadline is invalid or not in the future. + /// 410 - Invalid deadline (reserved; zero duration now selects the default period). InvalidDeadline = 410, + /// 411 - Ragequit requested with zero shares. + ZeroShares = 411, + /// 412 - Proposal is not in Passed (queued) status. + ProposalNotPassed = 412, + /// 413 - Voter cast a positive (Yes) vote and may not ragequit. + YesVoterCannotRagequit = 413, + /// 414 - Voter does not hold enough treasury shares. + InsufficientShares = 414, + /// 415 - Treasury has no reserves to pay out. + InsufficientReserves = 415, + /// 416 - Creator holds fewer credits than the proposal threshold. + BelowProposalThreshold = 416, + /// 417 - Voting period is outside the allowed bounds (1 to 30 days). + InvalidVotingPeriod = 417, + /// 418 - Timelock delay (48 hours) has not elapsed since the proposal was queued. + TimelockNotElapsed = 418, } diff --git a/contracts/dao_governance/src/lib.rs b/contracts/dao_governance/src/lib.rs index c8dfd56f..475bd60d 100644 --- a/contracts/dao_governance/src/lib.rs +++ b/contracts/dao_governance/src/lib.rs @@ -1,32 +1,68 @@ -//! # DAO Governance Module with Quadratic Voting +//! # DAO Governance Module with Quadratic Voting, Quorum and Timelock //! -//! Closes issue #717. +//! Closes issues #717 and #1625. //! -//! A Soroban-native DAO where any token holder can: -//! - create proposals with a configurable voting window, -//! - cast votes whose *cost* scales quadratically (cost = votes²), -//! so large stakeholders cannot dominate by raw token weight, -//! - finalize proposals once the deadline passes, -//! - execute passed proposals (hook for downstream actions). +//! A Soroban-native DAO where any voice-credit holder can: +//! - create proposals (creator must hold at least the proposal threshold), +//! - cast votes whose *cost* scales quadratically (cost = votes^2), +//! - finalize proposals once the voting period ends, +//! - execute passed proposals once the 48-hour timelock has elapsed. +//! +//! ## Parameters +//! - Default voting period: 7 days (`DEFAULT_VOTING_PERIOD`). Custom periods +//! (set by admin, or passed as `duration` to `create_proposal`) must lie in +//! `[MIN_VOTING_PERIOD, MAX_VOTING_PERIOD]` = [1 day, 30 days]. +//! - Quorum: a proposal passes only if the voice credits spent on it +//! (`credits_spent`, i.e. participating voting power) reach +//! `QUORUM_BPS` (20%) of the circulating governance supply, where supply is +//! the running total of all credits ever granted (`TotalGranted`). +//! The quorum is fixed at proposal creation. +//! - Timelock: finalize queues a passed proposal with `eta = now + 172800` +//! (48 hours). `execute` fails until `now >= eta`. +//! +//! ## Flash-loan protection (creation-time snapshot) +//! Every grant is stamped with a monotonically increasing sequence number and +//! appended to the member's grant log. Each proposal records the sequence +//! number at creation (`created_seq`). When a member votes, credits granted +//! with a sequence number greater than `created_seq` are NOT spendable on that +//! proposal: spendable = current balance - (sum of grants after creation). +//! Voting power therefore cannot increase because of grants made after the +//! proposal was created. This is conservative: if a post-creation grant was +//! already spent on another proposal, it is still subtracted here. //! //! ## Quadratic voting mechanics -//! Each voter is allocated `voice_credits` upon joining. Casting `v` votes -//! on a proposal costs `v²` credits, forcing voters to spread influence -//! across multiple proposals rather than concentrating on one. +//! Casting `v` votes on a proposal costs `v^2` credits, forcing voters to spread +//! influence across multiple proposals rather than concentrating on one. #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, - Env, String, + contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, String, + Vec, }; +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Default voting period: 7 days. +pub const DEFAULT_VOTING_PERIOD: u64 = 604_800; +/// Minimum allowed voting period: 1 day. +pub const MIN_VOTING_PERIOD: u64 = 86_400; +/// Maximum allowed voting period: 30 days. +pub const MAX_VOTING_PERIOD: u64 = 2_592_000; +/// Mandatory timelock between finalize (queue) and execute: 48 hours. +pub const TIMELOCK_DELAY: u64 = 172_800; +/// Quorum as basis points of circulating supply: 20%. +pub const QUORUM_BPS: u128 = 2_000; +/// Basis-point denominator. +pub const BPS_DENOMINATOR: u128 = 10_000; + // ── Types ───────────────────────────────────────────────────────────────────── #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ProposalStatus { Active, + /// Passed and queued in the timelock (`eta` set); executable once `now >= eta`. Passed, Failed, Executed, @@ -43,8 +79,23 @@ pub struct Proposal { pub status: ProposalStatus, /// Net vote tally (positive = support, negative = against). pub tally: i128, - /// Total voice-credits spent across all voters on this proposal. + /// Total voice-credits spent across all voters on this proposal + /// (participating voting power used for quorum). pub credits_spent: u128, + /// Grant sequence number at creation. Later grants are not spendable here. + pub created_seq: u64, + /// Quorum in voice credits, fixed at creation (QUORUM_BPS of supply). + pub quorum_required: u128, + /// Timelock ETA (ledger timestamp) set when passed; 0 while not queued. + pub eta: u64, +} + +/// One credit grant, stamped with its global sequence number. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GrantEntry { + pub seq: u64, + pub amount: u128, } #[contracttype] @@ -56,38 +107,26 @@ pub enum Key { Proposal(u64), Vote(u64, Address), UsedNonce(Address, u64), + /// Running total of all credits granted (circulating supply). + TotalGranted, + /// Global grant/proposal sequence counter. + Seq, + /// Per-member list of grants with sequence numbers. + GrantLog(Address), + /// Admin-configured default voting period (seconds). + VotingPeriod, + /// Minimum credits a proposal creator must hold. + ProposalThreshold, } +pub mod errors; pub mod ragequit; pub mod treasury; +pub use errors::DaoError; use ragequit::Ragequit; use treasury::{Treasury, TreasuryState}; -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum DaoError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - ProposalNotFound = 4, - ProposalClosed = 5, - VotingDeadlineLive = 6, - AlreadyVoted = 7, - InsufficientCredits = 8, - ZeroVotes = 9, - InvalidDeadline = 10, - ZeroShares = 11, - ProposalNotPassed = 12, - YesVoterCannotRagequit = 13, - InsufficientShares = 14, - InsufficientReserves = 15, -} -pub mod errors; -pub use errors::DaoError; - // ── Contract ────────────────────────────────────────────────────────────────── #[contract] @@ -106,18 +145,41 @@ impl DaoGovernance { } /// Grant `credits` voice-credits to `member`. Admin only. + /// Updates the circulating supply and the member's grant log. pub fn grant_credits(env: Env, member: Address, credits: u128) { Self::only_admin(&env); let key = Key::Credits(member.clone()); let prev: u128 = env.storage().persistent().get(&key).unwrap_or(0); - env.storage().persistent().set(&key, &(prev + credits)); + env.storage() + .persistent() + .set(&key, &prev.saturating_add(credits)); + + let total = read_total_granted(&env).saturating_add(credits); + env.storage().instance().set(&Key::TotalGranted, &total); + + let seq = next_seq(&env); + let log_key = Key::GrantLog(member.clone()); + let mut log = env + .storage() + .persistent() + .get::>(&log_key) + .unwrap_or_else(|| Vec::new(&env)); + log.push_back(GrantEntry { + seq, + amount: credits, + }); + env.storage().persistent().set(&log_key, &log); + env.events() .publish((symbol_short!("credits"),), (member, credits)); } /// Create a new proposal. Returns the proposal ID. /// - /// * `duration` – voting window in seconds. + /// * `duration` - voting window in seconds. `0` selects the configured + /// default voting period (7 days unless changed by admin). Any non-zero + /// value must be within [1 day, 30 days], otherwise `InvalidVotingPeriod`. + /// * The creator must hold at least the proposal threshold (default 0). pub fn create_proposal( env: Env, creator: Address, @@ -126,21 +188,36 @@ impl DaoGovernance { duration: u64, ) -> u64 { creator.require_auth(); - if duration == 0 { - panic_with_error!(&env, DaoError::InvalidDeadline); + + let period = if duration == 0 { + read_voting_period(&env) + } else { + check_voting_period(&env, duration); + duration + }; + + if credits_balance(&env, &creator) < read_proposal_threshold(&env) { + panic_with_error!(&env, DaoError::BelowProposalThreshold); } + let id: u64 = env.storage().instance().get(&Key::NextId).unwrap_or(0); env.storage().instance().set(&Key::NextId, &(id + 1)); + let quorum_required = + read_total_granted(&env).saturating_mul(QUORUM_BPS) / BPS_DENOMINATOR; + let proposal = Proposal { id, creator: creator.clone(), title, description, - deadline: env.ledger().timestamp() + duration, + deadline: env.ledger().timestamp().saturating_add(period), status: ProposalStatus::Active, tally: 0, credits_spent: 0, + created_seq: next_seq(&env), + quorum_required, + eta: 0, }; env.storage() .persistent() @@ -153,13 +230,9 @@ impl DaoGovernance { /// Cast `votes` on proposal `id`. /// /// * Positive = support, negative = against. - /// * Cost = votes² credits (quadratic scaling). - /// * Each address may vote at most once per proposal. - /// Cast `votes` on proposal `id`. - /// - /// * Positive = support, negative = against. - /// * Cost = votes² credits (quadratic scaling). + /// * Cost = votes^2 credits (quadratic scaling). /// * Each address may vote at most once per proposal. + /// * Only credits not granted after the proposal was created are spendable. pub fn vote(env: Env, voter: Address, proposal_id: u64, votes: i64) { voter.require_auth(); Self::internal_vote(&env, voter, proposal_id, votes); @@ -204,16 +277,19 @@ impl DaoGovernance { panic_with_error!(env, DaoError::AlreadyVoted); } - // Quadratic cost: cost = |votes|² + // Quadratic cost: cost = |votes|^2 let abs_v = votes.unsigned_abs() as u128; let cost: u128 = abs_v.saturating_mul(abs_v); - let credits_key = Key::Credits(voter.clone()); - let bal: u128 = env.storage().persistent().get(&credits_key).unwrap_or(0); - if bal < cost { + let spendable = eligible_credits(env, &voter, proposal.created_seq); + if spendable < cost { panic_with_error!(env, DaoError::InsufficientCredits); } - env.storage().persistent().set(&credits_key, &(bal - cost)); + let credits_key = Key::Credits(voter.clone()); + let bal = credits_balance(env, &voter); + env.storage() + .persistent() + .set(&credits_key, &bal.saturating_sub(cost)); proposal.tally += votes as i128; proposal.credits_spent = proposal.credits_spent.saturating_add(cost); @@ -227,6 +303,11 @@ impl DaoGovernance { } /// Finalize a proposal after its deadline. Anyone may call this. + /// + /// The proposal passes only if `credits_spent >= quorum_required` AND + /// `tally > 0`. A passing proposal is queued: status `Passed` with + /// `eta = now + TIMELOCK_DELAY`. Otherwise it is marked `Failed` + /// (including quorum failure); no panic is raised for that outcome. pub fn finalize(env: Env, proposal_id: u64) { let mut proposal: Proposal = env .storage() @@ -237,15 +318,17 @@ impl DaoGovernance { if proposal.status != ProposalStatus::Active { panic_with_error!(&env, DaoError::ProposalClosed); } - if env.ledger().timestamp() <= proposal.deadline { + let now = env.ledger().timestamp(); + if now <= proposal.deadline { panic_with_error!(&env, DaoError::VotingDeadlineLive); } - proposal.status = if proposal.tally > 0 { - ProposalStatus::Passed + if proposal.credits_spent >= proposal.quorum_required && proposal.tally > 0 { + proposal.status = ProposalStatus::Passed; + proposal.eta = now.saturating_add(TIMELOCK_DELAY); } else { - ProposalStatus::Failed - }; + proposal.status = ProposalStatus::Failed; + } env.storage() .persistent() .set(&Key::Proposal(proposal_id), &proposal); @@ -253,7 +336,10 @@ impl DaoGovernance { .publish((symbol_short!("finalize"),), (proposal_id, proposal.tally)); } - /// Mark a passed proposal as executed. Admin only. + /// Mark a queued (passed) proposal as executed. Admin only. + /// + /// Fails with `ProposalClosed` unless the status is `Passed`, and with + /// `TimelockNotElapsed` until `now >= eta` (48 hours after finalize). pub fn execute(env: Env, caller: Address, proposal_id: u64) { Self::only_admin(&env); caller.require_auth(); @@ -267,6 +353,9 @@ impl DaoGovernance { if proposal.status != ProposalStatus::Passed { panic_with_error!(&env, DaoError::ProposalClosed); } + if env.ledger().timestamp() < proposal.eta { + panic_with_error!(&env, DaoError::TimelockNotElapsed); + } proposal.status = ProposalStatus::Executed; env.storage() .persistent() @@ -275,6 +364,27 @@ impl DaoGovernance { .publish((symbol_short!("execute"),), (caller, proposal_id)); } + // ── Configuration (admin) ───────────────────────────────────────────────── + + /// Set the default voting period. Must be within [1 day, 30 days]. Admin only. + pub fn set_voting_period(env: Env, period: u64) { + Self::only_admin(&env); + check_voting_period(&env, period); + env.storage().instance().set(&Key::VotingPeriod, &period); + env.events() + .publish((symbol_short!("vp_set"),), period); + } + + /// Set the minimum credits a proposal creator must hold. Admin only. + pub fn set_proposal_threshold(env: Env, min_credits: u128) { + Self::only_admin(&env); + env.storage() + .instance() + .set(&Key::ProposalThreshold, &min_credits); + env.events() + .publish((symbol_short!("thr_set"),), min_credits); + } + // ── Views ───────────────────────────────────────────────────────────────── pub fn get_proposal(env: Env, id: u64) -> Option { @@ -282,10 +392,7 @@ impl DaoGovernance { } pub fn credits_of(env: Env, member: Address) -> u128 { - env.storage() - .persistent() - .get(&Key::Credits(member)) - .unwrap_or(0) + credits_balance(&env, &member) } pub fn vote_of(env: Env, proposal_id: u64, voter: Address) -> Option { @@ -294,6 +401,19 @@ impl DaoGovernance { .get(&Key::Vote(proposal_id, voter)) } + /// Circulating governance supply: total credits ever granted. + pub fn total_granted(env: Env) -> u128 { + read_total_granted(&env) + } + + pub fn get_voting_period(env: Env) -> u64 { + read_voting_period(&env) + } + + pub fn get_proposal_threshold(env: Env) -> u128 { + read_proposal_threshold(&env) + } + // ── Treasury & Ragequit (SC-042) ────────────────────────────────────────── pub fn deposit_treasury(env: Env, caller: Address, amount: u128) { @@ -340,218 +460,68 @@ impl DaoGovernance { } } -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, - Env, String, - }; - - fn setup() -> (Env, DaoGovernanceClient<'static>, Address) { - let env = Env::default(); - env.mock_all_auths(); - let id = env.register(DaoGovernance, ()); - let client = DaoGovernanceClient::new(&env, &id); - let admin = Address::generate(&env); - client.initialize(&admin); - (env, client, admin) - } - - #[test] - fn quadratic_cost_deducted_correctly() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &100u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - &3600, - ); - - // 3 votes → cost = 9 - client.vote(&voter, &pid, &3i64); - assert_eq!(client.credits_of(&voter), 91); - - let p = client.get_proposal(&pid).unwrap(); - assert_eq!(p.tally, 3); - assert_eq!(p.credits_spent, 9); - } - - #[test] - fn proposal_passes_when_tally_positive() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &1_000u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Upgrade"), - &String::from_str(&env, "Details"), - &100, - ); - client.vote(&voter, &pid, &5i64); - - env.ledger().with_mut(|l| l.timestamp += 200); - client.finalize(&pid); - - assert_eq!( - client.get_proposal(&pid).unwrap().status, - ProposalStatus::Passed - ); - } - - #[test] - fn proposal_fails_when_tally_non_positive() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &1_000u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Bad idea"), - &String::from_str(&env, "No"), - &100, - ); - client.vote(&voter, &pid, &-4i64); - - env.ledger().with_mut(|l| l.timestamp += 200); - client.finalize(&pid); +// ── Helpers ─────────────────────────────────────────────────────────────────── - assert_eq!( - client.get_proposal(&pid).unwrap().status, - ProposalStatus::Failed - ); - } +/// Advance and return the global grant/proposal sequence counter. +fn next_seq(env: &Env) -> u64 { + let seq: u64 = env.storage().instance().get(&Key::Seq).unwrap_or(0) + 1; + env.storage().instance().set(&Key::Seq, &seq); + seq +} - #[test] - #[should_panic(expected = "Error(Contract, #407)")] - fn cannot_vote_twice() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &1_000u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "D"), - &String::from_str(&env, "D"), - &3600, - ); - client.vote(&voter, &pid, &1i64); - client.vote(&voter, &pid, &1i64); // panic - } +fn read_total_granted(env: &Env) -> u128 { + env.storage() + .instance() + .get(&Key::TotalGranted) + .unwrap_or(0) +} - #[test] - #[should_panic(expected = "Error(Contract, #408)")] - fn insufficient_credits_rejected() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &3u128); // only 3 credits - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Big"), - &String::from_str(&env, "Big"), - &3600, - ); - client.vote(&voter, &pid, &5i64); // cost = 25 > 3 → panic - } +fn read_voting_period(env: &Env) -> u64 { + env.storage() + .instance() + .get(&Key::VotingPeriod) + .unwrap_or(DEFAULT_VOTING_PERIOD) +} - #[test] - fn test_ragequit_dissenting_voter_burns_shares_and_receives_pro_rata() { - let (env, client, admin) = setup(); - let voter_no = Address::generate(&env); - let voter_yes = Address::generate(&env); - - client.grant_credits(&voter_no, &1_000u128); - client.grant_credits(&voter_yes, &1_000u128); - - // Setup treasury: 1000 shares total (400 for voter_no, 600 for voter_yes), 50,000 reserves - client.grant_shares(&voter_no, &400u128); - client.grant_shares(&voter_yes, &600u128); - client.deposit_treasury(&admin, &50_000u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Controversial proposal"), - &String::from_str(&env, "Details"), - &100, - ); +fn read_proposal_threshold(env: &Env) -> u128 { + env.storage() + .instance() + .get(&Key::ProposalThreshold) + .unwrap_or(0) +} - // voter_no votes -2 (against), voter_yes votes +5 (for) - client.vote(&voter_no, &pid, &-2i64); - client.vote(&voter_yes, &pid, &5i64); - - // Proposal passes after deadline - env.ledger().with_mut(|l| l.timestamp += 200); - client.finalize(&pid); - assert_eq!(client.get_proposal(&pid).unwrap().status, ProposalStatus::Passed); - - // voter_no ragequits before proposal is executed: burns 400 shares - // Expected payout: 400 * 50,000 / 1000 = 20,000 - let payout = client.ragequit(&voter_no, &pid, &400u128); - assert_eq!(payout, 20_000u128); - - // Shares burned atomically - assert_eq!(client.treasury_shares_of(&voter_no), 0); - let treasury = client.get_treasury_state(); - assert_eq!(treasury.total_shares, 600); - assert_eq!(treasury.total_reserves, 30_000); +fn check_voting_period(env: &Env, period: u64) { + if period < MIN_VOTING_PERIOD || period > MAX_VOTING_PERIOD { + panic_with_error!(env, DaoError::InvalidVotingPeriod); } +} - #[test] - #[should_panic(expected = "Error(Contract, #13)")] - fn test_ragequit_prohibits_yes_voters() { - let (env, client, admin) = setup(); - let voter_yes = Address::generate(&env); - client.grant_credits(&voter_yes, &1_000u128); - client.grant_shares(&voter_yes, &500u128); - client.deposit_treasury(&admin, &50_000u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - &100, - ); - client.vote(&voter_yes, &pid, &5i64); - - env.ledger().with_mut(|l| l.timestamp += 200); - client.finalize(&pid); - - // Should panic with YesVoterCannotRagequit (code 13) - client.ragequit(&voter_yes, &pid, &100u128); - fn test_vote_sep0010_and_replay_rejection() { - let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &100u128); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "SEP10 Prop"), - &String::from_str(&env, "Desc"), - &3600, - ); - - let nonce = 9999u64; - let domain = String::from_str(&env, "governance.studentlab.stellar.org"); - - // Cast SEP-0010 authenticated vote - client.vote_sep0010(&voter, &pid, &3i64, &nonce, &domain); - - let prop = client.get_proposal(&pid).unwrap(); - assert_eq!(prop.tally, 3); - assert_eq!(client.credits_of(&voter), 100 - 9); +fn credits_balance(env: &Env, member: &Address) -> u128 { + env.storage() + .persistent() + .get::(&Key::Credits(member.clone())) + .unwrap_or(0) +} - // Replay attempt with same nonce is rejected - let res = client.try_vote_sep0010(&voter, &pid, &1i64, &nonce, &domain); - assert!(res.is_err()); +/// Credits `voter` may spend on a proposal created at `created_seq`: +/// current balance minus every grant received after that proposal was created. +fn eligible_credits(env: &Env, voter: &Address, created_seq: u64) -> u128 { + let balance = credits_balance(env, voter); + let log = env + .storage() + .persistent() + .get::>(&Key::GrantLog(voter.clone())) + .unwrap_or_else(|| Vec::new(env)); + let mut late: u128 = 0; + for entry in log.iter() { + if entry.seq > created_seq { + late = late.saturating_add(entry.amount); + } } + balance.saturating_sub(late) } +// ── Tests ───────────────────────────────────────────────────────────────────── + #[cfg(test)] mod test; diff --git a/contracts/dao_governance/src/test.rs b/contracts/dao_governance/src/test.rs index 97e00cf8..95c7b744 100644 --- a/contracts/dao_governance/src/test.rs +++ b/contracts/dao_governance/src/test.rs @@ -4,6 +4,8 @@ use soroban_sdk::{ Env, String, }; +const DAY: u64 = 86_400; + fn setup() -> (Env, DaoGovernanceClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); @@ -14,20 +16,46 @@ fn setup() -> (Env, DaoGovernanceClient<'static>, Address) { (env, client, admin) } +fn advance(env: &Env, secs: u64) { + env.ledger().with_mut(|l| l.timestamp += secs); +} + +/// Creates a proposal with the minimum voting period (1 day). +fn new_proposal(env: &Env, client: &DaoGovernanceClient<'_>, creator: &Address) -> u64 { + client.create_proposal( + creator, + &String::from_str(env, "P"), + &String::from_str(env, "D"), + &DAY, + ) +} + +/// Builds a proposal that passes: one voter holds 1000 credits (so quorum is +/// 200), votes +20 (cost 400 >= 200), and the proposal is finalized after its +/// deadline. Returns the proposal id and the voter. +fn passing_proposal( + env: &Env, + client: &DaoGovernanceClient<'_>, + admin: &Address, +) -> (u64, Address) { + let voter = Address::generate(env); + client.grant_credits(&voter, &1_000u128); + let pid = new_proposal(env, client, admin); + client.vote(&voter, &pid, &20i64); + advance(env, DAY + 1); + client.finalize(&pid); + (pid, voter) +} + #[test] fn quadratic_cost_deducted_correctly() { let (env, client, admin) = setup(); let voter = Address::generate(&env); client.grant_credits(&voter, &100u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - &3600, - ); + let pid = new_proposal(&env, &client, &admin); - // 3 votes → cost = 9 + // 3 votes -> cost = 9 client.vote(&voter, &pid, &3i64); assert_eq!(client.credits_of(&voter), 91); @@ -37,49 +65,52 @@ fn quadratic_cost_deducted_correctly() { } #[test] -fn proposal_passes_when_tally_positive() { +fn proposal_passes_when_quorum_met_and_tally_positive() { + let (env, client, admin) = setup(); + let (pid, _voter) = passing_proposal(&env, &client, &admin); + + let p = client.get_proposal(&pid).unwrap(); + assert_eq!(p.status, ProposalStatus::Passed); + // eta = finalize time + 48h + assert_eq!(p.eta, env.ledger().timestamp() + TIMELOCK_DELAY); +} + +#[test] +fn proposal_fails_when_tally_non_positive() { let (env, client, admin) = setup(); let voter = Address::generate(&env); client.grant_credits(&voter, &1_000u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Upgrade"), - &String::from_str(&env, "Details"), - &100, - ); - client.vote(&voter, &pid, &5i64); + let pid = new_proposal(&env, &client, &admin); + client.vote(&voter, &pid, &-20i64); // cost 400 >= quorum 200, but tally < 0 - env.ledger().with_mut(|l| l.timestamp += 200); + advance(&env, DAY + 1); client.finalize(&pid); - assert_eq!( - client.get_proposal(&pid).unwrap().status, - ProposalStatus::Passed - ); + let p = client.get_proposal(&pid).unwrap(); + assert_eq!(p.status, ProposalStatus::Failed); + assert_eq!(p.eta, 0); } #[test] -fn proposal_fails_when_tally_non_positive() { +fn quorum_failure_marks_proposal_failed() { let (env, client, admin) = setup(); - let voter = Address::generate(&env); - client.grant_credits(&voter, &1_000u128); + let a = Address::generate(&env); + let b = Address::generate(&env); + client.grant_credits(&a, &1_000u128); + client.grant_credits(&b, &1_000u128); // total supply 2000 -> quorum 400 - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Bad idea"), - &String::from_str(&env, "No"), - &100, - ); - client.vote(&voter, &pid, &-4i64); + let pid = new_proposal(&env, &client, &admin); + client.vote(&a, &pid, &5i64); // cost 25 < 400, tally 5 > 0 - env.ledger().with_mut(|l| l.timestamp += 200); + advance(&env, DAY + 1); client.finalize(&pid); - assert_eq!( - client.get_proposal(&pid).unwrap().status, - ProposalStatus::Failed - ); + let p = client.get_proposal(&pid).unwrap(); + assert_eq!(p.tally, 5); + assert_eq!(p.credits_spent, 25); + assert_eq!(p.quorum_required, 400); + assert_eq!(p.status, ProposalStatus::Failed); } #[test] @@ -89,12 +120,7 @@ fn cannot_vote_twice() { let voter = Address::generate(&env); client.grant_credits(&voter, &1_000u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "D"), - &String::from_str(&env, "D"), - &3600, - ); + let pid = new_proposal(&env, &client, &admin); client.vote(&voter, &pid, &1i64); client.vote(&voter, &pid, &1i64); // panic } @@ -106,49 +132,160 @@ fn insufficient_credits_rejected() { let voter = Address::generate(&env); client.grant_credits(&voter, &3u128); // only 3 credits + let pid = new_proposal(&env, &client, &admin); + client.vote(&voter, &pid, &5i64); // cost = 25 > 3 -> panic +} + +// ── Proposal threshold ──────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #416)")] +fn below_threshold_creator_rejected() { + let (env, client, _admin) = setup(); + client.set_proposal_threshold(&500u128); + assert_eq!(client.get_proposal_threshold(), 500); + + let creator = Address::generate(&env); + client.grant_credits(&creator, &499u128); + new_proposal(&env, &client, &creator); +} + +#[test] +fn threshold_met_allows_creation() { + let (env, client, _admin) = setup(); + client.set_proposal_threshold(&500u128); + + let creator = Address::generate(&env); + client.grant_credits(&creator, &500u128); + let pid = new_proposal(&env, &client, &creator); + assert_eq!(client.get_proposal(&pid).unwrap().creator, creator); +} + +// ── Voting period bounds ────────────────────────────────────────────────────── + +#[test] +fn zero_duration_uses_default_seven_day_period() { + let (env, client, admin) = setup(); + assert_eq!(client.get_voting_period(), 604_800); + assert_eq!(DEFAULT_VOTING_PERIOD, 604_800); + let pid = client.create_proposal( &admin, - &String::from_str(&env, "Big"), - &String::from_str(&env, "Big"), - &3600, + &String::from_str(&env, "Default"), + &String::from_str(&env, "Default period"), + &0, ); - client.vote(&voter, &pid, &5i64); // cost = 25 > 3 → panic + let p = client.get_proposal(&pid).unwrap(); + assert_eq!(p.deadline, env.ledger().timestamp() + 604_800); } -// ── Additional tests ────────────────────────────────────────────────────────── - -/// Proposal with zero duration should panic with InvalidDeadline (#410). #[test] -#[should_panic(expected = "Error(Contract, #410)")] -fn zero_duration_proposal_rejected() { +fn period_bounds_are_inclusive() { let (env, client, admin) = setup(); - // duration = 0 → InvalidDeadline - client.create_proposal( + let min_id = client.create_proposal( &admin, - &String::from_str(&env, "ZeroDur"), - &String::from_str(&env, "Should fail"), - &0, + &String::from_str(&env, "Min"), + &String::from_str(&env, "Min"), + &MIN_VOTING_PERIOD, + ); + let max_id = client.create_proposal( + &admin, + &String::from_str(&env, "Max"), + &String::from_str(&env, "Max"), + &MAX_VOTING_PERIOD, + ); + assert_eq!( + client.get_proposal(&min_id).unwrap().deadline, + MIN_VOTING_PERIOD + ); + assert_eq!( + client.get_proposal(&max_id).unwrap().deadline, + MAX_VOTING_PERIOD ); } -/// execute() only works once a proposal has status Passed; -/// calling it on an Active proposal must panic with ProposalClosed (#405). #[test] -#[should_panic(expected = "Error(Contract, #405)")] -fn execute_on_active_proposal_rejected() { +#[should_panic(expected = "Error(Contract, #417)")] +fn duration_below_min_rejected() { + let (env, client, admin) = setup(); + new_proposal_with(&env, &client, &admin, MIN_VOTING_PERIOD - 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #417)")] +fn duration_above_max_rejected() { let (env, client, admin) = setup(); + new_proposal_with(&env, &client, &admin, MAX_VOTING_PERIOD + 1); +} + +#[test] +fn set_voting_period_changes_default() { + let (env, client, admin) = setup(); + client.set_voting_period(&(2 * DAY)); + assert_eq!(client.get_voting_period(), 2 * DAY); let pid = client.create_proposal( &admin, - &String::from_str(&env, "Active"), - &String::from_str(&env, "Still active"), - &3600, + &String::from_str(&env, "Two days"), + &String::from_str(&env, "Two days"), + &0, ); - // Attempt to execute while still Active — must panic + assert_eq!(client.get_proposal(&pid).unwrap().deadline, 2 * DAY); +} + +#[test] +#[should_panic(expected = "Error(Contract, #417)")] +fn set_voting_period_out_of_bounds_rejected() { + let (_env, client, _admin) = setup(); + client.set_voting_period(&(DAY - 1)); +} + +fn new_proposal_with(env: &Env, client: &DaoGovernanceClient<'_>, creator: &Address, d: u64) { + client.create_proposal( + creator, + &String::from_str(env, "B"), + &String::from_str(env, "B"), + &d, + ); +} + +// ── Timelock ────────────────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #418)")] +fn timelock_blocks_early_execute() { + let (env, client, admin) = setup(); + let (pid, _voter) = passing_proposal(&env, &client, &admin); + assert_eq!( + client.get_proposal(&pid).unwrap().status, + ProposalStatus::Passed + ); + // Only ~1 day has passed since queueing; 48h timelock not elapsed. + client.execute(&admin, &pid); +} + +#[test] +fn execute_after_timelock_succeeds() { + let (env, client, admin) = setup(); + let (pid, _voter) = passing_proposal(&env, &client, &admin); + + advance(&env, TIMELOCK_DELAY); + client.execute(&admin, &pid); + + assert_eq!( + client.get_proposal(&pid).unwrap().status, + ProposalStatus::Executed + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #405)")] +fn execute_on_active_proposal_rejected() { + let (env, client, admin) = setup(); + let pid = new_proposal(&env, &client, &admin); client.execute(&admin, &pid); } -/// execute() on a Failed proposal must also panic with ProposalClosed (#405). #[test] #[should_panic(expected = "Error(Contract, #405)")] fn execute_on_failed_proposal_rejected() { @@ -156,59 +293,164 @@ fn execute_on_failed_proposal_rejected() { let voter = Address::generate(&env); client.grant_credits(&voter, &1_000u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Fails"), - &String::from_str(&env, "Negative tally"), - &100, - ); + let pid = new_proposal(&env, &client, &admin); client.vote(&voter, &pid, &-3i64); - env.ledger().with_mut(|l| l.timestamp += 200); + advance(&env, DAY + 1); client.finalize(&pid); assert_eq!( client.get_proposal(&pid).unwrap().status, ProposalStatus::Failed ); - // Attempt to execute a Failed proposal — must panic client.execute(&admin, &pid); } -/// Full lifecycle: create → vote (multiple voters) → finalize → execute. +// ── Snapshot / flash-loan protection ────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #408)")] +fn credits_granted_after_creation_add_no_voting_power() { + let (env, client, admin) = setup(); + let voter = Address::generate(&env); + client.grant_credits(&voter, &10u128); + + let pid = new_proposal(&env, &client, &admin); + + // Flash-loan style inflation after creation. + client.grant_credits(&voter, &1_000u128); + + // Balance is 1010, but only the 10 pre-creation credits are spendable here. + // cost = 25 > 10 -> panic + client.vote(&voter, &pid, &5i64); +} + +#[test] +fn pre_creation_credits_remain_spendable_after_late_grant() { + let (env, client, admin) = setup(); + let voter = Address::generate(&env); + client.grant_credits(&voter, &10u128); + + let pid = new_proposal(&env, &client, &admin); + client.grant_credits(&voter, &1_000u128); + + client.vote(&voter, &pid, &3i64); // cost 9 <= 10 + assert_eq!(client.credits_of(&voter), 1_001); + assert_eq!(client.get_proposal(&pid).unwrap().credits_spent, 9); +} + +#[test] +fn total_granted_tracks_circulating_supply() { + let (env, client, _admin) = setup(); + let a = Address::generate(&env); + let b = Address::generate(&env); + client.grant_credits(&a, &300u128); + client.grant_credits(&b, &700u128); + assert_eq!(client.total_granted(), 1_000); +} + +// ── Treasury / ragequit ─────────────────────────────────────────────────────── + +#[test] +fn test_ragequit_dissenting_voter_burns_shares_and_receives_pro_rata() { + let (env, client, admin) = setup(); + let voter_no = Address::generate(&env); + let voter_yes = Address::generate(&env); + + client.grant_credits(&voter_no, &1_000u128); + client.grant_credits(&voter_yes, &1_000u128); // supply 2000 -> quorum 400 + + // 1000 shares total (400 for voter_no, 600 for voter_yes), 50,000 reserves + client.grant_shares(&voter_no, &400u128); + client.grant_shares(&voter_yes, &600u128); + client.deposit_treasury(&admin, &50_000u128); + + let pid = new_proposal(&env, &client, &admin); + + client.vote(&voter_no, &pid, &-2i64); // cost 4 + client.vote(&voter_yes, &pid, &20i64); // cost 400; total spent 404 >= 400 + + advance(&env, DAY + 1); + client.finalize(&pid); + assert_eq!( + client.get_proposal(&pid).unwrap().status, + ProposalStatus::Passed + ); + + // Expected payout: 400 * 50,000 / 1000 = 20,000 + let payout = client.ragequit(&voter_no, &pid, &400u128); + assert_eq!(payout, 20_000u128); + + assert_eq!(client.treasury_shares_of(&voter_no), 0); + let treasury = client.get_treasury_state(); + assert_eq!(treasury.total_shares, 600); + assert_eq!(treasury.total_reserves, 30_000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #413)")] +fn test_ragequit_prohibits_yes_voters() { + let (env, client, admin) = setup(); + let (pid, voter_yes) = passing_proposal(&env, &client, &admin); + client.grant_shares(&voter_yes, &500u128); + client.deposit_treasury(&admin, &50_000u128); + + // voter_yes voted +20 (Yes): must be rejected with YesVoterCannotRagequit (413) + client.ragequit(&voter_yes, &pid, &100u128); +} + +// ── SEP-0010 nonce ──────────────────────────────────────────────────────────── + +#[test] +fn test_vote_sep0010_and_replay_rejection() { + let (env, client, admin) = setup(); + let voter = Address::generate(&env); + client.grant_credits(&voter, &100u128); + + let pid = new_proposal(&env, &client, &admin); + + let nonce = 9999u64; + let domain = String::from_str(&env, "governance.studentlab.stellar.org"); + + client.vote_sep0010(&voter, &pid, &3i64, &nonce, &domain); + + let prop = client.get_proposal(&pid).unwrap(); + assert_eq!(prop.tally, 3); + assert_eq!(client.credits_of(&voter), 100 - 9); + + // Replay with same nonce is rejected + let res = client.try_vote_sep0010(&voter, &pid, &1i64, &nonce, &domain); + assert!(res.is_err()); +} + +// ── Lifecycle, tally, and views ─────────────────────────────────────────────── + #[test] -fn execute_passed_proposal_succeeds() { +fn execute_passed_proposal_full_lifecycle() { let (env, client, admin) = setup(); let voter1 = Address::generate(&env); let voter2 = Address::generate(&env); client.grant_credits(&voter1, &500u128); - client.grant_credits(&voter2, &500u128); + client.grant_credits(&voter2, &500u128); // supply 1000 -> quorum 200 - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Upgrade"), - &String::from_str(&env, "Details"), - &100, - ); - client.vote(&voter1, &pid, &4i64); // cost = 16 - client.vote(&voter2, &pid, &3i64); // cost = 9 + let pid = new_proposal(&env, &client, &admin); + client.vote(&voter1, &pid, &10i64); // cost 100 + client.vote(&voter2, &pid, &10i64); // cost 100; spent 200 >= 200 - env.ledger().with_mut(|l| l.timestamp += 200); + advance(&env, DAY + 1); client.finalize(&pid); - assert_eq!( client.get_proposal(&pid).unwrap().status, ProposalStatus::Passed ); + advance(&env, TIMELOCK_DELAY); client.execute(&admin, &pid); - assert_eq!( client.get_proposal(&pid).unwrap().status, ProposalStatus::Executed ); } -/// Quorum simulation: multiple voters cast votes; final tally aggregates correctly. #[test] fn multiple_voter_tally_aggregates_correctly() { let (env, client, admin) = setup(); @@ -220,32 +462,25 @@ fn multiple_voter_tally_aggregates_correctly() { let opponents = [Address::generate(&env), Address::generate(&env)]; for addr in supporters.iter().chain(opponents.iter()) { - client.grant_credits(addr, &1_000u128); + client.grant_credits(addr, &1_000u128); // supply 5000 -> quorum 1000 } - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Quorum test"), - &String::from_str(&env, "Many voters"), - &500, - ); + let pid = new_proposal(&env, &client, &admin); - // 3 supporters each cast 2 votes (+2 tally each, cost = 4 each) + // 3 supporters x (+20, cost 400) = tally +60, spent 1200 for addr in &supporters { - client.vote(addr, &pid, &2i64); + client.vote(addr, &pid, &20i64); } - // 2 opponents each cast -1 vote (−1 tally each, cost = 1 each) + // 2 opponents x (-1, cost 1) = tally -2, spent 2 for addr in &opponents { client.vote(addr, &pid, &-1i64); } let p = client.get_proposal(&pid).unwrap(); - // tally = 3×2 − 2×1 = 4 - assert_eq!(p.tally, 4); - // credits_spent = 3×4 + 2×1 = 14 - assert_eq!(p.credits_spent, 14); + assert_eq!(p.tally, 58); + assert_eq!(p.credits_spent, 1_202); - env.ledger().with_mut(|l| l.timestamp += 600); + advance(&env, DAY + 1); client.finalize(&pid); assert_eq!( client.get_proposal(&pid).unwrap().status, @@ -253,64 +488,42 @@ fn multiple_voter_tally_aggregates_correctly() { ); } -/// finalize() before deadline must panic with VotingDeadlineLive (#406). #[test] #[should_panic(expected = "Error(Contract, #406)")] fn finalize_before_deadline_panics() { let (env, client, admin) = setup(); - - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "TooEarly"), - &String::from_str(&env, "Not yet"), - &3600, - ); - // Ledger timestamp is still 0, deadline is 3600 — finalize must panic + let pid = new_proposal(&env, &client, &admin); + // Timestamp is still 0, deadline is 1 day - finalize must panic client.finalize(&pid); } -/// get_proposal for an unknown ID returns None (does not panic). #[test] fn get_proposal_returns_none_for_unknown_id() { let (_env, client, _admin) = setup(); assert!(client.get_proposal(&999u64).is_none()); } -/// vote_of returns the correct vote after casting. #[test] fn vote_of_returns_correct_value() { let (env, client, admin) = setup(); let voter = Address::generate(&env); client.grant_credits(&voter, &1_000u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "VoteOf"), - &String::from_str(&env, "Check vote stored"), - &3600, - ); + let pid = new_proposal(&env, &client, &admin); client.vote(&voter, &pid, &-2i64); - let stored = client.vote_of(&pid, &voter); - assert_eq!(stored, Some(-2i64)); + assert_eq!(client.vote_of(&pid, &voter), Some(-2i64)); } -/// vote_of returns None when the voter has not yet cast a vote. #[test] fn vote_of_returns_none_before_voting() { let (env, client, admin) = setup(); let non_voter = Address::generate(&env); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "NoVote"), - &String::from_str(&env, "Unvoted"), - &3600, - ); + let pid = new_proposal(&env, &client, &admin); assert!(client.vote_of(&pid, &non_voter).is_none()); } -/// Credits accumulate correctly when grant_credits is called multiple times. #[test] fn grant_credits_accumulates() { let (env, client, _admin) = setup(); @@ -321,20 +534,14 @@ fn grant_credits_accumulates() { assert_eq!(client.credits_of(&member), 150u128); } -/// After voting the voter's credit balance decreases by votes². #[test] fn credits_decrease_by_quadratic_cost_on_negative_vote() { let (env, client, admin) = setup(); let voter = Address::generate(&env); client.grant_credits(&voter, &200u128); - let pid = client.create_proposal( - &admin, - &String::from_str(&env, "Neg"), - &String::from_str(&env, "Against"), - &3600, - ); - // −4 votes → cost = 16 + let pid = new_proposal(&env, &client, &admin); + // -4 votes -> cost 16 client.vote(&voter, &pid, &-4i64); assert_eq!(client.credits_of(&voter), 184u128); let p = client.get_proposal(&pid).unwrap();