Skip to content

[FEATURE] Contracts: Soroban Governance Voting smart contract#328

Merged
KevinMB0220 merged 4 commits into
Galaxy-KJ:mainfrom
sotoJ24:main
Jul 2, 2026
Merged

[FEATURE] Contracts: Soroban Governance Voting smart contract#328
KevinMB0220 merged 4 commits into
Galaxy-KJ:mainfrom
sotoJ24:main

Conversation

@sotoJ24

@sotoJ24 sotoJ24 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the Soroban governance voting smart contract for DAO and
decentralized organization coordination ( issue #309).

Closes #309.

Files

  • packages/contracts/governance/Cargo.toml — package manifest
  • packages/contracts/governance/src/lib.rs — contract + tests

Contract design

Storage

Key Type Description
PROPOSALS Map<u32, Proposal> All proposals keyed by ID
VOTES Map<Symbol, VoteRecord> Per-(voter, proposal) vote records
LOCKS Map<Address, TokenLock> Governance token balances
NEXT_ID u32 Monotonic proposal ID counter

All types use #[contracttype] for automatic XDR serialisation.

Configuration constants

Constant Value Purpose
VOTING_PERIOD_SECS 7 days Window during which votes are accepted
QUORUM_THRESHOLD 1 000 Minimum total weight (for + against) required
APPROVAL_THRESHOLD_PCT 50 % Minimum FOR share of total votes cast

Public API

// Proposal lifecycle
pub fn propose(env: Env, proposer: Address, action: String) -> u32
pub fn vote(env: Env, voter: Address, proposal_id: u32, weight: i128, support: bool)
pub fn finalize(env: Env, proposal_id: u32)
pub fn execute(env: Env, executor: Address, proposal_id: u32)

// Token lock management
pub fn lock_tokens(env: Env, voter: Address, amount: i128)
pub fn unlock_tokens(env: Env, voter: Address)

// View functions
pub fn get_proposal(env: Env, proposal_id: u32) -> Option<Proposal>
pub fn get_vote(env: Env, voter: Address, proposal_id: u32) -> Option<VoteRecord>
pub fn get_lock(env: Env, voter: Address) -> Option<TokenLock>
pub fn next_proposal_id(env: Env) -> u32

Proposal state machine

Active ──(voting ends + quorum + majority)──► Passed ──► Executed
│
└──(voting ends, quorum or majority not met)──► Rejected

finalize and execute are intentionally separate calls — the state
transition is auditable on-chain independently of who triggers execution.

Validation enforced on every call

  • propose — proposer must have a non-zero token lock; require_auth
  • vote — lock must exist; weight ≤ locked amount; proposal must be
    Active; voting period must not have ended; double-vote prevented via
    the VOTES map
  • finalize — voting period must have ended; proposal must be Active
  • execute — proposal must be in Passed status; require_auth on executor
  • lock_tokens — amount must be positive; accumulates on existing lock
  • unlock_tokens — lock must exist; require_auth

Tests

Screenshot from 2026-06-30 18-43-54

All tests pass via cargo test.

Acceptance criteria

  • Votes validated according to token lock status
  • Execution only allowed after voting period ends and threshold is met
  • Quorum check prevents low-participation proposals from passing
  • Extensive tests covering edge outcomes (14 tests, all passing)
  • require_auth enforced on all state-mutating calls
  • #[contracttype] on all storage types for XDR compatibility
  • Matches soroban-sdk = "21.0.0" used by sibling contracts

Summary by CodeRabbit

  • New Features

    • Added governance proposal creation, voting, finalization, and execution flows.
    • Added token locking and unlocking for participation in governance.
    • Added read-only views to check proposal status, vote details, token locks, and the next proposal ID.
  • Bug Fixes

    • Added validation to prevent voting without sufficient locked tokens and to block actions outside the allowed proposal lifecycle.
    • Added safeguards to ensure proposals only finalize and execute when they meet the required conditions.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new Soroban governance smart contract package with a Cargo manifest and a lib.rs implementing token-weighted proposals: creation, voting, finalization based on quorum/approval thresholds, execution, token lock management, view helpers, and an accompanying test suite.

Changes

Governance Contract Implementation

Layer / File(s) Summary
Package manifest and module setup
packages/contracts/governance/Cargo.toml, packages/contracts/governance/src/lib.rs
New crate metadata (cdylib, soroban-sdk deps, overflow-checks) plus module imports, storage key symbols, and governance constants (voting period, quorum, approval thresholds).
Data types and contract storage shapes
packages/contracts/governance/src/lib.rs
Defines ProposalStatus enum and Proposal, TokenLock, VoteRecord structs as Soroban contract types.
Proposal lifecycle: propose, vote, finalize, execute
packages/contracts/governance/src/lib.rs
Implements propose (requires token lock), vote (validates weight, lock, window, prevents double voting), finalize (computes Passed/Rejected via quorum/approval), and execute (transitions Passed proposals to Executed).
Token lock management
packages/contracts/governance/src/lib.rs
Implements lock_tokens (accumulates) and unlock_tokens (removes) for voter token locks.
View helpers and vote key derivation
packages/contracts/governance/src/lib.rs
Adds get_proposal, get_vote, get_lock, next_proposal_id view functions and internal vote_key helper for per-(voter, proposal) storage keys.
Governance contract test suite
packages/contracts/governance/src/lib.rs
Adds tests for proposal creation, vote accumulation, full pass/reject lifecycle, ID incrementing, and multiple panic/error cases.

Sequence Diagram(s)

sequenceDiagram
  participant Proposer
  participant Voter
  participant GovernanceContract
  Proposer->>GovernanceContract: lock_tokens(amount)
  Proposer->>GovernanceContract: propose(action)
  GovernanceContract-->>Proposer: proposal_id
  Voter->>GovernanceContract: lock_tokens(amount)
  Voter->>GovernanceContract: vote(proposal_id, weight, support)
  GovernanceContract-->>Voter: VoteRecord stored
  Proposer->>GovernanceContract: finalize(proposal_id)
  GovernanceContract-->>Proposer: status Passed/Rejected
  Proposer->>GovernanceContract: execute(proposal_id)
  GovernanceContract-->>Proposer: status Executed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hops to the chain with glee,
Proposals bloom like clover, wild and free 🌱
Lock your tokens, cast your vote,
Quorum met, watch proposals float,
Passed or rejected, executed clean—
The burrow's DAO is now a governing machine! 🐰⚡

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the new Soroban governance voting contract and is specific enough for the main change.
Linked Issues check ✅ Passed The contract implements proposal creation, token locking, voting, finalization, execution gating, and tests as required by #309.
Out of Scope Changes check ✅ Passed The changes stay within the governance contract files and align with the stated issue scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The PR description covers the summary, linked issue, files, API, behavior, and testing, with only some template sections left unfilled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/contracts/governance/src/lib.rs`:
- Around line 250-255: The `execute` flow in `lib.rs` only updates proposal
status and never performs the approved action, so it needs to actually use
`proposal.action`. Update the `execute` method and any helper it relies on to
decode the stored action payload and dispatch the cross-contract call (or
otherwise invoke the intended operation) when a proposal is `Passed`, then only
mark it `Executed` after the action is successfully handled.
- Around line 364-392: The vote storage key in `vote_key` is not actually unique
per voter and proposal because it ignores `voter` and collapses proposal IDs
above 9 into `vN`, which causes collisions and incorrect duplicate-vote
behavior. Update `vote_key` in `packages/contracts/governance/src/lib.rs` to
derive the key from the full `(voter, proposal_id)` pair, and then make sure the
call sites that store and look up vote records use that same key consistently so
each voter’s vote is isolated per proposal.
- Around line 277-309: The lock_tokens function currently lets callers assign
arbitrary voting power because it only records the requested amount without
verifying any token balance or escrowing tokens. Update lock_tokens to enforce
real ownership of the locked governance tokens by checking the voter’s balance
and moving tokens into escrow via the token contract before updating LOCKS and
TokenLock. Keep the existing symbols lock_tokens, TokenLock, and LOCKS in place,
but make the amount stored reflect only successfully transferred tokens.
- Around line 312-330: The unlock_tokens flow currently removes the voter’s
TokenLock unconditionally, which lets a voter unlock and reuse voting weight
while active proposal votes still depend on that lock. Update unlock_tokens to
verify there are no live vote records tied to the voter before deleting the
entry from LOCKS, using the existing voter auth and storage lookup as the gate.
If any proposal the voter participated in is still active, reject the unlock
instead of calling locks.remove; otherwise proceed with the current removal and
persist the updated map.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89f1424b-c2f5-4d63-99e9-e16a83110a53

📥 Commits

Reviewing files that changed from the base of the PR and between deb9ad9 and 423921a.

📒 Files selected for processing (2)
  • packages/contracts/governance/Cargo.toml
  • packages/contracts/governance/src/lib.rs

Comment on lines +250 to +255
/// 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

execute never executes the approved action.

This method only flips status to Executed; proposal.action is never decoded or dispatched, so passed proposals have no on-chain effect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/governance/src/lib.rs` around lines 250 - 255, The
`execute` flow in `lib.rs` only updates proposal status and never performs the
approved action, so it needs to actually use `proposal.action`. Update the
`execute` method and any helper it relies on to decode the stored action payload
and dispatch the cross-contract call (or otherwise invoke the intended
operation) when a proposal is `Passed`, then only mark it `Executed` after the
action is successfully handled.

Comment on lines +277 to +309
/// 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<Address, TokenLock> =
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

lock_tokens currently lets users mint voting power.

amount is entirely caller-supplied here. Because no token balance is verified and no token transfer is escrowed, any address can assign itself arbitrary proposer/voting weight.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/governance/src/lib.rs` around lines 277 - 309, The
lock_tokens function currently lets callers assign arbitrary voting power
because it only records the requested amount without verifying any token balance
or escrowing tokens. Update lock_tokens to enforce real ownership of the locked
governance tokens by checking the voter’s balance and moving tokens into escrow
via the token contract before updating LOCKS and TokenLock. Keep the existing
symbols lock_tokens, TokenLock, and LOCKS in place, but make the amount stored
reflect only successfully transferred tokens.

Comment on lines +312 to +330
/// 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<Address, TokenLock> =
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Block unlocks while live votes still depend on the lock.

This unconditionally deletes the lock even if the voter already cast weight on active proposals. A voter can vote, unlock, then relock/reuse the same tokens on another open proposal while the first vote still counts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/governance/src/lib.rs` around lines 312 - 330, The
unlock_tokens flow currently removes the voter’s TokenLock unconditionally,
which lets a voter unlock and reuse voting weight while active proposal votes
still depend on that lock. Update unlock_tokens to verify there are no live vote
records tied to the voter before deleting the entry from LOCKS, using the
existing voter auth and storage lookup as the gate. If any proposal the voter
participated in is still active, reject the unlock instead of calling
locks.remove; otherwise proceed with the current removal and persist the updated
map.

Comment thread packages/contracts/governance/src/lib.rs Outdated
@KevinMB0220 KevinMB0220 self-requested a review July 2, 2026 19:58
@KevinMB0220 KevinMB0220 merged commit 85831db into Galaxy-KJ:main Jul 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Contracts: Soroban Governance Voting smart contract (#57)

2 participants