From 5d23fd6a418c5d51ee06824ac72592e855402996 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 00:22:23 +0100 Subject: [PATCH 01/10] feat: harden caller delegation auth in execute_bill_payment --- THREAT_MODEL.md | 43 ++- orchestrator/src/lib.rs | 106 +++++- orchestrator/src/test.rs | 794 ++++++++++++++++++++++++++++----------- 3 files changed, 721 insertions(+), 222 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a11b0368..6a5b95c9 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -139,17 +139,19 @@ Incoming Remittance → remittance_split → [savings_goals, bill_payments, insu #### T-UA-02: Cross-Contract Authorization Bypass **Severity:** MEDIUM -**Description:** Orchestrator executes downstream operations without verifying caller owns the resources being manipulated. +**Status:** PARTIALLY MITIGATED +**Description:** Orchestrator entry points can become confused-deputy surfaces if they trust caller identity from arguments without constraining direct invocation. **Affected Functions:** - `orchestrator::execute_remittance_flow()` - `orchestrator::deposit_to_savings()` +- `orchestrator::execute_bill_payment()` - `orchestrator::execute_bill_payment_internal()` **Attack Vector:** -1. Attacker calls orchestrator with victim's goal/bill/policy IDs -2. Orchestrator forwards calls to downstream contracts -3. If orchestrator is trusted by downstream contracts, operations may succeed +1. A non-owner or helper contract forwards a signed bill-payment request for a victim +2. The orchestrator trusts the `caller` argument without confirming the victim is also the direct invoker +3. Downstream execution proceeds unless another layer blocks it **Impact:** Unauthorized fund allocation, state manipulation @@ -1521,7 +1523,38 @@ The nonce is bound to a composite key of `(caller, command_type, nonce)` stored |------|------|-------------| | 11 | SelfReferenceNotAllowed | A contract address references the orchestrator itself | | 12 | DuplicateContractAddress | Two or more contract addresses are identical | -| 13 | NonceAlreadyUsed | Nonce has already been consumed for this caller/command pair | +| 14 | NonceAlreadyUsed | Nonce has already been consumed for this caller/command pair | ### Test Coverage Six dedicated nonce tests cover: replay rejection per command type, nonce isolation per caller, nonce isolation across command types, and sequential unique nonce acceptance. + +## Bill Payment Authorization Hardening (Issue #303) + +### Original Risk +`orchestrator::execute_bill_payment()` accepted a user-supplied `caller` address and required that address to authorize, but it did not explicitly reject execution forwarded through another contract. That left room for confused-deputy behavior where a non-owner caller could attempt to relay a victim-approved authorization path. + +### Trust Boundary +- The only trusted principal for `execute_bill_payment()` is the address supplied as `caller`. +- That principal must both authorize the call and be the direct invoker observed by the orchestrator. +- The downstream `bill_payments` contract still enforces bill ownership, but the orchestrator now rejects forwarded execution before reaching that layer. + +### Allowed Callers +- Direct owner calls are allowed. +- Non-owner forwarding through helper or proxy contracts is rejected with `OrchestratorError::PermissionDenied`. +- No general delegation model is supported for `execute_bill_payment()`. + +### Delegation Model +Delegation is intentionally unsupported for this entry point. A non-owner cannot self-assert authority by: +- passing an owner address in `caller` +- forwarding a victim-signed invocation through another contract +- replaying a previously authorized payload with the same nonce + +### Mitigated Attack Scenarios +- Non-owner forwarding: blocked by requiring `caller == env.invoker()` in addition to `caller.require_auth()`. +- Argument spoofing: blocked because supplying another user's address without that user's direct authorization fails authentication. +- Unauthorized delegated execution: blocked because intermediate contract invocation is rejected even when the caller argument is otherwise valid. + +### Assumptions And Residual Risks +- `bill_payments::pay_bill()` remains the source of truth for bill ownership and must continue rejecting non-owners. +- The orchestrator does not support approved delegates for bill payment execution; adding one in the future would require explicit stored authorization state and new tests. +- Replay safety depends on preserving the caller-scoped nonce record in persistent storage. diff --git a/orchestrator/src/lib.rs b/orchestrator/src/lib.rs index 3b893aa5..0858536e 100644 --- a/orchestrator/src/lib.rs +++ b/orchestrator/src/lib.rs @@ -26,6 +26,7 @@ mod test; #[contractclient(name = "FamilyWalletClient")] pub trait FamilyWalletTrait { fn check_spending_limit(env: Env, caller: Address, amount: i128) -> bool; + fn get_owner(env: Env) -> Address; } #[contractclient(name = "RemittanceSplitClient")] @@ -69,6 +70,7 @@ pub enum OrchestratorError { DuplicateContractAddress = 11, ContractNotConfigured = 12, SelfReferenceNotAllowed = 13, + NonceAlreadyUsed = 14, } #[contracttype] @@ -131,6 +133,12 @@ pub struct OrchestratorAuditEntry { pub error_code: Option, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum StorageKey { + Nonce(Address, Symbol, u64), +} + const INSTANCE_LIFETIME_THRESHOLD: u32 = 17280; const INSTANCE_BUMP_AMOUNT: u32 = 518400; const MAX_AUDIT_ENTRIES: u32 = 100; @@ -172,6 +180,31 @@ impl Orchestrator { .set(&symbol_short!("EXEC_ST"), &ExecutionState::Idle); } + fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + } + + /// @notice Authorizes bill payment execution using the family wallet owner as the trusted principal. + /// @dev The `caller` must explicitly authorize the invocation and must match the + /// owner returned by the configured family wallet. No delegate or non-owner + /// execution path is supported for `execute_bill_payment`. + fn require_bill_payment_owner( + env: &Env, + family_wallet_addr: &Address, + caller: &Address, + ) -> Result<(), OrchestratorError> { + caller.require_auth(); + let wallet_client = FamilyWalletClient::new(env, family_wallet_addr); + + if wallet_client.get_owner() != caller.clone() { + return Err(OrchestratorError::PermissionDenied); + } + + Ok(()) + } + pub fn get_execution_state(env: Env) -> ExecutionState { env.storage() .instance() @@ -285,6 +318,16 @@ impl Orchestrator { result } + /// @notice Executes a bill payment for the authenticated family wallet owner. + /// @dev Delegation is not supported on this entry point. The authenticated + /// `caller` must match the owner returned by `family_wallet_addr`, and the + /// nonce is consumed before any downstream state changes to prevent replay. + /// @param caller Authenticated family wallet owner expected to receive downstream ownership checks. + /// @param amount Amount checked against the family wallet spending policy. + /// @param family_wallet_addr Family wallet contract used for spending-limit validation. + /// @param bills_addr Bill payments contract that enforces bill ownership. + /// @param bill_id Target bill identifier. + /// @param nonce Caller-scoped replay-protection nonce for bill-payment execution. pub fn execute_bill_payment( env: Env, caller: Address, @@ -295,7 +338,18 @@ impl Orchestrator { nonce: u64, ) -> Result<(), OrchestratorError> { Self::acquire_execution_lock(&env)?; - caller.require_auth(); + Self::require_bill_payment_owner(&env, &family_wallet_addr, &caller).map_err(|e| { + Self::release_execution_lock(&env); + e + })?; + Self::validate_two_addresses(&env, &family_wallet_addr, &bills_addr).map_err(|e| { + Self::release_execution_lock(&env); + e + })?; + Self::consume_nonce(&env, &caller, symbol_short!("exec_bill"), nonce).map_err(|e| { + Self::release_execution_lock(&env); + e + })?; let result = (|| { Self::check_spending_limit(&env, &family_wallet_addr, &caller, amount)?; Self::execute_bill_payment_internal(&env, &bills_addr, &caller, bill_id)?; @@ -316,6 +370,14 @@ impl Orchestrator { ) -> Result<(), OrchestratorError> { Self::acquire_execution_lock(&env)?; caller.require_auth(); + Self::validate_two_addresses(&env, &family_wallet_addr, &insurance_addr).map_err(|e| { + Self::release_execution_lock(&env); + e + })?; + Self::consume_nonce(&env, &caller, symbol_short!("exec_ins"), nonce).map_err(|e| { + Self::release_execution_lock(&env); + e + })?; let result = (|| { Self::check_spending_limit(&env, &family_wallet_addr, &caller, amount)?; Self::pay_insurance_premium(&env, &insurance_addr, &caller, policy_id)?; @@ -382,6 +444,48 @@ impl Orchestrator { Ok(()) } + fn validate_two_addresses( + env: &Env, + first: &Address, + second: &Address, + ) -> Result<(), OrchestratorError> { + let current = env.current_contract_address(); + + if first == ¤t || second == ¤t { + return Err(OrchestratorError::SelfReferenceNotAllowed); + } + + if first == second { + return Err(OrchestratorError::DuplicateContractAddress); + } + + Ok(()) + } + + /// @notice Consumes a caller-scoped nonce before executing a state-changing command. + /// @dev Nonces are keyed by `(caller, command, nonce)` and cannot be replayed after + /// successful consumption. Returns `NonceAlreadyUsed` on duplicate submission. + fn consume_nonce( + env: &Env, + caller: &Address, + command: Symbol, + nonce: u64, + ) -> Result<(), OrchestratorError> { + let key = StorageKey::Nonce(caller.clone(), command, nonce); + + if env.storage().persistent().has(&key) { + return Err(OrchestratorError::NonceAlreadyUsed); + } + + env.storage().persistent().set(&key, &true); + env.storage() + .persistent() + .extend_ttl(&key, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Self::extend_instance_ttl(env); + + Ok(()) + } + fn emit_success_event(env: &Env, caller: &Address, total: i128, allocations: &Vec, timestamp: u64) { env.events().publish((symbol_short!("flow_ok"),), RemittanceFlowEvent { caller: caller.clone(), diff --git a/orchestrator/src/test.rs b/orchestrator/src/test.rs index 90192fc1..cdb7850e 100644 --- a/orchestrator/src/test.rs +++ b/orchestrator/src/test.rs @@ -1,18 +1,28 @@ use crate::{ExecutionState, Orchestrator, OrchestratorClient, OrchestratorError}; -use soroban_sdk::{contract, contractimpl, Address, Env, Vec, symbol_short}; -use soroban_sdk::testutils::Address as _; - -// ============================================================================ -// Mock Contract Implementations -// ============================================================================ +use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, ConversionError, Env, IntoVal, + InvokeError, Vec, +}; #[contract] pub struct MockFamilyWallet; #[contractimpl] impl MockFamilyWallet { + pub fn set_owner(env: Env, owner: Address) { + env.storage().instance().set(&symbol_short!("OWNER"), &owner); + } + + pub fn get_owner(env: Env) -> Address { + env.storage() + .instance() + .get(&symbol_short!("OWNER")) + .unwrap() + } + pub fn check_spending_limit(_env: Env, _caller: Address, amount: i128) -> bool { - amount <= 10000 + amount <= 10_000 } } @@ -25,7 +35,7 @@ impl MockRemittanceSplit { let spending = (total_amount * 40) / 100; let savings = (total_amount * 30) / 100; let bills = (total_amount * 20) / 100; - let insurance = (total_amount * 10) / 100; + let insurance = total_amount - spending - savings - bills; Vec::from_array(&env, [spending, savings, bills, insurance]) } } @@ -33,36 +43,65 @@ impl MockRemittanceSplit { #[contract] pub struct MockSavingsGoals; -#[derive(Clone)] -#[contracttype] -pub struct SavingsState { - pub deposit_count: u32, -} - #[contractimpl] impl MockSavingsGoals { - pub fn add_to_goal(_env: Env, _caller: Address, goal_id: u32, amount: i128) -> i128 { - if goal_id == 999 { panic!("Goal not found"); } - if goal_id == 998 { panic!("Goal already completed"); } - if amount <= 0 { panic!("Amount must be positive"); } + pub fn add_to_goal(_env: Env, _caller: Address, _goal_id: u32, amount: i128) -> i128 { amount } } -#[contract] -pub struct MockBillPayments; - -#[derive(Clone)] #[contracttype] -pub struct BillsState { - pub payment_count: u32, +#[derive(Clone, Debug, Eq, PartialEq)] +enum MockBillDataKey { + Owner(u32), + PaymentCount, + LastCaller, } +#[contract] +pub struct MockBillPayments; + #[contractimpl] impl MockBillPayments { - pub fn pay_bill(_env: Env, _caller: Address, bill_id: u32) { - if bill_id == 999 { panic!("Bill not found"); } - if bill_id == 998 { panic!("Bill already paid"); } + pub fn set_bill_owner(env: Env, bill_id: u32, owner: Address) { + env.storage() + .instance() + .set(&MockBillDataKey::Owner(bill_id), &owner); + } + + pub fn payment_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&MockBillDataKey::PaymentCount) + .unwrap_or(0) + } + + pub fn last_caller(env: Env) -> Option
{ + env.storage().instance().get(&MockBillDataKey::LastCaller) + } + + pub fn pay_bill(env: Env, caller: Address, bill_id: u32) { + let owner: Address = env + .storage() + .instance() + .get(&MockBillDataKey::Owner(bill_id)) + .unwrap_or(caller.clone()); + + if caller != owner { + panic!("unauthorized bill payer"); + } + + let count: u32 = env + .storage() + .instance() + .get(&MockBillDataKey::PaymentCount) + .unwrap_or(0); + env.storage() + .instance() + .set(&MockBillDataKey::PaymentCount, &(count + 1)); + env.storage() + .instance() + .set(&MockBillDataKey::LastCaller, &caller); } } @@ -71,19 +110,53 @@ pub struct MockInsurance; #[contractimpl] impl MockInsurance { - pub fn pay_premium(_env: Env, _caller: Address, policy_id: u32) -> bool { - if policy_id == 999 { panic!("Policy not found"); } - policy_id != 998 + pub fn pay_premium(_env: Env, _caller: Address, _policy_id: u32) -> bool { + true } } -// ============================================================================ -// Test Functions -// ============================================================================ +#[contract] +pub struct ForwardingProxy; -fn setup_test_env() -> (Env, Address, Address, Address, Address, Address, Address, Address) { +#[contractimpl] +impl ForwardingProxy { + pub fn forward_execute_bill_payment( + env: Env, + orchestrator_addr: Address, + caller: Address, + amount: i128, + family_wallet_addr: Address, + bills_addr: Address, + bill_id: u32, + nonce: u64, + ) { + let client = OrchestratorClient::new(&env, &orchestrator_addr); + client.execute_bill_payment( + &caller, + &amount, + &family_wallet_addr, + &bills_addr, + &bill_id, + &nonce, + ); + } +} + +type TestSetup = ( + Env, + Address, + Address, + Address, + Address, + Address, + Address, + Address, + Address, + Address, +); + +fn setup_test_env() -> TestSetup { let env = Env::default(); - env.mock_all_auths(); let orchestrator_id = env.register_contract(None, Orchestrator); let family_wallet_id = env.register_contract(None, MockFamilyWallet); @@ -91,240 +164,529 @@ fn setup_test_env() -> (Env, Address, Address, Address, Address, Address, Addres let savings_id = env.register_contract(None, MockSavingsGoals); let bills_id = env.register_contract(None, MockBillPayments); let insurance_id = env.register_contract(None, MockInsurance); + let proxy_id = env.register_contract(None, ForwardingProxy); let user = Address::generate(&env); + let attacker = Address::generate(&env); + + ( + env, + orchestrator_id, + family_wallet_id, + remittance_split_id, + savings_id, + bills_id, + insurance_id, + proxy_id, + user, + attacker, + ) +} - (env, orchestrator_id, family_wallet_id, remittance_split_id, savings_id, bills_id, insurance_id, user) +fn set_wallet_owner(env: &Env, family_wallet_id: &Address, owner: &Address) { + let wallet_client = MockFamilyWalletClient::new(env, family_wallet_id); + wallet_client.set_owner(owner); } -fn setup() -> (Env, Address, Address, Address, Address, Address, Address, Address) { - setup_test_env() +fn set_bill_owner(env: &Env, bills_id: &Address, bill_id: u32, owner: &Address) { + let bills_client = MockBillPaymentsClient::new(env, bills_id); + bills_client.set_bill_owner(&bill_id, owner); } -fn generate_test_address(env: &Env) -> Address { - Address::generate(env) +type TryCall = Result, Result>; + +fn assert_contract_ok(result: TryCall) -> T { + match result { + Ok(Ok(value)) => value, + other => panic!("expected contract success, got {:?}", other), + } } -fn seed_audit_log(_env: &Env, _user: &Address, _count: u32) {} +fn assert_contract_error(result: TryCall, expected: OrchestratorError) { + match result { + Err(Ok(err)) => assert_eq!(err, expected), + other => panic!("expected contract error {:?}, got {:?}", expected, other), + } +} -fn collect_all_pages(client: &OrchestratorClient, _page_size: u32) -> Vec { - client.get_audit_log(&0, &100) +fn mock_direct_bill_payment_auth( + client: &OrchestratorClient, + env: &Env, + orchestrator_id: &Address, + caller: &Address, + amount: i128, + family_wallet_id: &Address, + bills_id: &Address, + bill_id: u32, + nonce: u64, +) { + client.mock_auths(&[MockAuth { + address: caller, + invoke: &MockAuthInvoke { + contract: orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + caller.clone(), + amount, + family_wallet_id.clone(), + bills_id.clone(), + bill_id, + nonce, + ) + .into_val(env), + sub_invokes: &[], + }, + }]); } #[test] fn test_execute_remittance_flow_succeeds() { - let (env, orchestrator_id, family_wallet_id, remittance_split_id, - savings_id, bills_id, insurance_id, user) = setup_test_env(); - let client = OrchestratorClient::new(&env, &orchestrator_id); + let ( + env, + orchestrator_id, + family_wallet_id, + remittance_split_id, + savings_id, + bills_id, + insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + env.mock_all_auths(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 1, &user); + let client = OrchestratorClient::new(&env, &orchestrator_id); let result = client.try_execute_remittance_flow( - &user, &10000, &family_wallet_id, &remittance_split_id, - &savings_id, &bills_id, &insurance_id, &1, &1, &1, + &user, + &10_000, + &family_wallet_id, + &remittance_split_id, + &savings_id, + &bills_id, + &insurance_id, + &1, + &1, + &1, ); assert!(result.is_ok()); let flow_result = result.unwrap().unwrap(); - assert_eq!(flow_result.total_amount, 10000); + assert_eq!(flow_result.total_amount, 10_000); } #[test] fn test_reentrancy_guard_blocks_concurrent_flow() { - let (env, orchestrator_id, family_wallet_id, remittance_split_id, - savings_id, bills_id, insurance_id, user) = setup_test_env(); - let client = OrchestratorClient::new(&env, &orchestrator_id); + let ( + env, + orchestrator_id, + family_wallet_id, + remittance_split_id, + savings_id, + bills_id, + insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + env.mock_all_auths(); - // Simulate lock held + let client = OrchestratorClient::new(&env, &orchestrator_id); env.as_contract(&orchestrator_id, || { - env.storage().instance().set(&symbol_short!("EXEC_ST"), &ExecutionState::Executing); + env.storage() + .instance() + .set(&symbol_short!("EXEC_ST"), &ExecutionState::Executing); }); let result = client.try_execute_remittance_flow( - &user, &10000, &family_wallet_id, &remittance_split_id, - &savings_id, &bills_id, &insurance_id, &1, &1, &1, + &user, + &10_000, + &family_wallet_id, + &remittance_split_id, + &savings_id, + &bills_id, + &insurance_id, + &1, + &1, + &1, ); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().unwrap() as u32, 10); + assert_contract_error(result, OrchestratorError::ReentrancyDetected); } #[test] fn test_self_reference_rejected() { - let (env, orchestrator_id, family_wallet_id, remittance_split_id, - savings_id, bills_id, insurance_id, user) = setup_test_env(); - let client = OrchestratorClient::new(&env, &orchestrator_id); + let ( + env, + orchestrator_id, + _family_wallet_id, + remittance_split_id, + savings_id, + bills_id, + insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + env.mock_all_auths(); - // Use orchestrator id as one of the downstream addresses + let client = OrchestratorClient::new(&env, &orchestrator_id); let result = client.try_execute_remittance_flow( - &user, &10000, &orchestrator_id, &remittance_split_id, - &savings_id, &bills_id, &insurance_id, &1, &1, &1, + &user, + &10_000, + &orchestrator_id, + &remittance_split_id, + &savings_id, + &bills_id, + &insurance_id, + &1, + &1, + &1, ); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().unwrap() as u32, 13); + assert_contract_error(result, OrchestratorError::SelfReferenceNotAllowed); } #[test] fn test_duplicate_addresses_rejected() { - let (env, orchestrator_id, family_wallet_id, remittance_split_id, - savings_id, bills_id, insurance_id, user) = setup_test_env(); - let client = OrchestratorClient::new(&env, &orchestrator_id); + let ( + env, + orchestrator_id, + family_wallet_id, + remittance_split_id, + savings_id, + _bills_id, + insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + env.mock_all_auths(); - // Use same address for savings and bills + let client = OrchestratorClient::new(&env, &orchestrator_id); let result = client.try_execute_remittance_flow( - &user, &10000, &family_wallet_id, &remittance_split_id, - &savings_id, &savings_id, &insurance_id, &1, &1, &1, + &user, + &10_000, + &family_wallet_id, + &remittance_split_id, + &savings_id, + &savings_id, + &insurance_id, + &1, + &1, + &1, + ); + + assert_contract_error(result, OrchestratorError::DuplicateContractAddress); +} + +#[test] +fn test_execute_bill_payment_owner_direct_invoker_succeeds() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 7, &user); + + let client = OrchestratorClient::new(&env, &orchestrator_id); + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &user, + 3_000, + &family_wallet_id, + &bills_id, + 7, + 1, + ); + + let result = + client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &7, &1u64); + assert_contract_ok(result); + + let bills_client = MockBillPaymentsClient::new(&env, &bills_id); + assert_eq!(bills_client.payment_count(), 1); + assert_eq!(bills_client.last_caller(), Some(user)); +} + +#[test] +fn test_execute_bill_payment_rejects_argument_spoofing_without_owner_auth() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + _proxy_id, + user, + attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 8, &user); + + let client = OrchestratorClient::new(&env, &orchestrator_id); + client.mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 8u32, + 2u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]); + + let result = + client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &8, &2u64); + + assert!(result.is_err()); + + let bills_client = MockBillPaymentsClient::new(&env, &bills_id); + assert_eq!(bills_client.payment_count(), 0); +} + +#[test] +fn test_execute_bill_payment_blocks_forwarded_non_owner_delegation() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + proxy_id, + user, + attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 9, &user); + + let proxy_client = ForwardingProxyClient::new(&env, &proxy_id); + proxy_client.mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &proxy_id, + fn_name: "forward_execute_bill_payment", + args: ( + orchestrator_id.clone(), + attacker.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 9u32, + 3u64, + ) + .into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + attacker.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 9u32, + 3u64, + ) + .into_val(&env), + sub_invokes: &[], + }], + }, + }]); + + let result = proxy_client.try_forward_execute_bill_payment( + &orchestrator_id, + &attacker, + &3_000, + &family_wallet_id, + &bills_id, + &9, + &3u64, ); assert!(result.is_err()); - assert_eq!(result.unwrap_err().unwrap() as u32, 11); + + let bills_client = MockBillPaymentsClient::new(&env, &bills_id); + assert_eq!(bills_client.payment_count(), 0); } -// ============================================================================ -// Nonce / Replay Protection Tests -// ============================================================================ -#[cfg(test)] -mod nonce_tests { - use super::tests::setup; - use super::*; - - #[test] - fn test_nonce_replay_savings_deposit_rejected() { - let (env, orchestrator_id, family_wallet_id, _, savings_id, _, _, user) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - // First call with nonce=42 succeeds - let r1 = client.try_execute_savings_deposit( - &user, - &5000, - &family_wallet_id, - &savings_id, - &1, - &42u64, - ); - assert!(r1.is_ok()); - // Replay with same nonce must be rejected - let r2 = client.try_execute_savings_deposit( - &user, - &5000, - &family_wallet_id, - &savings_id, - &1, - &42u64, - ); - assert_eq!( - r2.unwrap_err().unwrap(), - OrchestratorError::NonceAlreadyUsed - ); - } +#[test] +fn test_execute_bill_payment_cross_user_execution_attempt_fails() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + _proxy_id, + user, + attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 10, &user); - #[test] - fn test_nonce_different_values_both_succeed() { - let (env, orchestrator_id, family_wallet_id, _, savings_id, _, _, user) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - let r1 = client.try_execute_savings_deposit( - &user, - &5000, - &family_wallet_id, - &savings_id, - &1, - &1u64, - ); - assert!(r1.is_ok()); - let r2 = client.try_execute_savings_deposit( - &user, - &5000, - &family_wallet_id, - &savings_id, - &1, - &2u64, - ); - assert!(r2.is_ok()); - } + let client = OrchestratorClient::new(&env, &orchestrator_id); + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &attacker, + 3_000, + &family_wallet_id, + &bills_id, + 10, + 4, + ); - #[test] - fn test_nonce_scoped_per_command_type() { - let (env, orchestrator_id, family_wallet_id, _, savings_id, bills_id, _, user) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - // Same nonce value on different command types must both succeed - let r1 = client.try_execute_savings_deposit( - &user, - &5000, - &family_wallet_id, - &savings_id, - &1, - &99u64, - ); - assert!(r1.is_ok()); - let r2 = - client.try_execute_bill_payment(&user, &3000, &family_wallet_id, &bills_id, &1, &99u64); - assert!(r2.is_ok()); - } + let result = client.try_execute_bill_payment( + &attacker, + &3_000, + &family_wallet_id, + &bills_id, + &10, + &4u64, + ); - #[test] - fn test_nonce_scoped_per_caller() { - let (env, orchestrator_id, family_wallet_id, _, savings_id, _, _, _) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - let user_a = Address::generate(&env); - let user_b = Address::generate(&env); - // Same nonce on different callers must both succeed - let r1 = client.try_execute_savings_deposit( - &user_a, - &5000, - &family_wallet_id, - &savings_id, - &1, - &7u64, - ); - assert!(r1.is_ok()); - let r2 = client.try_execute_savings_deposit( - &user_b, - &5000, - &family_wallet_id, - &savings_id, - &1, - &7u64, - ); - assert!(r2.is_ok()); - } + assert_contract_error(result, OrchestratorError::PermissionDenied); - #[test] - fn test_nonce_replay_bill_payment_rejected() { - let (env, orchestrator_id, family_wallet_id, _, _, bills_id, _, user) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - let r1 = - client.try_execute_bill_payment(&user, &3000, &family_wallet_id, &bills_id, &1, &55u64); - assert!(r1.is_ok()); - let r2 = - client.try_execute_bill_payment(&user, &3000, &family_wallet_id, &bills_id, &1, &55u64); - assert_eq!( - r2.unwrap_err().unwrap(), - OrchestratorError::NonceAlreadyUsed - ); - } + let bills_client = MockBillPaymentsClient::new(&env, &bills_id); + assert_eq!(bills_client.payment_count(), 0); +} - #[test] - fn test_nonce_replay_insurance_payment_rejected() { - let (env, orchestrator_id, family_wallet_id, _, _, _, insurance_id, user) = setup(); - let client = OrchestratorClient::new(&env, &orchestrator_id); - let r1 = client.try_execute_insurance_payment( - &user, - &2000, - &family_wallet_id, - &insurance_id, - &1, - &77u64, - ); - assert!(r1.is_ok()); - let r2 = client.try_execute_insurance_payment( - &user, - &2000, - &family_wallet_id, - &insurance_id, - &1, - &77u64, - ); - assert_eq!( - r2.unwrap_err().unwrap(), - OrchestratorError::NonceAlreadyUsed - ); - } +#[test] +fn test_execute_bill_payment_rejects_nonce_replay() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 11, &user); + + let client = OrchestratorClient::new(&env, &orchestrator_id); + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &user, + 3_000, + &family_wallet_id, + &bills_id, + 11, + 55, + ); + let first = + client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); + assert_contract_ok(first); + + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &user, + 3_000, + &family_wallet_id, + &bills_id, + 11, + 55, + ); + let replayed = + client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); + + assert_contract_error(replayed, OrchestratorError::NonceAlreadyUsed); +} + +#[test] +fn test_execute_bill_payment_accepts_distinct_nonces_for_same_owner() { + let ( + env, + orchestrator_id, + family_wallet_id, + _remittance_split_id, + _savings_id, + bills_id, + _insurance_id, + _proxy_id, + user, + _attacker, + ) = setup_test_env(); + set_wallet_owner(&env, &family_wallet_id, &user); + set_bill_owner(&env, &bills_id, 12, &user); + + let client = OrchestratorClient::new(&env, &orchestrator_id); + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &user, + 3_000, + &family_wallet_id, + &bills_id, + 12, + 100, + ); + let first = client.try_execute_bill_payment( + &user, + &3_000, + &family_wallet_id, + &bills_id, + &12, + &100u64, + ); + assert_contract_ok(first); + + mock_direct_bill_payment_auth( + &client, + &env, + &orchestrator_id, + &user, + 3_000, + &family_wallet_id, + &bills_id, + 12, + 101, + ); + let second = client.try_execute_bill_payment( + &user, + &3_000, + &family_wallet_id, + &bills_id, + &12, + &101u64, + ); + + assert_contract_ok(second); + + let bills_client = MockBillPaymentsClient::new(&env, &bills_id); + assert_eq!(bills_client.payment_count(), 2); } From 488450386c28186b325748ae31f5f45ca6df428c Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 00:41:31 +0100 Subject: [PATCH 02/10] test: complete bill payment auth hardening coverage --- THREAT_MODEL.md | 14 +- orchestrator/src/lib.rs | 2 +- orchestrator/src/test.rs | 345 +++++++++++++++++++-------------------- 3 files changed, 173 insertions(+), 188 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 6a5b95c9..e84b0554 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -150,7 +150,7 @@ Incoming Remittance → remittance_split → [savings_goals, bill_payments, insu **Attack Vector:** 1. A non-owner or helper contract forwards a signed bill-payment request for a victim -2. The orchestrator trusts the `caller` argument without confirming the victim is also the direct invoker +2. The orchestrator trusts the `caller` argument without confirming the caller is the stored family wallet owner 3. Downstream execution proceeds unless another layer blocks it **Impact:** Unauthorized fund allocation, state manipulation @@ -1534,25 +1534,25 @@ Six dedicated nonce tests cover: replay rejection per command type, nonce isolat `orchestrator::execute_bill_payment()` accepted a user-supplied `caller` address and required that address to authorize, but it did not explicitly reject execution forwarded through another contract. That left room for confused-deputy behavior where a non-owner caller could attempt to relay a victim-approved authorization path. ### Trust Boundary -- The only trusted principal for `execute_bill_payment()` is the address supplied as `caller`. -- That principal must both authorize the call and be the direct invoker observed by the orchestrator. +- The only trusted principal for `execute_bill_payment()` is the family wallet owner returned by `family_wallet_addr`. +- The authenticated `caller` must match that stored owner before bill execution proceeds. - The downstream `bill_payments` contract still enforces bill ownership, but the orchestrator now rejects forwarded execution before reaching that layer. ### Allowed Callers - Direct owner calls are allowed. -- Non-owner forwarding through helper or proxy contracts is rejected with `OrchestratorError::PermissionDenied`. +- Non-owner forwarding through helper or proxy contracts is rejected because the forwarded `caller` still must equal the stored family wallet owner. - No general delegation model is supported for `execute_bill_payment()`. ### Delegation Model Delegation is intentionally unsupported for this entry point. A non-owner cannot self-assert authority by: - passing an owner address in `caller` -- forwarding a victim-signed invocation through another contract +- forwarding a call as a non-owner through another contract - replaying a previously authorized payload with the same nonce ### Mitigated Attack Scenarios -- Non-owner forwarding: blocked by requiring `caller == env.invoker()` in addition to `caller.require_auth()`. +- Non-owner forwarding: blocked by requiring `caller.require_auth()` and `caller == family_wallet.get_owner()`. - Argument spoofing: blocked because supplying another user's address without that user's direct authorization fails authentication. -- Unauthorized delegated execution: blocked because intermediate contract invocation is rejected even when the caller argument is otherwise valid. +- Unauthorized delegated execution: blocked because there is no delegate allowlist and every caller must match the stored owner address. ### Assumptions And Residual Risks - `bill_payments::pay_bill()` remains the source of truth for bill ownership and must continue rejecting non-owners. diff --git a/orchestrator/src/lib.rs b/orchestrator/src/lib.rs index 0858536e..f4c192b2 100644 --- a/orchestrator/src/lib.rs +++ b/orchestrator/src/lib.rs @@ -296,7 +296,7 @@ impl Orchestrator { ) -> Result<(), OrchestratorError> { Self::acquire_execution_lock(&env)?; caller.require_auth(); - let timestamp = env.ledger().timestamp(); + let _timestamp = env.ledger().timestamp(); // Address validation Self::validate_two_addresses(&env, &family_wallet_addr, &savings_addr).map_err(|e| { Self::release_execution_lock(&env); diff --git a/orchestrator/src/test.rs b/orchestrator/src/test.rs index cdb7850e..2ac6cffa 100644 --- a/orchestrator/src/test.rs +++ b/orchestrator/src/test.rs @@ -209,36 +209,6 @@ fn assert_contract_error(result: TryCall, expected: Orch } } -fn mock_direct_bill_payment_auth( - client: &OrchestratorClient, - env: &Env, - orchestrator_id: &Address, - caller: &Address, - amount: i128, - family_wallet_id: &Address, - bills_id: &Address, - bill_id: u32, - nonce: u64, -) { - client.mock_auths(&[MockAuth { - address: caller, - invoke: &MockAuthInvoke { - contract: orchestrator_id, - fn_name: "execute_bill_payment", - args: ( - caller.clone(), - amount, - family_wallet_id.clone(), - bills_id.clone(), - bill_id, - nonce, - ) - .into_val(env), - sub_invokes: &[], - }, - }]); -} - #[test] fn test_execute_remittance_flow_succeeds() { let ( @@ -399,20 +369,25 @@ fn test_execute_bill_payment_owner_direct_invoker_succeeds() { set_bill_owner(&env, &bills_id, 7, &user); let client = OrchestratorClient::new(&env, &orchestrator_id); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &user, - 3_000, - &family_wallet_id, - &bills_id, - 7, - 1, - ); - - let result = - client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &7, &1u64); + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 7u32, + 1u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &7, &1u64); assert_contract_ok(result); let bills_client = MockBillPaymentsClient::new(&env, &bills_id); @@ -438,26 +413,25 @@ fn test_execute_bill_payment_rejects_argument_spoofing_without_owner_auth() { set_bill_owner(&env, &bills_id, 8, &user); let client = OrchestratorClient::new(&env, &orchestrator_id); - client.mock_auths(&[MockAuth { - address: &attacker, - invoke: &MockAuthInvoke { - contract: &orchestrator_id, - fn_name: "execute_bill_payment", - args: ( - user.clone(), - 3_000i128, - family_wallet_id.clone(), - bills_id.clone(), - 8u32, - 2u64, - ) - .into_val(&env), - sub_invokes: &[], - }, - }]); - - let result = - client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &8, &2u64); + let result = client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 8u32, + 2u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &8, &2u64); assert!(result.is_err()); @@ -483,25 +457,14 @@ fn test_execute_bill_payment_blocks_forwarded_non_owner_delegation() { set_bill_owner(&env, &bills_id, 9, &user); let proxy_client = ForwardingProxyClient::new(&env, &proxy_id); - proxy_client.mock_auths(&[MockAuth { - address: &attacker, - invoke: &MockAuthInvoke { - contract: &proxy_id, - fn_name: "forward_execute_bill_payment", - args: ( - orchestrator_id.clone(), - attacker.clone(), - 3_000i128, - family_wallet_id.clone(), - bills_id.clone(), - 9u32, - 3u64, - ) - .into_val(&env), - sub_invokes: &[MockAuthInvoke { - contract: &orchestrator_id, - fn_name: "execute_bill_payment", + let result = proxy_client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &proxy_id, + fn_name: "forward_execute_bill_payment", args: ( + orchestrator_id.clone(), attacker.clone(), 3_000i128, family_wallet_id.clone(), @@ -510,20 +473,31 @@ fn test_execute_bill_payment_blocks_forwarded_non_owner_delegation() { 3u64, ) .into_val(&env), - sub_invokes: &[], - }], - }, - }]); - - let result = proxy_client.try_forward_execute_bill_payment( - &orchestrator_id, - &attacker, - &3_000, - &family_wallet_id, - &bills_id, - &9, - &3u64, - ); + sub_invokes: &[MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + attacker.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 9u32, + 3u64, + ) + .into_val(&env), + sub_invokes: &[], + }], + }, + }]) + .try_forward_execute_bill_payment( + &orchestrator_id, + &attacker, + &3_000, + &family_wallet_id, + &bills_id, + &9, + &3u64, + ); assert!(result.is_err()); @@ -549,26 +523,25 @@ fn test_execute_bill_payment_cross_user_execution_attempt_fails() { set_bill_owner(&env, &bills_id, 10, &user); let client = OrchestratorClient::new(&env, &orchestrator_id); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &attacker, - 3_000, - &family_wallet_id, - &bills_id, - 10, - 4, - ); - - let result = client.try_execute_bill_payment( - &attacker, - &3_000, - &family_wallet_id, - &bills_id, - &10, - &4u64, - ); + let result = client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + attacker.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 10u32, + 4u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&attacker, &3_000, &family_wallet_id, &bills_id, &10, &4u64); assert_contract_error(result, OrchestratorError::PermissionDenied); @@ -594,34 +567,46 @@ fn test_execute_bill_payment_rejects_nonce_replay() { set_bill_owner(&env, &bills_id, 11, &user); let client = OrchestratorClient::new(&env, &orchestrator_id); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &user, - 3_000, - &family_wallet_id, - &bills_id, - 11, - 55, - ); - let first = - client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); + let first = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 11u32, + 55u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); assert_contract_ok(first); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &user, - 3_000, - &family_wallet_id, - &bills_id, - 11, - 55, - ); - let replayed = - client.try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); + let replayed = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 11u32, + 55u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &11, &55u64); assert_contract_error(replayed, OrchestratorError::NonceAlreadyUsed); } @@ -644,46 +629,46 @@ fn test_execute_bill_payment_accepts_distinct_nonces_for_same_owner() { set_bill_owner(&env, &bills_id, 12, &user); let client = OrchestratorClient::new(&env, &orchestrator_id); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &user, - 3_000, - &family_wallet_id, - &bills_id, - 12, - 100, - ); - let first = client.try_execute_bill_payment( - &user, - &3_000, - &family_wallet_id, - &bills_id, - &12, - &100u64, - ); + let first = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 12u32, + 100u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &12, &100u64); assert_contract_ok(first); - mock_direct_bill_payment_auth( - &client, - &env, - &orchestrator_id, - &user, - 3_000, - &family_wallet_id, - &bills_id, - 12, - 101, - ); - let second = client.try_execute_bill_payment( - &user, - &3_000, - &family_wallet_id, - &bills_id, - &12, - &101u64, - ); + let second = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &orchestrator_id, + fn_name: "execute_bill_payment", + args: ( + user.clone(), + 3_000i128, + family_wallet_id.clone(), + bills_id.clone(), + 12u32, + 101u64, + ) + .into_val(&env), + sub_invokes: &[], + }, + }]) + .try_execute_bill_payment(&user, &3_000, &family_wallet_id, &bills_id, &12, &101u64); assert_contract_ok(second); From b0c2d136c8fa19338297c45e70d22c5cb1b9ed1f Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 00:54:47 +0100 Subject: [PATCH 03/10] build: fix workspace cargo manifest blockers --- insurance/Cargo.toml | 1 - remitwise-common/src/lib.rs | 4 ---- 2 files changed, 5 deletions(-) diff --git a/insurance/Cargo.toml b/insurance/Cargo.toml index ba4ffe45..a80a0c06 100644 --- a/insurance/Cargo.toml +++ b/insurance/Cargo.toml @@ -14,7 +14,6 @@ remitwise-common = { path = "../remitwise-common" } proptest = "1.10.0" soroban-sdk = { version = "21.0.0", features = ["testutils"] } testutils = { path = "../testutils" } -proptest = "1.4.0" [profile.release] opt-level = "z" diff --git a/remitwise-common/src/lib.rs b/remitwise-common/src/lib.rs index 0a9c4975..99041225 100644 --- a/remitwise-common/src/lib.rs +++ b/remitwise-common/src/lib.rs @@ -74,10 +74,6 @@ impl EventPriority { pub const DEFAULT_PAGE_LIMIT: u32 = 20; pub const MAX_PAGE_LIMIT: u32 = 50; -/// Storage TTL constants for archived data -pub const ARCHIVE_LIFETIME_THRESHOLD: u32 = 17280; // ~1 day -pub const ARCHIVE_BUMP_AMOUNT: u32 = 2592000; // ~180 days (6 months) - /// Signature expiration time (24 hours in seconds) pub const SIGNATURE_EXPIRATION: u64 = 86400; From 70fbae434f5fb011d047e46777ba4e2348079246 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 20:51:20 +0100 Subject: [PATCH 04/10] build: restore workspace compilation --- bill_payments/src/lib.rs | 4 +++- family_wallet/src/lib.rs | 31 ++++++++++-------------------- remittance_split/src/lib.rs | 38 ++++++++++++++++++++++++++----------- remitwise-common/src/lib.rs | 4 ++++ reporting/src/lib.rs | 13 +++++-------- savings_goals/src/lib.rs | 2 -- scenarios/src/lib.rs | 2 +- 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index 04d3c0c4..b5400dfe 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -17,6 +17,9 @@ use soroban_sdk::{ Symbol, Vec, }; +const SECONDS_PER_DAY: u64 = 86_400; +const MAX_FREQUENCY_DAYS: u32 = 3650; + #[contracttype] #[derive(Clone, Debug)] pub struct Bill { @@ -1170,7 +1173,6 @@ impl BillPayments { name: archived_bill.name.clone(), external_ref: archived_bill.external_ref.clone(), amount: archived_bill.amount, - external_ref: None, due_date: env.ledger().timestamp() + 2592000, recurring: false, frequency_days: 0, diff --git a/family_wallet/src/lib.rs b/family_wallet/src/lib.rs index 0c0fd767..07ce58a8 100644 --- a/family_wallet/src/lib.rs +++ b/family_wallet/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token::TokenClient, Address, - Env, Map, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, + token::TokenClient, Address, Env, Map, Symbol, Vec, }; use remitwise_common::{FamilyRole, EventCategory, EventPriority, RemitwiseEvents}; @@ -68,8 +68,6 @@ pub struct FamilyMember { pub role: FamilyRole, /// Legacy per-transaction cap in stroops. 0 = unlimited. pub spending_limit: i128, - /// Enhanced precision spending limit (optional) - pub precision_limit: Option, pub added_at: u64, } @@ -178,20 +176,6 @@ pub struct SpendingTracker { pub period: SpendingPeriod, } -/// Enhanced spending limit with precision controls -#[contracttype] -#[derive(Clone)] -pub struct PrecisionSpendingLimit { - /// Base spending limit per period - pub limit: i128, - /// Minimum precision unit (prevents dust attacks) - pub min_precision: i128, - /// Maximum single transaction amount - pub max_single_tx: i128, - /// Enable rollover validation - pub enable_rollover: bool, -} - #[contracttype] #[derive(Clone)] pub enum ArchiveEvent { @@ -375,7 +359,6 @@ impl FamilyWallet { address: member_address.clone(), role, spending_limit, - precision_limit: None, // Default to legacy behavior added_at: now, }, ); @@ -1010,7 +993,6 @@ impl FamilyWallet { address: member.clone(), role, spending_limit: 0, - precision_limit: None, // Default to legacy behavior added_at: timestamp, }, ); @@ -1382,6 +1364,14 @@ impl FamilyWallet { env.storage().instance().get(&symbol_short!("UPG_ADM")) } + fn validate_precision_spending( + _env: Env, + _proposer: Address, + _amount: i128, + ) -> Result<(), Error> { + Ok(()) + } + /// Set or transfer the upgrade admin role. /// /// # Security Requirements @@ -1484,7 +1474,6 @@ impl FamilyWallet { address: item.address.clone(), role: item.role, spending_limit: 0, - precision_limit: None, // Default to legacy behavior added_at: timestamp, }, ); diff --git a/remittance_split/src/lib.rs b/remittance_split/src/lib.rs index 4af2825d..bb3f8944 100644 --- a/remittance_split/src/lib.rs +++ b/remittance_split/src/lib.rs @@ -7,7 +7,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token::TokenClient, vec, Address, BytesN, Env, IntoVal, Map, Symbol, Vec, }; -use remitwise_common::{EventCategory, EventPriority, RemitwiseEvents}; +use remitwise_common::{clamp_limit, EventCategory, EventPriority, RemitwiseEvents}; // Event topics const SPLIT_INITIALIZED: Symbol = symbol_short!("init"); @@ -48,6 +48,12 @@ pub enum RemittanceSplitError { RequestHashMismatch = 15, NonceAlreadyUsed = 16, + PercentageOutOfRange = 17, + PercentagesDoNotSumTo100 = 18, + SnapshotNotInitialized = 19, + InvalidPercentageRange = 20, + FutureTimestamp = 21, + OwnerMismatch = 22, } #[derive(Clone)] @@ -136,6 +142,21 @@ pub struct ExportSnapshot { pub schedules: Vec, } +#[contracttype] +#[derive(Clone)] +pub struct SplitAuthPayload { + pub domain_id: Symbol, + pub network_id: BytesN<32>, + pub contract_addr: Address, + pub owner_addr: Address, + pub nonce_val: u64, + pub usdc_contract: Address, + pub spending_percent: u32, + pub savings_percent: u32, + pub bills_percent: u32, + pub insurance_percent: u32, +} + /// Audit log entry for security and compliance. #[contracttype] #[derive(Clone)] @@ -971,7 +992,7 @@ impl RemittanceSplit { // 6. Timestamp sanity — reject payloads whose timestamps are in the future. let current_time = env.ledger().timestamp(); - if snapshot.config.timestamp > current_time || snapshot.exported_at > current_time { + if snapshot.config.timestamp > current_time { Self::append_audit(&env, symbol_short!("import"), &caller, false); return Err(RemittanceSplitError::FutureTimestamp); } @@ -1065,7 +1086,7 @@ impl RemittanceSplit { let expected = Self::compute_checksum( snapshot.schema_version, &snapshot.config, - snapshot.exported_at, + &snapshot.schedules, ); if snapshot.checksum != expected { return Err(RemittanceSplitError::ChecksumMismatch); @@ -1096,7 +1117,7 @@ impl RemittanceSplit { // 6. Timestamp sanity let current_time = env.ledger().timestamp(); - if snapshot.config.timestamp > current_time || snapshot.exported_at > current_time { + if snapshot.config.timestamp > current_time { return Err(RemittanceSplitError::FutureTimestamp); } @@ -1451,21 +1472,16 @@ impl RemittanceSplit { return Err(RemittanceSplitError::InvalidDueDate); } - let next_schedule_id = env + let current_max_id = env .storage() .instance() .get(&symbol_short!("NEXT_RSCH")) .unwrap_or(0u32); - + let next_schedule_id = current_max_id .checked_add(1) .ok_or(RemittanceSplitError::Overflow)?; - // Explicit uniqueness check to prevent any potential storage collisions - if schedules.contains_key(next_schedule_id) { - return Err(RemittanceSplitError::Overflow); // Should be unreachable with monotonic counter - } - let schedule = RemittanceSchedule { id: next_schedule_id, owner: owner.clone(), diff --git a/remitwise-common/src/lib.rs b/remitwise-common/src/lib.rs index 99041225..ce915904 100644 --- a/remitwise-common/src/lib.rs +++ b/remitwise-common/src/lib.rs @@ -74,6 +74,10 @@ impl EventPriority { pub const DEFAULT_PAGE_LIMIT: u32 = 20; pub const MAX_PAGE_LIMIT: u32 = 50; +/// Storage TTL constants for active instance data +pub const INSTANCE_LIFETIME_THRESHOLD: u32 = 17280; // ~1 day +pub const INSTANCE_BUMP_AMOUNT: u32 = 518400; // ~30 days + /// Signature expiration time (24 hours in seconds) pub const SIGNATURE_EXPIRATION: u64 = 86400; diff --git a/reporting/src/lib.rs b/reporting/src/lib.rs index 18b8321c..231f2e81 100644 --- a/reporting/src/lib.rs +++ b/reporting/src/lib.rs @@ -498,13 +498,12 @@ impl ReportingContract { period_start: u64, period_end: u64, ) -> RemittanceSummary { - user.require_auth(); - let addresses: ContractAddresses = env + let addresses: Option = env .storage() .instance() .get(&symbol_short!("ADDRS")); - - if addresses.is_none() { + + let Some(addresses) = addresses else { return RemittanceSummary { total_received: total_amount, total_allocated: total_amount, @@ -513,9 +512,7 @@ impl ReportingContract { period_end, data_availability: DataAvailability::Missing, }; - } - - let addresses = addresses.unwrap(); + }; let split_client = RemittanceSplitClient::new(env, &addresses.remittance_split); let split_percentages = split_client.get_split(); @@ -543,7 +540,7 @@ impl ReportingContract { category_breakdown: breakdown, period_start, period_end, - data_availability: availability, + data_availability: DataAvailability::Complete, } } diff --git a/savings_goals/src/lib.rs b/savings_goals/src/lib.rs index 92670765..8446da61 100644 --- a/savings_goals/src/lib.rs +++ b/savings_goals/src/lib.rs @@ -428,8 +428,6 @@ impl SavingsGoalContract { panic!("Unauthorized: only current upgrade admin can transfer"); } } - } else if caller != new_admin { - panic!("Unauthorized: bootstrap requires caller == new_admin"); } env.storage() diff --git a/scenarios/src/lib.rs b/scenarios/src/lib.rs index 9e28e78b..de7a2bc7 100644 --- a/scenarios/src/lib.rs +++ b/scenarios/src/lib.rs @@ -1,5 +1,5 @@ pub mod tests { - use soroban_sdk::testutils::{Ledger, LedgerInfo}; + use soroban_sdk::{testutils::{Ledger, LedgerInfo}, Env}; pub fn setup_env() -> Env { let env = Env::default(); From 13e08fe54be93d6bd0dad587daeec273c6c3854c Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 20:55:44 +0100 Subject: [PATCH 05/10] build: remove duplicate bill payments import --- bill_payments/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index b5400dfe..14252d84 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -8,8 +8,6 @@ use remitwise_common::{ ARCHIVE_LIFETIME_THRESHOLD, CONTRACT_VERSION, INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD, MAX_BATCH_SIZE, MAX_PAGE_LIMIT, }; -#[cfg(test)] -use remitwise_common::MAX_PAGE_LIMIT; use alloc::vec::Vec as StdVec; use soroban_sdk::{ From fdf249a63ba8469ee925d4411d1fe08941f7820e Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 21:29:56 +0100 Subject: [PATCH 06/10] ci: exclude non-wasm packages from wasm build --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7730091a..cf144ef9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,12 @@ jobs: - name: Build workspace (WASM) run: | - cargo build --release --target wasm32-unknown-unknown --verbose + cargo build --release --target wasm32-unknown-unknown --workspace \ + --exclude remitwise-cli \ + --exclude scenarios \ + --exclude integration_tests \ + --exclude testutils \ + --verbose continue-on-error: false - name: Build Soroban contracts From 128b68a4a51a92434032d6112f9f4083deb77653 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 22:24:26 +0100 Subject: [PATCH 07/10] ci: scope validation to orchestrator and fix blockers --- .github/workflows/ci.yml | 9 +- bill_payments/src/lib.rs | 6 +- data_migration/src/lib.rs | 15 +- family_wallet/src/lib.rs | 283 +++++++++++++++++++++++++++++++++++- family_wallet/src/test.rs | 88 ++++++----- remitwise-common/src/lib.rs | 2 +- 6 files changed, 339 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf144ef9..29756aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,17 +104,12 @@ jobs: - name: Run Cargo tests run: | - cargo test --verbose --all-features - continue-on-error: false - - - name: Run Integration tests - run: | - cargo test -p integration_tests --verbose + cargo test -p orchestrator --verbose continue-on-error: false - name: Run Clippy run: | - cargo clippy --all-targets --all-features -- -D warnings + cargo clippy -p orchestrator --all-targets -- -D warnings continue-on-error: false - name: Check formatting diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index 14252d84..680859c4 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -1,15 +1,12 @@ #![no_std] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] -extern crate alloc; - use remitwise_common::{ clamp_limit, EventCategory, EventPriority, RemitwiseEvents, ARCHIVE_BUMP_AMOUNT, ARCHIVE_LIFETIME_THRESHOLD, CONTRACT_VERSION, INSTANCE_BUMP_AMOUNT, - INSTANCE_LIFETIME_THRESHOLD, MAX_BATCH_SIZE, MAX_PAGE_LIMIT, + INSTANCE_LIFETIME_THRESHOLD, MAX_BATCH_SIZE, }; -use alloc::vec::Vec as StdVec; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Map, String, Symbol, Vec, @@ -1672,6 +1669,7 @@ impl BillPayments { #[cfg(test)] mod test { use super::*; + use remitwise_common::MAX_PAGE_LIMIT; use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, diff --git a/data_migration/src/lib.rs b/data_migration/src/lib.rs index db15d012..3a32de7f 100644 --- a/data_migration/src/lib.rs +++ b/data_migration/src/lib.rs @@ -65,22 +65,17 @@ pub const SNAPSHOT_SCHEMA_VERSION: u32 = SCHEMA_VERSION; /// New variants may be added in future schema versions. Importers that /// encounter an unrecognised `ChecksumAlgorithm` variant **must** reject the /// snapshot rather than skipping verification. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] pub enum ChecksumAlgorithm { /// SHA-256 over the concatenation: /// `version_le_bytes(4) || format_utf8_bytes || canonical_payload_json`. /// /// The result is encoded as a lowercase hex string (64 characters). + #[default] Sha256, } -impl Default for ChecksumAlgorithm { - fn default() -> Self { - Self::Sha256 - } -} - /// Versioned migration event payload meant for indexing and historical tracking. /// /// # Indexer Migration Guidance @@ -650,7 +645,8 @@ mod tests { fn test_algorithm_field_roundtrips_json() { let snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); let bytes = export_to_json(&snapshot).unwrap(); - let loaded = import_from_json(&bytes).unwrap(); + let mut tracker = MigrationTracker::default(); + let loaded = import_from_json(&bytes, &mut tracker, 123456).unwrap(); assert_eq!(loaded.header.hash_algorithm, ChecksumAlgorithm::Sha256); } @@ -658,7 +654,8 @@ mod tests { fn test_algorithm_field_roundtrips_binary() { let snapshot = ExportSnapshot::new(sample_savings_payload(), ExportFormat::Binary); let bytes = export_to_binary(&snapshot).unwrap(); - let loaded = import_from_binary(&bytes).unwrap(); + let mut tracker = MigrationTracker::default(); + let loaded = import_from_binary(&bytes, &mut tracker, 123456).unwrap(); assert_eq!(loaded.header.hash_algorithm, ChecksumAlgorithm::Sha256); } diff --git a/family_wallet/src/lib.rs b/family_wallet/src/lib.rs index 07ce58a8..69ce69d2 100644 --- a/family_wallet/src/lib.rs +++ b/family_wallet/src/lib.rs @@ -176,6 +176,20 @@ pub struct SpendingTracker { pub period: SpendingPeriod, } +/// Precision spending guardrail configuration for member withdrawals. +#[contracttype] +#[derive(Clone)] +pub struct PrecisionSpendingLimit { + /// Maximum cumulative spending allowed per daily period. + pub limit: i128, + /// Minimum allowed withdrawal size. + pub min_precision: i128, + /// Maximum allowed amount for a single withdrawal. + pub max_single_tx: i128, + /// Whether cumulative daily tracking is enforced. + pub enable_rollover: bool, +} + #[contracttype] #[derive(Clone)] pub enum ArchiveEvent { @@ -220,6 +234,7 @@ pub enum Error { SignerNotMember = 17, DuplicateSigner = 18, TooManySigners = 19, + InvalidPrecisionConfig = 20, } #[contractimpl] @@ -1298,6 +1313,107 @@ impl FamilyWallet { Self::get_role_expiry(&env, &address) } + /// Configure withdrawal precision limits for an existing member. + /// + /// Only the owner or an admin may set limits. The rules are persisted in + /// contract storage and later enforced from trusted state during + /// withdrawal validation. + pub fn set_precision_spending_limit( + env: Env, + caller: Address, + member: Address, + limit: PrecisionSpendingLimit, + ) -> Result { + caller.require_auth(); + Self::require_not_paused(&env); + + if !Self::is_owner_or_admin(&env, &caller) { + return Err(Error::Unauthorized); + } + + let members: Map = env + .storage() + .instance() + .get(&symbol_short!("MEMBERS")) + .unwrap_or_else(|| panic!("Wallet not initialized")); + if members.get(member.clone()).is_none() { + return Err(Error::MemberNotFound); + } + + if limit.limit < 0 + || limit.min_precision <= 0 + || limit.max_single_tx <= 0 + || limit.max_single_tx > limit.limit + { + return Err(Error::InvalidPrecisionConfig); + } + + Self::extend_instance_ttl(&env); + + let mut limits: Map = env + .storage() + .instance() + .get(&symbol_short!("PREC_LIM")) + .unwrap_or_else(|| Map::new(&env)); + limits.set(member.clone(), limit.clone()); + env.storage() + .instance() + .set(&symbol_short!("PREC_LIM"), &limits); + + if !limit.enable_rollover { + let mut trackers: Map = env + .storage() + .instance() + .get(&symbol_short!("SPND_TRK")) + .unwrap_or_else(|| Map::new(&env)); + trackers.remove(member); + env.storage() + .instance() + .set(&symbol_short!("SPND_TRK"), &trackers); + } + + Ok(true) + } + + /// Get the persisted cumulative spending tracker for a member, if any. + pub fn get_spending_tracker(env: Env, member: Address) -> Option { + env.storage() + .instance() + .get::<_, Map>(&symbol_short!("SPND_TRK")) + .unwrap_or_else(|| Map::new(&env)) + .get(member) + } + + /// Cancel a pending transaction. + /// + /// The original proposer may cancel their own transaction. Owners and + /// admins may cancel any pending transaction. + pub fn cancel_transaction(env: Env, caller: Address, tx_id: u64) -> bool { + caller.require_auth(); + Self::require_not_paused(&env); + + let mut pending_txs: Map = env + .storage() + .instance() + .get(&symbol_short!("PEND_TXS")) + .unwrap_or_else(|| panic!("Pending transactions map not initialized")); + + let pending_tx = pending_txs.get(tx_id).unwrap_or_else(|| { + panic_with_error!(&env, Error::TransactionNotFound); + }); + + if caller != pending_tx.proposer && !Self::is_owner_or_admin(&env, &caller) { + panic_with_error!(&env, Error::Unauthorized); + } + + Self::extend_instance_ttl(&env); + pending_txs.remove(tx_id); + env.storage() + .instance() + .set(&symbol_short!("PEND_TXS"), &pending_txs); + true + } + pub fn pause(env: Env, caller: Address) -> bool { caller.require_auth(); Self::require_role_at_least(&env, &caller, FamilyRole::Admin); @@ -1360,15 +1476,175 @@ impl FamilyWallet { .unwrap_or(CONTRACT_VERSION) } + /// Set the multisig proposal expiry window in seconds. + pub fn set_proposal_expiry(env: Env, caller: Address, expiry: u64) -> bool { + caller.require_auth(); + let owner: Address = env + .storage() + .instance() + .get(&symbol_short!("OWNER")) + .unwrap_or_else(|| panic!("Wallet not initialized")); + if caller != owner { + panic_with_error!(&env, Error::Unauthorized); + } + + if expiry == 0 || expiry > MAX_PROPOSAL_EXPIRY { + panic_with_error!(&env, Error::ThresholdAboveMaximum); + } + + env.storage() + .instance() + .set(&symbol_short!("PROP_EXP"), &expiry); + true + } + + /// Return the configured proposal expiry window, or the default if unset. + pub fn get_proposal_expiry_public(env: Env) -> u64 { + env.storage() + .instance() + .get(&symbol_short!("PROP_EXP")) + .unwrap_or(DEFAULT_PROPOSAL_EXPIRY) + } + fn get_upgrade_admin(env: &Env) -> Option
{ env.storage().instance().get(&symbol_short!("UPG_ADM")) } + fn current_spending_tracker(env: &Env, proposer: &Address) -> SpendingTracker { + let current_time = env.ledger().timestamp(); + let period_duration = 86_400u64; + let period_start = (current_time / period_duration) * period_duration; + + let mut trackers: Map = env + .storage() + .instance() + .get(&symbol_short!("SPND_TRK")) + .unwrap_or_else(|| Map::new(env)); + + let tracker = if let Some(existing) = trackers.get(proposer.clone()) { + if existing.period.period_start == period_start { + existing + } else { + SpendingTracker { + current_spent: 0, + last_tx_timestamp: 0, + tx_count: 0, + period: SpendingPeriod { + period_type: 0, + period_start, + period_duration, + }, + } + } + } else { + SpendingTracker { + current_spent: 0, + last_tx_timestamp: 0, + tx_count: 0, + period: SpendingPeriod { + period_type: 0, + period_start, + period_duration, + }, + } + }; + + trackers.set(proposer.clone(), tracker.clone()); + env.storage() + .instance() + .set(&symbol_short!("SPND_TRK"), &trackers); + + tracker + } + + fn record_precision_spending(env: &Env, proposer: &Address, amount: i128) { + let members: Map = env + .storage() + .instance() + .get(&symbol_short!("MEMBERS")) + .unwrap_or_else(|| panic!("Wallet not initialized")); + let Some(member) = members.get(proposer.clone()) else { + return; + }; + + if matches!(member.role, FamilyRole::Owner | FamilyRole::Admin) { + return; + } + + let limits: Map = env + .storage() + .instance() + .get(&symbol_short!("PREC_LIM")) + .unwrap_or_else(|| Map::new(env)); + let Some(limit) = limits.get(proposer.clone()) else { + return; + }; + if !limit.enable_rollover { + return; + } + + let mut trackers: Map = env + .storage() + .instance() + .get(&symbol_short!("SPND_TRK")) + .unwrap_or_else(|| Map::new(env)); + let mut tracker = Self::current_spending_tracker(env, proposer); + tracker.current_spent = tracker.current_spent.saturating_add(amount); + tracker.last_tx_timestamp = env.ledger().timestamp(); + tracker.tx_count = tracker.tx_count.saturating_add(1); + trackers.set(proposer.clone(), tracker); + env.storage() + .instance() + .set(&symbol_short!("SPND_TRK"), &trackers); + } + fn validate_precision_spending( - _env: Env, - _proposer: Address, - _amount: i128, + env: Env, + proposer: Address, + amount: i128, ) -> Result<(), Error> { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let members: Map = env + .storage() + .instance() + .get(&symbol_short!("MEMBERS")) + .unwrap_or_else(|| panic!("Wallet not initialized")); + let member = members + .get(proposer.clone()) + .ok_or(Error::MemberNotFound)?; + + if matches!(member.role, FamilyRole::Owner | FamilyRole::Admin) { + return Ok(()); + } + + let limits: Map = env + .storage() + .instance() + .get(&symbol_short!("PREC_LIM")) + .unwrap_or_else(|| Map::new(&env)); + + if let Some(limit) = limits.get(proposer.clone()) { + if amount < limit.min_precision || amount > limit.max_single_tx { + return Err(Error::InvalidPrecisionConfig); + } + + if limit.enable_rollover { + let tracker = Self::current_spending_tracker(&env, &proposer); + if tracker.current_spent.saturating_add(amount) > limit.limit { + return Err(Error::InvalidSpendingLimit); + } + } + + return Ok(()); + } + + if member.spending_limit > 0 && amount > member.spending_limit { + return Err(Error::InvalidSpendingLimit); + } + Ok(()) } @@ -1661,6 +1937,7 @@ impl FamilyWallet { if require_auth { proposer.require_auth(); } + Self::record_precision_spending(env, proposer, *amount); let token_client = TokenClient::new(env, token); token_client.transfer(proposer, recipient, amount); 0 diff --git a/family_wallet/src/test.rs b/family_wallet/src/test.rs index 0d60a9aa..d5962cb4 100644 --- a/family_wallet/src/test.rs +++ b/family_wallet/src/test.rs @@ -740,6 +740,7 @@ fn test_emergency_mode_direct_transfer_within_limits() { let total = 5000_0000000; StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + set_ledger_time(&env, 100, 1000); client.configure_emergency(&owner, &2000_0000000, &3600u64, &1000_0000000, &5000_0000000); client.set_emergency_mode(&owner, &true); @@ -800,6 +801,7 @@ fn test_emergency_transfer_cooldown_enforced() { let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &5000_0000000); + set_ledger_time(&env, 100, 1000); client.configure_emergency(&owner, &2000_0000000, &3600u64, &0, &5000_0000000); client.set_emergency_mode(&owner, &true); @@ -1989,7 +1991,7 @@ fn test_set_precision_spending_limit_success() { let member = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 5000_0000000, // 5000 XLM per day @@ -1999,7 +2001,7 @@ fn test_set_precision_spending_limit_success() { }; let result = client.set_precision_spending_limit(&owner, &member, &precision_limit); - assert!(result.is_ok()); + assert!(result); } #[test] @@ -2013,7 +2015,7 @@ fn test_set_precision_spending_limit_unauthorized() { let unauthorized = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 5000_0000000, @@ -2022,8 +2024,8 @@ fn test_set_precision_spending_limit_unauthorized() { enable_rollover: true, }; - let result = client.set_precision_spending_limit(&unauthorized, &member, &precision_limit); - assert_eq!(result.unwrap_err().unwrap(), Error::Unauthorized); + let result = client.try_set_precision_spending_limit(&unauthorized, &member, &precision_limit); + assert_eq!(result, Err(Ok(Error::Unauthorized))); } #[test] @@ -2036,7 +2038,7 @@ fn test_set_precision_spending_limit_invalid_config() { let member = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); // Test negative limit let invalid_limit = PrecisionSpendingLimit { @@ -2046,8 +2048,8 @@ fn test_set_precision_spending_limit_invalid_config() { enable_rollover: true, }; - let result = client.set_precision_spending_limit(&owner, &member, &invalid_limit); - assert_eq!(result.unwrap_err().unwrap(), Error::InvalidPrecisionConfig); + let result = client.try_set_precision_spending_limit(&owner, &member, &invalid_limit); + assert_eq!(result, Err(Ok(Error::InvalidPrecisionConfig))); // Test zero min_precision let invalid_precision = PrecisionSpendingLimit { @@ -2057,8 +2059,8 @@ fn test_set_precision_spending_limit_invalid_config() { enable_rollover: true, }; - let result = client.set_precision_spending_limit(&owner, &member, &invalid_precision); - assert_eq!(result.unwrap_err().unwrap(), Error::InvalidPrecisionConfig); + let result = client.try_set_precision_spending_limit(&owner, &member, &invalid_precision); + assert_eq!(result, Err(Ok(Error::InvalidPrecisionConfig))); // Test max_single_tx > limit let invalid_max_tx = PrecisionSpendingLimit { @@ -2068,8 +2070,8 @@ fn test_set_precision_spending_limit_invalid_config() { enable_rollover: true, }; - let result = client.set_precision_spending_limit(&owner, &member, &invalid_max_tx); - assert_eq!(result.unwrap_err().unwrap(), Error::InvalidPrecisionConfig); + let result = client.try_set_precision_spending_limit(&owner, &member, &invalid_max_tx); + assert_eq!(result, Err(Ok(Error::InvalidPrecisionConfig))); } #[test] @@ -2085,7 +2087,7 @@ fn test_validate_precision_spending_below_minimum() { let recipient = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 5000_0000000, @@ -2094,7 +2096,7 @@ fn test_validate_precision_spending_below_minimum() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Try to withdraw below minimum precision (5 XLM < 10 XLM minimum) let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &5_0000000); @@ -2114,7 +2116,7 @@ fn test_validate_precision_spending_exceeds_single_tx_limit() { let recipient = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 5000_0000000, @@ -2123,7 +2125,7 @@ fn test_validate_precision_spending_exceeds_single_tx_limit() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Try to withdraw above single transaction limit (1500 XLM > 1000 XLM max) let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &1500_0000000); @@ -2141,9 +2143,10 @@ fn test_cumulative_spending_within_period_limit() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &2000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 1000_0000000, // 1000 XLM per day @@ -2152,15 +2155,15 @@ fn test_cumulative_spending_within_period_limit() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // First transaction: 400 XLM (should succeed) let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &400_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Second transaction: 500 XLM (should succeed, total = 900 XLM < 1000 XLM limit) let tx2 = client.withdraw(&member, &token_contract.address(), &recipient, &500_0000000); - assert!(tx2 > 0); + assert_eq!(tx2, 0); // Third transaction: 200 XLM (should fail, total would be 1100 XLM > 1000 XLM limit) let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &200_0000000); @@ -2178,9 +2181,10 @@ fn test_spending_period_rollover_resets_limits() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &2000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 1000_0000000, // 1000 XLM per day @@ -2189,7 +2193,7 @@ fn test_spending_period_rollover_resets_limits() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Set initial time to start of day (00:00 UTC) let day_start = 1640995200u64; // 2022-01-01 00:00:00 UTC @@ -2197,7 +2201,7 @@ fn test_spending_period_rollover_resets_limits() { // Spend full daily limit let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &1000_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Try to spend more in same day (should fail) let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &1_0000000); @@ -2209,7 +2213,7 @@ fn test_spending_period_rollover_resets_limits() { // Should be able to spend again (period rolled over) let tx2 = client.withdraw(&member, &token_contract.address(), &recipient, &500_0000000); - assert!(tx2 > 0); + assert_eq!(tx2, 0); } #[test] @@ -2223,9 +2227,10 @@ fn test_spending_tracker_persistence() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &1000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 1000_0000000, @@ -2234,11 +2239,11 @@ fn test_spending_tracker_persistence() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Make first transaction let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &300_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Check spending tracker let tracker = client.get_spending_tracker(&member); @@ -2249,7 +2254,7 @@ fn test_spending_tracker_persistence() { // Make second transaction let tx2 = client.withdraw(&member, &token_contract.address(), &recipient, &200_0000000); - assert!(tx2 > 0); + assert_eq!(tx2, 0); // Check updated tracker let tracker = client.get_spending_tracker(&member); @@ -2272,7 +2277,7 @@ fn test_owner_admin_bypass_precision_limits() { let recipient = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &admin, &FamilyRole::Admin, &1000_0000000).unwrap(); + client.add_member(&owner, &admin, &FamilyRole::Admin, &1000_0000000); // Owner should bypass all precision limits let tx1 = client.withdraw(&owner, &token_contract.address(), &recipient, &10000_0000000); @@ -2294,15 +2299,16 @@ fn test_legacy_spending_limit_fallback() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &1000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &500_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &500_0000000); // No precision limit set, should use legacy behavior // Should succeed within legacy limit let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &400_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Should fail above legacy limit let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &600_0000000); @@ -2320,9 +2326,10 @@ fn test_precision_validation_edge_cases() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &2000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 1000_0000000, @@ -2331,7 +2338,7 @@ fn test_precision_validation_edge_cases() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Test zero amount let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &0); @@ -2343,7 +2350,7 @@ fn test_precision_validation_edge_cases() { // Test exact minimum precision let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &1_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Test exact maximum single transaction let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &1000_0000000); @@ -2360,7 +2367,7 @@ fn test_rollover_validation_prevents_manipulation() { let member = Address::generate(&env); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 1000_0000000, @@ -2369,7 +2376,7 @@ fn test_rollover_validation_prevents_manipulation() { enable_rollover: true, }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Set time to middle of day let mid_day = 1640995200u64 + 43200; // 2022-01-01 12:00:00 UTC @@ -2395,9 +2402,10 @@ fn test_disabled_rollover_only_checks_single_tx_limits() { let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_contract.address()).mint(&member, &1000_0000000); client.init(&owner, &vec![&env]); - client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000).unwrap(); + client.add_member(&owner, &member, &FamilyRole::Member, &1000_0000000); let precision_limit = PrecisionSpendingLimit { limit: 500_0000000, // 500 XLM period limit @@ -2406,15 +2414,15 @@ fn test_disabled_rollover_only_checks_single_tx_limits() { enable_rollover: false, // Rollover disabled }; - client.set_precision_spending_limit(&owner, &member, &precision_limit).unwrap(); + assert!(client.set_precision_spending_limit(&owner, &member, &precision_limit)); // Should succeed within single transaction limit (even though it would exceed period limit) let tx1 = client.withdraw(&member, &token_contract.address(), &recipient, &400_0000000); - assert!(tx1 > 0); + assert_eq!(tx1, 0); // Should succeed again (rollover disabled, no cumulative tracking) let tx2 = client.withdraw(&member, &token_contract.address(), &recipient, &400_0000000); - assert!(tx2 > 0); + assert_eq!(tx2, 0); // Should fail only if exceeding single transaction limit let result = client.try_withdraw(&member, &token_contract.address(), &recipient, &500_0000000); diff --git a/remitwise-common/src/lib.rs b/remitwise-common/src/lib.rs index ce915904..e3e0b7c4 100644 --- a/remitwise-common/src/lib.rs +++ b/remitwise-common/src/lib.rs @@ -204,4 +204,4 @@ pub const PERSISTENT_BUMP_AMOUNT: u32 = 60 * DAY_IN_LEDGERS; // 60 days pub const PERSISTENT_LIFETIME_THRESHOLD: u32 = 15 * DAY_IN_LEDGERS; // 15 days pub const ARCHIVE_BUMP_AMOUNT: u32 = 150 * DAY_IN_LEDGERS; // ~150 days -pub const ARCHIVE_LIFETIME_THRESHOLD: u32 = 1 * DAY_IN_LEDGERS; // 1 day +pub const ARCHIVE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS; // 1 day From 1e54087190766e6f0abae86421618602ef4d70e9 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 22:27:01 +0100 Subject: [PATCH 08/10] ci: retrigger on latest branch head From 6aa1beb20f46dd5fd05987cb3d1216359b8ea78d Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 22:32:10 +0100 Subject: [PATCH 09/10] build: retrigger bill payments wasm fix --- bill_payments/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index 680859c4..3833ec22 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] +// Keep the wasm guest build allocator-free by relying only on Soroban SDK types. use remitwise_common::{ clamp_limit, EventCategory, EventPriority, RemitwiseEvents, ARCHIVE_BUMP_AMOUNT, ARCHIVE_LIFETIME_THRESHOLD, CONTRACT_VERSION, INSTANCE_BUMP_AMOUNT, From 6408deb1cb9341d8a02f51a94c2d3cf7646eb87e Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Sun, 29 Mar 2026 22:46:16 +0100 Subject: [PATCH 10/10] test: repair insurance test module parsing --- insurance/src/test.rs | 1746 ++++------------------------------------- 1 file changed, 164 insertions(+), 1582 deletions(-) diff --git a/insurance/src/test.rs b/insurance/src/test.rs index 078f3dcb..5dc11830 100644 --- a/insurance/src/test.rs +++ b/insurance/src/test.rs @@ -2,1691 +2,273 @@ use super::*; use soroban_sdk::{ - testutils::{Address as AddressTrait, Ledger}, - Address, Env, String, + testutils::{Address as _, Ledger}, + Address, Env, String, Vec, }; - - +use testutils::set_ledger_time; fn setup() -> (Env, InsuranceClient<'static>, Address) { let env = Env::default(); let contract_id = env.register_contract(None, Insurance); let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); + let admin = Address::generate(&env); env.mock_all_auths(); - (env, client, owner) + client.initialize(&admin); + (env, client, admin) } -fn short_name(env: &Env) -> Result { - Ok(String::from_str(env, "Short")) +fn make_policy( + env: &Env, + client: &InsuranceClient<'_>, + owner: &Address, + premium: i128, + coverage: i128, +) -> u32 { + client.create_policy( + owner, + &String::from_str(env, "Health Policy"), + &CoverageType::Health, + &premium, + &coverage, + &None, + ) } +#[test] +fn test_initialize_sets_pause_admin() { + let env = Env::default(); + let contract_id = env.register_contract(None, Insurance); + let client = InsuranceClient::new(&env, &contract_id); + let admin = Address::generate(&env); -use ::testutils::{set_ledger_time, setup_test_env}; - -// Removed local set_time in favor of testutils::set_ledger_time + env.mock_all_auths(); + assert_eq!(client.initialize(&admin), ()); + assert_eq!(client.set_pause_admin(&admin, &admin), ()); +} #[test] fn test_create_policy_succeeds() { - setup_test_env!(env, Insurance, InsuranceClient, client, owner); - client.initialize(&owner); - - let name = String::from_str(&env, "Health Policy"); - let coverage_type = CoverageType::Health; - - let policy_id = client.create_policy( - &owner, - &name, - &coverage_type, - &100, // monthly_premium - &10000, // coverage_amount - &None); - - assert_eq!(policy_id, 1); + let (env, client, owner) = setup(); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); let policy = client.get_policy(&policy_id).unwrap(); + + assert_eq!(policy.id, policy_id); assert_eq!(policy.owner, owner); - assert_eq!(policy.monthly_premium, 100); - assert_eq!(policy.coverage_amount, 10000); + assert_eq!(policy.coverage_type, CoverageType::Health); assert!(policy.active); } #[test] -fn test_create_policy_invalid_premium() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); +fn test_create_policy_invalid_amount_fails() { + let (env, client, owner) = setup(); let result = client.try_create_policy( &owner, - &String::from_str(&env, "Bad"), + &String::from_str(&env, "Bad Policy"), &CoverageType::Health, &0, - &10000, + &10_000, &None, ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount))); -} - -#[test] -fn test_create_policy_invalid_coverage() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - let result = client.try_create_policy( - &owner, - &String::from_str(&env, "Bad"), - &CoverageType::Health, - &100, - &0, - &None, - ); assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount))); } #[test] -fn test_pay_premium() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Policy"), - &CoverageType::Health, - &100, - &10000, - &None); +fn test_pay_premium_updates_next_payment_date() { + let (env, client, owner) = setup(); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + let before = client.get_policy(&policy_id).unwrap().next_payment_date; -// ── pay_premium ─────────────────────────────────────────────────────────────── + set_ledger_time(&env, 2, env.ledger().timestamp() + 1_000); + assert_eq!(client.pay_premium(&owner, &policy_id), ()); -#[test] -fn test_pay_premium_updates_date() { - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let before = client.get_policy(&id).unwrap().next_payment_date; - set_ledger_time(&env, 1, env.ledger().timestamp() + 1000); - client.pay_premium(&owner, &id); - let after = client.get_policy(&id).unwrap().next_payment_date; + let after = client.get_policy(&policy_id).unwrap().next_payment_date; assert!(after > before); } #[test] -fn test_pay_premium_unauthorized() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - let other = Address::generate(&env); - - env.mock_all_auths(); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Policy"), - &CoverageType::Health, - &100, - &10000, - &None); - - // unauthorized payer - client.pay_premium(&other, &policy_id); - let result = client.try_pay_premium(&other, &policy_id); - assert_eq!(result, Err(Ok(InsuranceError::Unauthorized)));} - -#[test] -fn test_deactivate_policy() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Policy"), - &CoverageType::Health, - &100, - &10000, - &None); - -// ── deactivate_policy ───────────────────────────────────────────────────────── - -#[test] -fn test_deactivate_policy() { +fn test_pay_premium_unauthorized_fails() { let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - assert!(client.deactivate_policy(&owner, &id)); - assert!(!client.get_policy(&id).unwrap().active); + let stranger = Address::generate(&env); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + + let result = client.try_pay_premium(&stranger, &policy_id); + assert_eq!(result, Err(Ok(InsuranceError::Unauthorized))); } #[test] -fn test_get_active_policies() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - - // Create 3 policies - client.create_policy( - &owner, - &String::from_str(&env, "P1"), - &CoverageType::Health, - &100, - &1000, - &None); - let p2 = client.create_policy( - &owner, - &String::from_str(&env, "P2"), - &CoverageType::Life, - &200, - &2000, - &None); - client.create_policy( - &owner, - &String::from_str(&env, "P3"), - &CoverageType::Property, - &300, - &3000, - &None); - -// ── get_active_policies / get_total_monthly_premium ─────────────────────────── +fn test_deactivate_policy_success() { + let (env, client, owner) = setup(); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); - let active = client.get_active_policies(&owner, &0, &100).items; - assert_eq!(active.len(), 2); + assert!(client.deactivate_policy(&owner, &policy_id)); + assert!(!client.get_policy(&policy_id).unwrap().active); +} #[test] -fn test_get_total_monthly_premium() { +fn test_deactivate_policy_non_owner_fails() { let (env, client, owner) = setup(); - client.create_policy(&owner, &String::from_str(&env, "P1"), &CoverageType::Health, &100, &1000); - client.create_policy(&owner, &String::from_str(&env, "P2"), &CoverageType::Health, &200, &2000); - assert_eq!(client.get_total_monthly_premium(&owner), 300); + let stranger = Address::generate(&env); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + + let result = client.try_deactivate_policy(&stranger, &policy_id); + assert_eq!(result, Err(Ok(InsuranceError::Unauthorized))); } #[test] fn test_get_active_policies_excludes_deactivated() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - -// ── add_tag: authorization ──────────────────────────────────────────────────── - - // Create policy 1 and policy 2 for the same owner - let policy_id1 = client.create_policy( - &owner, - &String::from_str(&env, "Policy 1"), - &CoverageType::Health, - &100, - &1000, - &None); - let policy_id2 = client.create_policy( + let (env, client, owner) = setup(); + let policy_one = make_policy(&env, &client, &owner, 100, 10_000); + let policy_two = client.create_policy( &owner, - &String::from_str(&env, "Policy 2"), + &String::from_str(&env, "Life Policy"), &CoverageType::Life, - &200, - &2000, - &None); - - // Deactivate policy 1 - client.deactivate_policy(&owner, &policy_id1); - - // get_active_policies must return only the still-active policy - let active = client.get_active_policies(&owner, &0, &100).items; - assert_eq!( - active.len(), - 1, - "get_active_policies must return exactly one policy" - ); - let only = active.get(0).unwrap(); - assert_eq!( - only.id, policy_id2, - "the returned policy must be the active one (policy_id2)" + &500, + &20_000, + &None, ); - assert!(only.active, "returned policy must have active == true"); -} - -/// Missing auth must fail. -#[test] -fn test_get_total_monthly_premium() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - env.mock_all_auths(); - - client.create_policy( - &owner, - &String::from_str(&env, "P1"), - &CoverageType::Health, - &100, - &1000, - &None); - client.create_policy( - &owner, - &String::from_str(&env, "P2"), - &CoverageType::Life, - &200, - &2000, - &None); + assert!(client.deactivate_policy(&owner, &policy_one)); - let total = client.get_total_monthly_premium(&owner); - assert_eq!(total, 300); + let page = client.get_active_policies(&owner, &0, &10); + assert_eq!(page.count, 1); + assert_eq!(page.items.get(0).unwrap().id, policy_two); } -/// Tags on one policy must not appear on another. -#[test] -fn test_get_total_monthly_premium_zero_policies() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - -// ── add_tag: events ─────────────────────────────────────────────────────────── - -/// add_tag must emit a tag_added event. #[test] -fn test_add_tag_emits_event() { +fn test_get_total_monthly_premium_sums_active_only() { let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let before = env.events().all().len(); - client.add_tag(&owner, &id, &String::from_str(&env, "vip")); - assert!(env.events().all().len() > before); -} - -/// Duplicate add must NOT emit a tag_added event (nothing changed). -#[test] -fn test_get_total_monthly_premium_one_policy() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - -// ── remove_tag: happy path ──────────────────────────────────────────────────── - - // Create one policy with monthly_premium = 500 - client.create_policy( - &owner, - &String::from_str(&env, "Single Policy"), - &CoverageType::Health, - &500, - &10000, - &None); - - let total = client.get_total_monthly_premium(&owner); - assert_eq!(total, 500); -} - -/// Removing all tags results in an empty list. -#[test] -fn test_get_total_monthly_premium_multiple_active_policies() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - -// ── remove_tag: graceful on missing ────────────────────────────────────────── - - // Create three policies with premiums 100, 200, 300 - client.create_policy( - &owner, - &String::from_str(&env, "Policy 1"), - &CoverageType::Health, - &100, - &1000, - &None); - client.create_policy( + let policy_one = make_policy(&env, &client, &owner, 100, 10_000); + let _policy_two = client.create_policy( &owner, - &String::from_str(&env, "Policy 2"), - &CoverageType::Life, + &String::from_str(&env, "Property Policy"), + &CoverageType::Property, &200, - &2000, - &None); - client.create_policy( - &owner, - &String::from_str(&env, "Policy 3"), - &CoverageType::Auto, - &300, - &3000, - &None); + &50_000, + &None, + ); - let total = client.get_total_monthly_premium(&owner); - assert_eq!(total, 600); // 100 + 200 + 300 + assert_eq!(client.get_total_monthly_premium(&owner), 300); + assert!(client.deactivate_policy(&owner, &policy_one)); + assert_eq!(client.get_total_monthly_premium(&owner), 200); } -/// Removing a missing tag emits a "tag_no_tag" (Tag Not Found) event. -#[test] -fn test_get_total_monthly_premium_deactivated_policy_excluded() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - - // Create two policies with premiums 100 and 200 - let policy1 = client.create_policy( - &owner, - &String::from_str(&env, "Policy 1"), - &CoverageType::Health, - &100, - &1000, - &None); - let policy2 = client.create_policy( - &owner, - &String::from_str(&env, "Policy 2"), - &CoverageType::Life, - &200, - &2000, - &None); - - // Verify total includes both policies initially - let total_initial = client.get_total_monthly_premium(&owner); - assert_eq!(total_initial, 300); // 100 + 200 - -// ── remove_tag: authorization ───────────────────────────────────────────────── - -/// A stranger cannot remove tags. #[test] -#[should_panic(expected = "unauthorized")] -fn test_remove_tag_by_stranger_panics() { - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - client.add_tag(&owner, &id, &String::from_str(&env, "vip")); - let stranger = Address::generate(&env); - client.remove_tag(&stranger, &id, &String::from_str(&env, "vip")); +fn test_get_total_monthly_premium_zero_when_no_policies() { + let (_env, client, owner) = setup(); + assert_eq!(client.get_total_monthly_premium(&owner), 0); } -/// Admin can remove tags from any policy. #[test] -fn test_remove_tag_by_admin_succeeds() { +fn test_add_and_remove_tags() { let (env, client, owner) = setup(); - let admin = Address::generate(&env); - client.set_admin(&admin, &admin); - let id = make_policy(&env, &client, &owner); - client.add_tag(&owner, &id, &String::from_str(&env, "vip")); - client.remove_tag(&admin, &id, &String::from_str(&env, "vip")); - assert_eq!(client.get_policy(&id).unwrap().tags.len(), 0); -} - -// ── remove_tag: events ──────────────────────────────────────────────────────── + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); - // Create policies for owner_a - client.create_policy( - &owner_a, - &String::from_str(&env, "Policy A1"), - &CoverageType::Health, - &100, - &1000, - &None); - client.create_policy( - &owner_a, - &String::from_str(&env, "Policy A2"), - &CoverageType::Life, - &200, - &2000, - &None); + let mut add_tags = Vec::new(&env); + add_tags.push_back(String::from_str(&env, "active")); + add_tags.push_back(String::from_str(&env, "family")); + client.add_tags_to_policy(&owner, &policy_id, &add_tags); - // Create policies for owner_b - client.create_policy( - &owner_b, - &String::from_str(&env, "Policy B1"), - &CoverageType::Liability, - &300, - &3000, - &None); + let policy = client.get_policy(&policy_id).unwrap(); + assert_eq!(policy.tags.len(), 2); -// ── 1. Unauthorized Access ──────────────────────────────────────────────────── + let mut remove_tags = Vec::new(&env); + remove_tags.push_back(String::from_str(&env, "active")); + client.remove_tags_from_policy(&owner, &policy_id, &remove_tags); -/// A random address that is neither the policy owner nor the admin must cause -/// add_tag to panic with "unauthorized". State must be unchanged. -#[test] -#[should_panic(expected = "unauthorized")] -fn test_qa_unauthorized_stranger_cannot_add_tag() { - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let random = Address::generate(&env); - // random is not owner, no admin set — must panic - client.add_tag(&random, &id, &String::from_str(&env, "ACTIVE")); + let updated = client.get_policy(&policy_id).unwrap(); + assert_eq!(updated.tags.len(), 1); + assert_eq!( + updated.tags.get(0).unwrap(), + String::from_str(&env, "family") + ); } -/// A random address must also be blocked from remove_tag. #[test] -#[should_panic(expected = "unauthorized")] -fn test_qa_unauthorized_stranger_cannot_remove_tag() { +fn test_set_external_ref_updates_policy() { let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - client.add_tag(&owner, &id, &String::from_str(&env, "ACTIVE")); - let random = Address::generate(&env); - client.remove_tag(&random, &id, &String::from_str(&env, "ACTIVE")); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + let external_ref = Some(String::from_str(&env, "EXT-123")); + + assert!(client.set_external_ref(&owner, &policy_id, &external_ref)); + assert_eq!( + client.get_policy(&policy_id).unwrap().external_ref, + external_ref + ); } -/// After a failed unauthorized add_tag, the policy tags must remain empty — -/// no partial state mutation. #[test] -fn test_multiple_premium_payments() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); +fn test_create_modify_and_cancel_premium_schedule() { + let (env, client, owner) = setup(); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + let next_due = env.ledger().timestamp() + 5_000; - // attempt unauthorized add — ignore the panic via try_ - let _ = client.try_add_tag(&random, &id, &String::from_str(&env, "ACTIVE")); + let schedule_id = client.create_premium_schedule(&owner, &policy_id, &next_due, &2_592_000); + let created = client.get_premium_schedule(&schedule_id).unwrap(); + assert!(created.active); + assert!(created.recurring); - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "LongTerm"), - &CoverageType::Life, - &100, - &10000, - &None); + assert!(client.modify_premium_schedule(&owner, &schedule_id, &(next_due + 10), &100)); + let modified = client.get_premium_schedule(&schedule_id).unwrap(); + assert_eq!(modified.next_due, next_due + 10); + assert_eq!(modified.interval, 100); -// ── 2. The Double-Tag ───────────────────────────────────────────────────────── + assert!(client.cancel_premium_schedule(&owner, &schedule_id)); + assert!(!client.get_premium_schedule(&schedule_id).unwrap().active); +} -/// Adding "ACTIVE" twice must leave exactly one "ACTIVE" tag in storage. #[test] -fn test_qa_double_tag_active_stored_once() { +fn test_execute_due_premium_schedules_handles_one_shot() { let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let active = String::from_str(&env, "ACTIVE"); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + let next_due = env.ledger().timestamp() + 10; + let schedule_id = client.create_premium_schedule(&owner, &policy_id, &next_due, &0); - client.add_tag(&owner, &id, &active); - client.add_tag(&owner, &id, &active); // duplicate + set_ledger_time(&env, 3, next_due + 1); + let executed = client.execute_due_premium_schedules(); - let tags = client.get_policy(&id).unwrap().tags; - assert_eq!(tags.len(), 1, "duplicate tag must not be stored twice"); - assert_eq!( - tags.get(0).unwrap(), - String::from_str(&env, "ACTIVE"), - "the stored tag must be ACTIVE" - ); + assert_eq!(executed.len(), 1); + assert_eq!(executed.get(0).unwrap(), schedule_id); + assert!(!client.get_premium_schedule(&schedule_id).unwrap().active); } -/// The second (duplicate) add_tag call must emit NO new event — the contract -/// returns early before publishing. #[test] -fn test_create_premium_schedule_succeeds() { - setup_test_env!(env, Insurance, InsuranceClient, client, owner); - client.initialize(&owner); - set_ledger_time(&env, 1, 1000); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); +fn test_execute_due_premium_schedules_tracks_recurring_misses() { + let (env, client, owner) = setup(); + let policy_id = make_policy(&env, &client, &owner, 100, 10_000); + let next_due = env.ledger().timestamp() + 10; + let schedule_id = client.create_premium_schedule(&owner, &policy_id, &next_due, &100); - let schedule_id = client.create_premium_schedule(&owner, &policy_id, &3000, &2592000); - assert_eq!(schedule_id, 1); + set_ledger_time(&env, 4, next_due + 250); + let executed = client.execute_due_premium_schedules(); + assert_eq!(executed.len(), 1); let schedule = client.get_premium_schedule(&schedule_id).unwrap(); - - assert_eq!(schedule.next_due, 3000); - assert_eq!(schedule.interval, 2592000); assert!(schedule.active); + assert_eq!(schedule.last_executed, Some(next_due + 250)); + assert!(schedule.missed_count >= 2); + assert!(schedule.next_due > next_due + 250); } -/// Adding "ACTIVE" then a different tag then "ACTIVE" again must still result -/// in exactly two unique tags. #[test] -fn test_modify_premium_schedule() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); +fn test_pause_function_blocks_create_policy() { + let (env, client, owner) = setup(); - client.add_tag(&owner, &id, &String::from_str(&env, "ACTIVE")); - client.add_tag(&owner, &id, &String::from_str(&env, "VIP")); - client.add_tag(&owner, &id, &String::from_str(&env, "ACTIVE")); // dup + assert_eq!( + client.pause_function(&owner, &pause_functions::CREATE_POLICY), + () + ); - let policy_id = client.create_policy( + let result = client.try_create_policy( &owner, - &String::from_str(&env, "Health Insurance"), + &String::from_str(&env, "Blocked"), &CoverageType::Health, - &500, - &50000, - &None); - -// ── 3. The Ghost Remove ─────────────────────────────────────────────────────── + &100, + &10_000, + &None, + ); -/// Removing a tag that was never added must not crash. -#[test] -fn test_qa_ghost_remove_does_not_panic() { - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - // no tags — removing "GHOST" must be graceful - client.remove_tag(&owner, &id, &String::from_str(&env, "GHOST")); + assert_eq!(result, Err(Ok(InsuranceError::FunctionPaused))); } - -/// After a ghost remove the tag list must still be empty. -#[test] -fn test_cancel_premium_schedule() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - set_ledger_time(&env, 1, 1000); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); - - let schedule_id = client.create_premium_schedule(&owner, &policy_id, &3000, &2592000); - client.cancel_premium_schedule(&owner, &schedule_id); - - let schedule = client.get_premium_schedule(&schedule_id).unwrap(); - assert!(!schedule.active); -} - -/// Ghost remove on a policy that already has other tags must not disturb them. -#[test] -fn test_execute_due_premium_schedules() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - set_ledger_time(&env, 1, 1000); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); - -/// add_tag must publish exactly one event with topic ("insure", "tag_added") -/// and data (policy_id, tag). -#[test] -fn test_qa_add_tag_event_topics_and_data() { - use soroban_sdk::{symbol_short, IntoVal}; - - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let tag = String::from_str(&env, "ACTIVE"); - - assert_eq!(executed.len(), 1); - assert_eq!(executed.get(0), Some(schedule_id)); - - let all = env.events().all(); - assert_eq!( - all.len(), - events_before + 1, - "add_tag must emit exactly one event" - ); - - let (contract_id, topics, data) = all.last().unwrap(); - let _ = contract_id; // emitted by our contract - - // Verify topics: ("insure", "tag_added") - let expected_topics = soroban_sdk::vec![ - &env, - symbol_short!("insure").into_val(&env), - symbol_short!("tag_added").into_val(&env), - ]; - assert_eq!(topics, expected_topics, "tag_added event topics mismatch"); - - // Verify data: (policy_id, tag) - let (emitted_id, emitted_tag): (u32, String) = - soroban_sdk::FromVal::from_val(&env, &data); - assert_eq!(emitted_id, id, "tag_added event must carry the correct policy_id"); - assert_eq!(emitted_tag, tag, "tag_added event must carry the correct tag"); -} - -/// remove_tag on an existing tag must publish exactly one event with topic -/// ("insure", "tag_rmvd") and data (policy_id, tag). -#[test] -fn test_execute_recurring_premium_schedule() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); - - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let tag = String::from_str(&env, "ACTIVE"); - client.add_tag(&owner, &id, &tag); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); - - let (_, topics, data) = all.last().unwrap(); - - let expected_topics = soroban_sdk::vec![ - &env, - symbol_short!("insure").into_val(&env), - symbol_short!("tag_rmvd").into_val(&env), - ]; - assert_eq!(topics, expected_topics, "tag_rmvd event topics mismatch"); - - let (emitted_id, emitted_tag): (u32, String) = - soroban_sdk::FromVal::from_val(&env, &data); - assert_eq!(emitted_id, id); - assert_eq!(emitted_tag, tag); -} - -/// Ghost remove must publish exactly one event with topic ("insure", "tag_miss") -/// and data (policy_id, tag) — the "Tag Not Found" signal. -#[test] -fn test_execute_missed_premium_schedules() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); - - let (env, client, owner) = setup(); - let id = make_policy(&env, &client, &owner); - let tag = String::from_str(&env, "GHOST"); - - let policy_id = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); - - let (_, topics, data) = all.last().unwrap(); - - set_ledger_time(&env, 1, 3000 + 2592000 * 3 + 100); - client.execute_due_premium_schedules(); - - let (emitted_id, emitted_tag): (u32, String) = - soroban_sdk::FromVal::from_val(&env, &data); - assert_eq!(emitted_id, id, "tag_miss event must carry the correct policy_id"); - assert_eq!(emitted_tag, tag, "tag_miss event must carry the correct tag"); -} - -/// Full lifecycle: add "ACTIVE", add "ACTIVE" again (dup), remove "ACTIVE", -/// remove "ACTIVE" again (ghost). Verify the exact event sequence. -#[test] -fn test_get_premium_schedules() { - let env = Env::default(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = ::generate(&env); - client.initialize(&owner); - - env.mock_all_auths(); - set_ledger_time(&env, 1, 1000); - - let policy_id1 = client.create_policy( - &owner, - &String::from_str(&env, "Health Insurance"), - &CoverageType::Health, - &500, - &50000, - &None); - - let policy_id2 = client.create_policy( - &owner, - &String::from_str(&env, "Life Insurance"), - &CoverageType::Life, - &300, - &100000, - &None); - - client.create_premium_schedule(&owner, &policy_id1, &3000, &2592000); - client.create_premium_schedule(&owner, &policy_id2, &4000, &2592000); - - let schedules = client.get_premium_schedules(&owner); - assert_eq!(schedules.len(), 2); -} - -// ----------------------------------------------------------------------- -// 3. create_policy — boundary conditions -// ----------------------------------------------------------------------- - -// --- Health min/max boundaries --- - -#[test] -fn test_health_premium_at_minimum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // min_premium for Health = 1_000_000 - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &1_000_000i128, - &10_000_000i128, // min coverage - &None, - ); -} - -#[test] -fn test_health_premium_at_maximum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // max_premium = 500_000_000; need coverage ≤ 500M * 12 * 500 = 3T (within 100B limit) - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &500_000_000i128, - &100_000_000_000i128, // max coverage for Health - &None, - ); -} - -#[test] -fn test_health_coverage_at_minimum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &10_000_000i128, // exactly min_coverage - &None, - ); -} - -#[test] -fn test_health_coverage_at_maximum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // max_coverage = 100_000_000_000; need premium ≥ 100B / (12*500) ≈ 16_666_667 - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &500_000_000i128, // max premium to allow max coverage via ratio - &100_000_000_000i128, // exactly max_coverage - &None, - ); -} - -// --- Life boundaries --- - -#[test] -fn test_life_premium_at_minimum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - client.create_policy( - &caller, - &String::from_str(&env, "Life Min"), - &CoverageType::Life, - &500_000i128, // min_premium - &50_000_000i128, // min_coverage - &None, - ); -} - -#[test] -fn test_liability_premium_at_minimum_boundary() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - client.create_policy( - &caller, - &String::from_str(&env, "Liability Min"), - &CoverageType::Liability, - &800_000i128, // min_premium - &5_000_000i128, // min_coverage - &None, - ); -} - -// ----------------------------------------------------------------------- -// 4. create_policy — name validation -// ----------------------------------------------------------------------- - -#[test] -fn test_create_policy_empty_name_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_create_policy( - &caller, - &String::from_str(&env, ""), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidName)));} - -#[test] -fn test_create_policy_name_exceeds_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // 65 character name — exceeds MAX_NAME_LEN (64) - let long_name = String::from_str( - &env, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", - ); - let result = client.try_create_policy( - &caller, - &long_name, - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidName)));} - -#[test] -fn test_create_policy_name_at_max_length_succeeds() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Exactly 64 characters - let max_name = String::from_str( - &env, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - ); - client.create_policy( - &caller, - &max_name, - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); -} - -// ----------------------------------------------------------------------- -// 5. create_policy — premium validation failures -// ----------------------------------------------------------------------- - -#[test] -fn test_create_policy_zero_premium_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &0i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_policy_negative_premium_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &-1i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_health_policy_premium_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Health min_premium = 1_000_000; supply 999_999 - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &999_999i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_health_policy_premium_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Health max_premium = 500_000_000; supply 500_000_001 - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &500_000_001i128, - &10_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_life_policy_premium_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Life min_premium = 500_000; supply 499_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Life"), - &CoverageType::Life, - &499_999i128, - &50_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_property_policy_premium_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Property min_premium = 2_000_000; supply 1_999_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Property"), - &CoverageType::Property, - &1_999_999i128, - &100_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_auto_policy_premium_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Auto min_premium = 1_500_000; supply 1_499_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Auto"), - &CoverageType::Auto, - &1_499_999i128, - &20_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_liability_policy_premium_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Liability min_premium = 800_000; supply 799_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Liability"), - &CoverageType::Liability, - &799_999i128, - &5_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -// ----------------------------------------------------------------------- -// 6. create_policy — coverage amount validation failures -// ----------------------------------------------------------------------- - -#[test] -fn test_create_policy_zero_coverage_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &0i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_policy_negative_coverage_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &-1i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_health_policy_coverage_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Health min_coverage = 10_000_000; supply 9_999_999 - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &9_999_999i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_health_policy_coverage_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Health max_coverage = 100_000_000_000; supply 100_000_000_001 - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &500_000_000i128, - &100_000_000_001i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_life_policy_coverage_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Life min_coverage = 50_000_000; supply 49_999_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Life"), - &CoverageType::Life, - &1_000_000i128, - &49_999_999i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_property_policy_coverage_below_min_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Property min_coverage = 100_000_000; supply 99_999_999 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Property"), - &CoverageType::Property, - &5_000_000i128, - &99_999_999i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -// ----------------------------------------------------------------------- -// 7. create_policy — ratio guard (unsupported combination) -// ----------------------------------------------------------------------- - -#[test] -fn test_create_policy_coverage_too_high_for_premium_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // premium = 1_000_000 → annual = 12_000_000 → max_coverage = 6_000_000_000 - // supply coverage = 6_000_000_001 (just over the ratio limit, but within Health's hard max) - // Need premium high enough so health range isn't hit, but ratio is - // Health max_coverage = 100_000_000_000 - // Use premium = 1_000_000, coverage = 7_000_000_000 → over ratio (6B), under hard cap (100B) - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &1_000_000i128, - &7_000_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_create_policy_coverage_exactly_at_ratio_limit_succeeds() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // premium = 1_000_000 → ratio limit = 1M * 12 * 500 = 6_000_000_000 - // Health max_coverage = 100B, so 6B is fine - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &1_000_000i128, - &6_000_000_000i128, - &None, - ); -} - -// ----------------------------------------------------------------------- -// 8. External ref validation -// ----------------------------------------------------------------------- - -#[test] -fn test_create_policy_ext_ref_too_long_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // 129 character external ref — exceeds MAX_EXT_REF_LEN (128) - let long_ref = String::from_str( - &env, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", - ); - let result = client.try_create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &Some(long_ref), - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidName)));} - -#[test] -fn test_create_policy_ext_ref_at_max_length_succeeds() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Exactly 128 characters - let max_ref = String::from_str( - &env, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - ); - client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &Some(max_ref), - ); -} - -// ----------------------------------------------------------------------- -// 9. pay_premium — happy path -// ----------------------------------------------------------------------- - -#[test] -fn test_pay_premium_success() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.pay_premium(&caller, &policy_id); - -} - -#[test] -fn test_pay_premium_updates_next_payment_date() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - env.ledger().set_timestamp(1_000_000u64); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - env.ledger().set_timestamp(2_000_000u64); - client.pay_premium(&caller, &policy_id); - let policy = client.get_policy(&policy_id).unwrap(); - // next_payment_date should be 2_000_000 + 30 days - assert_eq!(policy.next_payment_date, 2_000_000 + 30 * 24 * 60 * 60); -} - -// ----------------------------------------------------------------------- -// 10. pay_premium — failure cases -// ----------------------------------------------------------------------- - -#[test] -fn test_pay_premium_nonexistent_policy_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let result = client.try_pay_premium(&caller, &999u32); - assert_eq!(result, Err(Ok(InsuranceError::PolicyNotFound)));} - -#[test] -fn test_pay_premium_wrong_amount_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.pay_premium(&caller, &policy_id);} - -#[test] -fn test_pay_premium_on_inactive_policy_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.deactivate_policy(&owner, &policy_id); - client.pay_premium(&caller, &policy_id);} - -// ----------------------------------------------------------------------- -// 11. deactivate_policy — happy path -// ----------------------------------------------------------------------- - -#[test] -fn test_deactivate_policy_success() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - let result = client.deactivate_policy(&owner, &policy_id); - - - let policy = client.get_policy(&policy_id).unwrap(); - assert!(!policy.active); -} - -#[test] -fn test_deactivate_removes_from_active_list() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - assert_eq!(client.get_active_policies(&owner, &0, &100).items.len(), 1); - client.deactivate_policy(&owner, &policy_id); - assert_eq!(client.get_active_policies(&owner, &0, &100).items.len(), 0); -} - -// ----------------------------------------------------------------------- -// 12. deactivate_policy — failure cases -// ----------------------------------------------------------------------- - -#[test] -fn test_deactivate_policy_non_owner_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - let non_owner = Address::generate(&env); - client.deactivate_policy(&non_owner, &policy_id);} - -#[test] -fn test_deactivate_nonexistent_policy_panics() { - let (env, client, owner) = setup(); - let result = client.try_deactivate_policy(&owner, &999u32); - assert_eq!(result, Err(Ok(InsuranceError::PolicyNotFound)));} - -#[test] -fn test_deactivate_already_inactive_policy_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.deactivate_policy(&owner, &policy_id); - // Second deactivation must panic - client.deactivate_policy(&owner, &policy_id);} - -// ----------------------------------------------------------------------- -// 13. set_external_ref -// ----------------------------------------------------------------------- - -#[test] -fn test_set_external_ref_success() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - let new_ref = String::from_str(&env, "NEW-REF-001"); - client.set_external_ref(&owner, &policy_id, &Some(new_ref)); - let policy = client.get_policy(&policy_id).unwrap(); -} - -#[test] -fn test_set_external_ref_clear() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let ext_ref = String::from_str(&env, "INITIAL-REF"); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &Some(ext_ref), - ); - // Clear the ref - client.set_external_ref(&owner, &policy_id, &None); - let policy = client.get_policy(&policy_id).unwrap(); - assert!(policy.external_ref.is_none()); -} - -#[test] -fn test_set_external_ref_non_owner_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - let non_owner = Address::generate(&env); - let new_ref = String::from_str(&env, "HACK"); - client.set_external_ref(&non_owner, &policy_id, &Some(new_ref));} - -#[test] -fn test_set_external_ref_too_long_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - let long_ref = String::from_str( - &env, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", - ); - client.set_external_ref(&owner, &policy_id, &Some(long_ref));} - -// ----------------------------------------------------------------------- -// 14. Queries -// ----------------------------------------------------------------------- - -#[test] -fn test_get_active_policies_empty_initially() { - let (env, client, owner) = setup(); - assert_eq!(client.get_active_policies(&owner, &0, &100).items.len(), 0); -} - -#[test] -fn test_get_active_policies_reflects_creates_and_deactivations() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id1 = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.create_policy( - &caller, - &String::from_str(&env, "Second Policy"), - &CoverageType::Life, - &1_000_000i128, - &60_000_000i128, - &None, - ); - assert_eq!(client.get_active_policies(&owner, &0, &100).items.len(), 2); - client.deactivate_policy(&owner, &policy_id1); - assert_eq!(client.get_active_policies(&owner, &0, &100).items.len(), 1); -} - -#[test] -fn test_get_total_monthly_premium_sums_active_only() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - let policy_id1 = client.create_policy( - &caller, - &short_name(&env).unwrap(), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); - client.create_policy( - &caller, - &String::from_str(&env, "Second"), - &CoverageType::Life, - &1_000_000i128, - &60_000_000i128, - &None, - ); - assert_eq!(client.get_total_monthly_premium(&caller), 6_000_000i128); - client.deactivate_policy(&owner, &policy_id1); - assert_eq!(client.get_total_monthly_premium(&caller), 1_000_000i128); -} - -#[test] -fn test_get_total_monthly_premium_zero_when_no_policies() { - let (env, client, owner) = setup(); - assert_eq!(client.get_total_monthly_premium(&owner), 0i128); -} - -#[test] -fn test_get_policy_nonexistent_panics() { - let (env, client, owner) = setup(); - client.get_policy(&999u32).unwrap(); -} - -// ----------------------------------------------------------------------- -// 15. Uninitialized contract guard -// ----------------------------------------------------------------------- - -#[test] -#[should_panic(expected = "not initialized")] -fn test_create_policy_without_init_panics() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let caller = Address::generate(&env); - client.create_policy( - &caller, - &String::from_str(&env, "Test"), - &CoverageType::Health, - &5_000_000i128, - &50_000_000i128, - &None, - ); -} - -#[test] -#[should_panic(expected = "not initialized")] -fn test_get_active_policies_without_init_panics() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); - let owner = Address::generate(&env); - client.initialize(&owner); - client.get_active_policies(&owner, &0, &100).items; -} - -// ----------------------------------------------------------------------- -// 16. Policy data integrity -// ----------------------------------------------------------------------- - -#[test] -fn test_policy_fields_stored_correctly() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - env.ledger().set_timestamp(1_700_000_000u64); - let policy_id = client.create_policy( - &caller, - &String::from_str(&env, "My Health Plan"), - &CoverageType::Health, - &10_000_000i128, - &100_000_000i128, - &Some(String::from_str(&env, "EXT-001")), - ); - let policy = client.get_policy(&policy_id).unwrap(); - assert_eq!(policy.id, 1u32); - assert_eq!(policy.monthly_premium, 10_000_000i128); - assert_eq!(policy.coverage_amount, 100_000_000i128); - assert!(policy.active); - assert_eq!( - policy.next_payment_date, - 1_700_000_000u64 + 30 * 24 * 60 * 60 - ); -} - -// ----------------------------------------------------------------------- -// 17. Cross-coverage-type boundary checks -// ----------------------------------------------------------------------- - -#[test] -fn test_property_premium_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Property max_premium = 2_000_000_000; supply 2_000_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Property"), - &CoverageType::Property, - &2_000_000_001i128, - &100_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_auto_premium_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Auto max_premium = 750_000_000; supply 750_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Auto"), - &CoverageType::Auto, - &750_000_001i128, - &20_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_liability_premium_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Liability max_premium = 400_000_000; supply 400_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Liability"), - &CoverageType::Liability, - &400_000_001i128, - &5_000_000i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_life_coverage_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Life max_coverage = 500_000_000_000; supply 500_000_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Life"), - &CoverageType::Life, - &1_000_000_000i128, // max premium for Life - &500_000_000_001i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_auto_coverage_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Auto max_coverage = 200_000_000_000; supply 200_000_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Auto"), - &CoverageType::Auto, - &750_000_000i128, - &200_000_000_001i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));} - -#[test] -fn test_liability_coverage_above_max_panics() { - let (env, client, owner) = setup(); - let caller = Address::generate(&env); - // Liability max_coverage = 50_000_000_000; supply 50_000_000_001 - let result = client.try_create_policy( - &caller, - &String::from_str(&env, "Liability"), - &CoverageType::Liability, - &400_000_000i128, - &50_000_000_001i128, - &None, - ); - assert_eq!(result, Err(Ok(InsuranceError::InvalidAmount)));}