Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,11 @@ Together with the explicit pre-/post-conditions above, these tests help auditors

**Invariant**: For every reachable state of [`CalloraSettlement`](contracts/settlement/src/lib.rs#L45), every credited developer balance stored under [`DEVELOPER_BALANCES_KEY`](contracts/settlement/src/lib.rs#L42) is always **greater than or equal to 0**.

- **Storage field**: `Map<Address, i128>` stored at `DEVELOPER_BALANCES_KEY`
- **Storage field**: per-developer persistent storage entries keyed by `StorageKey::DeveloperBalance(Address)`
- **Accessors**:
- [`get_developer_balance(env: Env, developer: Address) -> i128`](contracts/settlement/src/lib.rs#L163)
- [`get_all_developer_balances(env: Env) -> Vec<DeveloperBalance>`](contracts/settlement/src/lib.rs#L172)
- [`get_all_developer_balances(env: Env, caller: Address) -> Result<Vec<DeveloperBalance>, SettlementError>`](contracts/settlement/src/lib.rs#L493)
- [`get_developer_balances_page(env: Env, caller: Address, start: u32, limit: u32) -> Result<Vec<DeveloperBalance>, SettlementError>`](contracts/settlement/src/lib.rs#L531)
- **Guarantee**: Any developer balance returned by these accessors is **never negative**.

This document lists all functions that can change credited developer balances and the pre-/post-conditions that preserve this invariant.
Expand Down
50 changes: 47 additions & 3 deletions SETTLEMENT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ pub struct BalanceCreditedEvent {
3. **Query Functions**
- `get_admin()`, `get_vault()`, `get_global_pool()`
- `get_developer_balance(developer)`
- `get_all_developer_balances()` (admin only)
- `get_all_developer_balances()` (admin only, safe only for <=100 developers)
- `get_developer_balances_page(start, limit)` (admin only, paginated)

4. **Admin Functions**
- `set_admin()` (admin only)
Expand Down Expand Up @@ -701,6 +702,9 @@ result
let index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
if index.len() > 100 {
return Err(SettlementError::GasExhaustionRisk);
}
let mut result = Vec::new(&env);
for address in index.iter() {
let balance = env
Expand All @@ -713,7 +717,46 @@ for address in index.iter() {
balance,
});
}
result
Ok(result)
```

#### New paginated query `get_developer_balances_page`

```rust
pub fn get_developer_balances_page(
env: Env,
caller: Address,
start: u32,
limit: u32,
) -> Result<Vec<DeveloperBalance>, SettlementError> {
let inst = env.storage().instance();
let index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
let end = start
.saturating_add(limit.min(50))
.min(index.len());
let mut result = Vec::new(&env);
let mut cursor = 0;
for address in index.iter() {
if cursor >= start && cursor < end {
let balance = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(address.clone()))
.unwrap_or(0);
result.push_back(DeveloperBalance {
address: address.clone(),
balance,
});
}
if cursor >= end {
break;
}
cursor += 1;
}
Ok(result)
}
```

#### Changes to `init`
Expand All @@ -735,7 +778,8 @@ inst.set(&StorageKey::DeveloperIndex, &empty_index);
#### Breaking Changes
- **Contract Upgrade Required**: This is a storage-level migration that requires a contract upgrade
- **Data Migration**: Existing developer balances in the old `Map<Address, i128>` format need to be migrated to the new persistent storage format
- **API Compatibility**: Public API remains unchanged (`receive_payment`, `get_developer_balance`, `get_all_developer_balances`)
- **API Compatibility**: `receive_payment` and `get_developer_balance` remain unchanged; `get_all_developer_balances` now returns an explicit `Result` and rejects full iteration once the developer index exceeds 100 entries
- **New Safe Query**: `get_developer_balances_page(start, limit)` is added for paginated admin reads and is capped at 50 records per call

#### Performance Improvements
- **Developer Credit**: O(1) point read/write instead of O(n) map operations
Expand Down
61 changes: 58 additions & 3 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ impl CalloraSettlement {
/// - **Order guarantees**: Based on insertion order (first credit = first in index)
///
/// # Returns
/// Vec of DeveloperBalance records. Iteration order is based on index insertion order.
/// Result containing a Vec of DeveloperBalance records or a gas exhaustion error.
/// Iteration order is based on index insertion order.
///
/// # Use Cases
/// ✅ Administrative dashboards and reporting
Expand All @@ -508,7 +509,10 @@ impl CalloraSettlement {
/// - 50 developers: ~500 gas
/// - 100 developers: ~1,000 gas
/// - 500 developers: ~5,000 gas (consider off-chain indexing)
pub fn get_all_developer_balances(env: Env, caller: Address) -> Vec<DeveloperBalance> {
pub fn get_all_developer_balances(
env: Env,
caller: Address,
) -> Result<Vec<DeveloperBalance>, SettlementError> {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
Expand All @@ -532,7 +536,58 @@ impl CalloraSettlement {
balance,
});
}
result
Ok(result)
}

/// Get a paginated slice of developer balances (admin only).
///
/// This method avoids expensive full-index iteration by returning
/// a bounded window of developer balance records. Use it for
/// admin dashboards and off-chain pagination.
pub fn get_developer_balances_page(
env: Env,
caller: Address,
start: u32,
limit: u32,
) -> Result<Vec<DeveloperBalance>, SettlementError> {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("unauthorized: caller is not admin");
}

let inst = env.storage().instance();
let index: Vec<Address> = inst
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));

if limit == 0 || start >= index.len() {
return Ok(Vec::new(&env));
}

let end = start
.saturating_add(limit.min(MAX_DEVELOPER_BALANCES_PAGE_SIZE))
.min(index.len());
let mut result = Vec::new(&env);
let mut cursor = 0;
for address in index.iter() {
if cursor >= start && cursor < end {
let balance = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(address.clone()))
.unwrap_or(0);
result.push_back(DeveloperBalance {
address: address.clone(),
balance,
});
}
if cursor >= end {
break;
}
cursor += 1;
}
Ok(result)
}

/// Return the pending admin address, or `None` if no transfer is in progress.
Expand Down
77 changes: 71 additions & 6 deletions contracts/settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ mod settlement_tests {
assert_eq!(global_pool.total_balance, 0);
assert_eq!(global_pool.last_updated, 1_700_000_000);

let all_balances = client.get_all_developer_balances(&admin);
let all_balances = client.try_get_all_developer_balances(&admin).unwrap();
assert_eq!(all_balances.len(), 0);
assert_eq!(client.get_developer_balance(&developer), 0);
}
Expand Down Expand Up @@ -187,7 +187,7 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);

let all = client.get_all_developer_balances(&admin);
let all = client.try_get_all_developer_balances(&admin).unwrap();
assert_eq!(all.len(), 0);
}

Expand Down Expand Up @@ -372,7 +372,7 @@ mod settlement_tests {
client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()));
client.receive_payment(&vault, &150i128, &false, &Some(dev1.clone()));

let all = client.get_all_developer_balances(&admin);
let all = client.try_get_all_developer_balances(&admin).unwrap();
assert_eq!(all.len(), 2);
let mut dev1_seen = false;
let mut dev2_seen = false;
Expand Down Expand Up @@ -401,10 +401,75 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);

let all = client.get_all_developer_balances(&admin);
let all = client.try_get_all_developer_balances(&admin).unwrap();
assert_eq!(all.len(), 0);
}

#[test]
fn test_get_developer_balances_page() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let dev1 = Address::generate(&env);
let dev2 = Address::generate(&env);
let dev3 = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);

client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()));
client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()));
client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone()));

let page = client
.try_get_developer_balances_page(&admin, &1u32, &2u32)
.unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page.get(0).unwrap().address, dev2);
assert_eq!(page.get(1).unwrap().address, dev3);
}

#[test]
fn test_get_developer_balances_page_respects_limit_cap() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);

for _ in 0..51 {
let developer = Address::generate(&env);
client.receive_payment(&vault, &1i128, &false, &Some(developer));
}

let page = client
.try_get_developer_balances_page(&admin, &0u32, &100u32)
.unwrap();
assert_eq!(page.len(), 50);
}

#[test]
fn test_get_all_developer_balances_rejects_large_index() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);

for _ in 0..101 {
let developer = Address::generate(&env);
client.receive_payment(&vault, &1i128, &false, &Some(developer));
}

let result = client.try_get_all_developer_balances(&admin);
assert_eq!(result, Err(crate::SettlementError::GasExhaustionRisk));
}

#[test]
fn test_set_admin_two_step() {
let env = Env::default();
Expand Down Expand Up @@ -1167,7 +1232,7 @@ mod settlement_tests {
assert_eq!(client.get_developer_balance(&developer), 500i128);

// Admin can still view all balances
let all_balances = client.get_all_developer_balances(&new_admin);
let all_balances = client.try_get_all_developer_balances(&new_admin).unwrap();
assert_eq!(all_balances.len(), 1);
assert_eq!(all_balances.get(0).unwrap().balance, 500i128);
}
Expand Down Expand Up @@ -1390,7 +1455,7 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);

// Admin can call
client.get_all_developer_balances(&admin);
client.try_get_all_developer_balances(&admin).unwrap();

// Vault cannot call
let result = client.try_get_all_developer_balances(&vault);
Expand Down
Binary file added rustup-init.exe
Binary file not shown.
Loading