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": [] }