From b87afae47a92373791ce0f4348baf49a40d013af Mon Sep 17 00:00:00 2001 From: mansur-codes Date: Thu, 30 Jul 2026 13:02:05 +0100 Subject: [PATCH] feat: price_registry write rate limit --- .kilo/kilo.jsonc | 4 + Cargo.lock | 11 +- contracts/settlement/src/errors.rs | 3 + contracts/settlement/src/lib.rs | 246 ++--------- contracts/settlement/src/price_registry.rs | 467 +++++++++++++++++++++ contracts/settlement/src/test.rs | 2 +- contracts/settlement/src/types.rs | 34 +- contracts/settlement/tests/auth_snap.rs | 13 +- contracts/settlement/tests/proptest.rs | 16 +- fix_lib.py | 77 ++++ 10 files changed, 626 insertions(+), 247 deletions(-) create mode 100644 .kilo/kilo.jsonc create mode 100644 contracts/settlement/src/price_registry.rs create mode 100644 fix_lib.py diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc new file mode 100644 index 00000000..06032919 --- /dev/null +++ b/.kilo/kilo.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://app.kilo.ai/config.json", + "snapshot": false +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a1cf6ce3..c14e3a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,15 +359,6 @@ dependencies = [ "soroban-sdk", ] -[[package]] -name = "callora-limits" -version = "0.0.1" -dependencies = [ - "callora-revenue-pool", - "callora-settlement", - "soroban-sdk", -] - [[package]] name = "callora-migrate" version = "0.1.0" @@ -2189,7 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", diff --git a/contracts/settlement/src/errors.rs b/contracts/settlement/src/errors.rs index 2cdc973e..f7a757de 100644 --- a/contracts/settlement/src/errors.rs +++ b/contracts/settlement/src/errors.rs @@ -40,6 +40,7 @@ use soroban_sdk::contracterror; /// | 30 | DeveloperFrozen | Developer is frozen and cannot withdraw | /// | 31 | DeveloperNotFrozen | Developer is not frozen; cannot unfreeze | /// | 32 | FreezeUnauthorized | Caller is not authorized to freeze/unfreeze | +/// | 33 | WriteRateLimitExceeded | Admin wrote prices too frequently | #[contracterror] #[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] @@ -79,4 +80,6 @@ pub enum SettlementError { DeveloperNotFrozen = 31, /// Caller is not authorized to freeze/unfreeze developers. FreezeUnauthorized = 32, + /// Admin attempted a price write before the minimum interval elapsed. + WriteRateLimitExceeded = 33, } diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index cda866a6..43e6d084 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -8,13 +8,12 @@ pub mod freeze; pub mod limits; pub mod migrate; pub mod pagination; +pub mod price_registry; pub mod replay_guard; pub mod timelock; mod types; -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec, -}; +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Symbol, Vec}; /// Maximum number of items allowed in a single `batch_receive_payment` call. pub const MAX_BATCH_SIZE: u32 = 50; @@ -22,212 +21,6 @@ pub const MAX_BATCH_SIZE: u32 = 50; /// Maximum number of developer balances returned per page in paginated queries. pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; -/// Typed errors for the settlement contract. -/// -/// Using `#[contracterror]` encodes each variant as a stable `u32` code. -/// Callers and indexers can match on the code rather than parsing raw panic strings, -/// and the WASM binary shrinks because no error string literals are embedded. -/// -/// | Code | Variant | When | -/// |------|------------------------------|---------------------------------------------------| -/// | 1 | NotInitialized | A function is called before `init` | -/// | 2 | AlreadyInitialized | `init` is called more than once | -/// | 3 | Unauthorized | Caller is not the vault or admin | -/// | 4 | AmountNotPositive | `amount` is zero or negative | -/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied | -/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given | -/// | 7 | PoolOverflow | Global pool `i128` addition would overflow | -/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow | -/// | 9 | UsdcTokenNotConfigured | USDC token address not configured for withdrawals | -/// | 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| -/// | 14 | GasExhaustionRisk | Index too large for safe full scan; use pagination| -/// | 15 | ReasonTooLong | Reason Symbol exceeds maximum allowed length | -/// | 16 | InvalidClaimWindow | Claim window end is before start | -/// | 17 | ClaimWindowClosed | Claim attempted outside developer's claim window | -#[contracterror] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum SettlementError { - NotInitialized = 1, - AlreadyInitialized = 2, - Unauthorized = 3, - AmountNotPositive = 4, - DeveloperRequired = 5, - DeveloperMustBeNone = 6, - PoolOverflow = 7, - DeveloperOverflow = 8, - UsdcTokenNotConfigured = 9, - InsufficientDeveloperBalance = 10, - DeveloperBalanceUnderflow = 11, - InsufficientContractBalance = 12, - DailyWithdrawCapExceeded = 13, - GasExhaustionRisk = 14, - ReasonTooLong = 15, - InvalidClaimWindow = 16, - ClaimWindowClosed = 17, -} - -/// Persistent storage keys for settlement contract -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum StorageKey { - Admin, - Vault, - PendingAdmin, - PendingVault, - DeveloperIndex, - DeveloperBalance(Address), - GlobalPool, - Usdc, - DailyWithdrawCap(Address), - WithdrawalToday(Address), - DeveloperClaimWindow(Address), - ContractVersion, -} - -/// Developer balance record in settlement contract -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeveloperBalance { - pub address: Address, - pub balance: i128, -} - -/// Global pool balance tracking. -/// -/// `last_updated` is set to `env.ledger().timestamp()` on every -/// `receive_payment` call that credits the pool (`to_pool = true`). -/// It is also set at `init` time. It is **not** updated when payments -/// are routed to individual developer balances. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct GlobalPool { - pub total_balance: i128, - /// Ledger timestamp of the last pool credit. Useful for analytics - /// and staleness checks. - 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, -} - -/// Timestamp range during which a developer may claim accrued balance. -/// -/// `start_ts` and `end_ts` are ledger timestamps in seconds. The window is -/// inclusive on both ends: a withdrawal is allowed when -/// `start_ts <= env.ledger().timestamp() <= end_ts`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeveloperClaimWindow { - pub start_ts: u64, - pub end_ts: u64, -} - -/// Read-only preview of a developer claim/withdrawal. -/// -/// Returned by `simulate_claim` after running the same validation checks as -/// `withdraw_developer_balance`, without requiring auth, transferring tokens, -/// writing storage, or emitting events. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ClaimSimulation { - pub developer: Address, - pub amount: i128, - pub recipient: Address, - pub token: Address, - pub current_balance: i128, - pub remaining_balance: i128, - pub contract_balance: i128, - pub daily_withdraw_cap: i128, - pub withdrawn_today: i128, - pub withdrawn_today_after: i128, -} - -/// Payment received event -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PaymentReceivedEvent { - pub from_vault: Address, - pub amount: i128, - pub to_pool: bool, // true if credited to global pool, false if to specific developer - pub developer: Option
, // developer address if credited to specific developer -} - -/// Balance credited event -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BalanceCreditedEvent { - pub developer: Address, - pub amount: i128, - pub new_balance: i128, -} - -/// Emitted when a new vault address is proposed via `propose_vault()`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct VaultProposedEvent { - pub current_vault: Address, - pub proposed_vault: Address, -} - -/// Emitted when the proposed vault is accepted via `accept_vault()`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct VaultAcceptedEvent { - pub old_vault: Address, - pub new_vault: Address, - pub accepted_by: Address, -} - -/// Emitted when a developer withdraws their balance. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeveloperWithdrawEvent { - pub developer: Address, - pub amount: i128, - pub remaining_balance: i128, - pub to: Address, -} - -/// 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, -} - -/// Emitted when the admin sets or clears a developer claim window. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeveloperClaimWindowChanged { - pub developer: Address, - pub start_ts: u64, - pub end_ts: u64, - pub enabled: bool, -} - -/// Emitted when an admin force-credits a developer balance (escape hatch). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeveloperForceCreditedEvent { - pub developer: Address, - pub amount: i128, - pub reason: Symbol, - pub new_balance: i128, -} - pub use errors::SettlementError; pub use migrate::{STORAGE_VERSION_V1, STORAGE_VERSION_V2}; pub use timelock::{PendingDeveloperMigration, DEVELOPER_MIGRATION_TIMELOCK_SECONDS}; @@ -436,15 +229,6 @@ impl CalloraSettlement { amount, }, ); - events::emit_deposit( - &env, - &dev_address.clone(), - DepositEvent { - developer: dev_address, - token, - amount, - }, - ); } } @@ -859,10 +643,14 @@ impl CalloraSettlement { Self::require_claim_window_open(&env, &developer)?; + let usdc_address = Self::get_usdc_token(env.clone())?; let current_balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(developer.clone())) + .get(&StorageKey::DeveloperBalance( + developer.clone(), + usdc_address.clone(), + )) .unwrap_or(0); if amount > current_balance { return Err(SettlementError::InsufficientDeveloperBalance); @@ -893,7 +681,6 @@ impl CalloraSettlement { let remaining_balance = current_balance .checked_sub(amount) .ok_or(SettlementError::DeveloperBalanceUnderflow)?; - let usdc_address = Self::get_usdc_token(env.clone())?; let contract_balance = token::Client::new(&env, &usdc_address).balance(&contract_address); if contract_balance < amount { return Err(SettlementError::InsufficientContractBalance); @@ -1613,7 +1400,24 @@ impl CalloraSettlement { freeze::is_developer_frozen(env, developer) } - // ─── Internal helpers ─────────────────────────────────────────────────── + pub fn set_price( + env: Env, + caller: Address, + offering_id: soroban_sdk::String, + price: soroban_sdk::String, + ) { + price_registry::set_price(&env, caller, offering_id, price); + } + + pub fn remove_price(env: Env, caller: Address, offering_id: soroban_sdk::String) { + price_registry::remove_price(&env, caller, offering_id); + } + + pub fn get_price(env: Env, offering_id: soroban_sdk::String) -> Option { + price_registry::get_price(&env, offering_id) + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Internal helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /// Abort with `Unauthorized` unless `caller` is the registered vault or admin. fn require_authorized_caller(env: Env, caller: Address) { diff --git a/contracts/settlement/src/price_registry.rs b/contracts/settlement/src/price_registry.rs new file mode 100644 index 00000000..3ca49b06 --- /dev/null +++ b/contracts/settlement/src/price_registry.rs @@ -0,0 +1,467 @@ +//! Per-admin write rate-limited price registry for the settlement contract. +//! +//! This module provides a simple on-chain price registry where the admin can +//! set and remove prices for offering identifiers. Every write operation +//! (`set_price`, `remove_price`) is subject to a per-admin rate limit that +//! prevents any single admin from updating prices more frequently than +//! [`MIN_WRITE_INTERVAL`] ledgers. +//! +//! # Rate Limit +//! +//! The rate limit is enforced by tracking the last ledger sequence in which +//! each admin wrote to the registry. The storage key is scoped per admin +//! address (`StorageKey::PriceRegistryLastWrite(Address)`), so one admin's +//! rate limit does not affect another admin. +//! +//! | Constant | Value | Meaning | +//! |----------|-------|---------| +//! | `MIN_WRITE_INTERVAL` | 10 ledgers | Minimum ledgers between consecutive writes by the same admin | +//! +//! # Errors +//! +//! Returns [`crate::SettlementError::WriteRateLimitExceeded`] when an admin +//! attempts to write before the interval has elapsed. + +use soroban_sdk::{Address, Env, String}; + +use crate::{CalloraSettlement, SettlementError, StorageKey}; + +/// Minimum number of ledgers that must pass between consecutive price writes +/// by the same admin. +/// +/// On Stellar, one ledger corresponds to approximately 5 seconds, so +/// `MIN_WRITE_INTERVAL = 10` represents a ~50-second minimum interval. +pub const MIN_WRITE_INTERVAL: u32 = 10; + +/// Set the price for an offering. +/// +/// # Access Control +/// The caller must be the current admin and must authorize the call via +/// `require_auth`. +/// +/// # Rate Limiting +/// If the admin's previous write occurred fewer than [`MIN_WRITE_INTERVAL`] +/// ledgers ago, the function returns [`SettlementError::WriteRateLimitExceeded`]. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `caller` - Must be the admin; `caller.require_auth()` is invoked. +/// * `offering_id` - Identifier for the offering whose price is being set. +/// * `price` - Price value as a string. +/// +/// # Panics +/// * [`SettlementError::Unauthorized`] — caller is not the admin. +/// * [`SettlementError::WriteRateLimitExceeded`] — write interval not elapsed. +pub fn set_price(env: &Env, caller: Address, offering_id: String, price: String) { + caller.require_auth(); + let admin = + CalloraSettlement::get_admin(env.clone()).unwrap_or_else(|e| env.panic_with_error(e)); + if caller != admin { + env.panic_with_error(SettlementError::Unauthorized); + } + enforce_write_rate_limit(env, &caller); + let key = StorageKey::Price(offering_id); + env.storage().persistent().set(&key, &price); + update_last_write_ledger(env, &caller); +} + +/// Remove the price for an offering. +/// +/// # Access Control +/// The caller must be the current admin and must authorize the call via +/// `require_auth`. +/// +/// # Rate Limiting +/// If the admin's previous write occurred fewer than [`MIN_WRITE_INTERVAL`] +/// ledgers ago, the function returns [`SettlementError::WriteRateLimitExceeded`]. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `caller` - Must be the admin; `caller.require_auth()` is invoked. +/// * `offering_id` - Identifier for the offering whose price is being removed. +/// +/// # Panics +/// * [`SettlementError::Unauthorized`] — caller is not the admin. +/// * [`SettlementError::WriteRateLimitExceeded`] — write interval not elapsed. +pub fn remove_price(env: &Env, caller: Address, offering_id: String) { + caller.require_auth(); + let admin = + CalloraSettlement::get_admin(env.clone()).unwrap_or_else(|e| env.panic_with_error(e)); + if caller != admin { + env.panic_with_error(SettlementError::Unauthorized); + } + enforce_write_rate_limit(env, &caller); + let key = StorageKey::Price(offering_id); + env.storage().persistent().remove(&key); + update_last_write_ledger(env, &caller); +} + +/// Get the price for an offering. +/// +/// Returns `None` if no price has been set for the given offering ID. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `offering_id` - Identifier for the offering. +pub fn get_price(env: &Env, offering_id: String) -> Option { + let key = StorageKey::Price(offering_id); + env.storage().persistent().get(&key) +} + +/// Enforce the per-admin write rate limit. +/// +/// Reads the admin's last write ledger from persistent storage and compares +/// it against the current ledger sequence. If the difference is less than +/// [`MIN_WRITE_INTERVAL`], panics with [`SettlementError::WriteRateLimitExceeded`]. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `admin` - Admin address whose rate limit is being checked. +fn enforce_write_rate_limit(env: &Env, admin: &Address) { + let current_ledger = env.ledger().sequence(); + let last_write_key = StorageKey::PriceRegistryLastWrite(admin.clone()); + let last_write_ledger: u32 = env.storage().persistent().get(&last_write_key).unwrap_or(0); + if last_write_ledger == 0 { + return; + } + let elapsed = current_ledger.saturating_sub(last_write_ledger); + if elapsed < MIN_WRITE_INTERVAL { + env.panic_with_error(SettlementError::WriteRateLimitExceeded); + } +} + +/// Update the admin's last write ledger to the current ledger sequence. +/// +/// Writes the current ledger sequence to persistent storage under +/// [`StorageKey::PriceRegistryLastWrite(admin)`](StorageKey::PriceRegistryLastWrite) +/// and extends the TTL. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `admin` - Admin address whose last write ledger is being updated. +fn update_last_write_ledger(env: &Env, admin: &Address) { + let current_ledger = env.ledger().sequence(); + let key = StorageKey::PriceRegistryLastWrite(admin.clone()); + env.storage().persistent().set(&key, ¤t_ledger); + env.storage().persistent().extend_ttl(&key, 50_000, 50_000); +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError}; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::Env; + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_sequence_number(100); + 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); + (env, addr, admin) + } + + #[test] + fn set_price_succeeds_on_first_call() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + let price = client.try_get_price(&String::from_str(&env, "offer1")); + match price { + Ok(Ok(Some(p))) => assert_eq!(p, String::from_str(&env, "100")), + _ => panic!("expected price to be set"), + } + } + + #[test] + fn set_price_succeeds_after_interval() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + // Advance ledger by exactly MIN_WRITE_INTERVAL + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32); + + client.set_price( + &admin, + &String::from_str(&env, "offer2"), + &String::from_str(&env, "200"), + ); + + let price1 = client.try_get_price(&String::from_str(&env, "offer1")); + let price2 = client.try_get_price(&String::from_str(&env, "offer2")); + match (price1, price2) { + (Ok(Ok(Some(p1))), Ok(Ok(Some(p2)))) => { + assert_eq!(p1, String::from_str(&env, "100")); + assert_eq!(p2, String::from_str(&env, "200")); + } + _ => panic!("expected both prices to be set"), + } + } + + #[test] + fn set_price_fails_when_rate_limit_exceeded() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + // Try again before the interval has passed + let result = client.try_set_price( + &admin, + &String::from_str(&env, "offer2"), + &String::from_str(&env, "200"), + ); + assert!(is_write_rate_limit_error(result)); + } + + #[test] + fn rate_limit_is_per_admin() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32); + + client.set_price( + &admin, + &String::from_str(&env, "offer2"), + &String::from_str(&env, "200"), + ); + + let price1 = client.try_get_price(&String::from_str(&env, "offer1")); + let price2 = client.try_get_price(&String::from_str(&env, "offer2")); + match (price1, price2) { + (Ok(Ok(Some(p1))), Ok(Ok(Some(p2)))) => { + assert_eq!(p1, String::from_str(&env, "100")); + assert_eq!(p2, String::from_str(&env, "200")); + } + _ => panic!("expected both prices to be set"), + } + } + + #[test] + fn set_price_requires_auth() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + let unauthorized = Address::generate(&env); + + env.set_auths(&[]); + let result = client.try_set_price( + &unauthorized, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + assert!(result.is_err()); + } + + #[test] + fn remove_price_succeeds() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + let before = client.try_get_price(&String::from_str(&env, "offer1")); + match before { + Ok(Ok(Some(p))) => assert_eq!(p, String::from_str(&env, "100")), + _ => panic!("expected price to be set before removal"), + } + + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32); + client.remove_price(&admin, &String::from_str(&env, "offer1")); + let after = client.try_get_price(&String::from_str(&env, "offer1")); + match after { + Ok(Ok(None)) => {} + _ => panic!("expected price to be None after removal"), + } + } + + #[test] + fn remove_price_fails_when_rate_limit_exceeded() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + let result = client.try_remove_price(&admin, &String::from_str(&env, "offer1")); + assert!(is_write_rate_limit_error(result)); + } + + #[test] + fn write_at_exact_interval_boundary_succeeds() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + // Advance ledger by exactly MIN_WRITE_INTERVAL — should succeed + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32); + + let result = client.try_set_price( + &admin, + &String::from_str(&env, "offer2"), + &String::from_str(&env, "200"), + ); + assert!( + result.is_ok(), + "write at exact interval boundary should succeed" + ); + } + + #[test] + fn write_one_ledger_before_interval_fails() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + + // Advance by MIN_WRITE_INTERVAL - 1 — should fail + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32 - 1); + + let result = client.try_set_price( + &admin, + &String::from_str(&env, "offer2"), + &String::from_str(&env, "200"), + ); + assert!(is_write_rate_limit_error(result)); + } + + #[test] + fn remove_price_requires_auth() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + let unauthorized = Address::generate(&env); + + env.set_auths(&[]); + let result = client.try_remove_price(&unauthorized, &String::from_str(&env, "offer1")); + assert!(result.is_err()); + } + + #[test] + fn get_price_returns_none_for_unknown_offering() { + let (env, contract, _admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + let result = client.try_get_price(&String::from_str(&env, "nonexistent")); + match result { + Ok(Ok(None)) => {} + _ => panic!("expected Ok(Ok(None)), got {:?}", result), + } + } + + #[test] + fn set_price_overwrites_previous_price() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + env.ledger() + .set_sequence_number(env.ledger().sequence() + MIN_WRITE_INTERVAL as u32); + client.set_price( + &admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "200"), + ); + + let price = client.try_get_price(&String::from_str(&env, "offer1")); + match price { + Ok(Ok(Some(p))) => assert_eq!(p, String::from_str(&env, "200")), + _ => panic!("expected overwritten price"), + } + } + + #[test] + fn set_price_unauthorized_non_admin() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + let non_admin = Address::generate(&env); + + let result = client.try_set_price( + &non_admin, + &String::from_str(&env, "offer1"), + &String::from_str(&env, "100"), + ); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn remove_price_unauthorized_non_admin() { + let (env, contract, admin) = setup(); + let client = CalloraSettlementClient::new(&env, &contract); + let non_admin = Address::generate(&env); + + let result = client.try_remove_price(&non_admin, &String::from_str(&env, "offer1")); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + fn is_write_rate_limit_error, E: Into>( + result: Result, Result>, + ) -> bool { + match result { + Err(Ok(e)) => e.into().get_code() == SettlementError::WriteRateLimitExceeded as u32, + _ => false, + } + } + + fn is_error, E: Into>( + result: Result, Result>, + expected: SettlementError, + ) -> bool { + let expected_code = expected as u32; + match result { + Err(Ok(e)) => e.into().get_code() == expected_code, + _ => false, + } + } +} diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 823377a3..295ef0eb 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -2090,7 +2090,7 @@ mod settlement_tests { env.as_contract(&addr, || { env.storage().persistent().set( - &crate::StorageKey::DeveloperBalance(developer.clone()), + &crate::StorageKey::DeveloperBalance(developer.clone(), token.clone()), &i128::MAX, ); }); diff --git a/contracts/settlement/src/types.rs b/contracts/settlement/src/types.rs index f6618388..a5c259a0 100644 --- a/contracts/settlement/src/types.rs +++ b/contracts/settlement/src/types.rs @@ -1,15 +1,5 @@ use soroban_sdk::{contracttype, Address, Symbol}; -/// The maximum message length in bytes allowed for `broadcast` calls. -pub const MAX_MESSAGE_LEN: u32 = 256; - -/// Maximum number of items allowed in a single `batch_receive_payment` call. -pub const MAX_BATCH_SIZE: u32 = 50; - -/// Maximum number of developer balance records returned in a single -/// non-cursor-based query (gas guard). -pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; - /// Minimum threshold of remaining ledgers before instance storage TTL is extended (~30 days). pub const INSTANCE_BUMP_THRESHOLD: u32 = 17_280 * 30; @@ -64,6 +54,30 @@ pub enum StorageKey { TotalReceived, /// Whether a specific developer's withdrawals are frozen. FrozenDeveloper(Address), + /// Per-admin last write ledger for price registry rate limiting. + PriceRegistryLastWrite(Address), + /// Price entry for a given offering identifier. + Price(soroban_sdk::String), +} + +/// Read-only preview of a developer claim/withdrawal. +/// +/// Returned by `simulate_claim` after running the same validation checks as +/// `withdraw_developer_balance`, without requiring auth, transferring tokens, +/// writing storage, or emitting events. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ClaimSimulation { + pub developer: Address, + pub amount: i128, + pub recipient: Address, + pub token: Address, + pub current_balance: i128, + pub remaining_balance: i128, + pub contract_balance: i128, + pub daily_withdraw_cap: i128, + pub withdrawn_today: i128, + pub withdrawn_today_after: i128, } /// Severity levels for admin broadcast messages. diff --git a/contracts/settlement/tests/auth_snap.rs b/contracts/settlement/tests/auth_snap.rs index a3110413..c4429564 100644 --- a/contracts/settlement/tests/auth_snap.rs +++ b/contracts/settlement/tests/auth_snap.rs @@ -186,7 +186,10 @@ fn clear_developer_claim_window_requires_auth() { env.set_auths(&[]); let res = client.try_clear_developer_claim_window(&admin, &dev); - assert!(res.is_err(), "clear_developer_claim_window must require auth"); + assert!( + res.is_err(), + "clear_developer_claim_window must require auth" + ); } #[test] @@ -197,7 +200,13 @@ fn force_credit_developer_requires_auth() { env.set_auths(&[]); let dev = Address::generate(&env); let token_addr = Address::generate(&env); - let res = client.try_force_credit_developer(&admin, &dev, &100_i128, &token_addr, &Symbol::new(&env, "test")); + let res = client.try_force_credit_developer( + &admin, + &dev, + &100_i128, + &token_addr, + &Symbol::new(&env, "test"), + ); assert!(res.is_err(), "force_credit_developer must require auth"); } diff --git a/contracts/settlement/tests/proptest.rs b/contracts/settlement/tests/proptest.rs index e954924e..350b708f 100644 --- a/contracts/settlement/tests/proptest.rs +++ b/contracts/settlement/tests/proptest.rs @@ -623,13 +623,23 @@ fn test_invariant_record_deduction() { client.record_deduction(&1_000, &1); assert_eq!(client.get_total_received(), 1_000); assert_eq!(client.get_global_pool().total_balance, 0); - assert_eq!(client.get_all_developer_balances(&admin, &usdc_addr).len(), 0); + assert_eq!( + client.get_all_developer_balances(&admin, &usdc_addr).len(), + 0 + ); client.record_deduction(&500, &2); assert_eq!(client.get_total_received(), 1_500); assert_eq!(client.get_global_pool().total_balance, 0); - client.receive_payment(&vault, &300, &false, &Some(Address::generate(env)), &usdc_addr, &1u32); + client.receive_payment( + &vault, + &300, + &false, + &Some(Address::generate(env)), + &usdc_addr, + &1u32, + ); assert_eq!(client.get_total_received(), 1_500); assert_eq!(client.get_global_pool().total_balance, 0); } @@ -743,7 +753,7 @@ fn test_invariant_daily_withdraw_cap() { let res = client.try_withdraw_developer_balance(&dev, &1_000, &None); assert!(res.is_err()); - + let balance = client.get_developer_balance(&dev, &usdc_addr).unwrap(); assert_eq!(balance, 3_500); } diff --git a/fix_lib.py b/fix_lib.py new file mode 100644 index 00000000..c7ff8928 --- /dev/null +++ b/fix_lib.py @@ -0,0 +1,77 @@ +import re + +# Fix lib.rs +with open(r'C:\Users\Administrator\Desktop\Callora-Contracts\contracts\settlement\src\lib.rs', 'rb') as f: + content = f.read().decode('utf-8') + +# Normalize line endings to LF for easier processing +content = content.replace('\r\n', '\n') + +# Remove contracterror and contracttype from imports +content = content.replace('contracterror, contracttype,', '') + +# Remove SettlementError inline block +pattern = r'^/// Typed errors for the settlement contract\.\n.*?^}\n\n' +content = re.sub(pattern, '', content, flags=re.MULTILINE | re.DOTALL) + +# Remove StorageKey inline block +pattern = r'^/// Persistent storage keys for settlement contract\n\[contracttype\]\n.*?^}\n\n' +content = re.sub(pattern, '', content, flags=re.MULTILINE | re.DOTALL) + +# Remove all inline struct blocks +struct_patterns = [ + r'^/// Developer balance record in settlement contract\n\[contracttype\]\n.*?^}\n\n', + r'^/// Global pool balance tracking\.\n.*?^}\n\n', + r"^/// Tracks a developer's cumulative withdrawal amount for a given epoch day\.\n.*?^}\n\n", + r'^/// Timestamp range during which a developer may claim accrued balance\.\n.*?^}\n\n', + r'^/// Read-only preview of a developer claim/withdrawal\.\n.*?^}\n\n', + r'^/// Payment received event\n\[contracttype\]\n.*?^}\n\n', + r'^/// Balance credited event\n\[contracttype\]\n.*?^}\n\n', + r'^/// Emitted when a new vault address is proposed via `propose_vault`\.\n\[contracttype\]\n.*?^}\n\n', + r'^/// Emitted when the proposed vault is accepted via `accept_vault`\.\n\[contracttype\]\n.*?^}\n\n', + r'^/// Emitted when a developer withdraws their balance\.\n\[contracttype\]\n.*?^}\n\n', + r"^/// Emitted when the admin sets or changes a developer's daily withdrawal cap\.\n\[contracttype\]\n.*?^}\n\n", + r'^/// Emitted when the admin sets or clears a developer claim window\.\n\[contracttype\]\n.*?^}\n\n', + r'^/// Emitted when an admin force-credits a developer balance \(escape hatch\)\.\n\[contracttype\]\n.*?^}\n\n', +] + +for pat in struct_patterns: + content = re.sub(pat, '', content, flags=re.MULTILINE | re.DOTALL) + +# Add price_registry entrypoints before internal helpers +content = content.replace( + ' // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Internal helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + ''' pub fn set_price(env: Env, caller: Address, offering_id: soroban_sdk::String, price: soroban_sdk::String) { + price_registry::set_price(&env, caller, offering_id, price); + } + + pub fn remove_price(env: Env, caller: Address, offering_id: soroban_sdk::String) { + price_registry::remove_price(&env, caller, offering_id); + } + + pub fn get_price(env: Env, offering_id: soroban_sdk::String) -> Option { + price_registry::get_price(&env, offering_id) + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Internal helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━''' +) + +with open(r'C:\Users\Administrator\Desktop\Callora-Contracts\contracts\settlement\src\lib.rs', 'wb') as f: + f.write(content.encode('utf-8')) + +print("lib.rs done") + +# Fix errors.rs duplicate +with open(r'C:\Users\Administrator\Desktop\Callora-Contracts\contracts\settlement\src\errors.rs', 'rb') as f: + content = f.read().decode('utf-8') + +content = content.replace('\r\n', '\n') +content = content.replace( + ' /// Admin attempted a price write before the minimum interval elapsed.\n WriteRateLimitExceeded = 33,\n /// Admin attempted a price write before the minimum interval elapsed.\n WriteRateLimitExceeded = 33,', + ' /// Admin attempted a price write before the minimum interval elapsed.\n WriteRateLimitExceeded = 33,' +) + +with open(r'C:\Users\Administrator\Desktop\Callora-Contracts\contracts\settlement\src\errors.rs', 'wb') as f: + f.write(content.encode('utf-8')) + +print("errors.rs done")