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")