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
23 changes: 23 additions & 0 deletions contracts/settlement/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,26 @@ The invariant would be violated if:
2. The global pool balance is modified without a corresponding payment.
3. Arithmetic overflow occurs and is not caught (prevented by `checked_add`).
4. Storage corruption or unauthorized direct storage modification occurs.

# Daily Withdraw Cap Invariant

A developer's cumulative withdrawals within a single UTC day (defined as `ledger_timestamp / 86400`) must never exceed their configured `DailyWithdrawCap`, unless the cap is `0` (unlimited).

## When It Holds

The invariant is enforced during `withdraw_developer_balance`:
- Before any state mutation, the function reads the developer's `DailyWithdrawCap` and `WithdrawalToday` accumulator.
- If `cap > 0` and `amount + accumulator > cap`, the call fails with `DailyWithdrawCapExceeded`.
- The accumulator auto-resets when `current_day != stored_day`.
- After a successful withdrawal, `WithdrawalToday.amount` is incremented by `amount` and `WithdrawalToday.day` is set to the current epoch day.

## Default Behavior

- A cap of `0` (the default) means unlimited — no daily limit is enforced.
- Caps are set per-developer by the admin via `set_daily_withdraw_cap` and emit a `daily_withdraw_cap_changed` event.

## Guarantees

- **No stale window data**: the day field is always compared against the current ledger timestamp on every write.
- **Per-developer isolation**: cap and accumulator are scoped to individual developer addresses.
- **Admin-only configuration**: only the current admin can modify caps, enforced by `require_auth`.
115 changes: 115 additions & 0 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100;
/// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount |
/// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow |
/// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC |
/// | 13 | DailyWithdrawCapExceeded | Developer's daily withdrawal cap would be exceeded|
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
Expand All @@ -44,6 +45,7 @@ pub enum SettlementError {
InsufficientDeveloperBalance = 10,
DeveloperBalanceUnderflow = 11,
InsufficientContractBalance = 12,
DailyWithdrawCapExceeded = 13,
}

/// Persistent storage keys for settlement contract
Expand All @@ -58,6 +60,8 @@ pub enum StorageKey {
DeveloperBalance(Address),
GlobalPool,
Usdc,
DailyWithdrawCap(Address),
WithdrawalToday(Address),
}

/// Developer balance record in settlement contract
Expand All @@ -83,6 +87,17 @@ pub struct GlobalPool {
pub last_updated: u64,
}

/// Tracks a developer's cumulative withdrawal amount for a given epoch day.
///
/// `day` is `timestamp / 86400` (UTC epoch day). When the current call's day
/// differs from the stored day the accumulator is silently reset.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct DailyWithdrawState {
pub day: u64,
pub amount: i128,
}

/// Payment received event
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -128,6 +143,14 @@ pub struct DeveloperWithdrawEvent {
pub remaining_balance: i128,
}

/// Emitted when the admin sets or changes a developer's daily withdrawal cap.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct DailyWithdrawCapChanged {
pub developer: Address,
pub new_cap: i128,
}


#[contract]
pub struct CalloraSettlement;
Expand Down Expand Up @@ -458,6 +481,27 @@ impl CalloraSettlement {
return Err(SettlementError::InsufficientDeveloperBalance);
}

let cap: i128 = env
.storage()
.persistent()
.get(&StorageKey::DailyWithdrawCap(developer.clone()))
.unwrap_or(0);
if cap > 0 {
let today = env.ledger().timestamp() / 86400;
let mut daily = env
.storage()
.persistent()
.get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone()))
.unwrap_or(DailyWithdrawState { day: today, amount: 0 });
if daily.day != today {
daily.day = today;
daily.amount = 0;
}
if daily.amount.checked_add(amount).is_none_or(|sum| sum > cap) {
return Err(SettlementError::DailyWithdrawCapExceeded);
}
}

let new_balance = current_balance
.checked_sub(amount)
.ok_or(SettlementError::DeveloperBalanceUnderflow)?;
Expand All @@ -479,6 +523,25 @@ impl CalloraSettlement {
.persistent()
.extend_ttl(&StorageKey::DeveloperBalance(developer.clone()), 50000, 50000);

// Update daily withdrawal accumulator
let today = env.ledger().timestamp() / 86400;
let mut daily = env
.storage()
.persistent()
.get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone()))
.unwrap_or(DailyWithdrawState { day: today, amount: 0 });
if daily.day != today {
daily.day = today;
daily.amount = 0;
}
daily.amount = daily.amount.saturating_add(amount);
env.storage()
.persistent()
.set(&StorageKey::WithdrawalToday(developer.clone()), &daily);
env.storage()
.persistent()
.extend_ttl(&StorageKey::WithdrawalToday(developer.clone()), 50000, 50000);

env.events().publish(
(Symbol::new(&env, "developer_withdraw"), developer.clone()),
DeveloperWithdrawEvent {
Expand All @@ -491,6 +554,58 @@ impl CalloraSettlement {
Ok(())
}

/// Set the daily withdrawal cap for a developer (admin only).
///
/// A cap of `0` means unlimited (no daily limit enforced).
///
/// # Access Control
/// Only the current admin can call this function.
///
/// # Events
/// Emits `daily_withdraw_cap_changed` with the developer and new cap.
pub fn set_daily_withdraw_cap(env: Env, caller: Address, developer: Address, cap: i128) {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
if caller != current_admin {
env.panic_with_error(SettlementError::Unauthorized);
}
env.storage()
.persistent()
.set(&StorageKey::DailyWithdrawCap(developer.clone()), &cap);
env.storage()
.persistent()
.extend_ttl(&StorageKey::DailyWithdrawCap(developer.clone()), 50000, 50000);

env.events().publish(
(Symbol::new(&env, "daily_withdraw_cap_changed"), caller),
DailyWithdrawCapChanged { developer, new_cap: cap },
);
}

/// Get the daily withdrawal cap for a developer.
///
/// Returns `0` if no cap has been set (meaning unlimited).
pub fn get_daily_withdraw_cap(env: Env, developer: Address) -> i128 {
env.storage()
.persistent()
.get(&StorageKey::DailyWithdrawCap(developer))
.unwrap_or(0)
}

/// Get the amount a developer has already withdrawn today.
///
/// Returns `0` if no withdrawal has been made today.
pub fn get_withdrawal_today(env: Env, developer: Address) -> i128 {
let state: Option<DailyWithdrawState> = env
.storage()
.persistent()
.get(&StorageKey::WithdrawalToday(developer));
match state {
Some(s) if s.day == env.ledger().timestamp() / 86400 => s.amount,
_ => 0,
}
}

/// Get all developer balances (admin only)
///
/// **CRITICAL**: Uses developer index for iteration; order is based on index insertion order.
Expand Down
Loading
Loading