diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs
index e18826a..797089a 100644
--- a/contracts/settlement/src/lib.rs
+++ b/contracts/settlement/src/lib.rs
@@ -1,6 +1,39 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec};
+use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec};
+
+/// Maximum number of items allowed in a single `batch_receive_payment` call.
+pub const MAX_BATCH_SIZE: u32 = 50;
+
+/// 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 |
+#[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,
+}
/// Persistent storage keys for settlement contract
#[contracttype]
@@ -64,14 +97,6 @@ pub struct VaultChangedEvent {
pub new_vault: Address,
}
-/// Storage key for the registered vault address.
-const VAULT_KEY: &str = "vault";
-/// Storage key for the admin address.
-const ADMIN_KEY: &str = "admin";
-const PENDING_ADMIN_KEY: &str = "pending_admin";
-const DEVELOPER_BALANCES_KEY: &str = "developer_balances";
-/// Storage key for the global pool state.
-const GLOBAL_POOL_KEY: &str = "global_pool";
#[contract]
pub struct CalloraSettlement;
@@ -86,7 +111,6 @@ impl CalloraSettlement {
/// Storage keys written:
/// - `StorageKey::Admin`
/// - `StorageKey::Vault`
- /// - `StorageKey::DeveloperIndex`
/// - `StorageKey::GlobalPool`
///
/// # Panics
@@ -98,7 +122,7 @@ impl CalloraSettlement {
admin.require_auth();
let inst = env.storage().instance();
if inst.has(&StorageKey::Admin) {
- panic!("settlement contract already initialized");
+ env.panic_with_error(SettlementError::AlreadyInitialized);
}
if admin == vault_address {
panic!("invalid config: admin and vault_address must be distinct");
@@ -109,10 +133,8 @@ impl CalloraSettlement {
if vault_address == env.current_contract_address() {
panic!("invalid config: vault_address cannot be the contract itself");
}
- inst.set(&Symbol::new(&env, ADMIN_KEY), &admin);
- inst.set(&Symbol::new(&env, VAULT_KEY), &vault_address);
- let empty_balances: Map
= Map::new(&env);
- inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &empty_balances);
+ inst.set(&StorageKey::Admin, &admin);
+ inst.set(&StorageKey::Vault, &vault_address);
let global_pool = GlobalPool {
total_balance: 0,
last_updated: env.ledger().timestamp(),
@@ -156,18 +178,18 @@ impl CalloraSettlement {
caller.require_auth();
Self::require_authorized_caller(env.clone(), caller.clone());
if amount <= 0 {
- panic!("amount must be positive");
+ env.panic_with_error(SettlementError::AmountNotPositive);
}
let inst = env.storage().instance();
if to_pool {
if developer.is_some() {
- panic!("developer address must be None when to_pool=true");
+ env.panic_with_error(SettlementError::DeveloperMustBeNone);
}
let mut global_pool = Self::get_global_pool(env.clone());
global_pool.total_balance = global_pool
.total_balance
.checked_add(amount)
- .unwrap_or_else(|| panic!("pool balance overflow"));
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::PoolOverflow));
global_pool.last_updated = env.ledger().timestamp();
inst.set(&StorageKey::GlobalPool, &global_pool);
env.events().publish(
@@ -181,7 +203,7 @@ impl CalloraSettlement {
);
} else {
let dev_address = developer
- .unwrap_or_else(|| panic!("developer address required when to_pool=false"));
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperRequired));
@@ -193,7 +215,7 @@ impl CalloraSettlement {
.unwrap_or(0);
let new_balance = current_balance
.checked_add(amount)
- .unwrap_or_else(|| panic!("developer balance overflow"));
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow));
// Write to persistent storage with TTL extension
env.storage()
@@ -274,17 +296,31 @@ impl CalloraSettlement {
}
let inst = env.storage().instance();
- let mut balances: Map = inst
- .get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY))
- .unwrap_or_else(|| Map::new(&env));
for item in items.iter() {
let (dev, amount) = item;
- let current = balances.get(dev.clone()).unwrap_or(0);
+ let current: i128 = env
+ .storage()
+ .persistent()
+ .get(&StorageKey::DeveloperBalance(dev.clone()))
+ .unwrap_or(0);
let new_balance = current
.checked_add(amount)
- .unwrap_or_else(|| panic!("developer balance overflow"));
- balances.set(dev.clone(), new_balance);
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow));
+ env.storage()
+ .persistent()
+ .set(&StorageKey::DeveloperBalance(dev.clone()), &new_balance);
+ env.storage()
+ .persistent()
+ .extend_ttl(&StorageKey::DeveloperBalance(dev.clone()), 50000, 50000);
+ // Add to index if not already present
+ let mut index: Vec = inst
+ .get(&StorageKey::DeveloperIndex)
+ .unwrap_or_else(|| Vec::new(&env));
+ if !index.iter().any(|a| a == &dev) {
+ index.push_back(dev.clone());
+ inst.set(&StorageKey::DeveloperIndex, &index);
+ }
env.events().publish(
(Symbol::new(&env, "balance_credited"), dev.clone()),
BalanceCreditedEvent {
@@ -294,8 +330,6 @@ impl CalloraSettlement {
},
);
}
-
- inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &balances);
}
/// Get current admin address
@@ -303,7 +337,7 @@ impl CalloraSettlement {
env.storage()
.instance()
.get(&StorageKey::Admin)
- .unwrap_or_else(|| panic!("settlement contract not initialized"))
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::NotInitialized))
}
/// Get registered vault address
@@ -311,7 +345,7 @@ impl CalloraSettlement {
env.storage()
.instance()
.get(&StorageKey::Vault)
- .unwrap_or_else(|| panic!("settlement contract not initialized"))
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::NotInitialized))
}
/// Get global pool information
@@ -319,7 +353,7 @@ impl CalloraSettlement {
env.storage()
.instance()
.get(&StorageKey::GlobalPool)
- .unwrap_or_else(|| panic!("settlement contract not initialized"))
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::NotInitialized))
}
/// Get developer balance
@@ -337,7 +371,7 @@ impl CalloraSettlement {
/// Safe for all use cases; uses persistent storage with TTL.
pub fn get_developer_balance(env: Env, developer: Address) -> i128 {
if !env.storage().instance().has(&StorageKey::Admin) {
- panic!("settlement contract not initialized");
+ env.panic_with_error(SettlementError::NotInitialized);
}
env.storage()
.persistent()
@@ -383,7 +417,7 @@ impl CalloraSettlement {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
- panic!("unauthorized: caller is not admin");
+ env.panic_with_error(SettlementError::Unauthorized);
}
let inst = env.storage().instance();
let index: Vec = inst
@@ -431,7 +465,7 @@ impl CalloraSettlement {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
if caller != current_admin {
- panic!("unauthorized: caller is not admin");
+ env.panic_with_error(SettlementError::Unauthorized);
}
env.storage()
.instance()
@@ -502,11 +536,11 @@ impl CalloraSettlement {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
if caller != current_admin {
- panic!("unauthorized: caller is not admin");
+ env.panic_with_error(SettlementError::Unauthorized);
}
let inst = env.storage().instance();
let old_vault = Self::get_vault(env.clone());
- inst.set(&Symbol::new(&env, VAULT_KEY), &new_vault);
+ inst.set(&StorageKey::Vault, &new_vault);
env.events().publish(
(Symbol::new(&env, "vault_changed"), caller.clone()),
@@ -522,7 +556,7 @@ impl CalloraSettlement {
let vault = Self::get_vault(env.clone());
let admin = Self::get_admin(env.clone());
if caller != vault && caller != admin {
- panic!("unauthorized: caller must be vault or admin");
+ env.panic_with_error(SettlementError::Unauthorized);
}
}
}
diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs
index d426bab..5ca1331 100644
--- a/contracts/settlement/src/test.rs
+++ b/contracts/settlement/src/test.rs
@@ -2,11 +2,9 @@
mod settlement_tests {
extern crate std;
- use crate::{CalloraSettlement, CalloraSettlementClient, StorageKey};
+ use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey};
use soroban_sdk::testutils::{Address as _, Ledger as _};
- use soroban_sdk::{Address, Env, Vec};
- use std::any::Any;
- use std::panic::{catch_unwind, AssertUnwindSafe};
+ use soroban_sdk::{Address, Env, InvokeError};
fn setup_contract() -> (Env, Address, Address, Address, Address) {
let env = Env::default();
@@ -20,13 +18,10 @@ mod settlement_tests {
(env, addr, admin, vault, third_party)
}
- fn panic_message(err: std::boxed::Box) -> std::string::String {
- if let Some(message) = err.downcast_ref::<&str>() {
- std::string::String::from(*message)
- } else if let Some(message) = err.downcast_ref::() {
- message.clone()
- } else {
- std::string::String::from("")
+ fn is_error(result: Result, expected: SettlementError) -> bool {
+ match result {
+ Err(InvokeError::Contract(code)) => code == expected as u32,
+ _ => false,
}
}
@@ -47,12 +42,8 @@ mod settlement_tests {
let inst = env.storage().instance();
assert!(inst.has(&StorageKey::Admin));
assert!(inst.has(&StorageKey::Vault));
- assert!(inst.has(&StorageKey::DeveloperIndex));
assert!(inst.has(&StorageKey::GlobalPool));
- let developer_index: Vec =
- inst.get(&StorageKey::DeveloperIndex).unwrap();
-
- assert_eq!(developer_index.len(), 0);
+ // DeveloperIndex is written lazily on first payment, not at init
});
assert_eq!(client.get_admin(), admin);
@@ -339,7 +330,6 @@ mod settlement_tests {
}
#[test]
- #[should_panic(expected = "unauthorized: caller is not admin")]
fn test_set_admin_unauthorized() {
let env = Env::default();
env.mock_all_auths();
@@ -350,12 +340,11 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- // Third party cannot set admin
- client.set_admin(&vault, &new_admin);
+ let result = client.try_set_admin(&vault, &new_admin);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
- #[should_panic(expected = "unauthorized: caller is not admin")]
fn test_set_vault_unauthorized() {
let env = Env::default();
env.mock_all_auths();
@@ -367,7 +356,8 @@ mod settlement_tests {
client.init(&admin, &vault);
let attacker = Address::generate(&env);
- client.set_vault(&attacker, &new_vault);
+ let result = client.try_set_vault(&attacker, &new_vault);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
@@ -496,7 +486,6 @@ mod settlement_tests {
}
#[test]
- #[should_panic(expected = "unauthorized: caller is not admin")]
fn test_pending_admin_cannot_set_admin() {
// Pending admin has no privileges until accepted
let env = Env::default();
@@ -510,7 +499,8 @@ mod settlement_tests {
client.set_admin(&admin, &new_admin);
// New admin tries to set another admin before accepting
- client.set_admin(&new_admin, &vault);
+ let result = client.try_set_admin(&new_admin, &vault);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
@@ -638,8 +628,7 @@ mod settlement_tests {
// ── panic / error paths ──────────────────────────────────────────────────
#[test]
- #[should_panic(expected = "settlement contract already initialized")]
- fn test_double_init_panics() {
+ fn test_double_init_returns_already_initialized() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
@@ -647,11 +636,14 @@ mod settlement_tests {
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- client.init(&admin, &vault);
+ let result = client.try_init(&admin, &vault);
+ assert!(
+ is_error(result, SettlementError::AlreadyInitialized),
+ "expected AlreadyInitialized"
+ );
}
#[test]
- #[should_panic(expected = "amount must be positive")]
fn test_receive_payment_zero_amount() {
let env = Env::default();
env.mock_all_auths();
@@ -661,11 +653,11 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- client.receive_payment(&vault, &0i128, &true, &None);
+ let result = client.try_receive_payment(&vault, &0i128, &true, &None);
+ assert!(is_error(result, SettlementError::AmountNotPositive));
}
#[test]
- #[should_panic(expected = "amount must be positive")]
fn test_receive_payment_negative_amount() {
let env = Env::default();
env.mock_all_auths();
@@ -675,12 +667,12 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- client.receive_payment(&vault, &-1i128, &true, &None);
+ let result = client.try_receive_payment(&vault, &-1i128, &true, &None);
+ assert!(is_error(result, SettlementError::AmountNotPositive));
}
#[test]
- #[should_panic(expected = "pool balance overflow")]
- fn test_receive_payment_to_pool_overflow_panics() {
+ fn test_receive_payment_to_pool_overflow() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
@@ -695,15 +687,15 @@ mod settlement_tests {
total_balance: i128::MAX,
last_updated: env.ledger().timestamp(),
};
- inst.set(&Symbol::new(&env, "global_pool"), &pool);
+ inst.set(&crate::StorageKey::GlobalPool, &pool);
});
- client.receive_payment(&vault, &1i128, &true, &None);
+ let result = client.try_receive_payment(&vault, &1i128, &true, &None);
+ assert!(is_error(result, SettlementError::PoolOverflow));
}
#[test]
- #[should_panic(expected = "developer balance overflow")]
- fn test_receive_payment_to_developer_overflow_panics() {
+ fn test_receive_payment_to_developer_overflow() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
@@ -714,18 +706,16 @@ mod settlement_tests {
client.init(&admin, &vault);
env.as_contract(&addr, || {
- let inst = env.storage().instance();
- let mut balances: Map =
- inst.get(&Symbol::new(&env, "developer_balances")).unwrap();
- balances.set(developer.clone(), i128::MAX);
- inst.set(&Symbol::new(&env, "developer_balances"), &balances);
+ env.storage()
+ .persistent()
+ .set(&crate::StorageKey::DeveloperBalance(developer.clone()), &i128::MAX);
});
- client.receive_payment(&vault, &1i128, &false, &Some(developer));
+ let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer));
+ assert!(is_error(result, SettlementError::DeveloperOverflow));
}
#[test]
- #[should_panic(expected = "developer address required when to_pool=false")]
fn test_receive_payment_pool_false_no_developer() {
let env = Env::default();
env.mock_all_auths();
@@ -735,11 +725,11 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- client.receive_payment(&vault, &100i128, &false, &None);
+ let result = client.try_receive_payment(&vault, &100i128, &false, &None);
+ assert!(is_error(result, SettlementError::DeveloperRequired));
}
#[test]
- #[should_panic(expected = "developer address must be None when to_pool=true")]
fn test_receive_payment_pool_true_with_developer() {
let env = Env::default();
env.mock_all_auths();
@@ -750,7 +740,8 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- client.receive_payment(&vault, &100i128, &true, &Some(developer));
+ let result = client.try_receive_payment(&vault, &100i128, &true, &Some(developer));
+ assert!(is_error(result, SettlementError::DeveloperMustBeNone));
}
#[test]
@@ -764,56 +755,35 @@ mod settlement_tests {
struct Case {
name: &'static str,
role: CallerRole,
- expected: Result<(), &'static str>,
+ should_succeed: bool,
}
let cases = [
- Case {
- name: "vault address succeeds",
- role: CallerRole::Vault,
- expected: Ok(()),
- },
- Case {
- name: "admin address succeeds",
- role: CallerRole::Admin,
- expected: Ok(()),
- },
- Case {
- name: "third party fails",
- role: CallerRole::ThirdParty,
- expected: Err("unauthorized: caller must be vault or admin"),
- },
+ Case { name: "vault address succeeds", role: CallerRole::Vault, should_succeed: true },
+ Case { name: "admin address succeeds", role: CallerRole::Admin, should_succeed: true },
+ Case { name: "third party fails", role: CallerRole::ThirdParty, should_succeed: false },
];
for case in cases {
let (env, addr, admin, vault, third_party) = setup_contract();
let client = CalloraSettlementClient::new(&env, &addr);
let caller = match case.role {
- CallerRole::Vault => vault,
- CallerRole::Admin => admin,
+ CallerRole::Vault => vault,
+ CallerRole::Admin => admin,
CallerRole::ThirdParty => third_party,
};
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.receive_payment(&caller, &100i128, &true, &None);
- }));
+ let result = client.try_receive_payment(&caller, &100i128, &true, &None);
- match case.expected {
- Ok(()) => {
- assert!(result.is_ok(), "expected success for case: {}", case.name);
- let global_pool = client.get_global_pool();
- assert_eq!(global_pool.total_balance, 100i128);
- }
- Err(expected_panic) => {
- let err = result.expect_err("expected panic but call succeeded");
- let message = panic_message(err);
- assert!(
- message.contains(expected_panic),
- "case: {} (got panic: {})",
- case.name,
- message
- );
- }
+ if case.should_succeed {
+ assert!(result.is_ok(), "expected success for case: {}", case.name);
+ assert_eq!(client.get_global_pool().total_balance, 100i128);
+ } else {
+ assert!(
+ is_error(result, SettlementError::Unauthorized),
+ "expected Unauthorized for case: {}",
+ case.name
+ );
}
}
}
@@ -980,11 +950,8 @@ mod settlement_tests {
client.set_vault(&admin, &new_vault);
// Old vault cannot send payments
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.receive_payment(&vault, &1000i128, &true, &None);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized"));
+ let result = client.try_receive_payment(&vault, &1000i128, &true, &None);
+ assert!(is_error(result, SettlementError::Unauthorized));
// New vault can send payments
client.receive_payment(&new_vault, &1000i128, &true, &None);
@@ -1144,18 +1111,12 @@ mod settlement_tests {
client.set_admin(&admin, &new_admin);
// Vault cannot set admin
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.set_admin(&vault, &new_admin);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_set_admin(&vault, &new_admin);
+ assert!(is_error(result, SettlementError::Unauthorized));
// Third party cannot set admin
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.set_admin(&third_party, &new_admin);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_set_admin(&third_party, &new_admin);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
@@ -1168,18 +1129,12 @@ mod settlement_tests {
client.set_vault(&admin, &new_vault);
// Vault cannot set vault
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.set_vault(&vault, &new_vault);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_set_vault(&vault, &new_vault);
+ assert!(is_error(result, SettlementError::Unauthorized));
// Third party cannot set vault
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.set_vault(&third_party, &new_vault);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_set_vault(&third_party, &new_vault);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
@@ -1204,18 +1159,12 @@ mod settlement_tests {
client.get_all_developer_balances(&admin);
// Vault cannot call
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.get_all_developer_balances(&vault);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_get_all_developer_balances(&vault);
+ assert!(is_error(result, SettlementError::Unauthorized));
// Third party cannot call
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.get_all_developer_balances(&third_party);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin"));
+ let result = client.try_get_all_developer_balances(&third_party);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
// ── batch_receive_payment tests ──────────────────────────────────────────
@@ -1271,12 +1220,8 @@ mod settlement_tests {
let client = CalloraSettlementClient::new(&env, &addr);
let items: soroban_sdk::Vec<(Address, i128)> = soroban_sdk::Vec::new(&env);
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.batch_receive_payment(&vault, &items);
- }));
+ let result = client.try_batch_receive_payment(&vault, &items);
assert!(result.is_err());
- assert!(panic_message(result.unwrap_err())
- .contains("batch_receive_payment requires at least one item"));
}
#[test]
@@ -1290,11 +1235,8 @@ mod settlement_tests {
for _ in 0..=MAX_BATCH_SIZE {
items.push_back((dev.clone(), 1i128));
}
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.batch_receive_payment(&vault, &items);
- }));
+ let result = client.try_batch_receive_payment(&vault, &items);
assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("batch too large"));
}
#[test]
@@ -1305,11 +1247,8 @@ mod settlement_tests {
let mut items = soroban_sdk::Vec::new(&env);
items.push_back((dev.clone(), 0i128));
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.batch_receive_payment(&vault, &items);
- }));
+ let result = client.try_batch_receive_payment(&vault, &items);
assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("amount must be positive"));
}
#[test]
@@ -1320,11 +1259,8 @@ mod settlement_tests {
let mut items = soroban_sdk::Vec::new(&env);
items.push_back((dev.clone(), -1i128));
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.batch_receive_payment(&vault, &items);
- }));
+ let result = client.try_batch_receive_payment(&vault, &items);
assert!(result.is_err());
- assert!(panic_message(result.unwrap_err()).contains("amount must be positive"));
}
#[test]
@@ -1335,12 +1271,8 @@ mod settlement_tests {
let mut items = soroban_sdk::Vec::new(&env);
items.push_back((dev.clone(), 100i128));
- let result = catch_unwind(AssertUnwindSafe(|| {
- client.batch_receive_payment(&third_party, &items);
- }));
- assert!(result.is_err());
- assert!(panic_message(result.unwrap_err())
- .contains("unauthorized: caller must be vault or admin"));
+ let result = client.try_batch_receive_payment(&third_party, &items);
+ assert!(is_error(result, SettlementError::Unauthorized));
}
#[test]
diff --git a/contracts/settlement/src/test_views.rs b/contracts/settlement/src/test_views.rs
index 9058a42..b1f3d02 100644
--- a/contracts/settlement/src/test_views.rs
+++ b/contracts/settlement/src/test_views.rs
@@ -1,57 +1,62 @@
-use crate::{CalloraSettlement, CalloraSettlementClient};
-use soroban_sdk::{testutils::Address as _, Address, Env};
+use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError};
+use soroban_sdk::{testutils::Address as _, Address, Env, InvokeError};
+
+fn is_not_initialized(result: Result) -> bool {
+ match result {
+ Err(InvokeError::Contract(code)) => code == SettlementError::NotInitialized as u32,
+ _ => false,
+ }
+}
#[test]
-#[should_panic(expected = "settlement contract not initialized")]
-fn test_get_admin_uninitialized_panics() {
+fn test_get_admin_uninitialized() {
let env = Env::default();
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
- client.get_admin();
+ assert!(is_not_initialized(client.try_get_admin()));
}
#[test]
-#[should_panic(expected = "settlement contract not initialized")]
-fn test_get_vault_uninitialized_panics() {
+fn test_get_vault_uninitialized() {
let env = Env::default();
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
- client.get_vault();
+ assert!(is_not_initialized(client.try_get_vault()));
}
#[test]
-#[should_panic(expected = "settlement contract not initialized")]
-fn test_get_global_pool_uninitialized_panics() {
+fn test_get_global_pool_uninitialized() {
let env = Env::default();
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
- client.get_global_pool();
+ assert!(is_not_initialized(client.try_get_global_pool()));
}
#[test]
-#[should_panic(expected = "settlement contract not initialized")]
-fn test_get_developer_balance_uninitialized_panics() {
+fn test_get_developer_balance_uninitialized() {
let env = Env::default();
let dev = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
- client.get_developer_balance(&dev);
+ assert!(is_not_initialized(client.try_get_developer_balance(&dev)));
}
#[test]
-#[should_panic(expected = "settlement contract not initialized")]
-fn test_get_all_developer_balances_uninitialized_panics() {
+fn test_get_all_developer_balances_uninitialized() {
let env = Env::default();
env.mock_all_auths();
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
let dummy = Address::generate(&env);
- client.get_all_developer_balances(&dummy);
+ // get_all_developer_balances calls get_admin internally, which returns NotInitialized
+ assert!(is_not_initialized(
+ client.try_get_all_developer_balances(&dummy)
+ ));
}
#[test]
diff --git a/docs/interfaces/settlement.json b/docs/interfaces/settlement.json
index ff3be12..89c5dd5 100644
--- a/docs/interfaces/settlement.json
+++ b/docs/interfaces/settlement.json
@@ -1,10 +1,24 @@
{
"contract": "callora-settlement",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Advanced settlement contract with per-developer balance tracking. Receives USDC forwarded by the vault and credits either a shared global pool or an individual developer's balance.",
"crate": "callora-settlement",
"source": "contracts/settlement/src/lib.rs",
+ "errors": {
+ "description": "Typed error codes emitted via env.panic_with_error(). Callers and indexers match on the u32 code rather than parsing raw strings.",
+ "variants": [
+ { "code": 1, "name": "NotInitialized", "when": "A function is called before init." },
+ { "code": 2, "name": "AlreadyInitialized", "when": "init is called more than once." },
+ { "code": 3, "name": "Unauthorized", "when": "Caller is not the registered vault or admin." },
+ { "code": 4, "name": "AmountNotPositive", "when": "amount is zero or negative." },
+ { "code": 5, "name": "DeveloperRequired", "when": "to_pool=false but no developer address was supplied." },
+ { "code": 6, "name": "DeveloperMustBeNone", "when": "to_pool=true but a developer address was given." },
+ { "code": 7, "name": "PoolOverflow", "when": "Global pool i128 addition would overflow." },
+ { "code": 8, "name": "DeveloperOverflow", "when": "Developer balance i128 addition would overflow." }
+ ]
+ },
+
"types": {
"DeveloperBalance": {
"description": "Snapshot of a single developer's tracked balance.",
@@ -49,8 +63,8 @@
{ "name": "vault_address", "type": "Address", "optional": false, "description": "Vault contract address permitted to call receive_payment." }
],
"returns": "void",
- "panics": [
- "\"settlement contract already initialized\" — called more than once."
+ "errors": [
+ { "code": 2, "name": "AlreadyInitialized", "when": "Called more than once." }
],
"events": []
},
@@ -66,12 +80,13 @@
{ "name": "developer", "type": "Address | null", "optional": true, "description": "Required when to_pool=false; the developer to credit. Ignored when to_pool=true." }
],
"returns": "void",
- "panics": [
- "\"unauthorized: caller must be vault or admin\" — caller is neither.",
- "\"amount must be positive\" — amount <= 0.",
- "\"developer address required when to_pool=false\" — developer is null and to_pool=false.",
- "\"pool balance overflow\" — extremely unlikely arithmetic overflow.",
- "\"developer balance overflow\" — extremely unlikely arithmetic overflow."
+ "errors": [
+ { "code": 3, "name": "Unauthorized", "when": "Caller is neither the vault nor the admin." },
+ { "code": 4, "name": "AmountNotPositive", "when": "amount <= 0." },
+ { "code": 5, "name": "DeveloperRequired", "when": "to_pool=false and developer is null." },
+ { "code": 6, "name": "DeveloperMustBeNone", "when": "to_pool=true and developer is not null." },
+ { "code": 7, "name": "PoolOverflow", "when": "Global pool i128 addition would overflow." },
+ { "code": 8, "name": "DeveloperOverflow", "when": "Developer balance i128 addition would overflow." }
],
"events": [
{
@@ -95,20 +110,23 @@
{ "name": "developer", "type": "Address", "optional": false, "description": "Developer address to query." }
],
"returns": "i128",
- "panics": [
- "\"settlement contract not initialized\" — called before init."
+ "errors": [
+ { "code": 1, "name": "NotInitialized", "when": "Called before init." }
],
"events": []
},
{
"name": "get_all_developer_balances",
- "description": "Return a list of all developer balances. Intended for admin use; may be expensive if the map is large.",
- "access": "any",
- "params": [],
+ "description": "Return a list of all developer balances. Admin only; may be expensive if the index is large.",
+ "access": "admin",
+ "params": [
+ { "name": "caller", "type": "Address", "optional": false, "description": "Must be the current admin." }
+ ],
"returns": "Vec",
- "panics": [
- "\"settlement contract not initialized\" — called before init."
+ "errors": [
+ { "code": 1, "name": "NotInitialized", "when": "Called before init." },
+ { "code": 3, "name": "Unauthorized", "when": "Caller is not the admin." }
],
"events": []
},
@@ -119,8 +137,8 @@
"access": "any",
"params": [],
"returns": "GlobalPool",
- "panics": [
- "\"settlement contract not initialized\" — called before init."
+ "errors": [
+ { "code": 1, "name": "NotInitialized", "when": "Called before init." }
],
"events": []
},
@@ -131,8 +149,8 @@
"access": "any",
"params": [],
"returns": "Address",
- "panics": [
- "\"settlement contract not initialized\" — called before init."
+ "errors": [
+ { "code": 1, "name": "NotInitialized", "when": "Called before init." }
],
"events": []
},
@@ -143,8 +161,8 @@
"access": "any",
"params": [],
"returns": "Address",
- "panics": [
- "\"settlement contract not initialized\" — called before init."
+ "errors": [
+ { "code": 1, "name": "NotInitialized", "when": "Called before init." }
],
"events": []
},
@@ -158,8 +176,8 @@
{ "name": "new_admin", "type": "Address", "optional": false, "description": "Proposed new admin address." }
],
"returns": "void",
- "panics": [
- "\"unauthorized: caller is not admin\" — caller != current admin."
+ "errors": [
+ { "code": 3, "name": "Unauthorized", "when": "Caller is not the current admin." }
],
"events": [
{ "topics": ["\"admin_nominated\"", "current_admin", "new_admin"], "data": "void" }
@@ -189,8 +207,8 @@
{ "name": "new_vault", "type": "Address", "optional": false, "description": "New vault contract address." }
],
"returns": "void",
- "panics": [
- "\"unauthorized: caller is not admin\" — caller != current admin."
+ "errors": [
+ { "code": 3, "name": "Unauthorized", "when": "Caller is not the current admin." }
],
"events": []
}