diff --git a/contracts/governance-token/Cargo.toml b/contracts/governance-token/Cargo.toml index 2a7b847..708edc4 100644 --- a/contracts/governance-token/Cargo.toml +++ b/contracts/governance-token/Cargo.toml @@ -6,7 +6,6 @@ publish = false [dependencies] soroban-sdk = "25.0.2" -stellarcade-shared = { path = "../shared" } [dev-dependencies] soroban-sdk = { version = "25.0.2", features = ["testutils"] } diff --git a/contracts/governance-token/README.md b/contracts/governance-token/README.md index 89038ee..0914425 100644 --- a/contracts/governance-token/README.md +++ b/contracts/governance-token/README.md @@ -4,8 +4,8 @@ This contract implements the governance token for the StellarCade platform. It p ## Methods -### `init(admin: Address, token_config: TokenConfig)` -Initializes the contract with an admin address and token configuration. +### `init(admin: Address, name: String, symbol: String, decimals: u32)` +Initializes the contract with an admin address and token configuration. Requires admin authorization. ### `mint(to: Address, amount: i128)` Mints new tokens to the specified address. Requires admin authorization. @@ -19,14 +19,32 @@ Transfers tokens from one address to another. Requires authorization from the se ### `total_supply() -> i128` Returns the current total supply of tokens. -### `balance_of(owner: Address) -> i128` +### `balance(owner: Address) -> i128` Returns the token balance of the specified owner. +### `latest_checkpoint(holder: Address) -> Option` +Returns the most recent voting checkpoint for `holder`, or `None` if the holder has no recorded history. A checkpoint captures the holder's balance at a specific ledger sequence number. + +### `checkpoint_history(holder: Address, limit: u32) -> Vec` +Returns up to `limit` most-recent checkpoints for `holder`, ordered oldest-first. `limit` is capped at 50. Returns an empty list for unknown holders. + +### `checkpoint_at_ledger(holder: Address, ledger: u32) -> Option` +Returns the most recent checkpoint at or before `ledger` for `holder`. Intended for snapshot-based vote weighting — pass a proposal's `start_ledger` to get the holder's balance at that point in time. Returns `None` for unknown holders or if no checkpoint precedes the requested ledger. + +## Checkpoint Behavior + +- A `Checkpoint { ledger, balance }` is written whenever a holder's balance changes (mint, burn, or transfer). +- Checkpoints are ordered by ledger sequence (ascending) and the list is oldest-first. +- If two balance changes occur within the same ledger, the existing entry for that ledger is overwritten rather than duplicated. +- At most 50 checkpoints are retained per holder; the oldest entry is evicted when the cap is reached. +- Querying an unknown holder via either accessor returns a deterministic empty/`None` result — never an ambiguous zero state. + ## Storage - `Admin`: The address with administrative privileges. - `TotalSupply`: Current total number of tokens in circulation. - `Balances`: Mapping of addresses to their respective token balances. +- `Checkpoints(Address)`: Per-holder ordered list of `Checkpoint` entries (bounded to 50). ## Events diff --git a/contracts/governance-token/src/lib.rs b/contracts/governance-token/src/lib.rs index 8eea070..4de912a 100644 --- a/contracts/governance-token/src/lib.rs +++ b/contracts/governance-token/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, - Address, Env, String, + Address, Env, String, Vec, }; #[contracterror] @@ -15,6 +15,9 @@ pub enum Error { Overflow = 5, } +/// Maximum number of checkpoints retained per holder. +const MAX_CHECKPOINTS: u32 = 50; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { @@ -24,6 +27,16 @@ pub enum DataKey { Decimals, Balance(Address), TotalSupply, + /// Ordered list of checkpoints for a holder (oldest → newest). + Checkpoints(Address), +} + +/// A single voting-weight snapshot recorded at a given ledger sequence. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Checkpoint { + pub ledger: u32, + pub balance: i128, } // ── Events ──────────────────────────────────────────────────────── @@ -62,6 +75,39 @@ pub struct TokenTransferred { #[contract] pub struct GovernanceToken; +// ── Internal helpers ────────────────────────────────────────────── +fn write_checkpoint(env: &Env, holder: &Address, new_balance: i128) { + let key = DataKey::Checkpoints(holder.clone()); + let mut history: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + + let cp = Checkpoint { + ledger: env.ledger().sequence(), + balance: new_balance, + }; + + // Overwrite if the last entry is from the same ledger (idempotent within a tx). + if let Some(last) = history.last() { + if last.ledger == cp.ledger { + let last_idx = history.len() - 1; + history.set(last_idx, cp); + env.storage().persistent().set(&key, &history); + return; + } + } + + // Evict oldest entry when the cap is reached. + if history.len() >= MAX_CHECKPOINTS { + history = history.slice(1..history.len()); + } + + history.push_back(cp); + env.storage().persistent().set(&key, &history); +} + #[contractimpl] impl GovernanceToken { /// Initializes the contract with the admin address and token setup. @@ -109,6 +155,7 @@ impl GovernanceToken { let balance = Self::balance(env.clone(), to.clone()); let new_balance = balance.checked_add(amount).ok_or(Error::Overflow)?; env.storage().persistent().set(&DataKey::Balance(to.clone()), &new_balance); + write_checkpoint(&env, &to, new_balance); let total_supply = Self::total_supply(env.clone()); let new_total_supply = total_supply.checked_add(amount).ok_or(Error::Overflow)?; @@ -135,6 +182,7 @@ impl GovernanceToken { let new_balance = balance.checked_sub(amount).ok_or(Error::Overflow)?; env.storage().persistent().set(&DataKey::Balance(from.clone()), &new_balance); + write_checkpoint(&env, &from, new_balance); let total_supply = Self::total_supply(env.clone()); let new_total_supply = total_supply.checked_sub(amount).ok_or(Error::Overflow)?; @@ -158,10 +206,12 @@ impl GovernanceToken { let new_balance_from = balance_from.checked_sub(amount).ok_or(Error::Overflow)?; env.storage().persistent().set(&DataKey::Balance(from.clone()), &new_balance_from); + write_checkpoint(&env, &from, new_balance_from); let balance_to = Self::balance(env.clone(), to.clone()); let new_balance_to = balance_to.checked_add(amount).ok_or(Error::Overflow)?; env.storage().persistent().set(&DataKey::Balance(to.clone()), &new_balance_to); + write_checkpoint(&env, &to, new_balance_to); TokenTransferred { from, to, amount }.publish(&env); Ok(()) @@ -186,13 +236,99 @@ impl GovernanceToken { pub fn decimals(env: Env) -> u32 { env.storage().instance().get(&DataKey::Decimals).unwrap() } + + // ── Checkpoint accessors ────────────────────────────────────── + + /// Returns the most recent checkpoint for `holder`. + /// Returns `None` when the holder has no recorded history. + pub fn latest_checkpoint(env: Env, holder: Address) -> Option { + let history: Vec = env + .storage() + .persistent() + .get(&DataKey::Checkpoints(holder)) + .unwrap_or_else(|| Vec::new(&env)); + history.last() + } + + /// Returns up to `limit` most-recent checkpoints for `holder`, ordered + /// oldest-first within the returned slice. `limit` is capped at + /// `MAX_CHECKPOINTS`. Returns an empty vec for unknown holders. + pub fn checkpoint_history(env: Env, holder: Address, limit: u32) -> Vec { + let history: Vec = env + .storage() + .persistent() + .get(&DataKey::Checkpoints(holder)) + .unwrap_or_else(|| Vec::new(&env)); + + let cap = limit.min(MAX_CHECKPOINTS) as usize; + let len = history.len() as usize; + if cap == 0 || len == 0 { + return Vec::new(&env); + } + + let start = if len > cap { len - cap } else { 0 }; + let mut result: Vec = Vec::new(&env); + for i in start..len { + result.push_back(history.get(i as u32).unwrap()); + } + result + } + + /// Returns the most recent checkpoint at or before `ledger` for `holder`. + /// Enables snapshot-based vote weighting: callers pass a proposal's + /// `start_ledger` to get the holder's balance at that point in time. + /// Returns `None` for unknown holders or if no checkpoint precedes `ledger`. + pub fn checkpoint_at_ledger(env: Env, holder: Address, ledger: u32) -> Option { + let history: Vec = env + .storage() + .persistent() + .get(&DataKey::Checkpoints(holder)) + .unwrap_or_else(|| Vec::new(&env)); + + // Binary search for the rightmost checkpoint with cp.ledger <= requested ledger. + let len = history.len(); + if len == 0 { + return None; + } + let mut lo: u32 = 0; + let mut hi: u32 = len; // exclusive + while lo < hi { + let mid = lo + (hi - lo) / 2; + if history.get(mid).unwrap().ledger <= ledger { + lo = mid + 1; + } else { + hi = mid; + } + } + // lo is now the first index with cp.ledger > ledger; lo-1 is our answer. + if lo == 0 { + None + } else { + Some(history.get(lo - 1).unwrap()) + } + } } #[cfg(test)] mod test { use super::*; - use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke}; - use soroban_sdk::{IntoVal}; + use soroban_sdk::testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}; + use soroban_sdk::IntoVal; + + fn setup() -> (Env, Address, soroban_sdk::Address, GovernanceTokenClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(GovernanceToken, ()); + let client = GovernanceTokenClient::new(&env, &contract_id); + client.init( + &admin, + &String::from_str(&env, "StellarCade Governance"), + &String::from_str(&env, "SCG"), + &18, + ); + (env, admin, contract_id, client) + } #[test] fn test_token_flow() { @@ -244,18 +380,155 @@ mod test { ); // Use mock_auths to simulate authorization from malicious address - client.mock_auths(&[ - MockAuth { - address: &malicious, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "mint", - args: (user.clone(), 1000i128).into_val(&env), - sub_invokes: &[], - }, + client.mock_auths(&[MockAuth { + address: &malicious, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "mint", + args: (user.clone(), 1000i128).into_val(&env), + sub_invokes: &[], }, - ]); + }]); client.mint(&user, &1000); } + + // ── Checkpoint tests ────────────────────────────────────────── + + #[test] + fn test_latest_checkpoint_after_mint() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + client.mint(&user, &500); + + let cp = client.latest_checkpoint(&user).unwrap(); + assert_eq!(cp.balance, 500); + } + + #[test] + fn test_latest_checkpoint_missing_holder_returns_none() { + let (env, _admin, _cid, client) = setup(); + let unknown = Address::generate(&env); + assert!(client.latest_checkpoint(&unknown).is_none()); + } + + #[test] + fn test_checkpoint_history_bounded() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + // Mint once — one checkpoint recorded. + client.mint(&user, &100); + + // Burn some — second checkpoint (same ledger → overwrites). + client.burn(&user, &40); + + // history with limit=1 should return only the latest. + let hist = client.checkpoint_history(&user, &1); + assert_eq!(hist.len(), 1); + assert_eq!(hist.get(0).unwrap().balance, 60); + } + + #[test] + fn test_checkpoint_history_missing_holder_returns_empty() { + let (env, _admin, _cid, client) = setup(); + let unknown = Address::generate(&env); + let hist = client.checkpoint_history(&unknown, &10); + assert_eq!(hist.len(), 0); + } + + #[test] + fn test_checkpoint_recorded_on_transfer() { + let (env, _admin, _cid, client) = setup(); + let sender = Address::generate(&env); + let receiver = Address::generate(&env); + + client.mint(&sender, &1000); + client.transfer(&sender, &receiver, &300); + + let sender_cp = client.latest_checkpoint(&sender).unwrap(); + assert_eq!(sender_cp.balance, 700); + + let receiver_cp = client.latest_checkpoint(&receiver).unwrap(); + assert_eq!(receiver_cp.balance, 300); + } + + #[test] + fn test_checkpoint_eviction_and_ordering() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + // Write MAX_CHECKPOINTS + 1 checkpoints across distinct ledgers. + for i in 0..=MAX_CHECKPOINTS { + env.ledger().with_mut(|li| li.sequence_number = i + 1); + client.mint(&user, &1); + } + + // History should be capped at MAX_CHECKPOINTS. + let hist = client.checkpoint_history(&user, &MAX_CHECKPOINTS); + assert_eq!(hist.len(), MAX_CHECKPOINTS); + + // Entries must be oldest-first (ascending ledger). + for i in 0..(hist.len() - 1) { + assert!(hist.get(i).unwrap().ledger < hist.get(i + 1).unwrap().ledger); + } + + // The oldest checkpoint (ledger 1) must have been evicted. + assert!(hist.get(0).unwrap().ledger > 1); + + // latest_checkpoint reflects the final cumulative balance. + let latest = client.latest_checkpoint(&user).unwrap(); + assert_eq!(latest.balance, (MAX_CHECKPOINTS as i128) + 1); + } + + #[test] + fn test_checkpoint_at_ledger_exact_match() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.sequence_number = 10); + client.mint(&user, &100); + env.ledger().with_mut(|li| li.sequence_number = 20); + client.mint(&user, &50); + + let cp = client.checkpoint_at_ledger(&user, &10).unwrap(); + assert_eq!(cp.ledger, 10); + assert_eq!(cp.balance, 100); + } + + #[test] + fn test_checkpoint_at_ledger_between_checkpoints() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.sequence_number = 5); + client.mint(&user, &200); + env.ledger().with_mut(|li| li.sequence_number = 15); + client.mint(&user, &100); + + // Ledger 10 is between 5 and 15 — should return the checkpoint at 5. + let cp = client.checkpoint_at_ledger(&user, &10).unwrap(); + assert_eq!(cp.ledger, 5); + assert_eq!(cp.balance, 200); + } + + #[test] + fn test_checkpoint_at_ledger_before_all_checkpoints_returns_none() { + let (env, _admin, _cid, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.sequence_number = 10); + client.mint(&user, &100); + + // Ledger 5 precedes the first checkpoint at ledger 10. + assert!(client.checkpoint_at_ledger(&user, &5).is_none()); + } + + #[test] + fn test_checkpoint_at_ledger_unknown_holder_returns_none() { + let (env, _admin, _cid, client) = setup(); + let unknown = Address::generate(&env); + assert!(client.checkpoint_at_ledger(&unknown, &100).is_none()); + } } diff --git a/docs/contracts/governance-token.md b/docs/contracts/governance-token.md index 0670b30..f9099cd 100644 --- a/docs/contracts/governance-token.md +++ b/docs/contracts/governance-token.md @@ -157,3 +157,76 @@ pub fn decimals(env: Env) -> u32 `u32` +### `latest_checkpoint` +Returns the most recent voting checkpoint for `holder`. Returns `None` when the holder has no recorded history. + +```rust +pub fn latest_checkpoint(env: Env, holder: Address) -> Option +``` + +#### Parameters + +| Name | Type | +|------|------| +| `env` | `Env` | +| `holder` | `Address` | + +#### Return Type + +`Option` + +### `checkpoint_history` +Returns up to `limit` most-recent checkpoints for `holder`, ordered oldest-first. `limit` is capped at 50. Returns an empty vec for unknown holders. + +```rust +pub fn checkpoint_history(env: Env, holder: Address, limit: u32) -> Vec +``` + +#### Parameters + +| Name | Type | +|------|------| +| `env` | `Env` | +| `holder` | `Address` | +| `limit` | `u32` | + +#### Return Type + +`Vec` + +### `checkpoint_at_ledger` +Returns the most recent checkpoint at or before `ledger` for `holder`. Intended for snapshot-based vote weighting — pass a proposal's `start_ledger` to get the holder's balance at that point in time. Returns `None` for unknown holders or if no checkpoint precedes the requested ledger. + +```rust +pub fn checkpoint_at_ledger(env: Env, holder: Address, ledger: u32) -> Option +``` + +#### Parameters + +| Name | Type | +|------|------| +| `env` | `Env` | +| `holder` | `Address` | +| `ledger` | `u32` | + +#### Return Type + +`Option` + +## Checkpoint Type + +```rust +pub struct Checkpoint { + pub ledger: u32, // Ledger sequence at which the snapshot was taken + pub balance: i128, // Holder balance at that ledger +} +``` + +## Checkpoint Ordering & Retention + +- A `Checkpoint { ledger, balance }` is written for the affected holder(s) on every `mint`, `burn`, and `transfer` operation. +- Checkpoints are stored per holder in ascending ledger-sequence order (oldest → newest). +- Multiple balance changes within the same ledger overwrite the single entry for that ledger rather than creating duplicates. +- A maximum of 50 checkpoints are retained per holder; the oldest entry is evicted when the cap is reached. +- Unknown holders return `None` from `latest_checkpoint` and an empty list from `checkpoint_history` — never an ambiguous zero state. +