From 8a4edd8728d3c15f90e1f8aaf748f107eef4dc3c Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Tue, 7 Apr 2026 15:57:32 +0100 Subject: [PATCH 1/4] feat(governance-token): add voting checkpoint history and latest checkpoint accessor Closes #466 - Add Checkpoint { ledger, balance } contracttype - Write checkpoint on every mint, burn, and transfer - latest_checkpoint(holder) -> Option: returns most recent snapshot or None for unknown holders - checkpoint_history(holder, limit) -> Vec: returns up to limit (capped at 50) most-recent checkpoints oldest-first; empty vec for unknown holders - Same-ledger writes overwrite rather than duplicate; oldest entry evicted when cap (50) is reached - Remove unused stellarcade-shared dependency from Cargo.toml - Add tests: latest checkpoint after mint, missing holder returns None/empty, bounded history, checkpoint on transfer - Update contracts/governance-token/README.md and docs/contracts/governance-token.md --- contracts/governance-token/Cargo.toml | 1 - contracts/governance-token/README.md | 15 ++ contracts/governance-token/src/lib.rs | 189 ++++++++++++++++++++++++-- docs/contracts/governance-token.md | 53 ++++++++ 4 files changed, 245 insertions(+), 13 deletions(-) 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..53d9aea 100644 --- a/contracts/governance-token/README.md +++ b/contracts/governance-token/README.md @@ -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` +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 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 diff --git a/contracts/governance-token/src/lib.rs b/contracts/governance-token/src/lib.rs index 8eea070..06fcb4d 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,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 = 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 = 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. @@ -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)?; @@ -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)?; @@ -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(()) @@ -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 { + 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 + } } #[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() { @@ -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); + } } diff --git a/docs/contracts/governance-token.md b/docs/contracts/governance-token.md index 0670b30..20bd7cf 100644 --- a/docs/contracts/governance-token.md +++ b/docs/contracts/governance-token.md @@ -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 +``` + +#### 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 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. + From 68a224084e45060ace5362d320aef78cbcd6841f Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Tue, 7 Apr 2026 16:27:43 +0100 Subject: [PATCH 2/4] fix(governance-token): address coderabbitai review comments - README: fix stale init signature (token_config -> name/symbol/decimals) and balance_of -> balance - docs: clarify checkpoint write triggers (mint/burn/transfer) in Ordering & Retention section - tests: add test_checkpoint_eviction_and_ordering to cover oldest-first ordering and head eviction past MAX_CHECKPOINTS across distinct ledgers --- contracts/governance-token/README.md | 6 +++--- contracts/governance-token/src/lib.rs | 30 ++++++++++++++++++++++++++- docs/contracts/governance-token.md | 7 ++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/contracts/governance-token/README.md b/contracts/governance-token/README.md index 53d9aea..5c60fb7 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,7 +19,7 @@ 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` diff --git a/contracts/governance-token/src/lib.rs b/contracts/governance-token/src/lib.rs index 06fcb4d..4443088 100644 --- a/contracts/governance-token/src/lib.rs +++ b/contracts/governance-token/src/lib.rs @@ -282,7 +282,7 @@ impl GovernanceToken { #[cfg(test)] mod test { use super::*; - use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke}; + use soroban_sdk::testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}; use soroban_sdk::IntoVal; fn setup() -> (Env, Address, soroban_sdk::Address, GovernanceTokenClient<'static>) { @@ -423,4 +423,32 @@ mod test { 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); + } } diff --git a/docs/contracts/governance-token.md b/docs/contracts/governance-token.md index 20bd7cf..ae94185 100644 --- a/docs/contracts/governance-token.md +++ b/docs/contracts/governance-token.md @@ -205,8 +205,9 @@ pub struct Checkpoint { ## 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. -- 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. +- 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. From 3f8cae3cf47b97561dc935120405098133cf61d2 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Tue, 7 Apr 2026 16:43:43 +0100 Subject: [PATCH 3/4] fix(governance-token): address second round of coderabbitai review comments - README: clarify storage key as Checkpoints(Address) (per-holder scoped) - lib.rs: replace manual eviction loop with history.slice(1..history.len()) - lib.rs: add checkpoint_at_ledger(holder, ledger) -> Option for snapshot-based vote weighting - docs: document checkpoint_at_ledger method signature and semantics --- contracts/governance-token/README.md | 5 ++++- contracts/governance-token/src/lib.rs | 30 ++++++++++++++++++++++----- docs/contracts/governance-token.md | 19 +++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/contracts/governance-token/README.md b/contracts/governance-token/README.md index 5c60fb7..0914425 100644 --- a/contracts/governance-token/README.md +++ b/contracts/governance-token/README.md @@ -28,6 +28,9 @@ Returns the most recent voting checkpoint for `holder`, or `None` if the holder ### `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). @@ -41,7 +44,7 @@ Returns up to `limit` most-recent checkpoints for `holder`, ordered oldest-first - `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). +- `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 4443088..058332b 100644 --- a/contracts/governance-token/src/lib.rs +++ b/contracts/governance-token/src/lib.rs @@ -101,11 +101,7 @@ fn write_checkpoint(env: &Env, holder: &Address, new_balance: i128) { // Evict oldest entry when the cap is reached. if history.len() >= MAX_CHECKPOINTS { - let mut trimmed: Vec = Vec::new(env); - for i in 1..history.len() { - trimmed.push_back(history.get(i).unwrap()); - } - history = trimmed; + history = history.slice(1..history.len()); } history.push_back(cp); @@ -277,6 +273,30 @@ impl GovernanceToken { } 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)); + + // Walk backwards to find the latest checkpoint whose ledger <= requested. + let mut result: Option = 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)] diff --git a/docs/contracts/governance-token.md b/docs/contracts/governance-token.md index ae94185..f9099cd 100644 --- a/docs/contracts/governance-token.md +++ b/docs/contracts/governance-token.md @@ -194,6 +194,25 @@ pub fn checkpoint_history(env: Env, holder: Address, limit: u32) -> 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 From b9b0ae53d3db7cb9c1b027ae47dffae421c9f107 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Tue, 7 Apr 2026 17:42:06 +0100 Subject: [PATCH 4/4] fix(governance-token): binary search in checkpoint_at_ledger + add tests - Replace linear scan with binary search (O(log n)) in checkpoint_at_ledger - Add test_checkpoint_at_ledger_exact_match - Add test_checkpoint_at_ledger_between_checkpoints - Add test_checkpoint_at_ledger_before_all_checkpoints_returns_none - Add test_checkpoint_at_ledger_unknown_holder_returns_none --- contracts/governance-token/src/lib.rs | 76 ++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/contracts/governance-token/src/lib.rs b/contracts/governance-token/src/lib.rs index 058332b..4de912a 100644 --- a/contracts/governance-token/src/lib.rs +++ b/contracts/governance-token/src/lib.rs @@ -285,17 +285,27 @@ impl GovernanceToken { .get(&DataKey::Checkpoints(holder)) .unwrap_or_else(|| Vec::new(&env)); - // Walk backwards to find the latest checkpoint whose ledger <= requested. - let mut result: Option = None; - for i in 0..history.len() { - let cp = history.get(i).unwrap(); - if cp.ledger <= ledger { - result = Some(cp); + // 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 { - break; + hi = mid; } } - result + // 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()) + } } } @@ -471,4 +481,54 @@ mod test { 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()); + } }