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