diff --git a/Cargo.lock b/Cargo.lock index 61ca4d35a..0ba72e8fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,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" @@ -1940,6 +1951,7 @@ dependencies = [ "soroban-test-helpers", "stellar-contract-utils", "stellar-event-assertion", + "stellar-governance", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 84392104c..a04caf22d 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..defaf282b 100644 --- a/packages/governance/README.md +++ b/packages/governance/README.md @@ -6,10 +6,30 @@ 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) +- Non-delegated voting units are not counted as votes + ### 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..2a8987ae8 --- /dev/null +++ b/packages/governance/src/votes/mod.rs @@ -0,0 +1,269 @@ +//! # Votes Module +//! +//! 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). **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 +//! +//! # Usage +//! +//! 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_votes_at_checkpoint, transfer_voting_units, +//! }; +//! +//! // 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); +//! } +//! +//! // 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, 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. +/// +/// 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 (delegated votes) of an account. + /// + /// Returns `0` if the account has no delegated voting power or does not + /// exist in the contract. + /// + /// # 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 (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 + /// + /// * `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_votes_at_checkpoint(e: &Env, account: Address, timepoint: u64) -> u128 { + 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 + /// status), not just delegated votes. + /// + /// Returns `0` if there were no voting units at the given timepoint. + /// + /// # 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_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 { + get_total_supply_at_checkpoint(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 get_delegate(e: &Env, account: Address) -> Option
{ + get_delegate(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 + /// + /// * topics - `["delegate_changed", delegator: Address]` + /// * data - `[from_delegate: Option
, to_delegate: Address]` + /// + /// * topics - `["delegate_votes_changed", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # 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, + /// Attempting to transfer more voting units than available + 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 ################## + +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 previous_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. +/// * `previous_votes` - The previous voting power. +/// * `new_votes` - The new voting power. +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 new file mode 100644 index 000000000..3350a8a24 --- /dev/null +++ b/packages/governance/src/votes/storage.rs @@ -0,0 +1,481 @@ +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, +}; + +// ################## 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 timepoint. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Checkpoint { + /// The timepoint when this checkpoint was created + pub timestamp: u64, + /// The voting power at this timepoint + 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 +/// 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 { + /// 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 ################## + +/// 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 +/// (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 +/// +/// * `e` - Access to Soroban environment. +/// * `account` - The address to query voting power for. +pub fn get_votes(e: &Env, account: &Address) -> u128 { + let cp_type = CheckpointType::Account(account.clone()); + let num = get_num_checkpoints(e, &cp_type); + if num == 0 { + return 0; + } + get_checkpoint(e, &cp_type, num - 1).votes +} + +/// 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 timepoint to query (must be in the past). +/// +/// # Errors +/// +/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. +pub fn get_votes_at_checkpoint(e: &Env, account: &Address, timepoint: u64) -> u128 { + if timepoint >= e.ledger().timestamp() { + panic_with_error!(e, VotesError::FutureLookup); + } + + let cp_type = CheckpointType::Account(account.clone()); + let num = get_num_checkpoints(e, &cp_type); + + lookup_checkpoint_at(e, timepoint, num, &cp_type) +} + +/// Returns the current total supply of voting units. +/// +/// # Arguments +/// +/// * `e` - Access to Soroban environment. +pub fn get_total_supply(e: &Env) -> u128 { + let cp_type = CheckpointType::TotalSupply; + let num = get_num_checkpoints(e, &cp_type); + if num == 0 { + return 0; + } + get_checkpoint(e, &cp_type, num - 1).votes +} + +/// 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 timepoint to query (must be in the past). +/// +/// # Errors +/// +/// * [`VotesError::FutureLookup`] - If `timepoint` >= current timestamp. +pub fn get_total_supply_at_checkpoint(e: &Env, timepoint: u64) -> u128 { + if timepoint >= e.ledger().timestamp() { + panic_with_error!(e, VotesError::FutureLookup); + } + + let cp_type = CheckpointType::TotalSupply; + let num = get_num_checkpoints(e, &cp_type); + + lookup_checkpoint_at(e, timepoint, num, &cp_type) +} + +/// 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 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); + 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 { + get_num_checkpoints(e, &CheckpointType::Account(account.clone())) +} + +/// 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 +/// +/// * topics - `["delegate_changed", delegator: Address]` +/// * data - `[from_delegate: Option
, to_delegate: Address]` +/// +/// * topics - `["delegate_votes_changed", 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. +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); + + 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. +/// +/// # Events +/// +/// * topics - `["delegate_votes_changed", delegate: Address]` +/// * data - `[previous_votes: u128, new_votes: u128]` +/// +/// # 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; + } + + // 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); + } else { + // Minting: increase total supply + push_checkpoint(e, &CheckpointType::TotalSupply, CheckpointOp::Add, 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); + } else { + // Burning: decrease total supply + push_checkpoint(e, &CheckpointType::TotalSupply, CheckpointOp::Sub, amount); + } + + move_delegate_votes(e, from_delegate.as_ref(), 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; + } + + if from == to { + return; + } + + if let Some(from_addr) = from { + 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 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. +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 { + return latest.votes; + } + + // Check if timepoint is before the first checkpoint + let first = get_checkpoint(e, checkpoint_type, 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, checkpoint_type, mid); + if checkpoint.timestamp <= timepoint { + low = mid; + } else { + high = mid - 1; + } + } + + get_checkpoint(e, checkpoint_type, 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)), + } +} + +/// 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), + } +} + +/// 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 + } + } + } +} + +/// 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 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 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); + } + } + + // Create new checkpoint + let key = checkpoint_storage_key(checkpoint_type, num); + e.storage().persistent().set(&key, &Checkpoint { timestamp, votes }); + + // Update checkpoint count + 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) +} diff --git a/packages/governance/src/votes/test.rs b/packages/governance/src/votes/test.rs new file mode 100644 index 000000000..26f08a22f --- /dev/null +++ b/packages/governance/src/votes/test.rs @@ -0,0 +1,630 @@ +use soroban_sdk::{ + contract, + testutils::{Address as _, Events, Ledger}, + Address, Env, +}; + +use crate::votes::{ + 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] +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 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_votes_at_checkpoint returns 0 when num_checkpoints == 0 + assert_eq!(get_votes_at_checkpoint(&e, &alice, 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); + }); +} + +#[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!(e.events().all().events().len(), 2); + assert_eq!(get_delegate(&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!(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); + }); +} + +#[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 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_votes_at_checkpoint_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_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_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_votes_at_checkpoint(&e, &alice, 1000); + }); +} + +#[test] +fn get_total_supply_at_checkpoint_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_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_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_total_supply_at_checkpoint(&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!(get_delegate(&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] +#[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); + + e.ledger().set_timestamp(1000); + e.as_contract(&contract_address, || { + transfer_voting_units(&e, None, Some(&alice), 100); + delegate(&e, &alice, &bob); + }); + + e.ledger().set_timestamp(2000); + e.as_contract(&contract_address, || { + // Should panic with SameDelegateReassignment + delegate(&e, &alice, &bob); + }); +} + +#[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_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); + }); +} + +#[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_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_votes_at_checkpoint(&e, &alice, 1000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +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_total_supply_at_checkpoint(&e, 1000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +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_votes_at_checkpoint(&e, &alice, 2000); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4100)")] +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_total_supply_at_checkpoint(&e, 2000); + }); +} + +#[test] +fn get_votes_at_checkpoint_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_votes_at_checkpoint(&e, &bob, 500), 0); + }); +} + +#[test] +fn get_total_supply_at_checkpoint_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_total_supply_at_checkpoint(&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_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); + }); +} + +#[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); + }); +} 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..e563091af --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/mod.rs @@ -0,0 +1,6 @@ +pub mod storage; + +#[cfg(test)] +mod test; + +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..2a9d50760 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/storage.rs @@ -0,0 +1,186 @@ +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. + /// + /// # Errors + /// + /// * refer to [`Base::transfer`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["delegate_votes_changed", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # 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. + /// + /// # Errors + /// + /// * refer to [`Base::transfer_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["delegate_votes_changed", 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, 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. + /// + /// # Errors + /// + /// * refer to [`Base::mint`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["mint", to: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["delegate_votes_changed", 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, 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 owner's + /// delegate. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `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 - `["delegate_votes_changed", delegate: Address]` + /// * data - `[previous_votes: u128, new_votes: u128]` + /// + /// # 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 owner'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. + /// + /// # Errors + /// + /// * refer to [`Base::burn_from`] errors. + /// * refer to [`transfer_voting_units`] errors. + /// + /// # Events + /// + /// * topics - `["Burn", from: Address]` + /// * data - `[amount: i128]` + /// + /// * topics - `["delegate_votes_changed", 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, 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/extensions/votes/test.rs b/packages/tokens/src/fungible/extensions/votes/test.rs new file mode 100644 index 000000000..1963e9d27 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/votes/test.rs @@ -0,0 +1,441 @@ +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, ContractOverrides}; + +#[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 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(); + 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); + }); +} diff --git a/packages/tokens/src/fungible/mod.rs b/packages/tokens/src/fungible/mod.rs index 5f1d8cc46..8e53e78dc 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, 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..9e66e6543 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/votes/storage.rs @@ -0,0 +1,218 @@ +use soroban_sdk::{Address, Env}; +use stellar_governance::votes::transfer_voting_units; + +use crate::non_fungible::{overrides::BurnableOverrides, 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 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). + /// + /// # 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 - `["delegate_votes_changed", 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 - `["delegate_votes_changed", 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 - `["delegate_votes_changed", 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 - `["delegate_votes_changed", 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 - `["delegate_votes_changed", 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 - `["delegate_votes_changed", 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..a960e3826 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/votes/test.rs @@ -0,0 +1,376 @@ +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, + overrides::{BurnableOverrides, ContractOverrides}, + 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 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(); + 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};