From 494aa1fd3532b24296c05657e449d0786169ca91 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:36:29 +0100 Subject: [PATCH 01/15] init mod, fungible extension and example --- Cargo.lock | 12 + Cargo.toml | 1 + examples/fungible-votes/Cargo.toml | 21 + examples/fungible-votes/src/contract.rs | 32 ++ examples/fungible-votes/src/lib.rs | 3 + packages/governance/README.md | 19 + packages/governance/src/lib.rs | 1 + packages/governance/src/votes/mod.rs | 237 +++++++++ packages/governance/src/votes/storage.rs | 459 +++++++++++++++++ packages/governance/src/votes/test.rs | 469 ++++++++++++++++++ packages/tokens/Cargo.toml | 1 + .../tokens/src/fungible/extensions/mod.rs | 1 + .../src/fungible/extensions/votes/mod.rs | 3 + .../src/fungible/extensions/votes/storage.rs | 121 +++++ packages/tokens/src/fungible/mod.rs | 2 +- 15 files changed, 1381 insertions(+), 1 deletion(-) create mode 100644 examples/fungible-votes/Cargo.toml create mode 100644 examples/fungible-votes/src/contract.rs create mode 100644 examples/fungible-votes/src/lib.rs create mode 100644 packages/governance/src/votes/mod.rs create mode 100644 packages/governance/src/votes/storage.rs create mode 100644 packages/governance/src/votes/test.rs create mode 100644 packages/tokens/src/fungible/extensions/votes/mod.rs create mode 100644 packages/tokens/src/fungible/extensions/votes/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 4d719c653..ad4889d2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,6 +689,17 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "fungible-votes-example" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-governance", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1886,6 +1897,7 @@ dependencies = [ "soroban-test-helpers", "stellar-contract-utils", "stellar-event-assertion", + "stellar-governance", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44a8c25b7..1a3b86a1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "examples/fungible-blocklist", "examples/fungible-capped", "examples/fungible-merkle-airdrop", + "examples/fungible-votes", "examples/fee-forwarder-permissioned", "examples/fee-forwarder-permissionless", "examples/fungible-pausable", diff --git a/examples/fungible-votes/Cargo.toml b/examples/fungible-votes/Cargo.toml new file mode 100644 index 000000000..2cfc00e5e --- /dev/null +++ b/examples/fungible-votes/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fungible-votes-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-governance = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/fungible-votes/src/contract.rs b/examples/fungible-votes/src/contract.rs new file mode 100644 index 000000000..316ad3737 --- /dev/null +++ b/examples/fungible-votes/src/contract.rs @@ -0,0 +1,32 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; +use stellar_access::ownable::{set_owner, Ownable}; +use stellar_governance::votes::Votes; +use stellar_macros::only_owner; +use stellar_tokens::fungible::{votes::FungibleVotes, Base, FungibleToken}; + +#[contract] +pub struct ExampleContract; + +#[contractimpl] +impl ExampleContract { + pub fn __constructor(e: &Env, owner: Address) { + Base::set_metadata(e, 7, String::from_str(e, "My Token"), String::from_str(e, "MTK")); + set_owner(e, &owner); + } + + #[only_owner] + pub fn mint(e: &Env, to: &Address, amount: i128) { + FungibleVotes::mint(e, to, amount); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for ExampleContract { + type ContractType = FungibleVotes; +} + +#[contractimpl(contracttrait)] +impl Votes for ExampleContract {} + +#[contractimpl(contracttrait)] +impl Ownable for ExampleContract {} diff --git a/examples/fungible-votes/src/lib.rs b/examples/fungible-votes/src/lib.rs new file mode 100644 index 000000000..0f095b838 --- /dev/null +++ b/examples/fungible-votes/src/lib.rs @@ -0,0 +1,3 @@ +#![no_std] + +mod contract; diff --git a/packages/governance/README.md b/packages/governance/README.md index 323164891..5f4d80dc4 100644 --- a/packages/governance/README.md +++ b/packages/governance/README.md @@ -6,10 +6,29 @@ Stellar governance functionalities This package provides governance modules for Soroban smart contracts: +- **Votes**: Vote tracking with delegation and historical checkpointing - **Timelock**: Time-delayed execution of operations ## Modules +### Votes + +The `votes` module provides vote tracking functionality with delegation and historical checkpointing for governance mechanisms. + +#### Core Concepts + +- **Voting Units**: The base unit of voting power, typically 1:1 with token balance +- **Delegation**: Accounts can delegate their voting power to another account (delegatee) +- **Checkpoints**: Historical snapshots of voting power at specific timestamps +- **Clock Mode**: Uses ledger timestamps (`e.ledger().timestamp()`) as the timepoint reference + +#### Key Features + +- Track voting power per account with historical checkpoints +- Support delegation (an account can delegate its voting power to another account) +- Provide historical vote queries at any past timestamp +- Explicit delegation required (accounts must self-delegate to use their own voting power) + ### Timelock The `timelock` module provides functionality for time-delayed execution of operations, enabling governance mechanisms where actions must wait for a minimum delay before execution. diff --git a/packages/governance/src/lib.rs b/packages/governance/src/lib.rs index 80cc74653..175ea8d90 100644 --- a/packages/governance/src/lib.rs +++ b/packages/governance/src/lib.rs @@ -1,3 +1,4 @@ #![no_std] pub mod timelock; +pub mod votes; diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs new file mode 100644 index 000000000..cbc9c02b7 --- /dev/null +++ b/packages/governance/src/votes/mod.rs @@ -0,0 +1,237 @@ +//! # Votes Module +//! +//! This module provides vote tracking functionality with delegation and +//! historical checkpointing for governance mechanisms. +//! +//! The module tracks voting power per account with historical checkpoints, +//! supports delegation (an account can delegate its voting power to another +//! account), and provides historical vote queries at any past timestamp. +//! +//! # Core Concepts +//! +//! - **Voting Units**: The base unit of voting power, typically 1:1 with token +//! balance +//! - **Delegation**: Accounts can delegate their voting power to another +//! account (delegatee) +//! - **Checkpoints**: Historical snapshots of voting power at specific +//! timestamps +//! - **Clock Mode**: Uses ledger timestamps (`e.ledger().timestamp()`) as the +//! timepoint reference +//! +//! # Design +//! +//! This module follows the design of OpenZeppelin's Solidity `Votes.sol`: +//! - Voting units must be explicitly delegated to count as votes +//! - Self-delegation is required for an account to use its own voting power +//! - Historical vote queries use binary search over checkpoints +//! +//! # Usage +//! +//! This module provides storage functions that can be integrated into a token +//! contract. The contract is responsible for: +//! - Calling `transfer_voting_units` on every balance change (mint/burn/transfer) +//! - Exposing delegation functionality to users +//! +//! # Example +//! +//! ```ignore +//! use stellar_governance::votes::{ +//! delegate, get_votes, get_past_votes, transfer_voting_units, +//! }; +//! +//! // In your token contract transfer: +//! pub fn transfer(e: &Env, from: Address, to: Address, amount: i128) { +//! // ... perform transfer logic ... +//! transfer_voting_units(e, Some(&from), Some(&to), amount as u128); +//! } +//! +//! // Expose delegation: +//! pub fn delegate(e: &Env, account: Address, delegatee: Address) { +//! votes::delegate(e, &account, &delegatee); +//! } +//! ``` + +mod storage; + +#[cfg(test)] +mod test; + +use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env}; + +pub use crate::votes::storage::{ + delegate, delegates, get_past_total_supply, get_past_votes, get_total_supply, get_votes, + get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, VotesStorageKey, +}; + +/// Trait for contracts that support vote tracking with delegation. +/// +/// This trait defines the interface for vote tracking functionality. +/// Contracts implementing this trait can be used in governance systems +/// that require historical vote queries and delegation. +/// +/// # Implementation Notes +/// +/// The implementing contract must: +/// - Call `transfer_voting_units` on every balance change +/// - Expose `delegate` functionality to users +#[contracttrait] +pub trait Votes { + /// Returns the current voting power of an account. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The address to query voting power for. + fn get_votes(e: &Env, account: Address) -> u128 { + get_votes(e, &account) + } + + /// Returns the voting power of an account at a specific past timestamp. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The address to query voting power for. + /// * `timepoint` - The timestamp to query (must be in the past). + /// + /// # Errors + /// + /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. + fn get_past_votes(e: &Env, account: Address, timepoint: u64) -> u128 { + get_past_votes(e, &account, timepoint) + } + + /// Returns the total supply of voting units at a specific past timestamp. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `timepoint` - The timestamp to query (must be in the past). + /// + /// # Errors + /// + /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. + fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { + get_past_total_supply(e, timepoint) + } + + /// Returns the current delegate for an account. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The address to query the delegate for. + /// + /// # Returns + /// + /// * `Some(Address)` - The delegate address if delegation is set. + /// * `None` - If the account has not delegated. + fn delegates(e: &Env, account: Address) -> Option
{ + delegates(e, &account) + } + + /// Delegates voting power from `account` to `delegatee`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The account delegating its voting power. + /// * `delegatee` - The account receiving the delegated voting power. + /// + /// # Events + /// + /// * [`DelegateChanged`] - Emitted when delegation changes. + /// * [`DelegateVotesChanged`] - Emitted for both old and new delegates + /// if their voting power changes. + /// + /// # Notes + /// + /// Authorization for `account` is required. + fn delegate(e: &Env, account: Address, delegatee: Address) { + delegate(e, &account, &delegatee); + } +} +// ################## ERRORS ################## + +/// Errors that can occur in votes operations. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum VotesError { + /// The timepoint is in the future + FutureLookup = 4100, + /// Arithmetic overflow occurred + MathOverflow = 4101, +} + +// ################## CONSTANTS ################## + +const DAY_IN_LEDGERS: u32 = 17280; + +/// TTL extension amount for storage entries (in ledgers) +pub const VOTES_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; + +/// TTL threshold for extending storage entries (in ledgers) +pub const VOTES_TTL_THRESHOLD: u32 = VOTES_EXTEND_AMOUNT - DAY_IN_LEDGERS; + +// ################## EVENTS ################## + +/// Event emitted when an account changes its delegate. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DelegateChanged { + /// The account that changed its delegate + #[topic] + pub delegator: Address, + /// The previous delegate (if any) + pub from_delegate: Option
, + /// The new delegate + pub to_delegate: Address, +} + +/// Emits an event when an account changes its delegate. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `delegator` - The account that changed its delegate. +/// * `from_delegate` - The previous delegate (if any). +/// * `to_delegate` - The new delegate. +pub fn emit_delegate_changed( + e: &Env, + delegator: &Address, + from_delegate: Option
, + to_delegate: &Address, +) { + DelegateChanged { + delegator: delegator.clone(), + from_delegate, + to_delegate: to_delegate.clone(), + } + .publish(e); +} + +/// Event emitted when a delegate's voting power changes. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DelegateVotesChanged { + /// The delegate whose voting power changed + #[topic] + pub delegate: Address, + /// The previous voting power + pub old_votes: u128, + /// The new voting power + pub new_votes: u128, +} + +/// Emits an event when a delegate's voting power changes. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `delegate` - The delegate whose voting power changed. +/// * `old_votes` - The previous voting power. +/// * `new_votes` - The new voting power. +pub fn emit_delegate_votes_changed(e: &Env, delegate: &Address, old_votes: u128, new_votes: u128) { + DelegateVotesChanged { delegate: delegate.clone(), old_votes, new_votes }.publish(e); +} diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs new file mode 100644 index 000000000..4fe59933c --- /dev/null +++ b/packages/governance/src/votes/storage.rs @@ -0,0 +1,459 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env}; + +use crate::votes::{ + emit_delegate_changed, emit_delegate_votes_changed, VotesError, VOTES_EXTEND_AMOUNT, + VOTES_TTL_THRESHOLD, +}; + +// ################## TYPES ################## + +/// A checkpoint recording voting power at a specific timestamp. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Checkpoint { + /// The timestamp when this checkpoint was created + pub timestamp: u64, + /// The voting power at this timestamp + pub votes: u128, +} + +/// Storage keys for the votes module. +#[derive(Clone)] +#[contracttype] +pub enum VotesStorageKey { + /// Maps account to its delegate + Delegatee(Address), + /// Number of checkpoints for a delegate + NumCheckpoints(Address), + /// Individual checkpoint for a delegate at index + DelegateCheckpoint(Address, u32), + /// Number of total supply checkpoints + NumTotalSupplyCheckpoints, + /// Individual total supply checkpoint at index + TotalSupplyCheckpoint(u32), + /// Voting units held by an account (tracked separately from delegation) + VotingUnits(Address), +} + +// ################## QUERY STATE ################## + +/// Returns the current voting power of an account. +/// +/// This is the total voting power delegated to this account by others +/// (and itself if self-delegated). +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query voting power for. +pub fn get_votes(e: &Env, account: &Address) -> u128 { + let num = num_checkpoints(e, account); + if num == 0 { + return 0; + } + get_checkpoint(e, account, num - 1).votes +} + +/// Returns the voting power of an account at a specific past timestamp. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query voting power for. +/// * `timepoint` - The timestamp to query (must be in the past). +/// +/// # Errors +/// +/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. +pub fn get_past_votes(e: &Env, account: &Address, timepoint: u64) -> u128 { + let current = e.ledger().timestamp(); + if timepoint >= current { + panic_with_error!(e, VotesError::FutureLookup); + } + checkpoint_lookup(e, account, timepoint) +} + +/// Returns the current total supply of voting units. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +pub fn get_total_supply(e: &Env) -> u128 { + let num = num_total_supply_checkpoints(e); + if num == 0 { + return 0; + } + get_total_supply_checkpoint(e, num - 1).votes +} + +/// Returns the total supply of voting units at a specific past timestamp. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `timepoint` - The timestamp to query (must be in the past). +/// +/// # Errors +/// +/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. +pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { + let current = e.ledger().timestamp(); + if timepoint >= current { + panic_with_error!(e, VotesError::FutureLookup); + } + total_supply_checkpoint_lookup(e, timepoint) +} + +/// Returns the delegate for an account. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query the delegate for. +/// +/// # Returns +/// +/// * `Some(Address)` - The delegate address if delegation is set. +/// * `None` - If the account has not delegated. +pub fn delegates(e: &Env, account: &Address) -> Option
{ + let key = VotesStorageKey::Delegatee(account.clone()); + if let Some(delegatee) = e.storage().persistent().get::<_, Address>(&key) { + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + Some(delegatee) + } else { + None + } +} + +/// Returns the number of checkpoints for an account. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query checkpoints for. +pub fn num_checkpoints(e: &Env, account: &Address) -> u32 { + let key = VotesStorageKey::NumCheckpoints(account.clone()); + e.storage().persistent().get(&key).unwrap_or(0) +} + +/// Returns the voting units held by an account. +/// +/// Voting units represent the underlying balance that can be delegated. +/// This is tracked separately from the delegated voting power. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query voting units for. +pub fn get_voting_units(e: &Env, account: &Address) -> u128 { + let key = VotesStorageKey::VotingUnits(account.clone()); + if let Some(units) = e.storage().persistent().get::<_, u128>(&key) { + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + units + } else { + 0 + } +} + +// ################## CHANGE STATE ################## + +/// Delegates voting power from `account` to `delegatee`. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The account delegating its voting power. +/// * `delegatee` - The account receiving the delegated voting power. +/// +/// # Events +/// +/// * [`DelegateChanged`] - Emitted when delegation changes. +/// * [`DelegateVotesChanged`] - Emitted for both old and new delegates +/// if their voting power changes. +/// +/// # Notes +/// +/// Authorization for `account` is required. +pub fn delegate(e: &Env, account: &Address, delegatee: &Address) { + account.require_auth(); + let old_delegate = delegates(e, account); + + let key = VotesStorageKey::Delegatee(account.clone()); + e.storage().persistent().set(&key, delegatee); + + emit_delegate_changed(e, account, old_delegate.clone(), delegatee); + + let voting_units = get_voting_units(e, account); + move_delegate_votes(e, old_delegate.as_ref(), Some(delegatee), voting_units); +} + +/// Transfers voting units between accounts. +/// +/// This function should be called by the token contract whenever tokens +/// are transferred, minted, or burned. It updates the voting power of +/// the delegates accordingly. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `from` - The source account (`None` for minting). +/// * `to` - The destination account (`None` for burning). +/// * `amount` - The amount of voting units to transfer. +/// +/// # Notes +/// +/// This function does not perform authorization - it should be called +/// from within the token contract's transfer/mint/burn logic. +pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Address>, amount: u128) { + if amount == 0 { + return; + } + + // Update total supply checkpoints for mint/burn + if from.is_none() { + // Minting: increase total supply + push_total_supply_checkpoint(e, true, amount); + } + if to.is_none() { + // Burning: decrease total supply + push_total_supply_checkpoint(e, false, amount); + } + + // Update voting units and move delegate votes + if let Some(from_addr) = from { + let from_units = get_voting_units(e, from_addr); + let new_from_units = from_units.saturating_sub(amount); + set_voting_units(e, from_addr, new_from_units); + + let from_delegate = delegates(e, from_addr); + move_delegate_votes(e, from_delegate.as_ref(), None, amount); + } + + if let Some(to_addr) = to { + let to_units = get_voting_units(e, to_addr); + let Some(new_to_units) = to_units.checked_add(amount) else { + panic_with_error!(e, VotesError::MathOverflow); + }; + set_voting_units(e, to_addr, new_to_units); + + let to_delegate = delegates(e, to_addr); + move_delegate_votes(e, None, to_delegate.as_ref(), amount); + } +} + +// ################## INTERNAL HELPERS ################## + +/// Sets the voting units for an account. +fn set_voting_units(e: &Env, account: &Address, units: u128) { + let key = VotesStorageKey::VotingUnits(account.clone()); + if units == 0 { + e.storage().persistent().remove(&key); + } else { + e.storage().persistent().set(&key, &units); + } +} + +/// Moves delegated votes from one delegate to another. +fn move_delegate_votes(e: &Env, from: Option<&Address>, to: Option<&Address>, amount: u128) { + if amount == 0 { + return; + } + + // Handle case where from and to are the same + if from == to { + return; + } + + if let Some(from_addr) = from { + let (old_value, new_value) = push_checkpoint(e, from_addr, false, amount); + emit_delegate_votes_changed(e, from_addr, old_value, new_value); + } + + if let Some(to_addr) = to { + let (old_value, new_value) = push_checkpoint(e, to_addr, true, amount); + emit_delegate_votes_changed(e, to_addr, old_value, new_value); + } +} + +/// Gets a checkpoint for a delegate at a specific index. +fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { + let key = VotesStorageKey::DelegateCheckpoint(account.clone(), index); + if let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) { + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + checkpoint + } else { + Checkpoint { timestamp: 0, votes: 0 } + } +} + +/// Pushes a new checkpoint or updates the last one if same timestamp. +/// Returns (old_value, new_value). +fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, u128) { + let num = num_checkpoints(e, account); + let current_timestamp = e.ledger().timestamp(); + + let old_value = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; + + let new_value = if add { + old_value + .checked_add(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) + } else { + old_value.saturating_sub(delta) + }; + + // Check if we can update the last checkpoint (same timestamp) + if num > 0 { + let last_checkpoint = get_checkpoint(e, account, num - 1); + if last_checkpoint.timestamp == current_timestamp { + // Update existing checkpoint + let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num - 1); + e.storage() + .persistent() + .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + return (old_value, new_value); + } + } + + // Create new checkpoint + let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num); + e.storage() + .persistent() + .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + + // Update checkpoint count + let num_key = VotesStorageKey::NumCheckpoints(account.clone()); + e.storage().persistent().set(&num_key, &(num + 1)); + + (old_value, new_value) +} + +/// Binary search for checkpoint value at a given timestamp. +fn checkpoint_lookup(e: &Env, account: &Address, timepoint: u64) -> u128 { + let num = num_checkpoints(e, account); + if num == 0 { + return 0; + } + + // Check if timepoint is after the latest checkpoint + let latest = get_checkpoint(e, account, num - 1); + if latest.timestamp <= timepoint { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_checkpoint(e, account, 0); + if first.timestamp > timepoint { + return 0; + } + + // Binary search + let mut low: u32 = 0; + let mut high: u32 = num - 1; + + while low < high { + let mid = (low + high).div_ceil(2); + let checkpoint = get_checkpoint(e, account, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_checkpoint(e, account, low).votes +} + +// ################## TOTAL SUPPLY CHECKPOINTS ################## + +/// Returns the number of total supply checkpoints. +fn num_total_supply_checkpoints(e: &Env) -> u32 { + let key = VotesStorageKey::NumTotalSupplyCheckpoints; + e.storage().instance().get(&key).unwrap_or(0) +} + +/// Gets a total supply checkpoint at a specific index. +fn get_total_supply_checkpoint(e: &Env, index: u32) -> Checkpoint { + let key = VotesStorageKey::TotalSupplyCheckpoint(index); + if let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) { + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + checkpoint + } else { + Checkpoint { timestamp: 0, votes: 0 } + } +} + +/// Pushes a new total supply checkpoint or updates the last one if same timestamp. +fn push_total_supply_checkpoint(e: &Env, add: bool, delta: u128) { + let num = num_total_supply_checkpoints(e); + let current_timestamp = e.ledger().timestamp(); + + let old_value = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; + + let new_value = if add { + old_value + .checked_add(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) + } else { + old_value.saturating_sub(delta) + }; + + // Check if we can update the last checkpoint (same timestamp) + if num > 0 { + let last_checkpoint = get_total_supply_checkpoint(e, num - 1); + if last_checkpoint.timestamp == current_timestamp { + // Update existing checkpoint + let key = VotesStorageKey::TotalSupplyCheckpoint(num - 1); + e.storage() + .persistent() + .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + return; + } + } + + // Create new checkpoint + let key = VotesStorageKey::TotalSupplyCheckpoint(num); + e.storage() + .persistent() + .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + + // Update checkpoint count + let num_key = VotesStorageKey::NumTotalSupplyCheckpoints; + e.storage().instance().set(&num_key, &(num + 1)); +} + +/// Binary search for total supply checkpoint value at a given timestamp. +fn total_supply_checkpoint_lookup(e: &Env, timepoint: u64) -> u128 { + let num = num_total_supply_checkpoints(e); + if num == 0 { + return 0; + } + + // Check if timepoint is after the latest checkpoint + let latest = get_total_supply_checkpoint(e, num - 1); + if latest.timestamp <= timepoint { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_total_supply_checkpoint(e, 0); + if first.timestamp > timepoint { + return 0; + } + + // Binary search + let mut low: u32 = 0; + let mut high: u32 = num - 1; + + while low < high { + let mid = (low + high).div_ceil(2); + let checkpoint = get_total_supply_checkpoint(e, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_total_supply_checkpoint(e, low).votes +} diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs new file mode 100644 index 000000000..c17225358 --- /dev/null +++ b/packages/governance/src/votes/test.rs @@ -0,0 +1,469 @@ +use soroban_sdk::{contract, testutils::{Address as _, Ledger}, Address, Env}; + +use crate::votes::{ + delegate, delegates, get_past_total_supply, get_past_votes, get_total_supply, get_votes, + get_voting_units, num_checkpoints, transfer_voting_units, +}; + +#[contract] +struct MockContract; + +fn setup_env() -> (Env, Address) { + let e = Env::default(); + e.mock_all_auths(); + let contract_address = e.register(MockContract, ()); + (e, contract_address) +} + +// ################## BASIC FUNCTIONALITY TESTS ################## + +#[test] +fn initial_state_has_zero_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + assert_eq!(get_votes(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(num_checkpoints(&e, &alice), 0); + assert_eq!(delegates(&e, &alice), None); + }); +} + +#[test] +fn initial_total_supply_is_zero() { + let (e, contract_address) = setup_env(); + + e.as_contract(&contract_address, || { + assert_eq!(get_total_supply(&e), 0); + }); +} + +// ################## TRANSFER VOTING UNITS TESTS ################## + +#[test] +fn mint_increases_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + + assert_eq!(get_voting_units(&e, &alice), 100); + assert_eq!(get_total_supply(&e), 100); + }); +} + +#[test] +fn mint_does_not_create_votes_without_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + + assert_eq!(get_voting_units(&e, &alice), 100); + assert_eq!(get_votes(&e, &alice), 0); + }); +} + +#[test] +fn burn_decreases_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + transfer_voting_units(&e, Some(&alice), None, 30); + + assert_eq!(get_voting_units(&e, &alice), 70); + assert_eq!(get_total_supply(&e), 70); + }); +} + +#[test] +fn transfer_moves_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + transfer_voting_units(&e, Some(&alice), Some(&bob), 40); + + assert_eq!(get_voting_units(&e, &alice), 60); + assert_eq!(get_voting_units(&e, &bob), 40); + assert_eq!(get_total_supply(&e), 100); + }); +} + +#[test] +fn zero_transfer_is_noop() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 0); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_total_supply(&e), 0); + }); +} + +// ################## DELEGATION TESTS ################## + +#[test] +fn delegate_to_self() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &alice); + + assert_eq!(delegates(&e, &alice), Some(alice.clone())); + assert_eq!(get_votes(&e, &alice), 100); + }); +} + +#[test] +fn delegate_to_other() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + + assert_eq!(delegates(&e, &alice), Some(bob.clone())); + assert_eq!(get_votes(&e, &alice), 0); + assert_eq!(get_votes(&e, &bob), 100); + }); +} + +#[test] +fn change_delegate() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 100); + assert_eq!(get_votes(&e, &charlie), 0); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &charlie); + assert_eq!(get_votes(&e, &bob), 0); + assert_eq!(get_votes(&e, &charlie), 100); + }); +} + +#[test] +fn multiple_delegators_to_same_delegate() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + transfer_voting_units(&e, None, Some(&bob), 50); + + delegate(&e, &alice, &charlie); + delegate(&e, &bob, &charlie); + + assert_eq!(get_votes(&e, &charlie), 150); + }); +} + +#[test] +fn transfer_updates_delegate_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let delegate_a = Address::generate(&e); + let delegate_b = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &delegate_a); + assert_eq!(get_votes(&e, &delegate_a), 100); + + transfer_voting_units(&e, None, Some(&bob), 50); + delegate(&e, &bob, &delegate_b); + assert_eq!(get_votes(&e, &delegate_b), 50); + + // Transfer from alice to bob + transfer_voting_units(&e, Some(&alice), Some(&bob), 30); + + assert_eq!(get_votes(&e, &delegate_a), 70); + assert_eq!(get_votes(&e, &delegate_b), 80); + }); +} + +// ################## CHECKPOINT TESTS ################## + +#[test] +fn checkpoints_created_on_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + + assert_eq!(num_checkpoints(&e, &bob), 1); + }); +} + +#[test] +fn multiple_operations_same_timestamp_single_checkpoint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + + transfer_voting_units(&e, None, Some(&alice), 50); + + // Should still have only 1 checkpoint since same timestamp + assert_eq!(num_checkpoints(&e, &bob), 1); + assert_eq!(get_votes(&e, &bob), 150); + }); +} + +#[test] +fn different_timestamps_create_new_checkpoints() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + assert_eq!(num_checkpoints(&e, &bob), 1); + + e.ledger().set_timestamp(2000); + transfer_voting_units(&e, None, Some(&alice), 50); + assert_eq!(num_checkpoints(&e, &bob), 2); + + e.ledger().set_timestamp(3000); + transfer_voting_units(&e, None, Some(&alice), 25); + assert_eq!(num_checkpoints(&e, &bob), 3); + + assert_eq!(get_votes(&e, &bob), 175); + }); +} + +// ################## HISTORICAL QUERY TESTS ################## + +#[test] +fn get_past_votes_returns_historical_value() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + + e.ledger().set_timestamp(2000); + transfer_voting_units(&e, None, Some(&alice), 50); + + e.ledger().set_timestamp(3000); + transfer_voting_units(&e, None, Some(&alice), 25); + + // Query at different timepoints + e.ledger().set_timestamp(4000); + + assert_eq!(get_past_votes(&e, &bob, 999), 0); + assert_eq!(get_past_votes(&e, &bob, 1000), 100); + assert_eq!(get_past_votes(&e, &bob, 1500), 100); + assert_eq!(get_past_votes(&e, &bob, 2000), 150); + assert_eq!(get_past_votes(&e, &bob, 2500), 150); + assert_eq!(get_past_votes(&e, &bob, 3000), 175); + assert_eq!(get_past_votes(&e, &bob, 3500), 175); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_votes_fails_for_future_timepoint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + get_past_votes(&e, &alice, 1000); + }); +} + +#[test] +fn get_past_total_supply_returns_historical_value() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + + e.ledger().set_timestamp(2000); + transfer_voting_units(&e, None, Some(&alice), 50); + + e.ledger().set_timestamp(3000); + transfer_voting_units(&e, Some(&alice), None, 30); + + // Query at different timepoints + e.ledger().set_timestamp(4000); + + assert_eq!(get_past_total_supply(&e, 999), 0); + assert_eq!(get_past_total_supply(&e, 1000), 100); + assert_eq!(get_past_total_supply(&e, 1500), 100); + assert_eq!(get_past_total_supply(&e, 2000), 150); + assert_eq!(get_past_total_supply(&e, 2500), 150); + assert_eq!(get_past_total_supply(&e, 3000), 120); + assert_eq!(get_past_total_supply(&e, 3500), 120); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_total_supply_fails_for_future_timepoint() { + let (e, contract_address) = setup_env(); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + get_past_total_supply(&e, 1000); + }); +} + +// ################## EDGE CASES ################## + +#[test] +fn delegate_without_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + + assert_eq!(delegates(&e, &alice), Some(bob.clone())); + assert_eq!(get_votes(&e, &bob), 0); + }); +} + +#[test] +fn receive_voting_units_after_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 0); + + transfer_voting_units(&e, None, Some(&alice), 100); + assert_eq!(get_votes(&e, &bob), 100); + }); +} + +#[test] +fn burn_all_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 100); + + transfer_voting_units(&e, Some(&alice), None, 100); + assert_eq!(get_votes(&e, &bob), 0); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_total_supply(&e), 0); + }); +} + +#[test] +fn delegate_to_same_delegate_is_noop() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.ledger().set_timestamp(1000); + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + }); + + let checkpoints_before = e.as_contract(&contract_address, || { + num_checkpoints(&e, &bob) + }); + + e.ledger().set_timestamp(2000); + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + // No new checkpoints should be created since votes didn't change + assert_eq!(num_checkpoints(&e, &bob), checkpoints_before); + }); +} + +#[test] +fn large_voting_power() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + let large_amount: u128 = u128::MAX / 2; + transfer_voting_units(&e, None, Some(&alice), large_amount); + delegate(&e, &alice, &bob); + + assert_eq!(get_votes(&e, &bob), large_amount); + assert_eq!(get_total_supply(&e), large_amount); + }); +} + +#[test] +fn binary_search_with_many_checkpoints() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + // Initial delegation at timestamp 0 creates first checkpoint + transfer_voting_units(&e, None, Some(&alice), 1000); + delegate(&e, &alice, &bob); + + // Create many more checkpoints (starting at timestamp 100) + for i in 1..=20 { + e.ledger().set_timestamp(i * 100); + transfer_voting_units(&e, None, Some(&alice), 10); + } + + // 1 initial + 20 more = 21 checkpoints + assert_eq!(num_checkpoints(&e, &bob), 21); + + // Query various historical points + e.ledger().set_timestamp(3000); + assert_eq!(get_past_votes(&e, &bob, 50), 1000); // After initial delegation + assert_eq!(get_past_votes(&e, &bob, 100), 1010); + assert_eq!(get_past_votes(&e, &bob, 500), 1050); + assert_eq!(get_past_votes(&e, &bob, 1000), 1100); + assert_eq!(get_past_votes(&e, &bob, 1500), 1150); + assert_eq!(get_past_votes(&e, &bob, 2000), 1200); + }); +} diff --git a/packages/tokens/Cargo.toml b/packages/tokens/Cargo.toml index e6520d9df..88d8f7a54 100644 --- a/packages/tokens/Cargo.toml +++ b/packages/tokens/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-contract-utils = { workspace = true } +stellar-governance = { workspace = true } [dev-dependencies] ed25519-dalek = { workspace = true } diff --git a/packages/tokens/src/fungible/extensions/mod.rs b/packages/tokens/src/fungible/extensions/mod.rs index dc364e5ad..7b9304053 100644 --- a/packages/tokens/src/fungible/extensions/mod.rs +++ b/packages/tokens/src/fungible/extensions/mod.rs @@ -2,3 +2,4 @@ pub mod allowlist; pub mod blocklist; pub mod burnable; pub mod capped; +pub mod votes; diff --git a/packages/tokens/src/fungible/extensions/votes/mod.rs b/packages/tokens/src/fungible/extensions/votes/mod.rs new file mode 100644 index 000000000..193855e43 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/mod.rs @@ -0,0 +1,3 @@ +pub mod storage; + +pub use storage::FungibleVotes; diff --git a/packages/tokens/src/fungible/extensions/votes/storage.rs b/packages/tokens/src/fungible/extensions/votes/storage.rs new file mode 100644 index 000000000..0fd2a3976 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/storage.rs @@ -0,0 +1,121 @@ +use soroban_sdk::{Address, Env, MuxedAddress}; +use stellar_governance::votes::transfer_voting_units; + +use crate::fungible::{Base, ContractOverrides}; + +pub struct FungibleVotes; + +impl ContractOverrides for FungibleVotes { + fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { + FungibleVotes::transfer(e, from, to, amount); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { + FungibleVotes::transfer_from(e, spender, from, to, amount); + } +} + +impl FungibleVotes { + /// Transfers `amount` of tokens from `from` to `to`. + /// Also updates voting units for the respective delegates. + /// + /// # Arguments + /// + /// * `e` - Access to Soroban environment. + /// * `from` - The address holding the tokens. + /// * `to` - The address receiving the transferred tokens. + /// * `amount` - The amount of tokens to be transferred. + /// + /// # Notes + /// + /// Authorization for `from` is required. + pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { + Base::transfer(e, from, to, amount); + if amount > 0 { + transfer_voting_units(e, Some(from), Some(&to.address()), amount as u128); + } + } + + /// Transfers `amount` of tokens from `from` to `to` using the + /// allowance mechanism. Also updates voting units for the respective + /// delegates. + /// + /// # Arguments + /// + /// * `e` - Access to Soroban environment. + /// * `spender` - The address authorizing the transfer. + /// * `from` - The address holding the tokens. + /// * `to` - The address receiving the transferred tokens. + /// * `amount` - The amount of tokens to be transferred. + /// + /// # Notes + /// + /// Authorization for `spender` is required. + pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { + Base::transfer_from(e, spender, from, to, amount); + if amount > 0 { + transfer_voting_units(e, Some(from), Some(to), amount as u128); + } + } + + /// Creates `amount` of tokens and assigns them to `to`. + /// Also updates voting units for the recipient's delegate. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `to` - The address receiving the new tokens. + /// * `amount` - The amount of tokens to mint. + /// + /// # Security Warning + /// + /// This function has NO AUTHORIZATION CONTROLS. + /// The caller must ensure proper authorization before calling. + pub fn mint(e: &Env, to: &Address, amount: i128) { + Base::mint(e, to, amount); + if amount > 0 { + transfer_voting_units(e, None, Some(to), amount as u128); + } + } + + /// Destroys `amount` of tokens from `from`. Updates the total + /// supply accordingly. Also updates voting units for the recipient's + /// delegate. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The account whose tokens are destroyed. + /// * `amount` - The amount of tokens to burn. + /// + /// # Notes + /// + /// Authorization for `from` is required. + pub fn burn(e: &Env, from: &Address, amount: i128) { + Base::burn(e, from, amount); + if amount > 0 { + transfer_voting_units(e, Some(from), None, amount as u128); + } + } + + /// Destroys `amount` of tokens from `from`. Updates the total + /// supply accordingly. Also updates voting units for the recipient's + /// delegate. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `spender` - The address authorized to burn the tokens. + /// * `from` - The account whose tokens are destroyed. + /// * `amount` - The amount of tokens to burn. + /// + /// # Notes + /// + /// Authorization for `spender` is required. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + Base::burn_from(e, spender, from, amount); + if amount > 0 { + transfer_voting_units(e, Some(from), None, amount as u128); + } + } +} diff --git a/packages/tokens/src/fungible/mod.rs b/packages/tokens/src/fungible/mod.rs index f45918c7b..080b25cb2 100644 --- a/packages/tokens/src/fungible/mod.rs +++ b/packages/tokens/src/fungible/mod.rs @@ -75,7 +75,7 @@ mod utils; #[cfg(test)] mod test; -pub use extensions::{allowlist, blocklist, burnable, capped}; +pub use extensions::{allowlist, blocklist, burnable, capped, votes}; pub use overrides::{Base, ContractOverrides}; use soroban_sdk::{ contracterror, contractevent, contracttrait, Address, Env, MuxedAddress, String, From 14fb3f5c9facb7ef22d7f9b8c7534d597e87501a Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:53:50 +0100 Subject: [PATCH 02/15] review votes --- packages/governance/src/votes/mod.rs | 20 +- packages/governance/src/votes/storage.rs | 244 +++++++++++------------ packages/governance/src/votes/test.rs | 22 +- 3 files changed, 136 insertions(+), 150 deletions(-) diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index cbc9c02b7..8b596d923 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -1,8 +1,5 @@ //! # Votes Module //! -//! This module provides vote tracking functionality with delegation and -//! historical checkpointing for governance mechanisms. -//! //! The module tracks voting power per account with historical checkpoints, //! supports delegation (an account can delegate its voting power to another //! account), and provides historical vote queries at any past timestamp. @@ -15,8 +12,6 @@ //! account (delegatee) //! - **Checkpoints**: Historical snapshots of voting power at specific //! timestamps -//! - **Clock Mode**: Uses ledger timestamps (`e.ledger().timestamp()`) as the -//! timepoint reference //! //! # Design //! @@ -29,7 +24,8 @@ //! //! This module provides storage functions that can be integrated into a token //! contract. The contract is responsible for: -//! - Calling `transfer_voting_units` on every balance change (mint/burn/transfer) +//! - Calling `transfer_voting_units` on every balance change +//! (mint/burn/transfer) //! - Exposing delegation functionality to users //! //! # Example @@ -59,7 +55,7 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env}; pub use crate::votes::storage::{ - delegate, delegates, get_past_total_supply, get_past_votes, get_total_supply, get_votes, + delegate, get_delegate, get_past_total_supply, get_past_votes, get_total_supply, get_votes, get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, VotesStorageKey, }; @@ -126,8 +122,8 @@ pub trait Votes { /// /// * `Some(Address)` - The delegate address if delegation is set. /// * `None` - If the account has not delegated. - fn delegates(e: &Env, account: Address) -> Option
{ - delegates(e, &account) + fn get_delegate(e: &Env, account: Address) -> Option
{ + get_delegate(e, &account) } /// Delegates voting power from `account` to `delegatee`. @@ -141,8 +137,8 @@ pub trait Votes { /// # Events /// /// * [`DelegateChanged`] - Emitted when delegation changes. - /// * [`DelegateVotesChanged`] - Emitted for both old and new delegates - /// if their voting power changes. + /// * [`DelegateVotesChanged`] - Emitted for both old and new delegates if + /// their voting power changes. /// /// # Notes /// @@ -162,6 +158,8 @@ pub enum VotesError { FutureLookup = 4100, /// Arithmetic overflow occurred MathOverflow = 4101, + /// Try to transfer more than available + InsufficientVotingUnits = 4102, } // ################## CONSTANTS ################## diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index 4fe59933c..e7b88e3ef 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -66,11 +66,42 @@ pub fn get_votes(e: &Env, account: &Address) -> u128 { /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. pub fn get_past_votes(e: &Env, account: &Address, timepoint: u64) -> u128 { - let current = e.ledger().timestamp(); - if timepoint >= current { + if timepoint >= e.ledger().timestamp() { panic_with_error!(e, VotesError::FutureLookup); } - checkpoint_lookup(e, account, timepoint) + + let num = num_checkpoints(e, account); + if num == 0 { + return 0; + } + + // Check if timepoint is after the latest checkpoint + let latest = get_checkpoint(e, account, num - 1); + if latest.timestamp <= timepoint { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_checkpoint(e, account, 0); + if first.timestamp > timepoint { + return 0; + } + + // Binary search + let mut low: u32 = 0; + let mut high: u32 = num - 1; + + while low < high { + let mid = (low + high).div_ceil(2); + let checkpoint = get_checkpoint(e, account, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_checkpoint(e, account, low).votes } /// Returns the current total supply of voting units. @@ -97,11 +128,42 @@ pub fn get_total_supply(e: &Env) -> u128 { /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { - let current = e.ledger().timestamp(); - if timepoint >= current { + if timepoint >= e.ledger().timestamp() { panic_with_error!(e, VotesError::FutureLookup); } - total_supply_checkpoint_lookup(e, timepoint) + + let num = num_total_supply_checkpoints(e); + if num == 0 { + return 0; + } + + // Check if timepoint is after the latest checkpoint + let latest = get_total_supply_checkpoint(e, num - 1); + if latest.timestamp <= timepoint { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_total_supply_checkpoint(e, 0); + if first.timestamp > timepoint { + return 0; + } + + // Binary search + let mut low: u32 = 0; + let mut high: u32 = num - 1; + + while low < high { + let mid = (low + high).div_ceil(2); + let checkpoint = get_total_supply_checkpoint(e, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_total_supply_checkpoint(e, low).votes } /// Returns the delegate for an account. @@ -115,7 +177,7 @@ pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { /// /// * `Some(Address)` - The delegate address if delegation is set. /// * `None` - If the account has not delegated. -pub fn delegates(e: &Env, account: &Address) -> Option
{ +pub fn get_delegate(e: &Env, account: &Address) -> Option
{ let key = VotesStorageKey::Delegatee(account.clone()); if let Some(delegatee) = e.storage().persistent().get::<_, Address>(&key) { e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); @@ -133,7 +195,12 @@ pub fn delegates(e: &Env, account: &Address) -> Option
{ /// * `account` - The address to query checkpoints for. pub fn num_checkpoints(e: &Env, account: &Address) -> u32 { let key = VotesStorageKey::NumCheckpoints(account.clone()); - e.storage().persistent().get(&key).unwrap_or(0) + if let Some(checkpoints) = e.storage().persistent().get::<_, u32>(&key) { + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + checkpoints + } else { + 0 + } } /// Returns the voting units held by an account. @@ -168,18 +235,17 @@ pub fn get_voting_units(e: &Env, account: &Address) -> u128 { /// # Events /// /// * [`DelegateChanged`] - Emitted when delegation changes. -/// * [`DelegateVotesChanged`] - Emitted for both old and new delegates -/// if their voting power changes. +/// * [`DelegateVotesChanged`] - Emitted for both old and new delegates if their +/// voting power changes. /// /// # Notes /// /// Authorization for `account` is required. pub fn delegate(e: &Env, account: &Address, delegatee: &Address) { account.require_auth(); - let old_delegate = delegates(e, account); + let old_delegate = get_delegate(e, account); - let key = VotesStorageKey::Delegatee(account.clone()); - e.storage().persistent().set(&key, delegatee); + e.storage().persistent().set(&VotesStorageKey::Delegatee(account.clone()), delegatee); emit_delegate_changed(e, account, old_delegate.clone(), delegatee); @@ -209,24 +275,18 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres return; } - // Update total supply checkpoints for mint/burn - if from.is_none() { - // Minting: increase total supply - push_total_supply_checkpoint(e, true, amount); - } - if to.is_none() { - // Burning: decrease total supply - push_total_supply_checkpoint(e, false, amount); - } - - // Update voting units and move delegate votes if let Some(from_addr) = from { let from_units = get_voting_units(e, from_addr); - let new_from_units = from_units.saturating_sub(amount); + let Some(new_from_units) = from_units.checked_sub(amount) else { + panic_with_error!(e, VotesError::InsufficientVotingUnits); + }; set_voting_units(e, from_addr, new_from_units); - let from_delegate = delegates(e, from_addr); + let from_delegate = get_delegate(e, from_addr); move_delegate_votes(e, from_delegate.as_ref(), None, amount); + } else { + // Minting: increase total supply + push_total_supply_checkpoint(e, true, amount); } if let Some(to_addr) = to { @@ -236,8 +296,11 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres }; set_voting_units(e, to_addr, new_to_units); - let to_delegate = delegates(e, to_addr); + let to_delegate = get_delegate(e, to_addr); move_delegate_votes(e, None, to_delegate.as_ref(), amount); + } else { + // Burning: decrease total supply + push_total_supply_checkpoint(e, false, amount); } } @@ -259,19 +322,18 @@ fn move_delegate_votes(e: &Env, from: Option<&Address>, to: Option<&Address>, am return; } - // Handle case where from and to are the same if from == to { return; } if let Some(from_addr) = from { - let (old_value, new_value) = push_checkpoint(e, from_addr, false, amount); - emit_delegate_votes_changed(e, from_addr, old_value, new_value); + let (old_votes, new_votes) = push_checkpoint(e, from_addr, false, amount); + emit_delegate_votes_changed(e, from_addr, old_votes, new_votes); } if let Some(to_addr) = to { - let (old_value, new_value) = push_checkpoint(e, to_addr, true, amount); - emit_delegate_votes_changed(e, to_addr, old_value, new_value); + let (old_votes, new_votes) = push_checkpoint(e, to_addr, true, amount); + emit_delegate_votes_changed(e, to_addr, old_votes, new_votes); } } @@ -287,19 +349,21 @@ fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { } /// Pushes a new checkpoint or updates the last one if same timestamp. -/// Returns (old_value, new_value). +/// Returns (last_votes, new_votes). fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, u128) { let num = num_checkpoints(e, account); let current_timestamp = e.ledger().timestamp(); - let old_value = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; + let last_votes = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; - let new_value = if add { - old_value + let votes = if add { + last_votes .checked_add(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) } else { - old_value.saturating_sub(delta) + last_votes + .checked_sub(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) }; // Check if we can update the last checkpoint (same timestamp) @@ -308,60 +372,20 @@ fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, if last_checkpoint.timestamp == current_timestamp { // Update existing checkpoint let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num - 1); - e.storage() - .persistent() - .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); - return (old_value, new_value); + e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); + return (last_votes, votes); } } // Create new checkpoint let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num); - e.storage() - .persistent() - .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); // Update checkpoint count let num_key = VotesStorageKey::NumCheckpoints(account.clone()); e.storage().persistent().set(&num_key, &(num + 1)); - (old_value, new_value) -} - -/// Binary search for checkpoint value at a given timestamp. -fn checkpoint_lookup(e: &Env, account: &Address, timepoint: u64) -> u128 { - let num = num_checkpoints(e, account); - if num == 0 { - return 0; - } - - // Check if timepoint is after the latest checkpoint - let latest = get_checkpoint(e, account, num - 1); - if latest.timestamp <= timepoint { - return latest.votes; - } - - // Check if timepoint is before the first checkpoint - let first = get_checkpoint(e, account, 0); - if first.timestamp > timepoint { - return 0; - } - - // Binary search - let mut low: u32 = 0; - let mut high: u32 = num - 1; - - while low < high { - let mid = (low + high).div_ceil(2); - let checkpoint = get_checkpoint(e, account, mid); - if checkpoint.timestamp <= timepoint { - low = mid; - } else { - high = mid - 1; - } - } - - get_checkpoint(e, account, low).votes + (last_votes, votes) } // ################## TOTAL SUPPLY CHECKPOINTS ################## @@ -383,77 +407,39 @@ fn get_total_supply_checkpoint(e: &Env, index: u32) -> Checkpoint { } } -/// Pushes a new total supply checkpoint or updates the last one if same timestamp. +/// Pushes a new total supply checkpoint or updates the last one if same +/// timestamp. fn push_total_supply_checkpoint(e: &Env, add: bool, delta: u128) { let num = num_total_supply_checkpoints(e); let current_timestamp = e.ledger().timestamp(); - let old_value = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; + let last_votes = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; - let new_value = if add { - old_value + let votes = if add { + last_votes .checked_add(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) } else { - old_value.saturating_sub(delta) + last_votes + .checked_sub(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) }; // Check if we can update the last checkpoint (same timestamp) if num > 0 { let last_checkpoint = get_total_supply_checkpoint(e, num - 1); if last_checkpoint.timestamp == current_timestamp { - // Update existing checkpoint let key = VotesStorageKey::TotalSupplyCheckpoint(num - 1); - e.storage() - .persistent() - .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); return; } } // Create new checkpoint let key = VotesStorageKey::TotalSupplyCheckpoint(num); - e.storage() - .persistent() - .set(&key, &Checkpoint { timestamp: current_timestamp, votes: new_value }); + e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); // Update checkpoint count let num_key = VotesStorageKey::NumTotalSupplyCheckpoints; e.storage().instance().set(&num_key, &(num + 1)); } - -/// Binary search for total supply checkpoint value at a given timestamp. -fn total_supply_checkpoint_lookup(e: &Env, timepoint: u64) -> u128 { - let num = num_total_supply_checkpoints(e); - if num == 0 { - return 0; - } - - // Check if timepoint is after the latest checkpoint - let latest = get_total_supply_checkpoint(e, num - 1); - if latest.timestamp <= timepoint { - return latest.votes; - } - - // Check if timepoint is before the first checkpoint - let first = get_total_supply_checkpoint(e, 0); - if first.timestamp > timepoint { - return 0; - } - - // Binary search - let mut low: u32 = 0; - let mut high: u32 = num - 1; - - while low < high { - let mid = (low + high).div_ceil(2); - let checkpoint = get_total_supply_checkpoint(e, mid); - if checkpoint.timestamp <= timepoint { - low = mid; - } else { - high = mid - 1; - } - } - - get_total_supply_checkpoint(e, low).votes -} diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index c17225358..708ea2bf9 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -1,7 +1,11 @@ -use soroban_sdk::{contract, testutils::{Address as _, Ledger}, Address, Env}; +use soroban_sdk::{ + contract, + testutils::{Address as _, Ledger}, + Address, Env, +}; use crate::votes::{ - delegate, delegates, get_past_total_supply, get_past_votes, get_total_supply, get_votes, + delegate, get_delegate, get_past_total_supply, get_past_votes, get_total_supply, get_votes, get_voting_units, num_checkpoints, transfer_voting_units, }; @@ -26,7 +30,7 @@ fn initial_state_has_zero_votes() { assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_voting_units(&e, &alice), 0); assert_eq!(num_checkpoints(&e, &alice), 0); - assert_eq!(delegates(&e, &alice), None); + assert_eq!(get_delegate(&e, &alice), None); }); } @@ -120,7 +124,7 @@ fn delegate_to_self() { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &alice); - assert_eq!(delegates(&e, &alice), Some(alice.clone())); + assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); assert_eq!(get_votes(&e, &alice), 100); }); } @@ -135,7 +139,7 @@ fn delegate_to_other() { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &bob); - assert_eq!(delegates(&e, &alice), Some(bob.clone())); + assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_votes(&e, &bob), 100); }); @@ -359,7 +363,7 @@ fn delegate_without_voting_units() { e.as_contract(&contract_address, || { delegate(&e, &alice, &bob); - assert_eq!(delegates(&e, &alice), Some(bob.clone())); + assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); assert_eq!(get_votes(&e, &bob), 0); }); } @@ -409,9 +413,7 @@ fn delegate_to_same_delegate_is_noop() { delegate(&e, &alice, &bob); }); - let checkpoints_before = e.as_contract(&contract_address, || { - num_checkpoints(&e, &bob) - }); + let checkpoints_before = e.as_contract(&contract_address, || num_checkpoints(&e, &bob)); e.ledger().set_timestamp(2000); e.as_contract(&contract_address, || { @@ -459,7 +461,7 @@ fn binary_search_with_many_checkpoints() { // Query various historical points e.ledger().set_timestamp(3000); - assert_eq!(get_past_votes(&e, &bob, 50), 1000); // After initial delegation + assert_eq!(get_past_votes(&e, &bob, 50), 1000); // After initial delegation assert_eq!(get_past_votes(&e, &bob, 100), 1010); assert_eq!(get_past_votes(&e, &bob, 500), 1050); assert_eq!(get_past_votes(&e, &bob, 1000), 1100); From 60c19e2378db5284ceb81ac7354e4d4c29bb0c1c Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:23:20 +0100 Subject: [PATCH 03/15] add tests and fix docstrings --- packages/governance/src/votes/mod.rs | 10 +- packages/governance/src/votes/storage.rs | 8 +- packages/governance/src/votes/test.rs | 199 +++++++-- .../src/fungible/extensions/votes/mod.rs | 3 + .../src/fungible/extensions/votes/test.rs | 393 ++++++++++++++++++ 5 files changed, 576 insertions(+), 37 deletions(-) create mode 100644 packages/tokens/src/fungible/extensions/votes/test.rs diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index 8b596d923..fc2881cac 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -136,9 +136,11 @@ pub trait Votes { /// /// # Events /// - /// * [`DelegateChanged`] - Emitted when delegation changes. - /// * [`DelegateVotesChanged`] - Emitted for both old and new delegates if - /// their voting power changes. + /// * topics - `["DelegateChanged", delegator: Address]` + /// * data - `[from_delegate: Option
, to_delegate: Address]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[old_votes: u128, new_votes: u128]` /// /// # Notes /// @@ -158,7 +160,7 @@ pub enum VotesError { FutureLookup = 4100, /// Arithmetic overflow occurred MathOverflow = 4101, - /// Try to transfer more than available + /// Attempting to transfer more voting units than available InsufficientVotingUnits = 4102, } diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index e7b88e3ef..0c5b566b7 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -234,9 +234,11 @@ pub fn get_voting_units(e: &Env, account: &Address) -> u128 { /// /// # Events /// -/// * [`DelegateChanged`] - Emitted when delegation changes. -/// * [`DelegateVotesChanged`] - Emitted for both old and new delegates if their -/// voting power changes. +/// * topics - `["DelegateChanged", delegator: Address]` +/// * data - `[from_delegate: Option
, to_delegate: Address]` +/// +/// * topics - `["DelegateVotesChanged", delegate: Address]` +/// * data - `[old_votes: u128, new_votes: u128]` /// /// # Notes /// diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 708ea2bf9..45cc7750d 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -19,8 +19,6 @@ fn setup_env() -> (Env, Address) { (e, contract_address) } -// ################## BASIC FUNCTIONALITY TESTS ################## - #[test] fn initial_state_has_zero_votes() { let (e, contract_address) = setup_env(); @@ -31,20 +29,10 @@ fn initial_state_has_zero_votes() { assert_eq!(get_voting_units(&e, &alice), 0); assert_eq!(num_checkpoints(&e, &alice), 0); assert_eq!(get_delegate(&e, &alice), None); - }); -} - -#[test] -fn initial_total_supply_is_zero() { - let (e, contract_address) = setup_env(); - - e.as_contract(&contract_address, || { assert_eq!(get_total_supply(&e), 0); }); } -// ################## TRANSFER VOTING UNITS TESTS ################## - #[test] fn mint_increases_voting_units() { let (e, contract_address) = setup_env(); @@ -166,24 +154,6 @@ fn change_delegate() { }); } -#[test] -fn multiple_delegators_to_same_delegate() { - let (e, contract_address) = setup_env(); - let alice = Address::generate(&e); - let bob = Address::generate(&e); - let charlie = Address::generate(&e); - - e.as_contract(&contract_address, || { - transfer_voting_units(&e, None, Some(&alice), 100); - transfer_voting_units(&e, None, Some(&bob), 50); - - delegate(&e, &alice, &charlie); - delegate(&e, &bob, &charlie); - - assert_eq!(get_votes(&e, &charlie), 150); - }); -} - #[test] fn transfer_updates_delegate_votes() { let (e, contract_address) = setup_env(); @@ -469,3 +439,172 @@ fn binary_search_with_many_checkpoints() { assert_eq!(get_past_votes(&e, &bob, 2000), 1200); }); } + +#[test] +#[should_panic(expected = "Error(Contract, #4102)")] +fn transfer_voting_units_insufficient_balance() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + // Try to transfer more than available + transfer_voting_units(&e, Some(&alice), Some(&bob), 150); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4101)")] +fn mint_overflow_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), u128::MAX); + // Try to mint more, causing overflow + transfer_voting_units(&e, None, Some(&alice), 1); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4101)")] +fn delegate_votes_overflow() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + // Give alice max voting units and delegate to charlie + transfer_voting_units(&e, None, Some(&alice), u128::MAX); + delegate(&e, &alice, &charlie); + + // Give bob 1 voting unit and delegate to charlie - should overflow + transfer_voting_units(&e, None, Some(&bob), 1); + delegate(&e, &bob, &charlie); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_votes_at_current_timestamp() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + // Query at exact current timestamp should fail + get_past_votes(&e, &alice, 1000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_total_supply_at_current_timestamp() { + let (e, contract_address) = setup_env(); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + // Query at exact current timestamp should fail + get_past_total_supply(&e, 1000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_votes_future_timestamp() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + // Query at future timestamp should fail + get_past_votes(&e, &alice, 2000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +fn get_past_total_supply_future_timestamp() { + let (e, contract_address) = setup_env(); + e.ledger().set_timestamp(1000); + + e.as_contract(&contract_address, || { + // Query at future timestamp should fail + get_past_total_supply(&e, 2000); + }); +} + +#[test] +fn get_past_votes_before_first_checkpoint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + + e.ledger().set_timestamp(2000); + // Query before first checkpoint + assert_eq!(get_past_votes(&e, &bob, 500), 0); + }); +} + +#[test] +fn get_past_total_supply_before_first_checkpoint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + + e.ledger().set_timestamp(2000); + // Query before first checkpoint + assert_eq!(get_past_total_supply(&e, 500), 0); + }); +} + +#[test] +fn transfer_to_self_with_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &alice); + assert_eq!(get_votes(&e, &alice), 100); + + // Transfer to self should be a no-op for votes + transfer_voting_units(&e, Some(&alice), Some(&alice), 50); + assert_eq!(get_votes(&e, &alice), 100); + assert_eq!(get_voting_units(&e, &alice), 100); + }); +} + +#[test] +fn total_supply_checkpoint_updates_on_mint_burn() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + e.ledger().set_timestamp(1000); + transfer_voting_units(&e, None, Some(&alice), 100); + + e.ledger().set_timestamp(2000); + transfer_voting_units(&e, None, Some(&alice), 50); + + e.ledger().set_timestamp(3000); + transfer_voting_units(&e, Some(&alice), None, 75); + + e.ledger().set_timestamp(4000); + + assert_eq!(get_past_total_supply(&e, 1000), 100); + assert_eq!(get_past_total_supply(&e, 2000), 150); + assert_eq!(get_past_total_supply(&e, 3000), 75); + assert_eq!(get_total_supply(&e), 75); + }); +} diff --git a/packages/tokens/src/fungible/extensions/votes/mod.rs b/packages/tokens/src/fungible/extensions/votes/mod.rs index 193855e43..e563091af 100644 --- a/packages/tokens/src/fungible/extensions/votes/mod.rs +++ b/packages/tokens/src/fungible/extensions/votes/mod.rs @@ -1,3 +1,6 @@ pub mod storage; +#[cfg(test)] +mod test; + pub use storage::FungibleVotes; diff --git a/packages/tokens/src/fungible/extensions/votes/test.rs b/packages/tokens/src/fungible/extensions/votes/test.rs new file mode 100644 index 000000000..29d1c1a16 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/test.rs @@ -0,0 +1,393 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, Address, Env, MuxedAddress}; +use stellar_governance::votes::{delegate, get_delegate, get_votes, get_voting_units}; + +use crate::fungible::{extensions::votes::FungibleVotes, Base}; + +#[contract] +struct MockContract; + +fn setup_env() -> (Env, Address) { + let e = Env::default(); + e.mock_all_auths(); + let contract_address = e.register(MockContract, ()); + (e, contract_address) +} + +#[test] +fn mint_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + + assert_eq!(Base::balance(&e, &alice), 100); + assert_eq!(Base::total_supply(&e), 100); + assert_eq!(get_voting_units(&e, &alice), 100); + }); +} + +#[test] +fn mint_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + FungibleVotes::mint(&e, &alice, 100); + + assert_eq!(Base::balance(&e, &alice), 100); + assert_eq!(get_voting_units(&e, &alice), 100); + assert_eq!(get_votes(&e, &bob), 100); + }); +} + +#[test] +fn burn_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::burn(&e, &alice, 30); + + assert_eq!(Base::balance(&e, &alice), 70); + assert_eq!(Base::total_supply(&e), 70); + assert_eq!(get_voting_units(&e, &alice), 70); + }); +} + +#[test] +fn burn_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + FungibleVotes::mint(&e, &alice, 100); + assert_eq!(get_votes(&e, &bob), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::burn(&e, &alice, 40); + assert_eq!(get_votes(&e, &bob), 60); + }); +} + +#[test] +fn burn_from_updates_voting_units() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 50, 1000); + FungibleVotes::burn_from(&e, &spender, &owner, 30); + + assert_eq!(Base::balance(&e, &owner), 70); + assert_eq!(get_voting_units(&e, &owner), 70); + }); +} + +#[test] +fn transfer_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::transfer(&e, &alice, &MuxedAddress::from(bob.clone()), 40); + + assert_eq!(Base::balance(&e, &alice), 60); + assert_eq!(Base::balance(&e, &bob), 40); + assert_eq!(get_voting_units(&e, &alice), 60); + assert_eq!(get_voting_units(&e, &bob), 40); + }); +} + +#[test] +fn transfer_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let delegate_a = Address::generate(&e); + let delegate_b = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + delegate(&e, &alice, &delegate_a); + assert_eq!(get_votes(&e, &delegate_a), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &bob, 50); + delegate(&e, &bob, &delegate_b); + assert_eq!(get_votes(&e, &delegate_b), 50); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::transfer(&e, &alice, &MuxedAddress::from(bob.clone()), 30); + + assert_eq!(get_votes(&e, &delegate_a), 70); + assert_eq!(get_votes(&e, &delegate_b), 80); + }); +} + +#[test] +fn transfer_from_updates_voting_units() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 50, 1000); + FungibleVotes::transfer_from(&e, &spender, &owner, &recipient, 30); + + assert_eq!(Base::balance(&e, &owner), 70); + assert_eq!(Base::balance(&e, &recipient), 30); + assert_eq!(get_voting_units(&e, &owner), 70); + assert_eq!(get_voting_units(&e, &recipient), 30); + }); +} + +#[test] +fn transfer_from_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let delegate_owner = Address::generate(&e); + let delegate_recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &owner, 100); + delegate(&e, &owner, &delegate_owner); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &recipient, &delegate_recipient); + }); + + e.as_contract(&contract_address, || { + Base::approve(&e, &owner, &spender, 50, 1000); + FungibleVotes::transfer_from(&e, &spender, &owner, &recipient, 30); + + assert_eq!(get_votes(&e, &delegate_owner), 70); + assert_eq!(get_votes(&e, &delegate_recipient), 30); + }); +} + +#[test] +fn zero_mint_is_noop() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 0); + assert_eq!(get_voting_units(&e, &alice), 100); + }); +} + +#[test] +fn zero_burn_is_noop() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + assert_eq!(get_voting_units(&e, &alice), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::burn(&e, &alice, 0); + assert_eq!(get_voting_units(&e, &alice), 100); + }); +} + +#[test] +fn zero_transfer_is_noop() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + assert_eq!(get_voting_units(&e, &alice), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::transfer(&e, &alice, &MuxedAddress::from(bob.clone()), 0); + assert_eq!(get_voting_units(&e, &alice), 100); + }); +} + +#[test] +fn delegate_before_mint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); + assert_eq!(get_votes(&e, &bob), 0); + + FungibleVotes::mint(&e, &alice, 100); + assert_eq!(get_votes(&e, &bob), 100); + }); +} + +#[test] +fn self_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + delegate(&e, &alice, &alice); + + assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); + assert_eq!(get_votes(&e, &alice), 100); + }); +} + +#[test] +fn change_delegate_after_mint() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 100); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &charlie); + assert_eq!(get_votes(&e, &bob), 0); + assert_eq!(get_votes(&e, &charlie), 100); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #100)")] +fn burn_insufficient_balance_panics() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::burn(&e, &alice, 150); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #100)")] +fn transfer_insufficient_balance_panics() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::transfer(&e, &alice, &MuxedAddress::from(bob.clone()), 150); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #101)")] +fn transfer_from_insufficient_allowance_panics() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 20, 1000); + FungibleVotes::transfer_from(&e, &spender, &owner, &recipient, 50); + }); +} + +#[test] +fn multiple_holders_with_same_delegate() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::mint(&e, &bob, 50); + + delegate(&e, &alice, &charlie); + delegate(&e, &bob, &charlie); + + assert_eq!(get_votes(&e, &charlie), 150); + }); +} + +#[test] +fn transfer_between_delegated_accounts() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + FungibleVotes::mint(&e, &bob, 50); + delegate(&e, &alice, &alice); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &bob, &bob); + }); + + e.as_contract(&contract_address, || { + assert_eq!(get_votes(&e, &alice), 100); + assert_eq!(get_votes(&e, &bob), 50); + + FungibleVotes::transfer(&e, &alice, &MuxedAddress::from(bob.clone()), 30); + + assert_eq!(get_votes(&e, &alice), 70); + assert_eq!(get_votes(&e, &bob), 80); + }); +} + +#[test] +fn burn_all_tokens() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + delegate(&e, &alice, &bob); + assert_eq!(get_votes(&e, &bob), 100); + }); + + e.as_contract(&contract_address, || { + FungibleVotes::burn(&e, &alice, 100); + + assert_eq!(Base::balance(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_votes(&e, &bob), 0); + }); +} From de26bd927bda48980fe23e815f2f749147e553da Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:48:49 +0100 Subject: [PATCH 04/15] add nft votes extension --- packages/governance/src/votes/mod.rs | 15 +- packages/governance/src/votes/storage.rs | 23 +- .../src/fungible/extensions/votes/storage.rs | 69 ++++- .../tokens/src/non_fungible/extensions/mod.rs | 1 + .../src/non_fungible/extensions/votes/mod.rs | 6 + .../non_fungible/extensions/votes/storage.rs | 208 +++++++++++++ .../src/non_fungible/extensions/votes/test.rs | 291 ++++++++++++++++++ packages/tokens/src/non_fungible/mod.rs | 2 +- 8 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 packages/tokens/src/non_fungible/extensions/votes/mod.rs create mode 100644 packages/tokens/src/non_fungible/extensions/votes/storage.rs create mode 100644 packages/tokens/src/non_fungible/extensions/votes/test.rs diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index fc2881cac..043986531 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -140,7 +140,7 @@ pub trait Votes { /// * data - `[from_delegate: Option
, to_delegate: Address]` /// /// * topics - `["DelegateVotesChanged", delegate: Address]` - /// * data - `[old_votes: u128, new_votes: u128]` + /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes /// @@ -219,7 +219,7 @@ pub struct DelegateVotesChanged { #[topic] pub delegate: Address, /// The previous voting power - pub old_votes: u128, + pub previous_votes: u128, /// The new voting power pub new_votes: u128, } @@ -230,8 +230,13 @@ pub struct DelegateVotesChanged { /// /// * `e` - Access to Soroban environment. /// * `delegate` - The delegate whose voting power changed. -/// * `old_votes` - The previous voting power. +/// * `previous_votes` - The previous voting power. /// * `new_votes` - The new voting power. -pub fn emit_delegate_votes_changed(e: &Env, delegate: &Address, old_votes: u128, new_votes: u128) { - DelegateVotesChanged { delegate: delegate.clone(), old_votes, new_votes }.publish(e); +pub fn emit_delegate_votes_changed( + e: &Env, + delegate: &Address, + previous_votes: u128, + new_votes: u128, +) { + DelegateVotesChanged { delegate: delegate.clone(), previous_votes, new_votes }.publish(e); } diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index 0c5b566b7..e7d2d7114 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -268,6 +268,11 @@ pub fn delegate(e: &Env, account: &Address, delegatee: &Address) { /// * `to` - The destination account (`None` for burning). /// * `amount` - The amount of voting units to transfer. /// +/// # Events +/// +/// * topics - `["DelegateVotesChanged", delegate: Address]` +/// * data - `[previous_votes: u128, new_votes: u128]` +/// /// # Notes /// /// This function does not perform authorization - it should be called @@ -351,19 +356,19 @@ fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { } /// Pushes a new checkpoint or updates the last one if same timestamp. -/// Returns (last_votes, new_votes). +/// Returns (previous_votes, new_votes). fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, u128) { let num = num_checkpoints(e, account); let current_timestamp = e.ledger().timestamp(); - let last_votes = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; + let previous_votes = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; let votes = if add { - last_votes + previous_votes .checked_add(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) } else { - last_votes + previous_votes .checked_sub(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) }; @@ -375,7 +380,7 @@ fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, // Update existing checkpoint let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num - 1); e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); - return (last_votes, votes); + return (previous_votes, votes); } } @@ -387,7 +392,7 @@ fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, let num_key = VotesStorageKey::NumCheckpoints(account.clone()); e.storage().persistent().set(&num_key, &(num + 1)); - (last_votes, votes) + (previous_votes, votes) } // ################## TOTAL SUPPLY CHECKPOINTS ################## @@ -415,14 +420,14 @@ fn push_total_supply_checkpoint(e: &Env, add: bool, delta: u128) { let num = num_total_supply_checkpoints(e); let current_timestamp = e.ledger().timestamp(); - let last_votes = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; + let previous_votes = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; let votes = if add { - last_votes + previous_votes .checked_add(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) } else { - last_votes + previous_votes .checked_sub(delta) .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) }; diff --git a/packages/tokens/src/fungible/extensions/votes/storage.rs b/packages/tokens/src/fungible/extensions/votes/storage.rs index 0fd2a3976..783e05067 100644 --- a/packages/tokens/src/fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/fungible/extensions/votes/storage.rs @@ -26,6 +26,19 @@ impl FungibleVotes { /// * `to` - The address receiving the transferred tokens. /// * `amount` - The amount of tokens to be transferred. /// + /// # Errors + /// + /// * refer to [`Base::transfer`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// /// # Notes /// /// Authorization for `from` is required. @@ -48,6 +61,19 @@ impl FungibleVotes { /// * `to` - The address receiving the transferred tokens. /// * `amount` - The amount of tokens to be transferred. /// + /// # Errors + /// + /// * refer to [`Base::transfer_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// /// # Notes /// /// Authorization for `spender` is required. @@ -67,6 +93,19 @@ impl FungibleVotes { /// * `to` - The address receiving the new tokens. /// * `amount` - The amount of tokens to mint. /// + /// # Errors + /// + /// * refer to [`Base::mint`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Mint", to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// /// # Security Warning /// /// This function has NO AUTHORIZATION CONTROLS. @@ -79,7 +118,7 @@ impl FungibleVotes { } /// Destroys `amount` of tokens from `from`. Updates the total - /// supply accordingly. Also updates voting units for the recipient's + /// supply accordingly. Also updates voting units for the owner's /// delegate. /// /// # Arguments @@ -88,6 +127,19 @@ impl FungibleVotes { /// * `from` - The account whose tokens are destroyed. /// * `amount` - The amount of tokens to burn. /// + /// # Errors + /// + /// * refer to [`Base::burn`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Burn", from: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// /// # Notes /// /// Authorization for `from` is required. @@ -99,7 +151,7 @@ impl FungibleVotes { } /// Destroys `amount` of tokens from `from`. Updates the total - /// supply accordingly. Also updates voting units for the recipient's + /// supply accordingly. Also updates voting units for the owner's /// delegate. /// /// # Arguments @@ -109,6 +161,19 @@ impl FungibleVotes { /// * `from` - The account whose tokens are destroyed. /// * `amount` - The amount of tokens to burn. /// + /// # Errors + /// + /// * refer to [`Base::burn_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Burn", from: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// /// # Notes /// /// Authorization for `spender` is required. diff --git a/packages/tokens/src/non_fungible/extensions/mod.rs b/packages/tokens/src/non_fungible/extensions/mod.rs index a66ab502e..c8bbbf7f6 100644 --- a/packages/tokens/src/non_fungible/extensions/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/mod.rs @@ -2,3 +2,4 @@ pub mod burnable; pub mod consecutive; pub mod enumerable; pub mod royalties; +pub mod votes; diff --git a/packages/tokens/src/non_fungible/extensions/votes/mod.rs b/packages/tokens/src/non_fungible/extensions/votes/mod.rs new file mode 100644 index 000000000..9f8225576 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/votes/mod.rs @@ -0,0 +1,6 @@ +pub mod storage; + +#[cfg(test)] +mod test; + +pub use storage::NonFungibleVotes; diff --git a/packages/tokens/src/non_fungible/extensions/votes/storage.rs b/packages/tokens/src/non_fungible/extensions/votes/storage.rs new file mode 100644 index 000000000..19bca4eea --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/votes/storage.rs @@ -0,0 +1,208 @@ +use soroban_sdk::{Address, Env}; +use stellar_governance::votes::transfer_voting_units; + +use crate::non_fungible::{Base, ContractOverrides}; + +pub struct NonFungibleVotes; + +impl ContractOverrides for NonFungibleVotes { + fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + NonFungibleVotes::transfer(e, from, to, token_id); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + NonFungibleVotes::transfer_from(e, spender, from, to, token_id); + } +} + +impl NonFungibleVotes { + /// Transfers a non-fungible token from `from` to `to`. + /// Also updates voting units for the respective delegates (1 unit per NFT). + /// + /// # Arguments + /// + /// * `e` - Access to Soroban environment. + /// * `from` - The address holding the token. + /// * `to` - The address receiving the transferred token. + /// * `token_id` - The identifier of the token to be transferred. + /// + /// # Errors + /// + /// * refer to [`Base::transfer`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Transfer", from: Address, to: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Notes + /// + /// Authorization for `from` is required. + pub fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + Base::transfer(e, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// Transfers a non-fungible token from `from` to `to` using the + /// approval mechanism. Also updates voting units for the respective + /// delegates (1 unit per NFT). + /// + /// # Arguments + /// + /// * `e` - Access to Soroban environment. + /// * `spender` - The address authorizing the transfer. + /// * `from` - The address holding the token. + /// * `to` - The address receiving the transferred token. + /// * `token_id` - The identifier of the token to be transferred. + /// + /// # Errors + /// + /// * refer to [`Base::transfer_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Transfer", from: Address, to: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Notes + /// + /// Authorization for `spender` is required. + pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + Base::transfer_from(e, spender, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// Creates a token with the provided `token_id` and assigns it to `to`. + /// Also updates voting units for the recipient's delegate (1 unit per NFT). + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `to` - The address receiving the new token. + /// * `token_id` - The token_id of the new token. + /// + /// # Errors + /// + /// * refer to [`Base::mint`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Mint", to: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Security Warning + /// + /// This function has NO AUTHORIZATION CONTROLS. + /// The caller must ensure proper authorization before calling. + pub fn mint(e: &Env, to: &Address, token_id: u32) { + Base::mint(e, to, token_id); + transfer_voting_units(e, None, Some(to), 1); + } + + /// Creates a token with the next available `token_id` and assigns it to + /// `to`. Returns the `token_id` for the newly minted token. + /// Also updates voting units for the recipient's delegate (1 unit per NFT). + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `to` - The address receiving the new token. + /// + /// # Errors + /// + /// * refer to [`Base::sequential_mint`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Mint", to: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Security Warning + /// + /// This function has NO AUTHORIZATION CONTROLS. + /// The caller must ensure proper authorization before calling. + pub fn sequential_mint(e: &Env, to: &Address) -> u32 { + let token_id = Base::sequential_mint(e, to); + transfer_voting_units(e, None, Some(to), 1); + token_id + } + + /// Destroys the token with `token_id` from `from`. + /// Also updates voting units for the owner's delegate (1 unit per NFT). + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The account whose token is destroyed. + /// * `token_id` - The identifier of the token to burn. + /// + /// # Errors + /// + /// * refer to [`Base::burn`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Burn", from: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Notes + /// + /// Authorization for `from` is required. + pub fn burn(e: &Env, from: &Address, token_id: u32) { + Base::burn(e, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } + + /// Destroys the token with `token_id` from `from`, by using `spender`s + /// approval. Also updates voting units for the owner's delegate (1 unit per + /// NFT). + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `spender` - The account that is allowed to burn the token on behalf of + /// the owner. + /// * `from` - The account whose token is destroyed. + /// * `token_id` - The identifier of the token to burn. + /// + /// # Errors + /// + /// * refer to [`Base::burn_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Burn", from: Address]` + /// * data - `[token_id: u32]` + /// + /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # Notes + /// + /// Authorization for `spender` is required. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + Base::burn_from(e, spender, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } +} diff --git a/packages/tokens/src/non_fungible/extensions/votes/test.rs b/packages/tokens/src/non_fungible/extensions/votes/test.rs new file mode 100644 index 000000000..d7e6861c0 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/votes/test.rs @@ -0,0 +1,291 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, Address, Env, String}; +use stellar_governance::votes::{delegate, get_delegate, get_votes, get_voting_units}; + +use crate::non_fungible::{extensions::votes::NonFungibleVotes, Base}; + +#[contract] +struct MockContract; + +fn setup_env() -> (Env, Address) { + let e = Env::default(); + e.mock_all_auths(); + let contract_address = e.register(MockContract, ()); + + e.as_contract(&contract_address, || { + Base::set_metadata( + &e, + String::from_str(&e, "https://example.com/"), + String::from_str(&e, "Test NFT"), + String::from_str(&e, "TNFT"), + ); + }); + + (e, contract_address) +} + +#[test] +fn mint_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + + assert_eq!(Base::balance(&e, &alice), 1); + assert_eq!(get_voting_units(&e, &alice), 1); + + NonFungibleVotes::mint(&e, &alice, 2); + + assert_eq!(Base::balance(&e, &alice), 2); + assert_eq!(get_voting_units(&e, &alice), 2); + }); +} + +#[test] +fn mint_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + NonFungibleVotes::mint(&e, &alice, 1); + + assert_eq!(Base::balance(&e, &alice), 1); + assert_eq!(get_voting_units(&e, &alice), 1); + assert_eq!(get_votes(&e, &bob), 1); + }); +} + +#[test] +fn sequential_mint_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + let token_id = NonFungibleVotes::sequential_mint(&e, &alice); + + assert_eq!(token_id, 0); + assert_eq!(Base::balance(&e, &alice), 1); + assert_eq!(get_voting_units(&e, &alice), 1); + }); +} + +#[test] +fn burn_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + assert_eq!(get_voting_units(&e, &alice), 2); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::burn(&e, &alice, 1); + assert_eq!(Base::balance(&e, &alice), 1); + assert_eq!(get_voting_units(&e, &alice), 1); + }); +} + +#[test] +fn burn_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + delegate(&e, &alice, &bob); + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + assert_eq!(get_votes(&e, &bob), 2); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::burn(&e, &alice, 1); + assert_eq!(get_votes(&e, &bob), 1); + }); +} + +#[test] +fn burn_from_updates_voting_units() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &owner, 1); + Base::approve(&e, &owner, &spender, 1, 1000); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::burn_from(&e, &spender, &owner, 1); + + assert_eq!(Base::balance(&e, &owner), 0); + assert_eq!(get_voting_units(&e, &owner), 0); + }); +} + +#[test] +fn transfer_updates_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + assert_eq!(get_voting_units(&e, &alice), 1); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::transfer(&e, &alice, &bob, 1); + + assert_eq!(Base::balance(&e, &alice), 0); + assert_eq!(Base::balance(&e, &bob), 1); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &bob), 1); + }); +} + +#[test] +fn transfer_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let delegate_a = Address::generate(&e); + let delegate_b = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + delegate(&e, &alice, &delegate_a); + assert_eq!(get_votes(&e, &delegate_a), 2); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &bob, &delegate_b); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::transfer(&e, &alice, &bob, 1); + + assert_eq!(get_votes(&e, &delegate_a), 1); + assert_eq!(get_votes(&e, &delegate_b), 1); + }); +} + +#[test] +fn transfer_from_updates_voting_units() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &owner, 1); + Base::approve(&e, &owner, &spender, 1, 1000); + }); + + e.as_contract(&contract_address, || { + NonFungibleVotes::transfer_from(&e, &spender, &owner, &recipient, 1); + + assert_eq!(Base::balance(&e, &owner), 0); + assert_eq!(Base::balance(&e, &recipient), 1); + assert_eq!(get_voting_units(&e, &owner), 0); + assert_eq!(get_voting_units(&e, &recipient), 1); + }); +} + +#[test] +fn transfer_from_with_delegation_updates_votes() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let delegate_owner = Address::generate(&e); + let delegate_recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &owner, 1); + delegate(&e, &owner, &delegate_owner); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &recipient, &delegate_recipient); + }); + + e.as_contract(&contract_address, || { + Base::approve(&e, &owner, &spender, 1, 1000); + NonFungibleVotes::transfer_from(&e, &spender, &owner, &recipient, 1); + + assert_eq!(get_votes(&e, &delegate_owner), 0); + assert_eq!(get_votes(&e, &delegate_recipient), 1); + }); +} + +#[test] +fn self_delegation() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + delegate(&e, &alice, &alice); + + assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); + assert_eq!(get_votes(&e, &alice), 1); + }); +} + +#[test] +fn multiple_holders_with_same_delegate() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let charlie = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + NonFungibleVotes::mint(&e, &bob, 3); + + delegate(&e, &alice, &charlie); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &bob, &charlie); + + assert_eq!(get_votes(&e, &charlie), 3); + }); +} + +#[test] +fn transfer_between_delegated_accounts() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + NonFungibleVotes::mint(&e, &bob, 3); + delegate(&e, &alice, &alice); + }); + + e.as_contract(&contract_address, || { + delegate(&e, &bob, &bob); + }); + + e.as_contract(&contract_address, || { + assert_eq!(get_votes(&e, &alice), 2); + assert_eq!(get_votes(&e, &bob), 1); + + NonFungibleVotes::transfer(&e, &alice, &bob, 1); + + assert_eq!(get_votes(&e, &alice), 1); + assert_eq!(get_votes(&e, &bob), 2); + }); +} diff --git a/packages/tokens/src/non_fungible/mod.rs b/packages/tokens/src/non_fungible/mod.rs index d277c0a90..582f87927 100644 --- a/packages/tokens/src/non_fungible/mod.rs +++ b/packages/tokens/src/non_fungible/mod.rs @@ -72,7 +72,7 @@ mod utils; #[cfg(test)] mod test; -pub use extensions::{burnable, consecutive, enumerable, royalties}; +pub use extensions::{burnable, consecutive, enumerable, royalties, votes}; pub use overrides::{Base, ContractOverrides}; // ################## TRAIT ################## use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, String}; From 063ed8609e5354c20a703eec0cd9277e2bd7f832 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:53:50 +0100 Subject: [PATCH 05/15] coverage --- packages/governance/src/votes/test.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 45cc7750d..16dfdbd14 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -24,12 +24,19 @@ fn initial_state_has_zero_votes() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); + e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_voting_units(&e, &alice), 0); assert_eq!(num_checkpoints(&e, &alice), 0); assert_eq!(get_delegate(&e, &alice), None); assert_eq!(get_total_supply(&e), 0); + + // Test get_past_votes returns 0 when num_checkpoints == 0 + assert_eq!(get_past_votes(&e, &alice, 500), 0); + + // Test get_past_total_supply returns 0 when num_total_supply_checkpoints == 0 + assert_eq!(get_past_total_supply(&e, 500), 0); }); } From 1ecdc7df067b0cf6033d0b28e1d4e742b2a0a78e Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:29:34 +0100 Subject: [PATCH 06/15] avoid redundant event emission --- packages/governance/src/votes/storage.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index e7d2d7114..f59a191a2 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -282,15 +282,16 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres return; } + // Look up delegates first so we can make a single move_delegate_votes call + let from_delegate = from.and_then(|addr| get_delegate(e, addr)); + let to_delegate = to.and_then(|addr| get_delegate(e, addr)); + if let Some(from_addr) = from { let from_units = get_voting_units(e, from_addr); let Some(new_from_units) = from_units.checked_sub(amount) else { panic_with_error!(e, VotesError::InsufficientVotingUnits); }; set_voting_units(e, from_addr, new_from_units); - - let from_delegate = get_delegate(e, from_addr); - move_delegate_votes(e, from_delegate.as_ref(), None, amount); } else { // Minting: increase total supply push_total_supply_checkpoint(e, true, amount); @@ -302,13 +303,12 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres panic_with_error!(e, VotesError::MathOverflow); }; set_voting_units(e, to_addr, new_to_units); - - let to_delegate = get_delegate(e, to_addr); - move_delegate_votes(e, None, to_delegate.as_ref(), amount); } else { // Burning: decrease total supply push_total_supply_checkpoint(e, false, amount); } + + move_delegate_votes(e, from_delegate.as_ref(), to_delegate.as_ref(), amount); } // ################## INTERNAL HELPERS ################## From fc99fdbb32f8bce56869fd75130b5cdacf57b9d1 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:41:57 +0100 Subject: [PATCH 07/15] suggestions --- packages/governance/README.md | 1 + packages/governance/src/votes/mod.rs | 57 +++-- packages/governance/src/votes/storage.rs | 202 ++++++++++-------- packages/governance/src/votes/test.rs | 60 +++--- .../non_fungible/extensions/votes/storage.rs | 12 +- 5 files changed, 183 insertions(+), 149 deletions(-) diff --git a/packages/governance/README.md b/packages/governance/README.md index 5f4d80dc4..defaf282b 100644 --- a/packages/governance/README.md +++ b/packages/governance/README.md @@ -28,6 +28,7 @@ The `votes` module provides vote tracking functionality with delegation and hist - Support delegation (an account can delegate its voting power to another account) - Provide historical vote queries at any past timestamp - Explicit delegation required (accounts must self-delegate to use their own voting power) +- Non-delegated voting units are not counted as votes ### Timelock diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index 043986531..c964928f4 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -1,41 +1,37 @@ //! # Votes Module //! -//! The module tracks voting power per account with historical checkpoints, -//! supports delegation (an account can delegate its voting power to another -//! account), and provides historical vote queries at any past timestamp. +//! This module provides utilities for tracking voting power per account with +//! historical checkpoints. It supports delegation (an account can delegate its +//! voting power to another account) and provides historical vote queries at any +//! past timestamp. //! //! # Core Concepts //! //! - **Voting Units**: The base unit of voting power, typically 1:1 with token //! balance //! - **Delegation**: Accounts can delegate their voting power to another -//! account (delegatee) +//! account (delegatee). **Only delegated voting power counts as votes** while +//! undelegated voting units are not counted. Self-delegation is required for +//! an account to use its own voting power. //! - **Checkpoints**: Historical snapshots of voting power at specific //! timestamps //! -//! # Design -//! -//! This module follows the design of OpenZeppelin's Solidity `Votes.sol`: -//! - Voting units must be explicitly delegated to count as votes -//! - Self-delegation is required for an account to use its own voting power -//! - Historical vote queries use binary search over checkpoints -//! //! # Usage //! -//! This module provides storage functions that can be integrated into a token -//! contract. The contract is responsible for: -//! - Calling `transfer_voting_units` on every balance change -//! (mint/burn/transfer) +//! This module is to be integrated into a token contract and is responsible +//! for: +//! - Overriding the transfer method to call `transfer_voting_units` on every +//! balance change (mint/burn/transfer), as shown in the example below //! - Exposing delegation functionality to users //! //! # Example //! //! ```ignore //! use stellar_governance::votes::{ -//! delegate, get_votes, get_past_votes, transfer_voting_units, +//! delegate, get_votes, get_votes_at_checkpoint, transfer_voting_units, //! }; //! -//! // In your token contract transfer: +//! // Override your token contract's transfer to update voting units: //! pub fn transfer(e: &Env, from: Address, to: Address, amount: i128) { //! // ... perform transfer logic ... //! transfer_voting_units(e, Some(&from), Some(&to), amount as u128); @@ -55,8 +51,9 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env}; pub use crate::votes::storage::{ - delegate, get_delegate, get_past_total_supply, get_past_votes, get_total_supply, get_votes, - get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, VotesStorageKey, + delegate, get_delegate, get_past_total_supply, get_total_supply, get_votes, + get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, + VotesStorageKey, }; /// Trait for contracts that support vote tracking with delegation. @@ -72,7 +69,10 @@ pub use crate::votes::storage::{ /// - Expose `delegate` functionality to users #[contracttrait] pub trait Votes { - /// Returns the current voting power of an account. + /// Returns the current voting power (delegated votes) of an account. + /// + /// Returns `0` if the account has no delegated voting power or does not + /// exist in the contract. /// /// # Arguments /// @@ -82,7 +82,11 @@ pub trait Votes { get_votes(e, &account) } - /// Returns the voting power of an account at a specific past timestamp. + /// Returns the voting power (delegated votes) of an account at a specific + /// past timestamp. + /// + /// Returns `0` if the account had no delegated voting power at the given + /// timepoint or does not exist in the contract. /// /// # Arguments /// @@ -93,12 +97,17 @@ pub trait Votes { /// # Errors /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. - fn get_past_votes(e: &Env, account: Address, timepoint: u64) -> u128 { - get_past_votes(e, &account, timepoint) + fn get_votes_at_checkpoint(e: &Env, account: Address, timepoint: u64) -> u128 { + get_votes_at_checkpoint(e, &account, timepoint) } /// Returns the total supply of voting units at a specific past timestamp. /// + /// This tracks all voting units in circulation (regardless of delegation + /// status), not just delegated votes. + /// + /// Returns `0` if there were no voting units at the given timepoint. + /// /// # Arguments /// /// * `e` - Access to the Soroban environment. @@ -162,6 +171,8 @@ pub enum VotesError { MathOverflow = 4101, /// Attempting to transfer more voting units than available InsufficientVotingUnits = 4102, + /// Attempting to delegate to the same delegate that is already set + SameDelegate = 4103, } // ################## CONSTANTS ################## diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index f59a191a2..710322525 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -5,19 +5,35 @@ use crate::votes::{ VOTES_TTL_THRESHOLD, }; +// ################## ENUMS ################## + +/// Represents the direction of a checkpoint delta operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CheckpointOp { + /// Add the delta to the previous value (e.g., minting, receiving votes). + Add, + /// Subtract the delta from the previous value (e.g., burning, losing + /// votes). + Sub, +} + // ################## TYPES ################## -/// A checkpoint recording voting power at a specific timestamp. +/// A checkpoint recording voting power at a specific timepoint. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Checkpoint { - /// The timestamp when this checkpoint was created + /// The timepoint when this checkpoint was created pub timestamp: u64, - /// The voting power at this timestamp + /// The voting power at this timepoint pub votes: u128, } /// Storage keys for the votes module. +/// +/// Only delegated voting power counts as votes (i.e., only delegatees can +/// vote), so the storage design tracks delegates and their checkpointed +/// voting power separately from the raw voting units held by each account. #[derive(Clone)] #[contracttype] pub enum VotesStorageKey { @@ -37,10 +53,12 @@ pub enum VotesStorageKey { // ################## QUERY STATE ################## -/// Returns the current voting power of an account. +/// Returns the current voting power (delegated votes) of an account. /// /// This is the total voting power delegated to this account by others -/// (and itself if self-delegated). +/// (and itself if self-delegated). Returns `0` if no voting power has been +/// delegated to this account, or if the account does not exist in the +/// contract. /// /// # Arguments /// @@ -54,18 +72,22 @@ pub fn get_votes(e: &Env, account: &Address) -> u128 { get_checkpoint(e, account, num - 1).votes } -/// Returns the voting power of an account at a specific past timestamp. +/// Returns the voting power (delegated votes) of an account at a specific +/// past timepoint. +/// +/// Returns `0` if no voting power had been delegated to this account at the +/// given timepoint, or if the account does not exist in the contract. /// /// # Arguments /// /// * `e` - Access to Soroban environment. /// * `account` - The address to query voting power for. -/// * `timepoint` - The timestamp to query (must be in the past). +/// * `timepoint` - The timepoint to query (must be in the past). /// /// # Errors /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. -pub fn get_past_votes(e: &Env, account: &Address, timepoint: u64) -> u128 { +pub fn get_votes_at_checkpoint(e: &Env, account: &Address, timepoint: u64) -> u128 { if timepoint >= e.ledger().timestamp() { panic_with_error!(e, VotesError::FutureLookup); } @@ -75,33 +97,7 @@ pub fn get_past_votes(e: &Env, account: &Address, timepoint: u64) -> u128 { return 0; } - // Check if timepoint is after the latest checkpoint - let latest = get_checkpoint(e, account, num - 1); - if latest.timestamp <= timepoint { - return latest.votes; - } - - // Check if timepoint is before the first checkpoint - let first = get_checkpoint(e, account, 0); - if first.timestamp > timepoint { - return 0; - } - - // Binary search - let mut low: u32 = 0; - let mut high: u32 = num - 1; - - while low < high { - let mid = (low + high).div_ceil(2); - let checkpoint = get_checkpoint(e, account, mid); - if checkpoint.timestamp <= timepoint { - low = mid; - } else { - high = mid - 1; - } - } - - get_checkpoint(e, account, low).votes + lookup_checkpoint_at(e, timepoint, num, |e, index| get_checkpoint(e, account, index)) } /// Returns the current total supply of voting units. @@ -117,12 +113,14 @@ pub fn get_total_supply(e: &Env) -> u128 { get_total_supply_checkpoint(e, num - 1).votes } -/// Returns the total supply of voting units at a specific past timestamp. +/// Returns the total supply of voting units at a specific past timepoint. +/// +/// Returns `0` if there were no voting units at the given timepoint. /// /// # Arguments /// /// * `e` - Access to Soroban environment. -/// * `timepoint` - The timestamp to query (must be in the past). +/// * `timepoint` - The timepoint to query (must be in the past). /// /// # Errors /// @@ -137,33 +135,7 @@ pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { return 0; } - // Check if timepoint is after the latest checkpoint - let latest = get_total_supply_checkpoint(e, num - 1); - if latest.timestamp <= timepoint { - return latest.votes; - } - - // Check if timepoint is before the first checkpoint - let first = get_total_supply_checkpoint(e, 0); - if first.timestamp > timepoint { - return 0; - } - - // Binary search - let mut low: u32 = 0; - let mut high: u32 = num - 1; - - while low < high { - let mid = (low + high).div_ceil(2); - let checkpoint = get_total_supply_checkpoint(e, mid); - if checkpoint.timestamp <= timepoint { - low = mid; - } else { - high = mid - 1; - } - } - - get_total_supply_checkpoint(e, low).votes + lookup_checkpoint_at(e, timepoint, num, get_total_supply_checkpoint) } /// Returns the delegate for an account. @@ -240,6 +212,11 @@ pub fn get_voting_units(e: &Env, account: &Address) -> u128 { /// * topics - `["DelegateVotesChanged", delegate: Address]` /// * data - `[old_votes: u128, new_votes: u128]` /// +/// # Errors +/// +/// * [`VotesError::SameDelegate`] - If `delegatee` is already the +/// current delegate for `account`. +/// /// # Notes /// /// Authorization for `account` is required. @@ -247,6 +224,10 @@ pub fn delegate(e: &Env, account: &Address, delegatee: &Address) { account.require_auth(); let old_delegate = get_delegate(e, account); + if old_delegate.as_ref() == Some(delegatee) { + panic_with_error!(e, VotesError::SameDelegate); + } + e.storage().persistent().set(&VotesStorageKey::Delegatee(account.clone()), delegatee); emit_delegate_changed(e, account, old_delegate.clone(), delegatee); @@ -294,7 +275,7 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres set_voting_units(e, from_addr, new_from_units); } else { // Minting: increase total supply - push_total_supply_checkpoint(e, true, amount); + push_total_supply_checkpoint(e, CheckpointOp::Add, amount); } if let Some(to_addr) = to { @@ -305,7 +286,7 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres set_voting_units(e, to_addr, new_to_units); } else { // Burning: decrease total supply - push_total_supply_checkpoint(e, false, amount); + push_total_supply_checkpoint(e, CheckpointOp::Sub, amount); } move_delegate_votes(e, from_delegate.as_ref(), to_delegate.as_ref(), amount); @@ -334,16 +315,67 @@ fn move_delegate_votes(e: &Env, from: Option<&Address>, to: Option<&Address>, am } if let Some(from_addr) = from { - let (old_votes, new_votes) = push_checkpoint(e, from_addr, false, amount); + let (old_votes, new_votes) = push_checkpoint(e, from_addr, CheckpointOp::Sub, amount); emit_delegate_votes_changed(e, from_addr, old_votes, new_votes); } if let Some(to_addr) = to { - let (old_votes, new_votes) = push_checkpoint(e, to_addr, true, amount); + let (old_votes, new_votes) = push_checkpoint(e, to_addr, CheckpointOp::Add, amount); emit_delegate_votes_changed(e, to_addr, old_votes, new_votes); } } +/// Binary search over checkpoints to find votes at a given timepoint. +/// +/// `num` must be > 0. `get_fn` retrieves a checkpoint at the given index. +fn lookup_checkpoint_at( + e: &Env, + timepoint: u64, + num: u32, + get_fn: impl Fn(&Env, u32) -> Checkpoint, +) -> u128 { + // Check if timepoint is after the latest checkpoint + let latest = get_fn(e, num - 1); + if latest.timestamp <= timepoint { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_fn(e, 0); + if first.timestamp > timepoint { + return 0; + } + + // Binary search + let mut low: u32 = 0; + let mut high: u32 = num - 1; + + while low < high { + let mid = (low + high).div_ceil(2); + let checkpoint = get_fn(e, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_fn(e, low).votes +} + +/// Applies a [`CheckpointOp`] to compute the new votes value from the +/// previous value and a delta. +fn apply_checkpoint_op(e: &Env, previous: u128, op: CheckpointOp, delta: u128) -> u128 { + match op { + CheckpointOp::Add => previous + .checked_add(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)), + CheckpointOp::Sub => previous + .checked_sub(delta) + .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)), + } +} + /// Gets a checkpoint for a delegate at a specific index. fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { let key = VotesStorageKey::DelegateCheckpoint(account.clone(), index); @@ -355,25 +387,16 @@ fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { } } -/// Pushes a new checkpoint or updates the last one if same timestamp. +/// Pushes a new checkpoint or updates the last one if same timepoint. /// Returns (previous_votes, new_votes). -fn push_checkpoint(e: &Env, account: &Address, add: bool, delta: u128) -> (u128, u128) { +fn push_checkpoint(e: &Env, account: &Address, op: CheckpointOp, delta: u128) -> (u128, u128) { let num = num_checkpoints(e, account); let current_timestamp = e.ledger().timestamp(); let previous_votes = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; + let votes = apply_checkpoint_op(e, previous_votes, op, delta); - let votes = if add { - previous_votes - .checked_add(delta) - .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) - } else { - previous_votes - .checked_sub(delta) - .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) - }; - - // Check if we can update the last checkpoint (same timestamp) + // Check if we can update the last checkpoint (same timepoint) if num > 0 { let last_checkpoint = get_checkpoint(e, account, num - 1); if last_checkpoint.timestamp == current_timestamp { @@ -415,24 +438,15 @@ fn get_total_supply_checkpoint(e: &Env, index: u32) -> Checkpoint { } /// Pushes a new total supply checkpoint or updates the last one if same -/// timestamp. -fn push_total_supply_checkpoint(e: &Env, add: bool, delta: u128) { +/// timepoint. +fn push_total_supply_checkpoint(e: &Env, op: CheckpointOp, delta: u128) { let num = num_total_supply_checkpoints(e); let current_timestamp = e.ledger().timestamp(); let previous_votes = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; + let votes = apply_checkpoint_op(e, previous_votes, op, delta); - let votes = if add { - previous_votes - .checked_add(delta) - .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) - } else { - previous_votes - .checked_sub(delta) - .unwrap_or_else(|| panic_with_error!(e, VotesError::MathOverflow)) - }; - - // Check if we can update the last checkpoint (same timestamp) + // Check if we can update the last checkpoint (same timepoint) if num > 0 { let last_checkpoint = get_total_supply_checkpoint(e, num - 1); if last_checkpoint.timestamp == current_timestamp { diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 16dfdbd14..481c43bef 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -5,8 +5,8 @@ use soroban_sdk::{ }; use crate::votes::{ - delegate, get_delegate, get_past_total_supply, get_past_votes, get_total_supply, get_votes, - get_voting_units, num_checkpoints, transfer_voting_units, + delegate, get_delegate, get_past_total_supply, get_total_supply, get_votes, + get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, }; #[contract] @@ -32,8 +32,8 @@ fn initial_state_has_zero_votes() { assert_eq!(get_delegate(&e, &alice), None); assert_eq!(get_total_supply(&e), 0); - // Test get_past_votes returns 0 when num_checkpoints == 0 - assert_eq!(get_past_votes(&e, &alice, 500), 0); + // Test get_votes_at_checkpoint returns 0 when num_checkpoints == 0 + assert_eq!(get_votes_at_checkpoint(&e, &alice, 500), 0); // Test get_past_total_supply returns 0 when num_total_supply_checkpoints == 0 assert_eq!(get_past_total_supply(&e, 500), 0); @@ -249,7 +249,7 @@ fn different_timestamps_create_new_checkpoints() { // ################## HISTORICAL QUERY TESTS ################## #[test] -fn get_past_votes_returns_historical_value() { +fn get_votes_at_checkpoint_returns_historical_value() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); let bob = Address::generate(&e); @@ -268,25 +268,25 @@ fn get_past_votes_returns_historical_value() { // Query at different timepoints e.ledger().set_timestamp(4000); - assert_eq!(get_past_votes(&e, &bob, 999), 0); - assert_eq!(get_past_votes(&e, &bob, 1000), 100); - assert_eq!(get_past_votes(&e, &bob, 1500), 100); - assert_eq!(get_past_votes(&e, &bob, 2000), 150); - assert_eq!(get_past_votes(&e, &bob, 2500), 150); - assert_eq!(get_past_votes(&e, &bob, 3000), 175); - assert_eq!(get_past_votes(&e, &bob, 3500), 175); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 999), 0); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 1000), 100); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 1500), 100); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 2000), 150); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 2500), 150); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 3000), 175); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 3500), 175); }); } #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_votes_fails_for_future_timepoint() { +fn get_votes_at_checkpoint_fails_for_future_timepoint() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { - get_past_votes(&e, &alice, 1000); + get_votes_at_checkpoint(&e, &alice, 1000); }); } @@ -379,7 +379,8 @@ fn burn_all_voting_units() { } #[test] -fn delegate_to_same_delegate_is_noop() { +#[should_panic(expected = "Error(Contract, #4103)")] +fn delegate_to_same_delegate_errors() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); let bob = Address::generate(&e); @@ -390,13 +391,10 @@ fn delegate_to_same_delegate_is_noop() { delegate(&e, &alice, &bob); }); - let checkpoints_before = e.as_contract(&contract_address, || num_checkpoints(&e, &bob)); - e.ledger().set_timestamp(2000); e.as_contract(&contract_address, || { + // Should panic with SameDelegateReassignment delegate(&e, &alice, &bob); - // No new checkpoints should be created since votes didn't change - assert_eq!(num_checkpoints(&e, &bob), checkpoints_before); }); } @@ -438,12 +436,12 @@ fn binary_search_with_many_checkpoints() { // Query various historical points e.ledger().set_timestamp(3000); - assert_eq!(get_past_votes(&e, &bob, 50), 1000); // After initial delegation - assert_eq!(get_past_votes(&e, &bob, 100), 1010); - assert_eq!(get_past_votes(&e, &bob, 500), 1050); - assert_eq!(get_past_votes(&e, &bob, 1000), 1100); - assert_eq!(get_past_votes(&e, &bob, 1500), 1150); - assert_eq!(get_past_votes(&e, &bob, 2000), 1200); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 50), 1000); // After initial delegation + assert_eq!(get_votes_at_checkpoint(&e, &bob, 100), 1010); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 500), 1050); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 1000), 1100); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 1500), 1150); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 2000), 1200); }); } @@ -495,14 +493,14 @@ fn delegate_votes_overflow() { #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_votes_at_current_timestamp() { +fn get_votes_at_checkpoint_at_current_timestamp() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { // Query at exact current timestamp should fail - get_past_votes(&e, &alice, 1000); + get_votes_at_checkpoint(&e, &alice, 1000); }); } @@ -520,14 +518,14 @@ fn get_past_total_supply_at_current_timestamp() { #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_votes_future_timestamp() { +fn get_votes_at_checkpoint_future_timestamp() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { // Query at future timestamp should fail - get_past_votes(&e, &alice, 2000); + get_votes_at_checkpoint(&e, &alice, 2000); }); } @@ -544,7 +542,7 @@ fn get_past_total_supply_future_timestamp() { } #[test] -fn get_past_votes_before_first_checkpoint() { +fn get_votes_at_checkpoint_before_first_checkpoint() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); let bob = Address::generate(&e); @@ -556,7 +554,7 @@ fn get_past_votes_before_first_checkpoint() { e.ledger().set_timestamp(2000); // Query before first checkpoint - assert_eq!(get_past_votes(&e, &bob, 500), 0); + assert_eq!(get_votes_at_checkpoint(&e, &bob, 500), 0); }); } diff --git a/packages/tokens/src/non_fungible/extensions/votes/storage.rs b/packages/tokens/src/non_fungible/extensions/votes/storage.rs index 19bca4eea..4d894bb26 100644 --- a/packages/tokens/src/non_fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/votes/storage.rs @@ -1,7 +1,7 @@ use soroban_sdk::{Address, Env}; use stellar_governance::votes::transfer_voting_units; -use crate::non_fungible::{Base, ContractOverrides}; +use crate::non_fungible::{overrides::BurnableOverrides, Base, ContractOverrides}; pub struct NonFungibleVotes; @@ -15,6 +15,16 @@ impl ContractOverrides for NonFungibleVotes { } } +impl BurnableOverrides for NonFungibleVotes { + fn burn(e: &Env, from: &Address, token_id: u32) { + NonFungibleVotes::burn(e, from, token_id); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + NonFungibleVotes::burn_from(e, spender, from, token_id); + } +} + impl NonFungibleVotes { /// Transfers a non-fungible token from `from` to `to`. /// Also updates voting units for the respective delegates (1 unit per NFT). From 4e4c5d41ae197b4cf90bad480b7ebb2fb0f13f53 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:27:28 +0100 Subject: [PATCH 08/15] refactor with unified fns --- packages/governance/src/votes/storage.rs | 186 ++++++++++++----------- 1 file changed, 99 insertions(+), 87 deletions(-) diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index 710322525..d25afdcde 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -17,6 +17,19 @@ pub(crate) enum CheckpointOp { Sub, } +/// Selects the checkpoint timeline to operate on. +/// +/// Each variant maps to a different set of storage keys so that +/// per-account voting-power history and aggregate total-supply history +/// are kept in separate checkpoint sequences. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CheckpointType { + /// The global total-supply checkpoint timeline. + TotalSupply, + /// A per-account (delegate) voting-power checkpoint timeline. + Account(Address), +} + // ################## TYPES ################## /// A checkpoint recording voting power at a specific timepoint. @@ -65,11 +78,12 @@ pub enum VotesStorageKey { /// * `e` - Access to Soroban environment. /// * `account` - The address to query voting power for. pub fn get_votes(e: &Env, account: &Address) -> u128 { - let num = num_checkpoints(e, account); + let cp_type = CheckpointType::Account(account.clone()); + let num = get_num_checkpoints(e, &cp_type); if num == 0 { return 0; } - get_checkpoint(e, account, num - 1).votes + get_checkpoint(e, &cp_type, num - 1).votes } /// Returns the voting power (delegated votes) of an account at a specific @@ -92,12 +106,13 @@ pub fn get_votes_at_checkpoint(e: &Env, account: &Address, timepoint: u64) -> u1 panic_with_error!(e, VotesError::FutureLookup); } - let num = num_checkpoints(e, account); + let cp_type = CheckpointType::Account(account.clone()); + let num = get_num_checkpoints(e, &cp_type); if num == 0 { return 0; } - lookup_checkpoint_at(e, timepoint, num, |e, index| get_checkpoint(e, account, index)) + lookup_checkpoint_at(e, timepoint, num, &cp_type) } /// Returns the current total supply of voting units. @@ -106,11 +121,12 @@ pub fn get_votes_at_checkpoint(e: &Env, account: &Address, timepoint: u64) -> u1 /// /// * `e` - Access to Soroban environment. pub fn get_total_supply(e: &Env) -> u128 { - let num = num_total_supply_checkpoints(e); + let cp_type = CheckpointType::TotalSupply; + let num = get_num_checkpoints(e, &cp_type); if num == 0 { return 0; } - get_total_supply_checkpoint(e, num - 1).votes + get_checkpoint(e, &cp_type, num - 1).votes } /// Returns the total supply of voting units at a specific past timepoint. @@ -130,12 +146,13 @@ pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { panic_with_error!(e, VotesError::FutureLookup); } - let num = num_total_supply_checkpoints(e); + let cp_type = CheckpointType::TotalSupply; + let num = get_num_checkpoints(e, &cp_type); if num == 0 { return 0; } - lookup_checkpoint_at(e, timepoint, num, get_total_supply_checkpoint) + lookup_checkpoint_at(e, timepoint, num, &cp_type) } /// Returns the delegate for an account. @@ -166,13 +183,7 @@ pub fn get_delegate(e: &Env, account: &Address) -> Option
{ /// * `e` - Access to Soroban environment. /// * `account` - The address to query checkpoints for. pub fn num_checkpoints(e: &Env, account: &Address) -> u32 { - let key = VotesStorageKey::NumCheckpoints(account.clone()); - if let Some(checkpoints) = e.storage().persistent().get::<_, u32>(&key) { - e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); - checkpoints - } else { - 0 - } + get_num_checkpoints(e, &CheckpointType::Account(account.clone())) } /// Returns the voting units held by an account. @@ -275,7 +286,7 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres set_voting_units(e, from_addr, new_from_units); } else { // Minting: increase total supply - push_total_supply_checkpoint(e, CheckpointOp::Add, amount); + push_checkpoint(e, &CheckpointType::TotalSupply, CheckpointOp::Add, amount); } if let Some(to_addr) = to { @@ -286,7 +297,7 @@ pub fn transfer_voting_units(e: &Env, from: Option<&Address>, to: Option<&Addres set_voting_units(e, to_addr, new_to_units); } else { // Burning: decrease total supply - push_total_supply_checkpoint(e, CheckpointOp::Sub, amount); + push_checkpoint(e, &CheckpointType::TotalSupply, CheckpointOp::Sub, amount); } move_delegate_votes(e, from_delegate.as_ref(), to_delegate.as_ref(), amount); @@ -315,33 +326,35 @@ fn move_delegate_votes(e: &Env, from: Option<&Address>, to: Option<&Address>, am } if let Some(from_addr) = from { - let (old_votes, new_votes) = push_checkpoint(e, from_addr, CheckpointOp::Sub, amount); + let cp_type = CheckpointType::Account(from_addr.clone()); + let (old_votes, new_votes) = push_checkpoint(e, &cp_type, CheckpointOp::Sub, amount); emit_delegate_votes_changed(e, from_addr, old_votes, new_votes); } if let Some(to_addr) = to { - let (old_votes, new_votes) = push_checkpoint(e, to_addr, CheckpointOp::Add, amount); + let cp_type = CheckpointType::Account(to_addr.clone()); + let (old_votes, new_votes) = push_checkpoint(e, &cp_type, CheckpointOp::Add, amount); emit_delegate_votes_changed(e, to_addr, old_votes, new_votes); } } /// Binary search over checkpoints to find votes at a given timepoint. /// -/// `num` must be > 0. `get_fn` retrieves a checkpoint at the given index. +/// `num` must be > 0. fn lookup_checkpoint_at( e: &Env, timepoint: u64, num: u32, - get_fn: impl Fn(&Env, u32) -> Checkpoint, + checkpoint_type: &CheckpointType, ) -> u128 { // Check if timepoint is after the latest checkpoint - let latest = get_fn(e, num - 1); + let latest = get_checkpoint(e, checkpoint_type, num - 1); if latest.timestamp <= timepoint { return latest.votes; } // Check if timepoint is before the first checkpoint - let first = get_fn(e, 0); + let first = get_checkpoint(e, checkpoint_type, 0); if first.timestamp > timepoint { return 0; } @@ -352,7 +365,7 @@ fn lookup_checkpoint_at( while low < high { let mid = (low + high).div_ceil(2); - let checkpoint = get_fn(e, mid); + let checkpoint = get_checkpoint(e, checkpoint_type, mid); if checkpoint.timestamp <= timepoint { low = mid; } else { @@ -360,7 +373,7 @@ fn lookup_checkpoint_at( } } - get_fn(e, low).votes + get_checkpoint(e, checkpoint_type, low).votes } /// Applies a [`CheckpointOp`] to compute the new votes value from the @@ -376,59 +389,40 @@ fn apply_checkpoint_op(e: &Env, previous: u128, op: CheckpointOp, delta: u128) - } } -/// Gets a checkpoint for a delegate at a specific index. -fn get_checkpoint(e: &Env, account: &Address, index: u32) -> Checkpoint { - let key = VotesStorageKey::DelegateCheckpoint(account.clone(), index); - if let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) { - e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); - checkpoint - } else { - Checkpoint { timestamp: 0, votes: 0 } +/// Returns the storage key for a checkpoint at the given index. +fn checkpoint_storage_key(checkpoint_type: &CheckpointType, index: u32) -> VotesStorageKey { + match checkpoint_type { + CheckpointType::TotalSupply => VotesStorageKey::TotalSupplyCheckpoint(index), + CheckpointType::Account(account) => { + VotesStorageKey::DelegateCheckpoint(account.clone(), index) + } } } -/// Pushes a new checkpoint or updates the last one if same timepoint. -/// Returns (previous_votes, new_votes). -fn push_checkpoint(e: &Env, account: &Address, op: CheckpointOp, delta: u128) -> (u128, u128) { - let num = num_checkpoints(e, account); - let current_timestamp = e.ledger().timestamp(); - - let previous_votes = if num > 0 { get_checkpoint(e, account, num - 1).votes } else { 0 }; - let votes = apply_checkpoint_op(e, previous_votes, op, delta); - - // Check if we can update the last checkpoint (same timepoint) - if num > 0 { - let last_checkpoint = get_checkpoint(e, account, num - 1); - if last_checkpoint.timestamp == current_timestamp { - // Update existing checkpoint - let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num - 1); - e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); - return (previous_votes, votes); +/// Returns the number of checkpoints for the given checkpoint type. +fn get_num_checkpoints(e: &Env, checkpoint_type: &CheckpointType) -> u32 { + match checkpoint_type { + CheckpointType::TotalSupply => { + let key = VotesStorageKey::NumTotalSupplyCheckpoints; + e.storage().instance().get(&key).unwrap_or(0) + } + CheckpointType::Account(account) => { + let key = VotesStorageKey::NumCheckpoints(account.clone()); + if let Some(checkpoints) = e.storage().persistent().get::<_, u32>(&key) { + e.storage() + .persistent() + .extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + checkpoints + } else { + 0 + } } } - - // Create new checkpoint - let key = VotesStorageKey::DelegateCheckpoint(account.clone(), num); - e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); - - // Update checkpoint count - let num_key = VotesStorageKey::NumCheckpoints(account.clone()); - e.storage().persistent().set(&num_key, &(num + 1)); - - (previous_votes, votes) -} - -// ################## TOTAL SUPPLY CHECKPOINTS ################## - -/// Returns the number of total supply checkpoints. -fn num_total_supply_checkpoints(e: &Env) -> u32 { - let key = VotesStorageKey::NumTotalSupplyCheckpoints; - e.storage().instance().get(&key).unwrap_or(0) } -/// Gets a total supply checkpoint at a specific index. -fn get_total_supply_checkpoint(e: &Env, index: u32) -> Checkpoint { - let key = VotesStorageKey::TotalSupplyCheckpoint(index); +/// Gets a checkpoint at a specific index for the given checkpoint type. +fn get_checkpoint(e: &Env, checkpoint_type: &CheckpointType, index: u32) -> Checkpoint { + let key = checkpoint_storage_key(checkpoint_type, index); if let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) { e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); checkpoint @@ -437,30 +431,48 @@ fn get_total_supply_checkpoint(e: &Env, index: u32) -> Checkpoint { } } -/// Pushes a new total supply checkpoint or updates the last one if same -/// timepoint. -fn push_total_supply_checkpoint(e: &Env, op: CheckpointOp, delta: u128) { - let num = num_total_supply_checkpoints(e); - let current_timestamp = e.ledger().timestamp(); - - let previous_votes = if num > 0 { get_total_supply_checkpoint(e, num - 1).votes } else { 0 }; +/// Pushes a new checkpoint or updates the last one if same timepoint. +/// Returns (previous_votes, new_votes). +fn push_checkpoint( + e: &Env, + checkpoint_type: &CheckpointType, + op: CheckpointOp, + delta: u128, +) -> (u128, u128) { + let num = get_num_checkpoints(e, checkpoint_type); + let timestamp = e.ledger().timestamp(); + + let previous_votes = + if num > 0 { get_checkpoint(e, checkpoint_type, num - 1).votes } else { 0 }; let votes = apply_checkpoint_op(e, previous_votes, op, delta); // Check if we can update the last checkpoint (same timepoint) if num > 0 { - let last_checkpoint = get_total_supply_checkpoint(e, num - 1); - if last_checkpoint.timestamp == current_timestamp { - let key = VotesStorageKey::TotalSupplyCheckpoint(num - 1); - e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); - return; + let last_checkpoint = get_checkpoint(e, checkpoint_type, num - 1); + if last_checkpoint.timestamp == timestamp { + let key = checkpoint_storage_key(checkpoint_type, num - 1); + e.storage() + .persistent() + .set(&key, &Checkpoint { timestamp, votes }); + return (previous_votes, votes); } } // Create new checkpoint - let key = VotesStorageKey::TotalSupplyCheckpoint(num); - e.storage().persistent().set(&key, &Checkpoint { timestamp: current_timestamp, votes }); + let key = checkpoint_storage_key(checkpoint_type, num); + e.storage().persistent().set(&key, &Checkpoint { timestamp, votes }); // Update checkpoint count - let num_key = VotesStorageKey::NumTotalSupplyCheckpoints; - e.storage().instance().set(&num_key, &(num + 1)); + match checkpoint_type { + CheckpointType::TotalSupply => { + let num_key = VotesStorageKey::NumTotalSupplyCheckpoints; + e.storage().instance().set(&num_key, &(num + 1)); + } + CheckpointType::Account(account) => { + let num_key = VotesStorageKey::NumCheckpoints(account.clone()); + e.storage().persistent().set(&num_key, &(num + 1)); + } + } + + (previous_votes, votes) } From 4e4ffa1cbe17b1fc71d21c2af1790e4d1e12b560 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:48:09 +0100 Subject: [PATCH 09/15] add total_supply in trait --- packages/governance/src/votes/mod.rs | 20 +++++++++++++++--- packages/governance/src/votes/storage.rs | 27 ++++++++++-------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index c964928f4..a35f9e779 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -51,7 +51,7 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env}; pub use crate::votes::storage::{ - delegate, get_delegate, get_past_total_supply, get_total_supply, get_votes, + delegate, get_delegate, get_total_supply, get_total_supply_at_checkpoint, get_votes, get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, VotesStorageKey, }; @@ -101,6 +101,20 @@ pub trait Votes { get_votes_at_checkpoint(e, &account, timepoint) } + /// Returns the current total supply of voting units. + /// + /// This tracks all voting units in circulation (regardless of delegation + /// status), not just delegated votes. + /// + /// Returns `0` if no voting units exist. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn get_total_supply(e: &Env) -> u128 { + get_total_supply(e) + } + /// Returns the total supply of voting units at a specific past timestamp. /// /// This tracks all voting units in circulation (regardless of delegation @@ -116,8 +130,8 @@ pub trait Votes { /// # Errors /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. - fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { - get_past_total_supply(e, timepoint) + fn get_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 { + get_total_supply_at_checkpoint(e, timepoint) } /// Returns the current delegate for an account. diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index d25afdcde..678621881 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -20,13 +20,13 @@ pub(crate) enum CheckpointOp { /// Selects the checkpoint timeline to operate on. /// /// Each variant maps to a different set of storage keys so that -/// per-account voting-power history and aggregate total-supply history -/// are kept in separate checkpoint sequences. +/// per-account voting-power history and aggregate total supply history +/// are kept separate. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CheckpointType { - /// The global total-supply checkpoint timeline. + /// The global total supply checkpoint. TotalSupply, - /// A per-account (delegate) voting-power checkpoint timeline. + /// A per-account (delegate) voting-power checkpoint. Account(Address), } @@ -141,7 +141,7 @@ pub fn get_total_supply(e: &Env) -> u128 { /// # Errors /// /// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. -pub fn get_past_total_supply(e: &Env, timepoint: u64) -> u128 { +pub fn get_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 { if timepoint >= e.ledger().timestamp() { panic_with_error!(e, VotesError::FutureLookup); } @@ -225,8 +225,8 @@ pub fn get_voting_units(e: &Env, account: &Address) -> u128 { /// /// # Errors /// -/// * [`VotesError::SameDelegate`] - If `delegatee` is already the -/// current delegate for `account`. +/// * [`VotesError::SameDelegate`] - If `delegatee` is already the current +/// delegate for `account`. /// /// # Notes /// @@ -393,9 +393,8 @@ fn apply_checkpoint_op(e: &Env, previous: u128, op: CheckpointOp, delta: u128) - fn checkpoint_storage_key(checkpoint_type: &CheckpointType, index: u32) -> VotesStorageKey { match checkpoint_type { CheckpointType::TotalSupply => VotesStorageKey::TotalSupplyCheckpoint(index), - CheckpointType::Account(account) => { - VotesStorageKey::DelegateCheckpoint(account.clone(), index) - } + CheckpointType::Account(account) => + VotesStorageKey::DelegateCheckpoint(account.clone(), index), } } @@ -409,9 +408,7 @@ fn get_num_checkpoints(e: &Env, checkpoint_type: &CheckpointType) -> u32 { CheckpointType::Account(account) => { let key = VotesStorageKey::NumCheckpoints(account.clone()); if let Some(checkpoints) = e.storage().persistent().get::<_, u32>(&key) { - e.storage() - .persistent() - .extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); checkpoints } else { 0 @@ -451,9 +448,7 @@ fn push_checkpoint( let last_checkpoint = get_checkpoint(e, checkpoint_type, num - 1); if last_checkpoint.timestamp == timestamp { let key = checkpoint_storage_key(checkpoint_type, num - 1); - e.storage() - .persistent() - .set(&key, &Checkpoint { timestamp, votes }); + e.storage().persistent().set(&key, &Checkpoint { timestamp, votes }); return (previous_votes, votes); } } From 7729c3efe8f705d1dc8d3e21a7cedb591bbdd7a3 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:14:48 +0100 Subject: [PATCH 10/15] fix events --- packages/governance/src/votes/mod.rs | 4 +- packages/governance/src/votes/storage.rs | 6 +-- packages/governance/src/votes/test.rs | 52 ++++++++++--------- .../src/fungible/extensions/votes/storage.rs | 18 +++---- .../non_fungible/extensions/votes/storage.rs | 24 ++++----- 5 files changed, 53 insertions(+), 51 deletions(-) diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index a35f9e779..7a3ef6a21 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -159,10 +159,10 @@ pub trait Votes { /// /// # Events /// - /// * topics - `["DelegateChanged", delegator: Address]` + /// * topics - `["delegate_changed", delegator: Address]` /// * data - `[from_delegate: Option
, to_delegate: Address]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index 678621881..f90bd4f11 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -217,10 +217,10 @@ pub fn get_voting_units(e: &Env, account: &Address) -> u128 { /// /// # Events /// -/// * topics - `["DelegateChanged", delegator: Address]` +/// * topics - `["delegate_changed", delegator: Address]` /// * data - `[from_delegate: Option
, to_delegate: Address]` /// -/// * topics - `["DelegateVotesChanged", delegate: Address]` +/// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[old_votes: u128, new_votes: u128]` /// /// # Errors @@ -262,7 +262,7 @@ pub fn delegate(e: &Env, account: &Address, delegatee: &Address) { /// /// # Events /// -/// * topics - `["DelegateVotesChanged", delegate: Address]` +/// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 481c43bef..59a592499 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -1,11 +1,10 @@ +extern crate std; use soroban_sdk::{ - contract, - testutils::{Address as _, Ledger}, - Address, Env, + contract, testutils::{Address as _, Events, Ledger}, Address, Env, }; use crate::votes::{ - delegate, get_delegate, get_past_total_supply, get_total_supply, get_votes, + delegate, get_delegate, get_total_supply, get_total_supply_at_checkpoint, get_votes, get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, }; @@ -35,8 +34,9 @@ fn initial_state_has_zero_votes() { // Test get_votes_at_checkpoint returns 0 when num_checkpoints == 0 assert_eq!(get_votes_at_checkpoint(&e, &alice, 500), 0); - // Test get_past_total_supply returns 0 when num_total_supply_checkpoints == 0 - assert_eq!(get_past_total_supply(&e, 500), 0); + // Test get_total_supply_at_checkpoint returns 0 when + // num_total_supply_checkpoints == 0 + assert_eq!(get_total_supply_at_checkpoint(&e, 500), 0); }); } @@ -119,6 +119,7 @@ fn delegate_to_self() { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &alice); + assert_eq!(e.events().all().len(), 2); assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); assert_eq!(get_votes(&e, &alice), 100); }); @@ -134,6 +135,7 @@ fn delegate_to_other() { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &bob); + assert_eq!(e.events().all().len(), 2); assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_votes(&e, &bob), 100); @@ -291,7 +293,7 @@ fn get_votes_at_checkpoint_fails_for_future_timepoint() { } #[test] -fn get_past_total_supply_returns_historical_value() { +fn get_total_supply_at_checkpoint_returns_historical_value() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); @@ -308,24 +310,24 @@ fn get_past_total_supply_returns_historical_value() { // Query at different timepoints e.ledger().set_timestamp(4000); - assert_eq!(get_past_total_supply(&e, 999), 0); - assert_eq!(get_past_total_supply(&e, 1000), 100); - assert_eq!(get_past_total_supply(&e, 1500), 100); - assert_eq!(get_past_total_supply(&e, 2000), 150); - assert_eq!(get_past_total_supply(&e, 2500), 150); - assert_eq!(get_past_total_supply(&e, 3000), 120); - assert_eq!(get_past_total_supply(&e, 3500), 120); + assert_eq!(get_total_supply_at_checkpoint(&e, 999), 0); + assert_eq!(get_total_supply_at_checkpoint(&e, 1000), 100); + assert_eq!(get_total_supply_at_checkpoint(&e, 1500), 100); + assert_eq!(get_total_supply_at_checkpoint(&e, 2000), 150); + assert_eq!(get_total_supply_at_checkpoint(&e, 2500), 150); + assert_eq!(get_total_supply_at_checkpoint(&e, 3000), 120); + assert_eq!(get_total_supply_at_checkpoint(&e, 3500), 120); }); } #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_total_supply_fails_for_future_timepoint() { +fn get_total_supply_at_checkpoint_fails_for_future_timepoint() { let (e, contract_address) = setup_env(); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { - get_past_total_supply(&e, 1000); + get_total_supply_at_checkpoint(&e, 1000); }); } @@ -506,13 +508,13 @@ fn get_votes_at_checkpoint_at_current_timestamp() { #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_total_supply_at_current_timestamp() { +fn get_total_supply_at_checkpoint_at_current_timestamp() { let (e, contract_address) = setup_env(); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { // Query at exact current timestamp should fail - get_past_total_supply(&e, 1000); + get_total_supply_at_checkpoint(&e, 1000); }); } @@ -531,13 +533,13 @@ fn get_votes_at_checkpoint_future_timestamp() { #[test] #[should_panic(expected = "Error(Contract, #4100)")] -fn get_past_total_supply_future_timestamp() { +fn get_total_supply_at_checkpoint_future_timestamp() { let (e, contract_address) = setup_env(); e.ledger().set_timestamp(1000); e.as_contract(&contract_address, || { // Query at future timestamp should fail - get_past_total_supply(&e, 2000); + get_total_supply_at_checkpoint(&e, 2000); }); } @@ -559,7 +561,7 @@ fn get_votes_at_checkpoint_before_first_checkpoint() { } #[test] -fn get_past_total_supply_before_first_checkpoint() { +fn get_total_supply_at_checkpoint_before_first_checkpoint() { let (e, contract_address) = setup_env(); let alice = Address::generate(&e); @@ -569,7 +571,7 @@ fn get_past_total_supply_before_first_checkpoint() { e.ledger().set_timestamp(2000); // Query before first checkpoint - assert_eq!(get_past_total_supply(&e, 500), 0); + assert_eq!(get_total_supply_at_checkpoint(&e, 500), 0); }); } @@ -607,9 +609,9 @@ fn total_supply_checkpoint_updates_on_mint_burn() { e.ledger().set_timestamp(4000); - assert_eq!(get_past_total_supply(&e, 1000), 100); - assert_eq!(get_past_total_supply(&e, 2000), 150); - assert_eq!(get_past_total_supply(&e, 3000), 75); + assert_eq!(get_total_supply_at_checkpoint(&e, 1000), 100); + assert_eq!(get_total_supply_at_checkpoint(&e, 2000), 150); + assert_eq!(get_total_supply_at_checkpoint(&e, 3000), 75); assert_eq!(get_total_supply(&e), 75); }); } diff --git a/packages/tokens/src/fungible/extensions/votes/storage.rs b/packages/tokens/src/fungible/extensions/votes/storage.rs index 783e05067..2a9d50760 100644 --- a/packages/tokens/src/fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/fungible/extensions/votes/storage.rs @@ -33,10 +33,10 @@ impl FungibleVotes { /// /// # Events /// - /// * topics - `["Transfer", from: Address, to: Address]` + /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[amount: i128]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -68,10 +68,10 @@ impl FungibleVotes { /// /// # Events /// - /// * topics - `["Transfer", from: Address, to: Address]` + /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[amount: i128]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -100,10 +100,10 @@ impl FungibleVotes { /// /// # Events /// - /// * topics - `["Mint", to: Address]` + /// * topics - `["mint", to: Address]` /// * data - `[amount: i128]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Security Warning @@ -134,10 +134,10 @@ impl FungibleVotes { /// /// # Events /// - /// * topics - `["Burn", from: Address]` + /// * topics - `["burn", from: Address]` /// * data - `[amount: i128]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -171,7 +171,7 @@ impl FungibleVotes { /// * topics - `["Burn", from: Address]` /// * data - `[amount: i128]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes diff --git a/packages/tokens/src/non_fungible/extensions/votes/storage.rs b/packages/tokens/src/non_fungible/extensions/votes/storage.rs index 4d894bb26..9e66e6543 100644 --- a/packages/tokens/src/non_fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/votes/storage.rs @@ -43,10 +43,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Transfer", from: Address, to: Address]` + /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -76,10 +76,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Transfer", from: Address, to: Address]` + /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -106,10 +106,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Mint", to: Address]` + /// * topics - `["mint", to: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Security Warning @@ -137,10 +137,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Mint", to: Address]` + /// * topics - `["mint", to: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Security Warning @@ -169,10 +169,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Burn", from: Address]` + /// * topics - `["burn", from: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes @@ -202,10 +202,10 @@ impl NonFungibleVotes { /// /// # Events /// - /// * topics - `["Burn", from: Address]` + /// * topics - `["burn", from: Address]` /// * data - `[token_id: u32]` /// - /// * topics - `["DelegateVotesChanged", delegate: Address]` + /// * topics - `["delegate_votes_changed", delegate: Address]` /// * data - `[previous_votes: u128, new_votes: u128]` /// /// # Notes From f02043685ab7f13bc86598e4bff7cdecf897b7bc Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:42:12 +0100 Subject: [PATCH 11/15] get_checkpoint as pub with error instead default --- packages/governance/src/votes/mod.rs | 8 ++- packages/governance/src/votes/storage.rs | 82 +++++++++++++----------- packages/governance/src/votes/test.rs | 20 +++++- 3 files changed, 67 insertions(+), 43 deletions(-) diff --git a/packages/governance/src/votes/mod.rs b/packages/governance/src/votes/mod.rs index 7a3ef6a21..2a8987ae8 100644 --- a/packages/governance/src/votes/mod.rs +++ b/packages/governance/src/votes/mod.rs @@ -51,9 +51,9 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env}; pub use crate::votes::storage::{ - delegate, get_delegate, get_total_supply, get_total_supply_at_checkpoint, get_votes, - get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, Checkpoint, - VotesStorageKey, + delegate, get_checkpoint, get_delegate, get_total_supply, get_total_supply_at_checkpoint, + get_votes, get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, + Checkpoint, CheckpointType, VotesStorageKey, }; /// Trait for contracts that support vote tracking with delegation. @@ -187,6 +187,8 @@ pub enum VotesError { InsufficientVotingUnits = 4102, /// Attempting to delegate to the same delegate that is already set SameDelegate = 4103, + /// A checkpoint that was expected to exist was not found in storage + CheckpointNotFound = 4104, } // ################## CONSTANTS ################## diff --git a/packages/governance/src/votes/storage.rs b/packages/governance/src/votes/storage.rs index f90bd4f11..3350a8a24 100644 --- a/packages/governance/src/votes/storage.rs +++ b/packages/governance/src/votes/storage.rs @@ -17,19 +17,6 @@ pub(crate) enum CheckpointOp { Sub, } -/// Selects the checkpoint timeline to operate on. -/// -/// Each variant maps to a different set of storage keys so that -/// per-account voting-power history and aggregate total supply history -/// are kept separate. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum CheckpointType { - /// The global total supply checkpoint. - TotalSupply, - /// A per-account (delegate) voting-power checkpoint. - Account(Address), -} - // ################## TYPES ################## /// A checkpoint recording voting power at a specific timepoint. @@ -42,6 +29,20 @@ pub struct Checkpoint { pub votes: u128, } +/// Selects the checkpoint timeline to operate on. +/// +/// Each variant maps to a different set of storage keys so that +/// per-account voting-power history and aggregate total supply history +/// are kept separate. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum CheckpointType { + /// The global total supply checkpoint. + TotalSupply, + /// A per-account (delegate) voting-power checkpoint. + Account(Address), +} + /// Storage keys for the votes module. /// /// Only delegated voting power counts as votes (i.e., only delegatees can @@ -66,6 +67,27 @@ pub enum VotesStorageKey { // ################## QUERY STATE ################## +/// Gets a checkpoint at a specific index for the given checkpoint type. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +/// * `checkpoint_type` - Type of the checkpoint (per-account or total supply). +/// * `index` - Index of the checkpoint. +/// +/// # Errors +/// +/// [`VotesError::CheckpointNotFound`] - If no checkpoint exists +/// at the given index. +pub fn get_checkpoint(e: &Env, checkpoint_type: &CheckpointType, index: u32) -> Checkpoint { + let key = checkpoint_storage_key(checkpoint_type, index); + let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) else { + panic_with_error!(e, VotesError::CheckpointNotFound); + }; + e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); + checkpoint +} + /// Returns the current voting power (delegated votes) of an account. /// /// This is the total voting power delegated to this account by others @@ -108,9 +130,6 @@ pub fn get_votes_at_checkpoint(e: &Env, account: &Address, timepoint: u64) -> u1 let cp_type = CheckpointType::Account(account.clone()); let num = get_num_checkpoints(e, &cp_type); - if num == 0 { - return 0; - } lookup_checkpoint_at(e, timepoint, num, &cp_type) } @@ -148,9 +167,6 @@ pub fn get_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 { let cp_type = CheckpointType::TotalSupply; let num = get_num_checkpoints(e, &cp_type); - if num == 0 { - return 0; - } lookup_checkpoint_at(e, timepoint, num, &cp_type) } @@ -339,14 +355,16 @@ fn move_delegate_votes(e: &Env, from: Option<&Address>, to: Option<&Address>, am } /// Binary search over checkpoints to find votes at a given timepoint. -/// -/// `num` must be > 0. fn lookup_checkpoint_at( e: &Env, timepoint: u64, num: u32, checkpoint_type: &CheckpointType, ) -> u128 { + if num == 0 { + return 0; + } + // Check if timepoint is after the latest checkpoint let latest = get_checkpoint(e, checkpoint_type, num - 1); if latest.timestamp <= timepoint { @@ -417,17 +435,6 @@ fn get_num_checkpoints(e: &Env, checkpoint_type: &CheckpointType) -> u32 { } } -/// Gets a checkpoint at a specific index for the given checkpoint type. -fn get_checkpoint(e: &Env, checkpoint_type: &CheckpointType, index: u32) -> Checkpoint { - let key = checkpoint_storage_key(checkpoint_type, index); - if let Some(checkpoint) = e.storage().persistent().get::<_, Checkpoint>(&key) { - e.storage().persistent().extend_ttl(&key, VOTES_TTL_THRESHOLD, VOTES_EXTEND_AMOUNT); - checkpoint - } else { - Checkpoint { timestamp: 0, votes: 0 } - } -} - /// Pushes a new checkpoint or updates the last one if same timepoint. /// Returns (previous_votes, new_votes). fn push_checkpoint( @@ -439,14 +446,15 @@ fn push_checkpoint( let num = get_num_checkpoints(e, checkpoint_type); let timestamp = e.ledger().timestamp(); - let previous_votes = - if num > 0 { get_checkpoint(e, checkpoint_type, num - 1).votes } else { 0 }; + let last_checkpoint = + if num > 0 { Some(get_checkpoint(e, checkpoint_type, num - 1)) } else { None }; + + let previous_votes = last_checkpoint.as_ref().map_or(0, |cp| cp.votes); let votes = apply_checkpoint_op(e, previous_votes, op, delta); // Check if we can update the last checkpoint (same timepoint) - if num > 0 { - let last_checkpoint = get_checkpoint(e, checkpoint_type, num - 1); - if last_checkpoint.timestamp == timestamp { + if let Some(cp) = &last_checkpoint { + if cp.timestamp == timestamp { let key = checkpoint_storage_key(checkpoint_type, num - 1); e.storage().persistent().set(&key, &Checkpoint { timestamp, votes }); return (previous_votes, votes); diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 59a592499..a27903888 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -1,11 +1,14 @@ extern crate std; use soroban_sdk::{ - contract, testutils::{Address as _, Events, Ledger}, Address, Env, + contract, + testutils::{Address as _, Events, Ledger}, + Address, Env, }; use crate::votes::{ - delegate, get_delegate, get_total_supply, get_total_supply_at_checkpoint, get_votes, - get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, + delegate, get_checkpoint, get_delegate, get_total_supply, get_total_supply_at_checkpoint, + get_votes, get_votes_at_checkpoint, get_voting_units, num_checkpoints, transfer_voting_units, + CheckpointType, }; #[contract] @@ -615,3 +618,14 @@ fn total_supply_checkpoint_updates_on_mint_burn() { assert_eq!(get_total_supply(&e), 75); }); } + +#[test] +#[should_panic(expected = "Error(Contract, #4104)")] +fn get_checkpoint_directly_panics_when_missing() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + get_checkpoint(&e, &CheckpointType::Account(alice.clone()), 0); + }); +} From f89128accd8827227d9e8a874121fbee9787323d Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:11:37 +0100 Subject: [PATCH 12/15] fix tests --- packages/governance/src/votes/test.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index a27903888..852bbc6c2 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -121,8 +121,9 @@ fn delegate_to_self() { e.as_contract(&contract_address, || { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &alice); + let events = e.events().all(); - assert_eq!(e.events().all().len(), 2); + assert_eq!(events.len(), 2); assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); assert_eq!(get_votes(&e, &alice), 100); }); @@ -137,8 +138,9 @@ fn delegate_to_other() { e.as_contract(&contract_address, || { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &bob); + let events = e.events().all(); - assert_eq!(e.events().all().len(), 2); + assert_eq!(events.len(), 2); assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_votes(&e, &bob), 100); From fc2543b2cc088f34cbe5a01ab2d73838b377e0bc Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:22:09 +0100 Subject: [PATCH 13/15] fix tests --- packages/governance/src/votes/test.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 852bbc6c2..9635a101d 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -1,8 +1,7 @@ -extern crate std; use soroban_sdk::{ contract, testutils::{Address as _, Events, Ledger}, - Address, Env, + Address, Env }; use crate::votes::{ @@ -121,9 +120,8 @@ fn delegate_to_self() { e.as_contract(&contract_address, || { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &alice); - let events = e.events().all(); - assert_eq!(events.len(), 2); + assert_eq!(e.events().all().events().len(), 2); assert_eq!(get_delegate(&e, &alice), Some(alice.clone())); assert_eq!(get_votes(&e, &alice), 100); }); @@ -138,9 +136,8 @@ fn delegate_to_other() { e.as_contract(&contract_address, || { transfer_voting_units(&e, None, Some(&alice), 100); delegate(&e, &alice, &bob); - let events = e.events().all(); - assert_eq!(events.len(), 2); + assert_eq!(e.events().all().events().len(), 2); assert_eq!(get_delegate(&e, &alice), Some(bob.clone())); assert_eq!(get_votes(&e, &alice), 0); assert_eq!(get_votes(&e, &bob), 100); From 36e553f20f98e3ac96fe084f280eeb7a5fd0cb12 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:25:46 +0100 Subject: [PATCH 14/15] fmt --- packages/governance/src/votes/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs index 9635a101d..26f08a22f 100644 --- a/packages/governance/src/votes/test.rs +++ b/packages/governance/src/votes/test.rs @@ -1,7 +1,7 @@ use soroban_sdk::{ contract, testutils::{Address as _, Events, Ledger}, - Address, Env + Address, Env, }; use crate::votes::{ From 2fe0d53f4ec18e72455ee21a46803e0d3ff037c9 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:18:33 +0100 Subject: [PATCH 15/15] override tests --- .../src/fungible/extensions/votes/test.rs | 50 ++++++++++- .../src/non_fungible/extensions/votes/test.rs | 87 ++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/tokens/src/fungible/extensions/votes/test.rs b/packages/tokens/src/fungible/extensions/votes/test.rs index 29d1c1a16..1963e9d27 100644 --- a/packages/tokens/src/fungible/extensions/votes/test.rs +++ b/packages/tokens/src/fungible/extensions/votes/test.rs @@ -3,7 +3,7 @@ extern crate std; use soroban_sdk::{contract, testutils::Address as _, Address, Env, MuxedAddress}; use stellar_governance::votes::{delegate, get_delegate, get_votes, get_voting_units}; -use crate::fungible::{extensions::votes::FungibleVotes, Base}; +use crate::fungible::{extensions::votes::FungibleVotes, Base, ContractOverrides}; #[contract] struct MockContract; @@ -371,6 +371,54 @@ fn transfer_between_delegated_accounts() { }); } +#[test] +fn contract_overrides_transfer() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &alice, 100); + assert_eq!(get_voting_units(&e, &alice), 100); + }); + + e.as_contract(&contract_address, || { + ::transfer( + &e, + &alice, + &MuxedAddress::from(bob.clone()), + 40, + ); + + assert_eq!(Base::balance(&e, &alice), 60); + assert_eq!(Base::balance(&e, &bob), 40); + assert_eq!(get_voting_units(&e, &alice), 60); + assert_eq!(get_voting_units(&e, &bob), 40); + }); +} + +#[test] +fn contract_overrides_transfer_from() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + FungibleVotes::mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 50, 1000); + }); + + e.as_contract(&contract_address, || { + ::transfer_from(&e, &spender, &owner, &recipient, 30); + + assert_eq!(Base::balance(&e, &owner), 70); + assert_eq!(Base::balance(&e, &recipient), 30); + assert_eq!(get_voting_units(&e, &owner), 70); + assert_eq!(get_voting_units(&e, &recipient), 30); + }); +} + #[test] fn burn_all_tokens() { let (e, contract_address) = setup_env(); diff --git a/packages/tokens/src/non_fungible/extensions/votes/test.rs b/packages/tokens/src/non_fungible/extensions/votes/test.rs index d7e6861c0..a960e3826 100644 --- a/packages/tokens/src/non_fungible/extensions/votes/test.rs +++ b/packages/tokens/src/non_fungible/extensions/votes/test.rs @@ -3,7 +3,11 @@ extern crate std; use soroban_sdk::{contract, testutils::Address as _, Address, Env, String}; use stellar_governance::votes::{delegate, get_delegate, get_votes, get_voting_units}; -use crate::non_fungible::{extensions::votes::NonFungibleVotes, Base}; +use crate::non_fungible::{ + extensions::votes::NonFungibleVotes, + overrides::{BurnableOverrides, ContractOverrides}, + Base, +}; #[contract] struct MockContract; @@ -262,6 +266,87 @@ fn multiple_holders_with_same_delegate() { }); } +#[test] +fn contract_overrides_transfer() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + assert_eq!(get_voting_units(&e, &alice), 1); + }); + + e.as_contract(&contract_address, || { + ::transfer(&e, &alice, &bob, 1); + + assert_eq!(Base::balance(&e, &alice), 0); + assert_eq!(Base::balance(&e, &bob), 1); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &bob), 1); + }); +} + +#[test] +fn contract_overrides_transfer_from() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &owner, 1); + Base::approve(&e, &owner, &spender, 1, 1000); + }); + + e.as_contract(&contract_address, || { + ::transfer_from(&e, &spender, &owner, &recipient, 1); + + assert_eq!(Base::balance(&e, &owner), 0); + assert_eq!(Base::balance(&e, &recipient), 1); + assert_eq!(get_voting_units(&e, &owner), 0); + assert_eq!(get_voting_units(&e, &recipient), 1); + }); +} + +#[test] +fn burnable_overrides_burn() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &alice, 1); + NonFungibleVotes::mint(&e, &alice, 2); + assert_eq!(get_voting_units(&e, &alice), 2); + }); + + e.as_contract(&contract_address, || { + ::burn(&e, &alice, 1); + + assert_eq!(Base::balance(&e, &alice), 1); + assert_eq!(get_voting_units(&e, &alice), 1); + }); +} + +#[test] +fn burnable_overrides_burn_from() { + let (e, contract_address) = setup_env(); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + NonFungibleVotes::mint(&e, &owner, 1); + Base::approve(&e, &owner, &spender, 1, 1000); + }); + + e.as_contract(&contract_address, || { + ::burn_from(&e, &spender, &owner, 1); + + assert_eq!(Base::balance(&e, &owner), 0); + assert_eq!(get_voting_units(&e, &owner), 0); + }); +} + #[test] fn transfer_between_delegated_accounts() { let (e, contract_address) = setup_env();