Skip to content

Commit 606ec84

Browse files
authored
Governance: Votes (OpenZeppelin#552)
* init mod, fungible extension and example * review votes * add tests and fix docstrings * add nft votes extension * coverage * avoid redundant event emission * suggestions * refactor with unified fns * add total_supply in trait * fix events * get_checkpoint as pub with error instead default
1 parent 63167bb commit 606ec84

File tree

21 files changed

+2708
-2
lines changed

21 files changed

+2708
-2
lines changed

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"examples/fungible-blocklist",
66
"examples/fungible-capped",
77
"examples/fungible-merkle-airdrop",
8+
"examples/fungible-votes",
89
"examples/fee-forwarder-permissioned",
910
"examples/fee-forwarder-permissionless",
1011
"examples/fungible-pausable",

examples/fungible-votes/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "fungible-votes-example"
3+
edition.workspace = true
4+
license.workspace = true
5+
repository.workspace = true
6+
publish = false
7+
version.workspace = true
8+
9+
[lib]
10+
crate-type = ["cdylib"]
11+
doctest = false
12+
13+
[dependencies]
14+
soroban-sdk = { workspace = true }
15+
stellar-access = { workspace = true }
16+
stellar-governance = { workspace = true }
17+
stellar-macros = { workspace = true }
18+
stellar-tokens = { workspace = true }
19+
20+
[dev-dependencies]
21+
soroban-sdk = { workspace = true, features = ["testutils"] }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String};
2+
use stellar_access::ownable::{set_owner, Ownable};
3+
use stellar_governance::votes::Votes;
4+
use stellar_macros::only_owner;
5+
use stellar_tokens::fungible::{votes::FungibleVotes, Base, FungibleToken};
6+
7+
#[contract]
8+
pub struct ExampleContract;
9+
10+
#[contractimpl]
11+
impl ExampleContract {
12+
pub fn __constructor(e: &Env, owner: Address) {
13+
Base::set_metadata(e, 7, String::from_str(e, "My Token"), String::from_str(e, "MTK"));
14+
set_owner(e, &owner);
15+
}
16+
17+
#[only_owner]
18+
pub fn mint(e: &Env, to: &Address, amount: i128) {
19+
FungibleVotes::mint(e, to, amount);
20+
}
21+
}
22+
23+
#[contractimpl(contracttrait)]
24+
impl FungibleToken for ExampleContract {
25+
type ContractType = FungibleVotes;
26+
}
27+
28+
#[contractimpl(contracttrait)]
29+
impl Votes for ExampleContract {}
30+
31+
#[contractimpl(contracttrait)]
32+
impl Ownable for ExampleContract {}

examples/fungible-votes/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#![no_std]
2+
3+
mod contract;

packages/governance/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,30 @@ Stellar governance functionalities
66

77
This package provides governance modules for Soroban smart contracts:
88

9+
- **Votes**: Vote tracking with delegation and historical checkpointing
910
- **Timelock**: Time-delayed execution of operations
1011

1112
## Modules
1213

14+
### Votes
15+
16+
The `votes` module provides vote tracking functionality with delegation and historical checkpointing for governance mechanisms.
17+
18+
#### Core Concepts
19+
20+
- **Voting Units**: The base unit of voting power, typically 1:1 with token balance
21+
- **Delegation**: Accounts can delegate their voting power to another account (delegatee)
22+
- **Checkpoints**: Historical snapshots of voting power at specific timestamps
23+
- **Clock Mode**: Uses ledger timestamps (`e.ledger().timestamp()`) as the timepoint reference
24+
25+
#### Key Features
26+
27+
- Track voting power per account with historical checkpoints
28+
- Support delegation (an account can delegate its voting power to another account)
29+
- Provide historical vote queries at any past timestamp
30+
- Explicit delegation required (accounts must self-delegate to use their own voting power)
31+
- Non-delegated voting units are not counted as votes
32+
1333
### Timelock
1434

1535
The `timelock` module provides functionality for time-delayed execution of operations, enabling governance mechanisms where actions must wait for a minimum delay before execution.

packages/governance/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#![no_std]
22

33
pub mod timelock;
4+
pub mod votes;
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
//! # Votes Module
2+
//!
3+
//! This module provides utilities for tracking voting power per account with
4+
//! historical checkpoints. It supports delegation (an account can delegate its
5+
//! voting power to another account) and provides historical vote queries at any
6+
//! past timestamp.
7+
//!
8+
//! # Core Concepts
9+
//!
10+
//! - **Voting Units**: The base unit of voting power, typically 1:1 with token
11+
//! balance
12+
//! - **Delegation**: Accounts can delegate their voting power to another
13+
//! account (delegatee). **Only delegated voting power counts as votes** while
14+
//! undelegated voting units are not counted. Self-delegation is required for
15+
//! an account to use its own voting power.
16+
//! - **Checkpoints**: Historical snapshots of voting power at specific
17+
//! timestamps
18+
//!
19+
//! # Usage
20+
//!
21+
//! This module is to be integrated into a token contract and is responsible
22+
//! for:
23+
//! - Overriding the transfer method to call `transfer_voting_units` on every
24+
//! balance change (mint/burn/transfer), as shown in the example below
25+
//! - Exposing delegation functionality to users
26+
//!
27+
//! # Example
28+
//!
29+
//! ```ignore
30+
//! use stellar_governance::votes::{
31+
//! delegate, get_votes, get_votes_at_checkpoint, transfer_voting_units,
32+
//! };
33+
//!
34+
//! // Override your token contract's transfer to update voting units:
35+
//! pub fn transfer(e: &Env, from: Address, to: Address, amount: i128) {
36+
//! // ... perform transfer logic ...
37+
//! transfer_voting_units(e, Some(&from), Some(&to), amount as u128);
38+
//! }
39+
//!
40+
//! // Expose delegation:
41+
//! pub fn delegate(e: &Env, account: Address, delegatee: Address) {
42+
//! votes::delegate(e, &account, &delegatee);
43+
//! }
44+
//! ```
45+
46+
mod storage;
47+
48+
#[cfg(test)]
49+
mod test;
50+
51+
use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env};
52+
53+
pub use crate::votes::storage::{
54+
delegate, get_checkpoint, get_delegate, get_total_supply, get_total_supply_at_checkpoint,
55+
get_votes, get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units,
56+
Checkpoint, CheckpointType, VotesStorageKey,
57+
};
58+
59+
/// Trait for contracts that support vote tracking with delegation.
60+
///
61+
/// This trait defines the interface for vote tracking functionality.
62+
/// Contracts implementing this trait can be used in governance systems
63+
/// that require historical vote queries and delegation.
64+
///
65+
/// # Implementation Notes
66+
///
67+
/// The implementing contract must:
68+
/// - Call `transfer_voting_units` on every balance change
69+
/// - Expose `delegate` functionality to users
70+
#[contracttrait]
71+
pub trait Votes {
72+
/// Returns the current voting power (delegated votes) of an account.
73+
///
74+
/// Returns `0` if the account has no delegated voting power or does not
75+
/// exist in the contract.
76+
///
77+
/// # Arguments
78+
///
79+
/// * `e` - Access to the Soroban environment.
80+
/// * `account` - The address to query voting power for.
81+
fn get_votes(e: &Env, account: Address) -> u128 {
82+
get_votes(e, &account)
83+
}
84+
85+
/// Returns the voting power (delegated votes) of an account at a specific
86+
/// past timestamp.
87+
///
88+
/// Returns `0` if the account had no delegated voting power at the given
89+
/// timepoint or does not exist in the contract.
90+
///
91+
/// # Arguments
92+
///
93+
/// * `e` - Access to the Soroban environment.
94+
/// * `account` - The address to query voting power for.
95+
/// * `timepoint` - The timestamp to query (must be in the past).
96+
///
97+
/// # Errors
98+
///
99+
/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp.
100+
fn get_votes_at_checkpoint(e: &Env, account: Address, timepoint: u64) -> u128 {
101+
get_votes_at_checkpoint(e, &account, timepoint)
102+
}
103+
104+
/// Returns the current total supply of voting units.
105+
///
106+
/// This tracks all voting units in circulation (regardless of delegation
107+
/// status), not just delegated votes.
108+
///
109+
/// Returns `0` if no voting units exist.
110+
///
111+
/// # Arguments
112+
///
113+
/// * `e` - Access to the Soroban environment.
114+
fn get_total_supply(e: &Env) -> u128 {
115+
get_total_supply(e)
116+
}
117+
118+
/// Returns the total supply of voting units at a specific past timestamp.
119+
///
120+
/// This tracks all voting units in circulation (regardless of delegation
121+
/// status), not just delegated votes.
122+
///
123+
/// Returns `0` if there were no voting units at the given timepoint.
124+
///
125+
/// # Arguments
126+
///
127+
/// * `e` - Access to the Soroban environment.
128+
/// * `timepoint` - The timestamp to query (must be in the past).
129+
///
130+
/// # Errors
131+
///
132+
/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp.
133+
fn get_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 {
134+
get_total_supply_at_checkpoint(e, timepoint)
135+
}
136+
137+
/// Returns the current delegate for an account.
138+
///
139+
/// # Arguments
140+
///
141+
/// * `e` - Access to the Soroban environment.
142+
/// * `account` - The address to query the delegate for.
143+
///
144+
/// # Returns
145+
///
146+
/// * `Some(Address)` - The delegate address if delegation is set.
147+
/// * `None` - If the account has not delegated.
148+
fn get_delegate(e: &Env, account: Address) -> Option<Address> {
149+
get_delegate(e, &account)
150+
}
151+
152+
/// Delegates voting power from `account` to `delegatee`.
153+
///
154+
/// # Arguments
155+
///
156+
/// * `e` - Access to the Soroban environment.
157+
/// * `account` - The account delegating its voting power.
158+
/// * `delegatee` - The account receiving the delegated voting power.
159+
///
160+
/// # Events
161+
///
162+
/// * topics - `["delegate_changed", delegator: Address]`
163+
/// * data - `[from_delegate: Option<Address>, to_delegate: Address]`
164+
///
165+
/// * topics - `["delegate_votes_changed", delegate: Address]`
166+
/// * data - `[previous_votes: u128, new_votes: u128]`
167+
///
168+
/// # Notes
169+
///
170+
/// Authorization for `account` is required.
171+
fn delegate(e: &Env, account: Address, delegatee: Address) {
172+
delegate(e, &account, &delegatee);
173+
}
174+
}
175+
// ################## ERRORS ##################
176+
177+
/// Errors that can occur in votes operations.
178+
#[contracterror]
179+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
180+
#[repr(u32)]
181+
pub enum VotesError {
182+
/// The timepoint is in the future
183+
FutureLookup = 4100,
184+
/// Arithmetic overflow occurred
185+
MathOverflow = 4101,
186+
/// Attempting to transfer more voting units than available
187+
InsufficientVotingUnits = 4102,
188+
/// Attempting to delegate to the same delegate that is already set
189+
SameDelegate = 4103,
190+
/// A checkpoint that was expected to exist was not found in storage
191+
CheckpointNotFound = 4104,
192+
}
193+
194+
// ################## CONSTANTS ##################
195+
196+
const DAY_IN_LEDGERS: u32 = 17280;
197+
198+
/// TTL extension amount for storage entries (in ledgers)
199+
pub const VOTES_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS;
200+
201+
/// TTL threshold for extending storage entries (in ledgers)
202+
pub const VOTES_TTL_THRESHOLD: u32 = VOTES_EXTEND_AMOUNT - DAY_IN_LEDGERS;
203+
204+
// ################## EVENTS ##################
205+
206+
/// Event emitted when an account changes its delegate.
207+
#[contractevent]
208+
#[derive(Clone, Debug, Eq, PartialEq)]
209+
pub struct DelegateChanged {
210+
/// The account that changed its delegate
211+
#[topic]
212+
pub delegator: Address,
213+
/// The previous delegate (if any)
214+
pub from_delegate: Option<Address>,
215+
/// The new delegate
216+
pub to_delegate: Address,
217+
}
218+
219+
/// Emits an event when an account changes its delegate.
220+
///
221+
/// # Arguments
222+
///
223+
/// * `e` - Access to Soroban environment.
224+
/// * `delegator` - The account that changed its delegate.
225+
/// * `from_delegate` - The previous delegate (if any).
226+
/// * `to_delegate` - The new delegate.
227+
pub fn emit_delegate_changed(
228+
e: &Env,
229+
delegator: &Address,
230+
from_delegate: Option<Address>,
231+
to_delegate: &Address,
232+
) {
233+
DelegateChanged {
234+
delegator: delegator.clone(),
235+
from_delegate,
236+
to_delegate: to_delegate.clone(),
237+
}
238+
.publish(e);
239+
}
240+
241+
/// Event emitted when a delegate's voting power changes.
242+
#[contractevent]
243+
#[derive(Clone, Debug, Eq, PartialEq)]
244+
pub struct DelegateVotesChanged {
245+
/// The delegate whose voting power changed
246+
#[topic]
247+
pub delegate: Address,
248+
/// The previous voting power
249+
pub previous_votes: u128,
250+
/// The new voting power
251+
pub new_votes: u128,
252+
}
253+
254+
/// Emits an event when a delegate's voting power changes.
255+
///
256+
/// # Arguments
257+
///
258+
/// * `e` - Access to Soroban environment.
259+
/// * `delegate` - The delegate whose voting power changed.
260+
/// * `previous_votes` - The previous voting power.
261+
/// * `new_votes` - The new voting power.
262+
pub fn emit_delegate_votes_changed(
263+
e: &Env,
264+
delegate: &Address,
265+
previous_votes: u128,
266+
new_votes: u128,
267+
) {
268+
DelegateVotesChanged { delegate: delegate.clone(), previous_votes, new_votes }.publish(e);
269+
}

0 commit comments

Comments
 (0)