Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion contracts/governance-token/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
24 changes: 21 additions & 3 deletions contracts/governance-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Checkpoint>`
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<Checkpoint>`
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<Checkpoint>`
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

Expand Down
239 changes: 226 additions & 13 deletions contracts/governance-token/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![no_std]
use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype,
Address, Env, String,
Address, Env, String, Vec,
};

#[contracterror]
Expand All @@ -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 {
Expand All @@ -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 ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<Checkpoint> = 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.
Expand Down Expand Up @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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(())
Expand All @@ -186,13 +236,89 @@ 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<Checkpoint> {
let history: Vec<Checkpoint> = 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<Checkpoint> {
let history: Vec<Checkpoint> = 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<Checkpoint> = 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<Checkpoint> {
let history: Vec<Checkpoint> = env
.storage()
.persistent()
.get(&DataKey::Checkpoints(holder))
.unwrap_or_else(|| Vec::new(&env));

// Walk backwards to find the latest checkpoint whose ledger <= requested.
let mut result: Option<Checkpoint> = None;
for i in 0..history.len() {
let cp = history.get(i).unwrap();
if cp.ledger <= ledger {
result = Some(cp);
} else {
break;
}
}
result
}
}

#[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() {
Expand Down Expand Up @@ -244,18 +370,105 @@ 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);
}
}
Loading