Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions contracts/governance-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,26 @@ Returns the current total supply of tokens.
### `balance_of(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 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`: Per-holder ordered list of `Checkpoint` entries (bounded to 50).

## Events

Expand Down
189 changes: 177 additions & 12 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,43 @@ 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 {
let mut trimmed: Vec<Checkpoint> = Vec::new(env);
for i in 1..history.len() {
trimmed.push_back(history.get(i).unwrap());
}
history = trimmed;
}

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 +159,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 +186,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 +210,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 +240,65 @@ 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
}
}

#[cfg(test)]
mod test {
use super::*;
use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke};
use soroban_sdk::{IntoVal};
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 +350,77 @@ 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);
}
}
53 changes: 53 additions & 0 deletions docs/contracts/governance-token.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,56 @@ 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<Checkpoint>
```

#### Parameters

| Name | Type |
|------|------|
| `env` | `Env` |
| `holder` | `Address` |

#### Return Type

`Option<Checkpoint>`

### `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<Checkpoint>
```

#### Parameters

| Name | Type |
|------|------|
| `env` | `Env` |
| `holder` | `Address` |
| `limit` | `u32` |

#### Return Type

`Vec<Checkpoint>`

## 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

- 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.
- A maximum of 50 checkpoints are retained per holder; the oldest is evicted when the cap is reached.
- Unknown holders return `None` (latest) or an empty list (history) — never an ambiguous zero state.