diff --git a/packages/contracts/governance/Cargo.toml b/packages/contracts/governance/Cargo.toml new file mode 100644 index 00000000..b94c2177 --- /dev/null +++ b/packages/contracts/governance/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "governance" +version = "1.0.0" +edition = "2021" +description = "Soroban governance voting contract with token-weighted proposals and on-chain execution" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[profile.release] +overflow-checks = true \ No newline at end of file diff --git a/packages/contracts/governance/src/lib.rs b/packages/contracts/governance/src/lib.rs new file mode 100644 index 00000000..cb2093b3 --- /dev/null +++ b/packages/contracts/governance/src/lib.rs @@ -0,0 +1,656 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, Map, String, Symbol, +}; + +// ── Storage keys ────────────────────────────────────────────────────────────── + +const PROPOSALS: Symbol = symbol_short!("PROPOSALS"); +const VOTES: Symbol = symbol_short!("VOTES"); // Map<(Address, u32), VoteRecord> +const LOCKS: Symbol = symbol_short!("LOCKS"); +const NEXT_ID: Symbol = symbol_short!("NEXT_ID"); + +// ── Configuration ───────────────────────────────────────────────────────────── + +/// Voting period in ledger seconds (7 days at ~5 s/ledger). +const VOTING_PERIOD_SECS: u64 = 7 * 24 * 60 * 60; + +/// Minimum total weight (for + against) required for a proposal to pass. +/// Prevents low-participation proposals from executing. +const QUORUM_THRESHOLD: i128 = 1_000; + +/// Minimum FOR weight as a percentage of total votes cast (50 % = majority). +const APPROVAL_THRESHOLD_PCT: i128 = 50; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// On-chain status of a proposal. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalStatus { + /// Voting is still open. + Active, + /// Voting period ended, threshold met, not yet executed. + Passed, + /// Threshold not met or quorum not reached after voting period. + Rejected, + /// Proposal has been executed. + Executed, +} + +/// A single governance proposal. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Proposal { + pub id: u32, + /// Address that created the proposal. + pub proposer: Address, + /// Human-readable description / encoded action. + pub action: String, + /// Ledger timestamp when the proposal was created. + pub created_at: u64, + /// Voting is open until this timestamp. + pub voting_ends_at: u64, + /// Accumulated weight voting FOR. + pub votes_for: i128, + /// Accumulated weight voting AGAINST. + pub votes_against: i128, + pub status: ProposalStatus, +} + +/// A voter's locked token balance used as voting weight. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenLock { + pub voter: Address, + pub amount: i128, + pub locked_at: u64, +} + +/// The vote cast by a single voter on a single proposal. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteRecord { + pub voter: Address, + pub proposal_id: u32, + pub weight: i128, + pub support: bool, +} + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct GovernanceContract; + +#[contractimpl] +impl GovernanceContract { + // ── Proposal management ─────────────────────────────────────────────────── + + /// Create a new governance proposal. + /// + /// The proposer must have locked tokens (see `lock_tokens`). + /// Returns the newly assigned proposal ID. + pub fn propose(env: Env, proposer: Address, action: String) -> u32 { + proposer.require_auth(); + + // Proposer must have locked tokens to submit a proposal. + let locks: Map = + env.storage().instance().get(&LOCKS).unwrap_or(Map::new(&env)); + + if !locks.contains_key(proposer.clone()) { + panic!("proposer must lock tokens before creating a proposal"); + } + + let lock = locks.get(proposer.clone()).unwrap(); + if lock.amount == 0 { + panic!("proposer must have a non-zero token lock"); + } + + // Assign a monotonically increasing proposal ID. + let id: u32 = env.storage().instance().get(&NEXT_ID).unwrap_or(0u32); + let next_id = id.checked_add(1).expect("proposal ID overflow"); + + let now = env.ledger().timestamp(); + let proposal = Proposal { + id, + proposer, + action, + created_at: now, + voting_ends_at: now + VOTING_PERIOD_SECS, + votes_for: 0, + votes_against: 0, + status: ProposalStatus::Active, + }; + + let mut proposals: Map = + env.storage().instance().get(&PROPOSALS).unwrap_or(Map::new(&env)); + + proposals.set(id, proposal); + env.storage().instance().set(&PROPOSALS, &proposals); + env.storage().instance().set(&NEXT_ID, &next_id); + + id + } + + /// Cast a vote on an active proposal. + /// + /// `weight` — token-weighted voting power; must not exceed the voter's + /// current lock (prevents double-counting across proposals). + /// `support` — `true` = vote FOR, `false` = vote AGAINST. + pub fn vote(env: Env, voter: Address, proposal_id: u32, weight: i128, support: bool) { + voter.require_auth(); + + if weight <= 0 { + panic!("voting weight must be positive"); + } + + // Validate token lock. + let locks: Map = + env.storage().instance().get(&LOCKS).unwrap_or(Map::new(&env)); + + let lock = locks.get(voter.clone()).expect("voter has no locked tokens"); + + if weight > lock.amount { + panic!("voting weight exceeds locked token balance"); + } + + // Load proposal and assert it is still Active. + let mut proposals: Map = + env.storage().instance().get(&PROPOSALS).unwrap_or(Map::new(&env)); + + let mut proposal = proposals + .get(proposal_id) + .expect("proposal not found"); + + if proposal.status != ProposalStatus::Active { + panic!("proposal is not active"); + } + + if env.ledger().timestamp() > proposal.voting_ends_at { + panic!("voting period has ended"); + } + + // Prevent double-voting on the same proposal. + let vote_key = (voter.clone(), proposal_id); + let all_votes: Map<(Address, u32), VoteRecord> = + env.storage().instance().get(&VOTES).unwrap_or(Map::new(&env)); + + if all_votes.contains_key(vote_key.clone()) { + panic!("voter has already voted on this proposal"); + } + + // Record vote. + let record = VoteRecord { + voter: voter.clone(), + proposal_id, + weight, + support, + }; + + let mut all_votes = all_votes; + all_votes.set(vote_key, record); + env.storage().instance().set(&VOTES, &all_votes); + + // Accumulate weight. + if support { + proposal.votes_for = proposal + .votes_for + .checked_add(weight) + .expect("votes_for overflow"); + } else { + proposal.votes_against = proposal + .votes_against + .checked_add(weight) + .expect("votes_against overflow"); + } + + proposals.set(proposal_id, proposal); + env.storage().instance().set(&PROPOSALS, &proposals); + } + + /// Finalise a proposal after its voting period has ended. + /// + /// Sets the status to `Passed` or `Rejected` based on quorum and + /// approval threshold. Must be called before `execute`. + pub fn finalize(env: Env, proposal_id: u32) { + let mut proposals: Map = + env.storage().instance().get(&PROPOSALS).unwrap_or(Map::new(&env)); + + let mut proposal = proposals + .get(proposal_id) + .expect("proposal not found"); + + if proposal.status != ProposalStatus::Active { + panic!("proposal is not active"); + } + + if env.ledger().timestamp() <= proposal.voting_ends_at { + panic!("voting period has not yet ended"); + } + + let total = proposal + .votes_for + .checked_add(proposal.votes_against) + .expect("total votes overflow"); + + let passed = total >= QUORUM_THRESHOLD + && proposal.votes_for * 100 / total >= APPROVAL_THRESHOLD_PCT; + + proposal.status = if passed { + ProposalStatus::Passed + } else { + ProposalStatus::Rejected + }; + + proposals.set(proposal_id, proposal); + env.storage().instance().set(&PROPOSALS, &proposals); + } + + /// Execute a proposal that has `Passed`. + /// + /// Marks the proposal as `Executed`. Actual cross-contract dispatch is + /// intentionally left to the caller — the contract records intent on-chain + /// and the `action` string encodes what must be done (XDR-encoded call + /// or human-readable instruction). + pub fn execute(env: Env, executor: Address, proposal_id: u32) { + executor.require_auth(); + + let mut proposals: Map = + env.storage().instance().get(&PROPOSALS).unwrap_or(Map::new(&env)); + + let mut proposal = proposals + .get(proposal_id) + .expect("proposal not found"); + + if proposal.status != ProposalStatus::Passed { + panic!("proposal must be in Passed status to execute"); + } + + proposal.status = ProposalStatus::Executed; + proposals.set(proposal_id, proposal); + env.storage().instance().set(&PROPOSALS, &proposals); + } + + // ── Token lock management ───────────────────────────────────────────────── + + /// Lock `amount` governance tokens to gain voting weight. + /// + /// Calling again overwrites the existing lock (adds to it conceptually — + /// in a production contract this would call a token contract's + /// `transfer_from` to escrow the tokens on-chain). + pub fn lock_tokens(env: Env, voter: Address, amount: i128) { + voter.require_auth(); + + if amount <= 0 { + panic!("lock amount must be positive"); + } + + let mut locks: Map = + env.storage().instance().get(&LOCKS).unwrap_or(Map::new(&env)); + + // If a previous lock exists, accumulate rather than replace. + let existing_amount = locks + .get(voter.clone()) + .map(|l| l.amount) + .unwrap_or(0); + + let new_amount = existing_amount + .checked_add(amount) + .expect("lock amount overflow"); + + let lock = TokenLock { + voter: voter.clone(), + amount: new_amount, + locked_at: env.ledger().timestamp(), + }; + + locks.set(voter, lock); + env.storage().instance().set(&LOCKS, &locks); + } + + /// Unlock and return governance tokens to the voter. + /// + /// A voter may only unlock after all proposals they voted on have been + /// finalized (Passed, Rejected, or Executed) — preventing double-spend + /// of voting weight across overlapping proposals. + /// For simplicity in this implementation the caller asserts readiness; + /// a production version would iterate active vote records. + pub fn unlock_tokens(env: Env, voter: Address) { + voter.require_auth(); + + let mut locks: Map = + env.storage().instance().get(&LOCKS).unwrap_or(Map::new(&env)); + + if !locks.contains_key(voter.clone()) { + panic!("no token lock found for voter"); + } + + locks.remove(voter); + env.storage().instance().set(&LOCKS, &locks); + } + + // ── View functions ──────────────────────────────────────────────────────── + + /// Return a proposal by ID. + pub fn get_proposal(env: Env, proposal_id: u32) -> Option { + let proposals: Map = + env.storage().instance().get(&PROPOSALS).unwrap_or(Map::new(&env)); + proposals.get(proposal_id) + } + + /// Return the current vote record for a (voter, proposal_id) pair. + pub fn get_vote(env: Env, voter: Address, proposal_id: u32) -> Option { + let key = (voter, proposal_id); + let all_votes: Map<(Address, u32), VoteRecord> = + env.storage().instance().get(&VOTES).unwrap_or(Map::new(&env)); + all_votes.get(key) + } + + /// Return the token lock for a voter. + pub fn get_lock(env: Env, voter: Address) -> Option { + let locks: Map = + env.storage().instance().get(&LOCKS).unwrap_or(Map::new(&env)); + locks.get(voter) + } + + /// Return the next proposal ID that will be assigned. + pub fn next_proposal_id(env: Env) -> u32 { + env.storage().instance().get(&NEXT_ID).unwrap_or(0u32) + } + +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String}; + + fn setup() -> (Env, GovernanceContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, GovernanceContract); + let client = GovernanceContractClient::new(&env, &contract_id); + (env, client) + } + + // ── Happy path ──────────────────────────────────────────────────────────── + + #[test] + fn test_lock_and_propose() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + + let lock = client.get_lock(&proposer).unwrap(); + assert_eq!(lock.amount, 500); + + let action = String::from_str(&env, "transfer_treasury_100_USDC"); + let id = client.propose(&proposer, &action); + assert_eq!(id, 0); + + let proposal = client.get_proposal(&0).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Active); + assert_eq!(proposal.votes_for, 0); + assert_eq!(proposal.votes_against, 0); + } + + #[test] + fn test_vote_for_and_against() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter_a = Address::generate(&env); + let voter_b = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter_a, &800); + client.lock_tokens(&voter_b, &400); + + let action = String::from_str(&env, "upgrade_protocol_v2"); + let id = client.propose(&proposer, &action); + + client.vote(&voter_a, &id, &800, &true); + client.vote(&voter_b, &id, &400, &false); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.votes_for, 800); + assert_eq!(proposal.votes_against, 400); + } + + #[test] + fn test_full_lifecycle_passes() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let executor = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter, &2_000); + + let id = client.propose(&proposer, &String::from_str(&env, "mint_rewards")); + + client.vote(&voter, &id, &2_000, &true); + + // Advance ledger past voting period. + env.ledger().with_mut(|li| { + li.timestamp += VOTING_PERIOD_SECS + 1; + }); + + client.finalize(&id); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Passed); + + client.execute(&executor, &id); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + } + + #[test] + fn test_full_lifecycle_rejected_low_approval() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter_for = Address::generate(&env); + let voter_against = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter_for, &400); + client.lock_tokens(&voter_against, &700); + + let id = client.propose(&proposer, &String::from_str(&env, "change_fee_params")); + + client.vote(&voter_for, &id, &400, &true); + client.vote(&voter_against, &id, &700, &false); + + env.ledger().with_mut(|li| { + li.timestamp += VOTING_PERIOD_SECS + 1; + }); + + client.finalize(&id); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Rejected); + } + + #[test] + fn test_full_lifecycle_rejected_below_quorum() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter, &100); + + let id = client.propose(&proposer, &String::from_str(&env, "minor_param_update")); + + // voter_for = 100 — below QUORUM_THRESHOLD of 1_000 + client.vote(&voter, &id, &100, &true); + + env.ledger().with_mut(|li| { + li.timestamp += VOTING_PERIOD_SECS + 1; + }); + + client.finalize(&id); + + let proposal = client.get_proposal(&id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Rejected); + } + + // ── Edge / error cases ──────────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "proposer must lock tokens before creating a proposal")] + fn test_propose_without_lock_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + client.propose(&proposer, &String::from_str(&env, "no_lock_action")); + } + + #[test] + #[should_panic(expected = "voter has no locked tokens")] + fn test_vote_without_lock_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.vote(&voter, &id, &100, &true); + } + + #[test] + #[should_panic(expected = "voting weight exceeds locked token balance")] + fn test_vote_exceeds_lock_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter, &200); + + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.vote(&voter, &id, &500, &true); // 500 > 200 locked + } + + #[test] + #[should_panic(expected = "voting period has ended")] + fn test_vote_after_period_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + client.lock_tokens(&voter, &500); + + let id = client.propose(&proposer, &String::from_str(&env, "action")); + + env.ledger().with_mut(|li| { + li.timestamp += VOTING_PERIOD_SECS + 1; + }); + + client.vote(&voter, &id, &500, &true); + } + + #[test] + #[should_panic(expected = "voting period has not yet ended")] + fn test_finalize_before_period_ends_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.finalize(&id); + } + + #[test] + #[should_panic(expected = "proposal must be in Passed status to execute")] + fn test_execute_rejected_proposal_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let executor = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter, &100); + + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.vote(&voter, &id, &100, &true); // quorum not met + + env.ledger().with_mut(|li| { + li.timestamp += VOTING_PERIOD_SECS + 1; + }); + + client.finalize(&id); + client.execute(&executor, &id); // status = Rejected → panic + } + + #[test] + #[should_panic(expected = "proposal must be in Passed status to execute")] + fn test_execute_active_proposal_panics() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let executor = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.execute(&executor, &id); + } + + #[test] + fn test_accumulate_lock_tokens() { + let (env, client) = setup(); + let voter = Address::generate(&env); + + client.lock_tokens(&voter, &300); + client.lock_tokens(&voter, &700); + + let lock = client.get_lock(&voter).unwrap(); + assert_eq!(lock.amount, 1_000); + } + + #[test] + fn test_unlock_tokens() { + let (env, client) = setup(); + let voter = Address::generate(&env); + + client.lock_tokens(&voter, &500); + client.unlock_tokens(&voter); + + assert!(client.get_lock(&voter).is_none()); + } + + #[test] + fn test_next_proposal_id_increments() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + + client.lock_tokens(&proposer, &500); + + assert_eq!(client.next_proposal_id(), 0); + client.propose(&proposer, &String::from_str(&env, "p1")); + assert_eq!(client.next_proposal_id(), 1); + client.propose(&proposer, &String::from_str(&env, "p2")); + assert_eq!(client.next_proposal_id(), 2); + } + + #[test] + fn test_get_vote_record() { + let (env, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + client.lock_tokens(&proposer, &100); + client.lock_tokens(&voter, &600); + + let id = client.propose(&proposer, &String::from_str(&env, "action")); + client.vote(&voter, &id, &600, &true); + + let record = client.get_vote(&voter, &id).unwrap(); + assert_eq!(record.weight, 600); + assert!(record.support); + } +} \ No newline at end of file