From b14b53c2e7fb4cbe5a1c87816524437daf5d73b1 Mon Sep 17 00:00:00 2001 From: macbook Date: Fri, 19 Jun 2026 10:39:49 +0100 Subject: [PATCH 1/3] reporting-family-spending-report --- bill_payments/src/lib.rs | 14 +- bill_payments/tests/tests_overdue.rs | 20 +- data_migration/src/lib.rs | 63 ++- docs/reporting-family-spending.md | 56 +++ family_wallet/src/lib.rs | 71 ++- family_wallet/src/test.rs | 56 ++- insurance/src/lib.rs | 42 +- remittance_split/src/test.rs | 75 ++-- remitwise-common/src/lib.rs | 46 +- reporting/src/lib.rs | 207 +++++++-- reporting/src/tests.rs | 498 +++++++++++++++++++++- reporting/src/tests_auth_acl.rs | 3 +- reporting/tests/insurance_report_ratio.rs | 5 +- savings_goals/src/event_test.rs | 4 +- savings_goals/src/lib.rs | 20 +- savings_goals/src/test.rs | 29 +- savings_goals/src/tests_schedule_exec.rs | 37 +- 17 files changed, 1054 insertions(+), 192 deletions(-) create mode 100644 docs/reporting-family-spending.md diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index aaf86caa..e6b40666 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -379,7 +379,8 @@ impl BillPayments { /// Get bill IDs for a specific owner and currency fn get_bills_by_owner_currency(env: &Env, owner: &Address, currency: &String) -> Vec { let idx = Self::get_currency_index(env); - idx.get((owner.clone(), currency.clone())).unwrap_or_else(|| Vec::new(env)) + idx.get((owner.clone(), currency.clone())) + .unwrap_or_else(|| Vec::new(env)) } /// Add a bill ID to the currency index for (owner, currency) @@ -387,7 +388,7 @@ impl BillPayments { let mut idx = Self::get_currency_index(env); let key = (owner.clone(), currency.clone()); let mut ids = idx.get(key.clone()).unwrap_or_else(|| Vec::new(env)); - + // Insert in ascending order let mut new_ids: Vec = Vec::new(env); let mut inserted = false; @@ -405,7 +406,7 @@ impl BillPayments { if !inserted { new_ids.push_back(bill_id); } - + idx.set(key, new_ids); Self::save_currency_index(env, &idx); } @@ -431,7 +432,12 @@ impl BillPayments { } /// Remove multiple bill IDs from the currency index for (owner, currency) - fn index_remove_currency_batch(env: &Env, owner: &Address, currency: &String, bill_ids: &Vec) { + fn index_remove_currency_batch( + env: &Env, + owner: &Address, + currency: &String, + bill_ids: &Vec, + ) { let mut idx = Self::get_currency_index(env); let key = (owner.clone(), currency.clone()); if let Some(ids) = idx.get(key.clone()) { diff --git a/bill_payments/tests/tests_overdue.rs b/bill_payments/tests/tests_overdue.rs index 3c7dda45..66a41f8e 100644 --- a/bill_payments/tests/tests_overdue.rs +++ b/bill_payments/tests/tests_overdue.rs @@ -235,10 +235,7 @@ fn test_overdue_empty_when_all_bills_paid() { client.pay_bill(&owner, &id2); let page = client.get_overdue_bills(&0, &100); - assert_eq!( - page.count, 0, - "all bills paid: overdue list must be empty" - ); + assert_eq!(page.count, 0, "all bills paid: overdue list must be empty"); } // ───────────────────────────────────────────────────────────────────────────── @@ -374,7 +371,10 @@ fn test_overdue_owner_isolation_no_cross_contamination() { set_time(&env, BASE_TIME); let page = client.get_overdue_bills(&0, &100); - assert_eq!(page.count, 3, "all 3 overdue bills must appear in global list"); + assert_eq!( + page.count, 3, + "all 3 overdue bills must appear in global list" + ); let mut a_count = 0u32; let mut b_count = 0u32; @@ -395,8 +395,14 @@ fn test_overdue_owner_isolation_no_cross_contamination() { panic!("unexpected owner in overdue list"); } } - assert_eq!(a_count, 2, "owner A must have 2 overdue bills in global list"); - assert_eq!(b_count, 1, "owner B must have 1 overdue bill in global list"); + assert_eq!( + a_count, 2, + "owner A must have 2 overdue bills in global list" + ); + assert_eq!( + b_count, 1, + "owner B must have 1 overdue bill in global list" + ); } /// Paying one owner's overdue bill does not affect the other owner's overdue count. diff --git a/data_migration/src/lib.rs b/data_migration/src/lib.rs index 174b730d..6526c84c 100644 --- a/data_migration/src/lib.rs +++ b/data_migration/src/lib.rs @@ -2211,7 +2211,11 @@ mod tests { let result = import_from_json(&bytes, &mut tracker, 123_456); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 0, + min: 1, + max: 1 + } )); } @@ -2224,7 +2228,11 @@ mod tests { let result = import_from_json(&bytes, &mut tracker, 123_456); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 2, + min: 1, + max: 1 + } )); } @@ -2247,7 +2255,11 @@ mod tests { let result = import_from_binary(&bytes, &mut tracker, 123_456); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 0, + min: 1, + max: 1 + } )); } @@ -2260,7 +2272,11 @@ mod tests { let result = import_from_binary(&bytes, &mut tracker, 123_456); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 2, + min: 1, + max: 1 + } )); } @@ -2282,7 +2298,11 @@ mod tests { let result = import_from_json_untracked(&bytes); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 0, + min: 1, + max: 1 + } )); } @@ -2294,7 +2314,11 @@ mod tests { let result = import_from_json_untracked(&bytes); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 2, + min: 1, + max: 1 + } )); } @@ -2315,7 +2339,11 @@ mod tests { let result = import_from_binary_untracked(&bytes); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 0, + min: 1, + max: 1 + } )); } @@ -2327,7 +2355,11 @@ mod tests { let result = import_from_binary_untracked(&bytes); assert!(matches!( result.unwrap_err(), - MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + MigrationError::IncompatibleVersion { + found: 2, + min: 1, + max: 1 + } )); } @@ -2566,7 +2598,10 @@ mod tests { let csv_string = String::from_utf8_lossy(&exported_bytes); // Tab is not a formula injection character, so it should not be escaped - assert!(csv_string.contains("\tSUM(A1:A10)"), "Tab should not be escaped"); + assert!( + csv_string.contains("\tSUM(A1:A10)"), + "Tab should not be escaped" + ); } #[test] @@ -2588,7 +2623,10 @@ mod tests { let csv_string = String::from_utf8_lossy(&exported_bytes); // Backslash is not a formula injection character, so it should not be escaped - assert!(csv_string.contains("\\SUM(A1:A10)"), "Backslash should not be escaped"); + assert!( + csv_string.contains("\\SUM(A1:A10)"), + "Backslash should not be escaped" + ); } #[test] @@ -2610,7 +2648,10 @@ mod tests { let csv_string = String::from_utf8_lossy(&exported_bytes); // Pipe is not a formula injection character, so it should not be escaped - assert!(csv_string.contains("|SUM(A1:A10)"), "Pipe should not be escaped"); + assert!( + csv_string.contains("|SUM(A1:A10)"), + "Pipe should not be escaped" + ); } #[test] diff --git a/docs/reporting-family-spending.md b/docs/reporting-family-spending.md new file mode 100644 index 00000000..bf7afe33 --- /dev/null +++ b/docs/reporting-family-spending.md @@ -0,0 +1,56 @@ +# Family Spending Report + +`ReportingContract::get_family_spending_report(caller, user, period_start, period_end)` +builds a family-wallet spending snapshot from the configured `family_wallet` +dependency. + +## Authorization + +- `user.require_auth()` is enforced, matching the other user-facing reporting + endpoints. +- `caller` is currently unused and kept for signature consistency with the + savings, bills, insurance, and financial-health report methods. + +## Data source + +The report reads two family-wallet views: + +1. `get_member_addresses_page(cursor, limit)` to enumerate the member set + without fixed-limit truncation. +2. `get_spending_tracker(member)` to read each member's current cumulative + spending amount. + +## Output semantics + +`FamilySpendingReport` now includes: + +- `member_breakdown`: one entry per unique member address. +- `total_members`: number of unique members observed from the dependency. +- `total_spending`: sum of successfully read member spending totals. +- `average_per_member`: `total_spending / total_members`, or `0` when there are + no members. +- `data_availability`: report completeness signal. + +Each `FamilyMemberSpending` entry contains: + +- `member`: member address. +- `total_spending`: tracked spending amount, or `0` when no tracker exists or + the per-member read failed. +- `data_available`: `false` when that member's spending read failed. + +## DataAvailability rules + +- `Complete`: member enumeration succeeded and every member spending read + succeeded. +- `Partial`: pagination hit `MAX_DEP_PAGES`, a later member page failed, a + per-member spending read failed, or aggregate addition overflowed and had to + clamp with saturating arithmetic. +- `Missing`: the first member-page read failed or the dependency returned zero + members on the first page. + +## Arithmetic safety + +- Aggregate `i128` totals use `checked_add`. +- On overflow, the report does not panic. It marks + `data_availability = Partial` and clamps the aggregate with + `saturating_add`. diff --git a/family_wallet/src/lib.rs b/family_wallet/src/lib.rs index f03f5c61..8633aa1a 100644 --- a/family_wallet/src/lib.rs +++ b/family_wallet/src/lib.rs @@ -35,6 +35,8 @@ const MAX_AUDIT_PAGE_LIMIT: u32 = 50; const DEFAULT_AUDIT_PAGE_LIMIT: u32 = 20; const MAX_PENDING_PAGE_LIMIT: u32 = 100; const DEFAULT_PENDING_PAGE_LIMIT: u32 = 20; +const MAX_MEMBER_PAGE_LIMIT: u32 = 100; +const DEFAULT_MEMBER_PAGE_LIMIT: u32 = 20; /// Hard cap on the number of entries retained in `ARCH_TX`. /// When the archive reaches this limit the oldest entry (lowest `tx_id`) is @@ -135,6 +137,14 @@ pub struct SpendingTracker { pub period: SpendingPeriod, } +#[contracttype] +#[derive(Clone)] +pub struct MemberAddressPage { + pub items: Vec
, + pub next_cursor: u32, + pub count: u32, +} + /// Enhanced spending limit with precision controls #[contracttype] #[derive(Clone)] @@ -1723,6 +1733,55 @@ impl FamilyWallet { .get(member) } + /// Paginated listing of family-member addresses for downstream readers. + /// + /// Cursor is the number of members already returned. Pass `0` for the + /// first page. + pub fn get_member_addresses_page(env: Env, cursor: u32, limit: u32) -> MemberAddressPage { + let capped_limit = if limit == 0 { + DEFAULT_MEMBER_PAGE_LIMIT + } else { + limit.min(MAX_MEMBER_PAGE_LIMIT) + }; + + let members: Map = env + .storage() + .instance() + .get(&symbol_short!("MEMBERS")) + .unwrap_or_else(|| panic!("Wallet not initialized")); + + let mut items: Vec
= Vec::new(&env); + let mut seen = 0u32; + let mut has_more = false; + + for (address, _) in members.iter() { + if seen < cursor { + seen = seen.saturating_add(1); + continue; + } + + if items.len() < capped_limit { + items.push_back(address); + seen = seen.saturating_add(1); + } else { + has_more = true; + break; + } + } + + let next_cursor = if has_more { + cursor.saturating_add(items.len()) + } else { + 0 + }; + + MemberAddressPage { + count: items.len(), + items, + next_cursor, + } + } + /// Cancel a pending transaction. /// /// The original proposer may cancel their own transaction. Owners and @@ -1950,7 +2009,10 @@ impl FamilyWallet { .unwrap_or_else(|| Map::new(env)); let mut tracker = Self::current_spending_tracker(env, proposer); // Overflow-safe tracker accumulation - tracker.current_spent = tracker.current_spent.checked_add(amount).unwrap_or(i128::MAX); + tracker.current_spent = tracker + .current_spent + .checked_add(amount) + .unwrap_or(i128::MAX); tracker.last_tx_timestamp = env.ledger().timestamp(); tracker.tx_count = tracker.tx_count.saturating_add(1); trackers.set(proposer.clone(), tracker); @@ -1993,7 +2055,10 @@ impl FamilyWallet { if limit.enable_rollover { let tracker = Self::current_spending_tracker(&env, &proposer); // Overflow-safe addition to prevent DoS via integer overflow in accumulated spend - let new_spent = tracker.current_spent.checked_add(amount).ok_or(Error::InvalidSpendingLimit)?; + let new_spent = tracker + .current_spent + .checked_add(amount) + .ok_or(Error::InvalidSpendingLimit)?; if new_spent > limit.limit { return Err(Error::InvalidSpendingLimit); } @@ -2922,4 +2987,4 @@ impl FamilyWallet { #[cfg(test)] mod events_schema_test; #[cfg(test)] -mod test; \ No newline at end of file +mod test; diff --git a/family_wallet/src/test.rs b/family_wallet/src/test.rs index 4a80b154..2c86b8da 100644 --- a/family_wallet/src/test.rs +++ b/family_wallet/src/test.rs @@ -1053,8 +1053,12 @@ fn test_emergency_transfer_min_balance_boundary_exact_floor_succeeds() { client.set_emergency_mode(&owner, &true); let recipient = Address::generate(&env); - let result = - client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &amount); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &amount, + ); assert!(result.is_ok()); assert_eq!(token_client.balance(&owner), min_balance); @@ -1086,8 +1090,12 @@ fn test_emergency_transfer_min_balance_boundary_one_stroop_under_floor_rejected( client.set_emergency_mode(&owner, &true); let recipient = Address::generate(&env); - let result = - client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &amount); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &amount, + ); assert_eq!(result, emergency_error(Error::MinBalanceViolation)); assert_eq!(token_client.balance(&owner), total); @@ -1118,8 +1126,12 @@ fn test_emergency_transfer_zero_min_balance_disables_floor() { let recipient = Address::generate(&env); // Drain the entire balance — leaves exactly 0, which satisfies `>= 0`. - let result = - client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &total); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &total, + ); assert!(result.is_ok()); assert_eq!(token_client.balance(&owner), 0); @@ -1162,7 +1174,13 @@ fn test_emergency_transfer_min_balance_interacts_with_daily_limit() { .unwrap_or(0i128) }; - client.configure_emergency(&owner, &5_000_0000000, &0u64, &9_500_0000000, &10_000_0000000); + client.configure_emergency( + &owner, + &5_000_0000000, + &0u64, + &9_500_0000000, + &10_000_0000000, + ); client.set_emergency_mode(&owner, &true); let recipient = Address::generate(&env); @@ -1228,7 +1246,11 @@ fn test_emergency_transfer_min_balance_interacts_with_daily_limit() { emergency_error(Error::MinBalanceViolation), "this rejection should come from the daily cap, not the min_balance floor" ); - assert_eq!(read_em_vol(), 800_0000000, "a cap-rejected transfer must not mutate EM_VOL"); + assert_eq!( + read_em_vol(), + 800_0000000, + "a cap-rejected transfer must not mutate EM_VOL" + ); } /// The min_balance floor and the cooldown timer are independent checks. A @@ -1256,7 +1278,13 @@ fn test_emergency_transfer_min_balance_interacts_with_cooldown() { let min_balance = 4_000_0000000; let cooldown = 3_600u64; - client.configure_emergency(&owner, &2_000_0000000, &cooldown, &min_balance, &10_000_0000000); + client.configure_emergency( + &owner, + &2_000_0000000, + &cooldown, + &min_balance, + &10_000_0000000, + ); client.set_emergency_mode(&owner, &true); let recipient = Address::generate(&env); @@ -3539,8 +3567,6 @@ fn test_disabled_rollover_only_checks_single_tx_limits() { assert!(result.is_err()); } - - // ============================================================================ // Role Expiry Enforcement Tests (#494) // ============================================================================ @@ -4954,15 +4980,15 @@ fn test_precision_spending_overflow_graceful() { let env = Env::default(); let contract_id = env.register_contract(None, FamilyWallet); let client = FamilyWalletClient::new(&env, &contract_id); - + let admin = Address::generate(&env); let member = Address::generate(&env); let mut initial_members = Vec::new(&env); initial_members.push_back(member.clone()); - + client.init(&admin, &initial_members); - + // Assert that calling with near i128::MAX returns a graceful error or handles it cleanly let result = client.try_validate_precision_spending(&member, &i128::MAX); assert!(result.is_err()); -} \ No newline at end of file +} diff --git a/insurance/src/lib.rs b/insurance/src/lib.rs index f592e709..5a1795f4 100644 --- a/insurance/src/lib.rs +++ b/insurance/src/lib.rs @@ -161,7 +161,7 @@ impl Insurance { // ── Initialization ─────────────────────────────────────────────────────── /// Initialize the insurance contract with the given owner. - /// + /// /// # Errors /// - `AlreadyInitialized` if the contract has already been initialized pub fn init(env: Env, owner: Address) -> Result<(), InsuranceError> { @@ -220,7 +220,7 @@ impl Insurance { // ── Public API ─────────────────────────────────────────────────────────── /// Create a new insurance policy. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized /// - `InvalidName` if the name is empty or too long @@ -337,7 +337,7 @@ impl Insurance { } /// Pay the premium for a policy. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized /// - `PolicyNotFound` if the policy does not exist @@ -379,11 +379,15 @@ impl Insurance { } /// Pay premiums for multiple policies in a single transaction. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized /// - `PolicyNotFound` if any policy does not exist - pub fn batch_pay_premiums(env: Env, caller: Address, ids: Vec) -> Result { + pub fn batch_pay_premiums( + env: Env, + caller: Address, + ids: Vec, + ) -> Result { Self::require_initialized(&env)?; caller.require_auth(); @@ -403,7 +407,7 @@ impl Insurance { } /// Set an external reference for a policy (admin only). - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized /// - `Unauthorized` if the caller is not the contract owner @@ -432,13 +436,17 @@ impl Insurance { } /// Deactivate a policy. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized /// - `PolicyNotFound` if the policy does not exist /// - `Unauthorized` if the caller is not the policy owner or contract owner /// - `PolicyInactive` if the policy is already inactive - pub fn deactivate_policy(env: Env, caller: Address, policy_id: u32) -> Result { + pub fn deactivate_policy( + env: Env, + caller: Address, + policy_id: u32, + ) -> Result { Self::require_initialized(&env)?; caller.require_auth(); let mut policy = Self::load_policy(&env, policy_id)?; @@ -482,10 +490,15 @@ impl Insurance { } /// Get a paginated list of active policies for an owner. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized - pub fn get_active_policies(env: Env, owner: Address, cursor: u32, limit: u32) -> Result { + pub fn get_active_policies( + env: Env, + owner: Address, + cursor: u32, + limit: u32, + ) -> Result { Self::require_initialized(&env)?; let owner_ids = env .storage() @@ -529,16 +542,19 @@ impl Insurance { } /// Get a policy by ID. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized - pub fn get_policy(env: Env, policy_id: u32) -> Result, InsuranceError> { + pub fn get_policy( + env: Env, + policy_id: u32, + ) -> Result, InsuranceError> { Self::require_initialized(&env)?; Ok(env.storage().instance().get(&DataKey::Policy(policy_id))) } /// Get the total monthly premium for all active policies owned by an address. - /// + /// /// # Errors /// - `NotInitialized` if the contract has not been initialized pub fn get_total_monthly_premium(env: Env, owner: Address) -> Result { diff --git a/remittance_split/src/test.rs b/remittance_split/src/test.rs index 077a3c6f..d25ae156 100644 --- a/remittance_split/src/test.rs +++ b/remittance_split/src/test.rs @@ -847,7 +847,9 @@ fn test_request_hash_mismatch_nonce_reuse_new_deadline() { ); } -fn setup_request_hash_distribution(env: &Env) -> ( +fn setup_request_hash_distribution( + env: &Env, +) -> ( RemittanceSplitClient<'_>, Address, Address, @@ -986,7 +988,10 @@ fn test_request_hash_hashed_path_rejects_used_nonce() { stellar_client.mint(&owner, &(request.total_amount * 2)); let hash = client.get_request_hash(&request); - assert_eq!(client.try_distribute_usdc_hashed(&request, &hash), Ok(Ok(true))); + assert_eq!( + client.try_distribute_usdc_hashed(&request, &hash), + Ok(Ok(true)) + ); let replay = client.try_distribute_usdc_hashed(&request, &hash); assert_eq!(replay, Err(Ok(RemittanceSplitError::NonceAlreadyUsed))); @@ -1028,7 +1033,10 @@ fn test_request_hash_hashed_path_rejects_self_transfer() { let result = client.try_distribute_usdc_hashed(&request, &hash); - assert_eq!(result, Err(Ok(RemittanceSplitError::SelfTransferNotAllowed))); + assert_eq!( + result, + Err(Ok(RemittanceSplitError::SelfTransferNotAllowed)) + ); } #[test] @@ -1044,7 +1052,10 @@ fn test_request_hash_hashed_path_rejects_untrusted_token_contract() { let result = client.try_distribute_usdc_hashed(&request, &hash); - assert_eq!(result, Err(Ok(RemittanceSplitError::UntrustedTokenContract))); + assert_eq!( + result, + Err(Ok(RemittanceSplitError::UntrustedTokenContract)) + ); } // ============================================================================ @@ -1424,7 +1435,9 @@ fn test_execute_mixed_due_not_due() { // // Security: expired/invalid deadlines must NOT advance the nonce. -fn setup_signed_distribution(env: &Env) -> ( +fn setup_signed_distribution( + env: &Env, +) -> ( RemittanceSplitClient<'_>, Address, Address, @@ -1478,11 +1491,11 @@ fn test_deadline_zero_is_invalid() { let now = env.ledger().timestamp(); let request = make_request(&env, token_addr.clone(), owner.clone(), 1, 0); - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); - assert_eq!( - result, - Err(Ok(RemittanceSplitError::InvalidDeadline)) + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), ); + assert_eq!(result, Err(Ok(RemittanceSplitError::InvalidDeadline))); } /// deadline == now must be rejected (DeadlineExpired) @@ -1493,11 +1506,11 @@ fn test_deadline_equal_to_now_is_expired() { let now = env.ledger().timestamp(); let request = make_request(&env, token_addr.clone(), owner.clone(), 1, now); - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); - assert_eq!( - result, - Err(Ok(RemittanceSplitError::DeadlineExpired)) + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), ); + assert_eq!(result, Err(Ok(RemittanceSplitError::DeadlineExpired))); } /// deadline == now - 1 must be rejected (DeadlineExpired) @@ -1508,11 +1521,11 @@ fn test_deadline_one_second_past_is_expired() { let now = env.ledger().timestamp(); let request = make_request(&env, token_addr.clone(), owner.clone(), 1, now - 1); - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); - assert_eq!( - result, - Err(Ok(RemittanceSplitError::DeadlineExpired)) + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), ); + assert_eq!(result, Err(Ok(RemittanceSplitError::DeadlineExpired))); } /// deadline == now + 1 must be accepted (valid boundary) @@ -1524,7 +1537,10 @@ fn test_deadline_one_second_future_is_accepted() { let request = make_request(&env, token_addr.clone(), owner.clone(), 1, now + 1); // Should not return DeadlineExpired or InvalidDeadline - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), + ); assert!( result != Err(Ok(RemittanceSplitError::DeadlineExpired)) && result != Err(Ok(RemittanceSplitError::InvalidDeadline)), @@ -1546,7 +1562,10 @@ fn test_deadline_at_max_window_is_accepted() { 1, now + MAX_DEADLINE_WINDOW_SECS, ); - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), + ); assert!( result != Err(Ok(RemittanceSplitError::DeadlineExpired)) && result != Err(Ok(RemittanceSplitError::InvalidDeadline)), @@ -1568,11 +1587,11 @@ fn test_deadline_beyond_max_window_is_invalid() { 1, now + MAX_DEADLINE_WINDOW_SECS + 1, ); - let result = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); - assert_eq!( - result, - Err(Ok(RemittanceSplitError::InvalidDeadline)) + let result = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), ); + assert_eq!(result, Err(Ok(RemittanceSplitError::InvalidDeadline))); } /// Expired deadline must NOT advance the nonce (replay-window safety) @@ -1585,7 +1604,10 @@ fn test_expired_deadline_does_not_advance_nonce() { let nonce_before = client.get_nonce(&owner); let request = make_request(&env, token_addr.clone(), owner.clone(), 1, now - 1); - let _ = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); + let _ = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), + ); let nonce_after = client.get_nonce(&owner); assert_eq!( @@ -1603,7 +1625,10 @@ fn test_invalid_deadline_does_not_advance_nonce() { let nonce_before = client.get_nonce(&owner); let request = make_request(&env, token_addr.clone(), owner.clone(), 1, 0); - let _ = client.try_distribute_usdc_hashed(&request, &RemittanceSplit::get_request_hash(env.clone(), request.clone())); + let _ = client.try_distribute_usdc_hashed( + &request, + &RemittanceSplit::get_request_hash(env.clone(), request.clone()), + ); let nonce_after = client.get_nonce(&owner); assert_eq!( diff --git a/remitwise-common/src/lib.rs b/remitwise-common/src/lib.rs index 0e4342fe..af95cc77 100644 --- a/remitwise-common/src/lib.rs +++ b/remitwise-common/src/lib.rs @@ -194,22 +194,22 @@ pub struct RemitwiseEvents; impl RemitwiseEvents { /// Emits a single event with the given category, priority, and action. -/// -/// * `category` – The `EventCategory` describing the type of event. -/// * `priority` – The `EventPriority` indicating the importance level. -/// * `action` – A short `Symbol` identifying the specific action. -/// * `data` – The event payload implementing `IntoVal`. -/// -/// The emitted event follows the topic schema defined in `docs/EVENT_TAXONOMY.md`. -pub fn emit( - env: &soroban_sdk::Env, - category: EventCategory, - priority: EventPriority, - action: Symbol, - data: T, -) where - T: soroban_sdk::IntoVal, -{ + /// + /// * `category` – The `EventCategory` describing the type of event. + /// * `priority` – The `EventPriority` indicating the importance level. + /// * `action` – A short `Symbol` identifying the specific action. + /// * `data` – The event payload implementing `IntoVal`. + /// + /// The emitted event follows the topic schema defined in `docs/EVENT_TAXONOMY.md`. + pub fn emit( + env: &soroban_sdk::Env, + category: EventCategory, + priority: EventPriority, + action: Symbol, + data: T, + ) where + T: soroban_sdk::IntoVal, + { let topics = ( symbol_short!("Remitwise"), category.to_u32(), @@ -220,13 +220,13 @@ pub fn emit( } /// Emits a batch event for the given category and action with a count. -/// -/// * `category` – The `EventCategory` of the batched events. -/// * `action` – Symbol representing the batch action. -/// * `count` – Number of events in the batch. -/// -/// This always uses `EventPriority::Low` for batch events. -pub fn emit_batch(env: &soroban_sdk::Env, category: EventCategory, action: Symbol, count: u32) { + /// + /// * `category` – The `EventCategory` of the batched events. + /// * `action` – Symbol representing the batch action. + /// * `count` – Number of events in the batch. + /// + /// This always uses `EventPriority::Low` for batch events. + pub fn emit_batch(env: &soroban_sdk::Env, category: EventCategory, action: Symbol, count: u32) { let topics = ( symbol_short!("Remitwise"), category.to_u32(), diff --git a/reporting/src/lib.rs b/reporting/src/lib.rs index 7c4cf90d..201d4b8d 100644 --- a/reporting/src/lib.rs +++ b/reporting/src/lib.rs @@ -17,7 +17,7 @@ pub const INSTANCE_BUMP_AMOUNT: u32 = PERSISTENT_BUMP_AMOUNT; pub const INSTANCE_LIFETIME_THRESHOLD: u32 = PERSISTENT_LIFETIME_THRESHOLD; 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 /// Maximum number of pages fetched from any single dependency per report call. /// Loops that reach this cap mark the result `DataAvailability::Partial` so @@ -149,11 +149,28 @@ pub struct InsuranceReport { #[contracttype] #[derive(Clone)] pub struct FamilySpendingReport { + pub member_breakdown: Vec, pub total_members: u32, pub total_spending: i128, pub average_per_member: i128, pub period_start: u64, pub period_end: u64, + pub data_availability: DataAvailability, +} + +/// Per-member family spending breakdown entry. +#[contracttype] +#[derive(Clone)] +pub struct FamilyMemberSpending { + /// Family-wallet member address. + pub member: Address, + /// Aggregated spending fetched from the family wallet's `SpendingTracker`. + /// + /// This is `0` when no tracker exists yet or when the per-member spending + /// read was unavailable. + pub total_spending: i128, + /// `true` when `total_spending` reflects a successful downstream read. + pub data_available: bool, } /// Overall financial health report @@ -317,6 +334,8 @@ pub trait InsuranceTrait { #[contractclient(name = "FamilyWalletClient")] pub trait FamilyWalletTrait { fn get_owner(env: Env) -> Address; + fn get_member_addresses_page(env: Env, cursor: u32, limit: u32) -> MemberAddressPage; + fn get_spending_tracker(env: Env, member: Address) -> Option; } // Data structures from other contracts (needed for client traits) @@ -391,6 +410,31 @@ pub struct PolicyPage { pub count: u32, } +#[contracttype] +#[derive(Clone)] +pub struct MemberAddressPage { + pub items: Vec
, + pub next_cursor: u32, + pub count: u32, +} + +#[contracttype] +#[derive(Clone)] +pub struct SpendingPeriod { + pub period_type: u32, + pub period_start: u64, + pub period_duration: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct SpendingTracker { + pub current_spent: i128, + pub last_tx_timestamp: u64, + pub tx_count: u32, + pub period: SpendingPeriod, +} + /// Compute `(numerator * scale) / denominator` using checked arithmetic. /// /// Returns `0` when `denominator <= 0` (safe default for percentage/ratio math). @@ -670,7 +714,6 @@ impl ReportingContract { /// /// # Panics /// * If `caller` does not authorize the transaction - pub fn configure_addresses( env: Env, caller: Address, @@ -772,10 +815,7 @@ impl ReportingContract { // Check remittance_split let split_client = RemittanceSplitClient::new(&env, &addresses.remittance_split); - let split_ok = match split_client.try_get_split() { - Ok(Ok(_)) => true, - _ => false, - }; + let split_ok = matches!(split_client.try_get_split(), Ok(Ok(_))); statuses.push_back(DependencyStatus { name: soroban_sdk::String::from_str(&env, "remittance_split"), ok: split_ok, @@ -788,10 +828,10 @@ impl ReportingContract { // Check savings_goals let savings_client = SavingsGoalsClient::new(&env, &addresses.savings_goals); - let savings_ok = match savings_client.try_get_all_goals(&env.current_contract_address()) { - Ok(Ok(_)) => true, - _ => false, - }; + let savings_ok = matches!( + savings_client.try_get_all_goals(&env.current_contract_address()), + Ok(Ok(_)) + ); statuses.push_back(DependencyStatus { name: soroban_sdk::String::from_str(&env, "savings_goals"), ok: savings_ok, @@ -804,10 +844,10 @@ impl ReportingContract { // Check bill_payments let bill_client = BillPaymentsClient::new(&env, &addresses.bill_payments); - let bill_ok = match bill_client.try_get_total_unpaid(&env.current_contract_address()) { - Ok(Ok(_)) => true, - _ => false, - }; + let bill_ok = matches!( + bill_client.try_get_total_unpaid(&env.current_contract_address()), + Ok(Ok(_)) + ); statuses.push_back(DependencyStatus { name: soroban_sdk::String::from_str(&env, "bill_payments"), ok: bill_ok, @@ -823,11 +863,10 @@ impl ReportingContract { // Check insurance let insurance_client = InsuranceClient::new(&env, &addresses.insurance); - let insurance_ok = - match insurance_client.try_get_total_monthly_premium(&env.current_contract_address()) { - Ok(Ok(_)) => true, - _ => false, - }; + let insurance_ok = matches!( + insurance_client.try_get_total_monthly_premium(&env.current_contract_address()), + Ok(Ok(_)) + ); statuses.push_back(DependencyStatus { name: soroban_sdk::String::from_str(&env, "insurance"), ok: insurance_ok, @@ -843,10 +882,7 @@ impl ReportingContract { // Check family_wallet let family_client = FamilyWalletClient::new(&env, &addresses.family_wallet); - let family_ok = match family_client.try_get_owner() { - Ok(Ok(_)) => true, - _ => false, - }; + let family_ok = matches!(family_client.try_get_owner(), Ok(Ok(_))); statuses.push_back(DependencyStatus { name: soroban_sdk::String::from_str(&env, "family_wallet"), ok: family_ok, @@ -1156,6 +1192,129 @@ impl ReportingContract { }) } + /// Generate a family-wallet spending report. + /// + /// Reads the configured `family_wallet` dependency to enumerate members and + /// fetch each member's current `SpendingTracker`, returning a per-member + /// breakdown plus aggregate totals. When the family wallet is unreachable + /// the report degrades to `DataAvailability::Missing`; when only part of + /// the dependency data can be read the report degrades to + /// `DataAvailability::Partial`. + pub fn get_family_spending_report( + env: Env, + _caller: Address, + user: Address, + period_start: u64, + period_end: u64, + ) -> Result { + Self::validate_period(period_start, period_end)?; + user.require_auth(); + Self::get_family_spending_report_internal(&env, period_start, period_end) + } + + fn get_family_spending_report_internal( + env: &Env, + period_start: u64, + period_end: u64, + ) -> Result { + let addresses: ContractAddresses = env + .storage() + .instance() + .get(&symbol_short!("ADDRS")) + .ok_or(ReportingError::AddressesNotConfigured)?; + + let family_client = FamilyWalletClient::new(env, &addresses.family_wallet); + let mut availability = DataAvailability::Complete; + let mut breakdown: Vec = Vec::new(env); + let mut seen_members: Map = Map::new(env); + let mut total_spending = 0i128; + + let mut cursor = 0u32; + let mut page_index = 0u32; + let mut saw_member_page = false; + + loop { + if page_index >= MAX_DEP_PAGES { + availability = DataAvailability::Partial; + break; + } + + let page = match family_client.try_get_member_addresses_page(&cursor, &DEP_PAGE_LIMIT) { + Ok(Ok(page)) => page, + _ if saw_member_page => { + availability = DataAvailability::Partial; + break; + } + _ => { + availability = DataAvailability::Missing; + break; + } + }; + + page_index = page_index.saturating_add(1); + + if page.items.is_empty() && cursor == 0 { + availability = DataAvailability::Missing; + break; + } + + saw_member_page = true; + + for member in page.items.iter() { + if seen_members.get(member.clone()).unwrap_or(false) { + continue; + } + seen_members.set(member.clone(), true); + + let tracker_result = family_client.try_get_spending_tracker(&member); + let (member_spending, data_available) = match tracker_result { + Ok(Ok(Some(tracker))) => (tracker.current_spent, true), + Ok(Ok(None)) => (0, true), + _ => { + availability = DataAvailability::Partial; + (0, false) + } + }; + + total_spending = match total_spending.checked_add(member_spending) { + Some(sum) => sum, + None => { + availability = DataAvailability::Partial; + total_spending.saturating_add(member_spending) + } + }; + + breakdown.push_back(FamilyMemberSpending { + member, + total_spending: member_spending, + data_available, + }); + } + + if page.next_cursor == 0 { + break; + } + cursor = page.next_cursor; + } + + let total_members = breakdown.len(); + let average_per_member = if total_members == 0 { + 0 + } else { + total_spending / (total_members as i128) + }; + + Ok(FamilySpendingReport { + member_breakdown: breakdown, + total_members, + total_spending, + average_per_member, + period_start, + period_end, + data_availability: availability, + }) + } + /// Calculate financial health score with hardened arithmetic and normalization /// /// This function computes a comprehensive financial health score (0-100) based on: @@ -1266,7 +1425,7 @@ impl ReportingContract { }; // Convert percentage to score: (progress * 40) / 100 - let score = (progress_percentage as u32 * 40) / 100; + let score = (progress_percentage * 40) / 100; score.min(40) // Ensure maximum is 40 } diff --git a/reporting/src/tests.rs b/reporting/src/tests.rs index c9e7ca10..6e942353 100644 --- a/reporting/src/tests.rs +++ b/reporting/src/tests.rs @@ -7,8 +7,8 @@ use soroban_sdk::{ use testutils::set_ledger_time; use crate::{ - Category, ContractAddresses, DataAvailability, ReportingContract, - ReportingContractClient, ReportingError, MAX_DEP_PAGES, + Category, ContractAddresses, DataAvailability, ReportingContract, ReportingContractClient, + ReportingError, MAX_DEP_PAGES, }; /// Minimal env with mock_all_auths — replaces the removed create_test_env helper. @@ -229,8 +229,9 @@ mod insurance { } mod family_wallet { + use crate::{MemberAddressPage, SpendingPeriod, SpendingTracker}; use soroban_sdk::testutils::Address as _; - use soroban_sdk::{contract, contractimpl, Address, Env}; + use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Vec}; #[contract] pub struct FamilyWallet; @@ -240,7 +241,171 @@ mod family_wallet { pub fn get_owner(env: Env) -> Address { Address::generate(&env) } + + pub fn get_member_addresses_page(env: Env, _cursor: u32, _limit: u32) -> MemberAddressPage { + MemberAddressPage { + items: Vec::new(&env), + next_cursor: 0, + count: 0, + } + } + + pub fn get_spending_tracker(_env: Env, _member: Address) -> Option { + None + } + } + + fn tracker(current_spent: i128) -> SpendingTracker { + SpendingTracker { + current_spent, + last_tx_timestamp: 1_704_067_200, + tx_count: 1, + period: SpendingPeriod { + period_type: 2, + period_start: 1_704_067_200, + period_duration: 2_592_000, + }, + } + } + + pub const MODE_COMPLETE: u32 = 0; + pub const MODE_PARTIAL_TRACKER: u32 = 1; + pub const MODE_MISSING: u32 = 2; + pub const MODE_EMPTY: u32 = 3; + pub const MODE_OVERFLOW: u32 = 4; + + mod scenario { + use super::*; + + #[contract] + pub struct FamilyWalletScenario; + + #[contractimpl] + impl FamilyWalletScenario { + pub fn seed(env: Env, mode: u32, members: Vec
) { + env.storage().instance().set(&symbol_short!("MODE"), &mode); + env.storage() + .instance() + .set(&symbol_short!("MBRS"), &members); + } + + pub fn get_owner(env: Env) -> Address { + let members: Vec
= env + .storage() + .instance() + .get(&symbol_short!("MBRS")) + .unwrap_or_else(|| Vec::new(&env)); + members.get(0).unwrap_or_else(|| Address::generate(&env)) + } + + pub fn get_member_addresses_page( + env: Env, + cursor: u32, + _limit: u32, + ) -> MemberAddressPage { + let mode: u32 = env + .storage() + .instance() + .get(&symbol_short!("MODE")) + .unwrap_or(0); + if mode == MODE_MISSING { + panic!("family wallet unreachable"); + } + if mode == MODE_EMPTY { + return MemberAddressPage { + items: Vec::new(&env), + next_cursor: 0, + count: 0, + }; + } + + let members: Vec
= env + .storage() + .instance() + .get(&symbol_short!("MBRS")) + .unwrap_or_else(|| Vec::new(&env)); + + match mode { + MODE_COMPLETE if cursor == 0 => { + let mut items = Vec::new(&env); + if let Some(a) = members.get(0) { + items.push_back(a); + } + if let Some(b) = members.get(1) { + items.push_back(b); + } + MemberAddressPage { + count: items.len(), + items, + next_cursor: if members.len() > 2 { 2 } else { 0 }, + } + } + MODE_COMPLETE if cursor == 2 => { + let mut items = Vec::new(&env); + if let Some(c) = members.get(2) { + items.push_back(c); + } + MemberAddressPage { + count: items.len(), + items, + next_cursor: 0, + } + } + _ => MemberAddressPage { + count: members.len(), + items: members, + next_cursor: 0, + }, + } + } + + pub fn get_spending_tracker(env: Env, member: Address) -> Option { + let mode: u32 = env + .storage() + .instance() + .get(&symbol_short!("MODE")) + .unwrap_or(0); + if mode == MODE_MISSING { + panic!("family wallet unreachable"); + } + + let members: Vec
= env + .storage() + .instance() + .get(&symbol_short!("MBRS")) + .unwrap_or_else(|| Vec::new(&env)); + + match mode { + MODE_COMPLETE => { + if Some(member.clone()) == members.get(0) { + Some(tracker(150)) + } else if Some(member.clone()) == members.get(1) { + Some(tracker(50)) + } else { + None + } + } + MODE_PARTIAL_TRACKER => { + if Some(member) == members.get(0) { + Some(tracker(25)) + } else { + panic!("tracker unavailable") + } + } + MODE_OVERFLOW => { + if Some(member) == members.get(0) { + Some(tracker(i128::MAX)) + } else { + Some(tracker(1)) + } + } + _ => None, + } + } + } } + + pub use scenario::{FamilyWalletScenario, FamilyWalletScenarioClient}; } #[test] @@ -823,6 +988,261 @@ fn test_get_insurance_report_rejects_invalid_period() { assert!(matches!(result, Err(Ok(ReportingError::InvalidPeriod)))); } +#[test] +fn test_get_family_spending_report_complete() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let mut members = soroban_sdk::Vec::new(&env); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_COMPLETE, &members); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let report = + client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + assert_eq!(report.total_members, 3); + assert_eq!(report.total_spending, 200); + assert_eq!(report.average_per_member, 66); + assert_eq!(report.data_availability, DataAvailability::Complete); + assert_eq!(report.member_breakdown.len(), 3); + + let first = report.member_breakdown.get(0).unwrap(); + let second = report.member_breakdown.get(1).unwrap(); + let third = report.member_breakdown.get(2).unwrap(); + + assert_ne!(first.member, second.member); + assert_ne!(first.member, third.member); + assert_ne!(second.member, third.member); + + assert_eq!(first.total_spending, 150); + assert!(first.data_available); + assert_eq!(second.total_spending, 50); + assert!(second.data_available); + assert_eq!(third.total_spending, 0); + assert!(third.data_available); +} + +#[test] +fn test_get_family_spending_report_partial_when_member_tracker_fails() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let mut members = soroban_sdk::Vec::new(&env); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_PARTIAL_TRACKER, &members); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let report = + client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + assert_eq!(report.total_members, 2); + assert_eq!(report.total_spending, 25); + assert_eq!(report.data_availability, DataAvailability::Partial); + assert_eq!(report.member_breakdown.len(), 2); + assert!(report.member_breakdown.get(0).unwrap().data_available); + assert!(!report.member_breakdown.get(1).unwrap().data_available); + assert_eq!(report.member_breakdown.get(1).unwrap().total_spending, 0); +} + +#[test] +fn test_get_family_spending_report_missing_when_wallet_unreachable() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let mut members = soroban_sdk::Vec::new(&env); + members.push_back(Address::generate(&env)); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_MISSING, &members); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let report = + client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + assert_eq!(report.total_members, 0); + assert_eq!(report.total_spending, 0); + assert_eq!(report.average_per_member, 0); + assert_eq!(report.data_availability, DataAvailability::Missing); + assert_eq!(report.member_breakdown.len(), 0); +} + +#[test] +fn test_get_family_spending_report_zero_members_maps_to_missing() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_EMPTY, &soroban_sdk::Vec::new(&env)); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let report = + client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + assert_eq!(report.total_members, 0); + assert_eq!(report.total_spending, 0); + assert_eq!(report.data_availability, DataAvailability::Missing); +} + +#[test] +fn test_get_family_spending_report_overflow_clamps_and_marks_partial() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let mut members = soroban_sdk::Vec::new(&env); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_OVERFLOW, &members); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let report = + client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + assert_eq!(report.total_members, 2); + assert_eq!(report.total_spending, i128::MAX); + assert_eq!(report.average_per_member, i128::MAX / 2); + assert_eq!(report.data_availability, DataAvailability::Partial); +} + +#[test] +fn test_get_family_spending_report_requires_user_auth() { + let env = create_test_env(); + set_ledger_time(&env, 1, 1704067200); + let contract_id = env.register_contract(None, ReportingContract); + let client = ReportingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + + let remittance_split_id = env.register_contract(None, remittance_split::RemittanceSplit); + let savings_goals_id = env.register_contract(None, savings_goals::SavingsGoalsContract); + let bill_payments_id = env.register_contract(None, bill_payments::BillPayments); + let insurance_id = env.register_contract(None, insurance::Insurance); + let family_wallet_id = env.register_contract(None, family_wallet::FamilyWalletScenario); + let mut members = soroban_sdk::Vec::new(&env); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + let family_client = family_wallet::FamilyWalletScenarioClient::new(&env, &family_wallet_id); + family_client.seed(&family_wallet::MODE_COMPLETE, &members); + + client.configure_addresses( + &admin, + &remittance_split_id, + &savings_goals_id, + &bill_payments_id, + &insurance_id, + &family_wallet_id, + ); + + let _ = client.get_family_spending_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + + let auths = env.auths(); + let found = auths.iter().any(|(addr, _)| *addr == user); + assert!(found, "family spending report must require user auth"); +} + #[test] fn test_calculate_health_score() { let env = Env::default(); @@ -1027,10 +1447,10 @@ fn test_calculate_health_score_bounds_guarantee() { let health_score = client.calculate_health_score(&user, &10000); // All scores must be within bounds - assert!(health_score.score >= 0 && health_score.score <= 100); - assert!(health_score.savings_score >= 0 && health_score.savings_score <= 40); - assert!(health_score.bills_score >= 0 && health_score.bills_score <= 40); - assert!(health_score.insurance_score >= 0 && health_score.insurance_score <= 20); + assert!(health_score.score <= 100); + assert!(health_score.savings_score <= 40); + assert!(health_score.bills_score <= 40); + assert!(health_score.insurance_score <= 20); // Total should equal sum of components assert_eq!( @@ -1194,8 +1614,13 @@ fn test_archive_old_reports() { &family_wallet, ); - let result = - client.try_get_financial_health_report(&user, &user, &10000i128, &1704067200u64, &1706745600u64); + let result = client.try_get_financial_health_report( + &user, + &user, + &10000i128, + &1704067200u64, + &1706745600u64, + ); assert!(result.is_ok()); let report = result.unwrap().unwrap(); @@ -1206,7 +1631,9 @@ fn test_archive_old_reports() { assert!(archive_result.is_ok()); assert_eq!(archive_result.unwrap().unwrap(), 1); - assert!(client.get_stored_report(&user, &user, &period_key).is_none()); + assert!(client + .get_stored_report(&user, &user, &period_key) + .is_none()); } #[test] @@ -1236,8 +1663,13 @@ fn test_cleanup_old_reports() { &family_wallet, ); - let result = - client.try_get_financial_health_report(&user, &user, &10000i128, &1704067200u64, &1706745600u64); + let result = client.try_get_financial_health_report( + &user, + &user, + &10000i128, + &1704067200u64, + &1706745600u64, + ); assert!(result.is_ok()); let report = result.unwrap().unwrap(); client.store_report(&user, &report, &202401); @@ -1295,7 +1727,8 @@ fn test_storage_stats_regression_across_archive_and_cleanup_cycles() { let base_ts = 1_000_000u64; for i in 0..TOTAL { set_ledger_time(&env, 10 + i as u32, base_ts + i); - let report = client.get_financial_health_report(&user, &user, &10000, &1704067200, &1706745600); + let report = + client.get_financial_health_report(&user, &user, &10000, &1704067200, &1706745600); client.store_report(&user, &report, &(202_400 + i)); } @@ -1482,7 +1915,13 @@ fn make_report( client: &ReportingContractClient, user: &Address, ) -> crate::FinancialHealthReport { - client.get_financial_health_report(user, user, &10_000i128, &1_704_067_200u64, &1_706_745_600u64) + client.get_financial_health_report( + user, + user, + &10_000i128, + &1_704_067_200u64, + &1_706_745_600u64, + ) } // ── store_report authorization ──────────────────────────────────────────────── @@ -1647,11 +2086,19 @@ fn test_get_stored_report_multiple_periods_same_user() { client.store_report(&user, &report, &202_402u64); client.store_report(&user, &report, &202_403u64); - assert!(client.get_stored_report(&user, &user, &202_401u64).is_some()); - assert!(client.get_stored_report(&user, &user, &202_402u64).is_some()); - assert!(client.get_stored_report(&user, &user, &202_403u64).is_some()); + assert!(client + .get_stored_report(&user, &user, &202_401u64) + .is_some()); + assert!(client + .get_stored_report(&user, &user, &202_402u64) + .is_some()); + assert!(client + .get_stored_report(&user, &user, &202_403u64) + .is_some()); // Non-existent period returns None - assert!(client.get_stored_report(&user, &user, &202_404u64).is_none()); + assert!(client + .get_stored_report(&user, &user, &202_404u64) + .is_none()); } /// Overwriting a report for the same (user, period) replaces the previous value. @@ -2033,7 +2480,9 @@ fn test_archive_timestamp_boundary_preserves_recent_reports() { // Report must still be in active storage assert!( - client.get_stored_report(&user, &user, &202_401u64).is_some(), + client + .get_stored_report(&user, &user, &202_401u64) + .is_some(), "recent report must remain in active storage" ); } @@ -2441,7 +2890,7 @@ fn setup_paging_test( env: &Env, bill_payments_id: Address, insurance_id: Address, -) -> (ReportingContractClient, Address) { +) -> (ReportingContractClient<'_>, Address) { let contract_id = env.register_contract(None, ReportingContract); let client = ReportingContractClient::new(env, &contract_id); let admin = Address::generate(env); @@ -2474,7 +2923,8 @@ fn test_bill_paging_terminates_at_cursor_zero() { let (client, _) = setup_paging_test(&env, bill_id, ins_id); let user = Address::generate(&env); - let report = client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + let report = + client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); // All 3 pages fetched — no items are filtered out because created_at == period_start assert_eq!( @@ -2501,7 +2951,8 @@ fn test_bill_paging_terminates_at_cap() { let (client, _) = setup_paging_test(&env, bill_id, ins_id); let user = Address::generate(&env); - let report = client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + let report = + client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); assert_eq!( report.data_availability, @@ -2528,7 +2979,8 @@ fn test_bill_paging_cursor_monotonicity() { let user = Address::generate(&env); // Each page delivers exactly 1 bill; 3 pages → 3 bills total. // If the loop visited the same page twice, count would differ. - let report = client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); + let report = + client.get_bill_compliance_report(&user, &user, &1_704_067_200u64, &1_706_745_600u64); assert_eq!( report.total_bills, 3, "cursor must advance monotonically so each page is visited exactly once" diff --git a/reporting/src/tests_auth_acl.rs b/reporting/src/tests_auth_acl.rs index 3688a6ad..9782ae0f 100644 --- a/reporting/src/tests_auth_acl.rs +++ b/reporting/src/tests_auth_acl.rs @@ -294,8 +294,7 @@ fn test_all_report_endpoints_accessible() { let _ = client.try_get_stored_report(&user, &user, &1u64); let _ = client.get_archived_reports(&user); - // No panics = all accessible - assert!(true); + // No panics means the endpoints remained callable through the auth path. } /// Test 14: Authorization call count validates enforcement diff --git a/reporting/tests/insurance_report_ratio.rs b/reporting/tests/insurance_report_ratio.rs index e647f845..52058344 100644 --- a/reporting/tests/insurance_report_ratio.rs +++ b/reporting/tests/insurance_report_ratio.rs @@ -225,7 +225,10 @@ fn aggregation_is_active_only() { let report = rc.get_insurance_report(&caller, &user, &0u64, &100u64); - assert_eq!(report.active_policies, 1, "inactive policy must not be counted"); + assert_eq!( + report.active_policies, 1, + "inactive policy must not be counted" + ); assert_eq!( report.total_coverage, 500_000_000, "inactive coverage must be excluded" diff --git a/savings_goals/src/event_test.rs b/savings_goals/src/event_test.rs index 85726af0..864f77b6 100644 --- a/savings_goals/src/event_test.rs +++ b/savings_goals/src/event_test.rs @@ -36,9 +36,7 @@ mod goal_completed_event_tests { .iter() .filter(|(_, topics, _)| { topics.iter().any(|t| { - Symbol::try_from_val(env, &t) - .ok() - .as_ref() + Symbol::try_from_val(env, &t).ok().as_ref() == Some(&Symbol::new(env, "completed")) }) }) diff --git a/savings_goals/src/lib.rs b/savings_goals/src/lib.rs index d99db226..bac49bc8 100644 --- a/savings_goals/src/lib.rs +++ b/savings_goals/src/lib.rs @@ -619,7 +619,7 @@ impl SavingsGoalContract { .persistent() .get(&key) .unwrap_or_else(|| Vec::new(env)); - + // Avoid duplicate goal IDs in the index let mut exists = false; for id in ids.iter() { @@ -631,7 +631,7 @@ impl SavingsGoalContract { if !exists { ids.push_back(goal_id); } - + env.storage().persistent().set(&key, &ids); env.storage().persistent().extend_ttl( &key, @@ -1524,14 +1524,22 @@ impl SavingsGoalContract { /// # Notes /// - Uses the tag index for O(matching goals) performance instead of scanning all goals. /// - Tag is canonicalized (lowercased) to match storage keys. - pub fn get_goals_by_tag(env: Env, owner: Address, tag: String, cursor: u32, limit: u32) -> GoalPage { + pub fn get_goals_by_tag( + env: Env, + owner: Address, + tag: String, + cursor: u32, + limit: u32, + ) -> GoalPage { let limit = Self::clamp_limit(limit); // Canonicalize the tag for lookup let mut tags_vec = Vec::new(&env); tags_vec.push_back(tag.clone()); let normalized = Self::validate_and_normalize_tags(&env, &tags_vec); - let canonical_tag = normalized.get(0).unwrap_or_else(|| panic!("Tag normalization failed")); + let canonical_tag = normalized + .get(0) + .unwrap_or_else(|| panic!("Tag normalization failed")); let ids: Vec = env .storage() @@ -1697,12 +1705,12 @@ impl SavingsGoalContract { .persistent() .remove(&DataKey::ArchivedGoal(goal_id)); let restored_goal = archived_goal.into_goal(); - + // Re-index goal in all tag indexes for tag in restored_goal.tags.iter() { Self::add_to_tag_index(&env, &caller, &tag, goal_id); } - + env.storage() .persistent() .set(&DataKey::Goal(goal_id), &restored_goal); diff --git a/savings_goals/src/test.rs b/savings_goals/src/test.rs index 7a17304b..732e89e9 100644 --- a/savings_goals/src/test.rs +++ b/savings_goals/src/test.rs @@ -74,10 +74,7 @@ fn test_create_goal_empty_name_fails() { let name = String::from_str(&env, ""); let res = client.try_create_goal(&user, &name, &1000, &1735689600); assert!(res.is_err()); - assert_eq!( - res.unwrap_err().unwrap(), - SavingsGoalError::InvalidGoalName - ); + assert_eq!(res.unwrap_err().unwrap(), SavingsGoalError::InvalidGoalName); } #[test] @@ -108,10 +105,7 @@ fn test_create_goal_over_max_len_fails() { let name = String::from_str(&env, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); let res = client.try_create_goal(&user, &name, &1000, &1735689600); assert!(res.is_err()); - assert_eq!( - res.unwrap_err().unwrap(), - SavingsGoalError::InvalidGoalName - ); + assert_eq!(res.unwrap_err().unwrap(), SavingsGoalError::InvalidGoalName); } #[test] @@ -127,10 +121,7 @@ fn test_create_goal_control_char_fails() { let name = String::from_str(&env, "Goal\nName"); let res = client.try_create_goal(&user, &name, &1000, &1735689600); assert!(res.is_err()); - assert_eq!( - res.unwrap_err().unwrap(), - SavingsGoalError::InvalidGoalName - ); + assert_eq!(res.unwrap_err().unwrap(), SavingsGoalError::InvalidGoalName); } // In production or integration, init() may be called more than once (e.g. by // different entrypoints or upgrade paths). These tests lock in that: @@ -5235,7 +5226,6 @@ fn test_import_snapshot_ordering_version_validation_comes_first() { ); } - // ============================================================================ // Tag Index Tests // ============================================================================ @@ -5277,7 +5267,13 @@ fn test_add_tags_maintains_index() { let page = client.get_goals_by_tag(&user, &tag, &0, &50); assert_eq!(page.count, 1); - assert_eq!(page.items.get(0).unwrap_or_else(|| panic!("Goal not found")).id, goal_id); + assert_eq!( + page.items + .get(0) + .unwrap_or_else(|| panic!("Goal not found")) + .id, + goal_id + ); } #[test] @@ -5456,7 +5452,8 @@ fn test_multiple_tags_per_goal() { let page_travel = client.get_goals_by_tag(&user, &String::from_str(&env, "travel"), &0, &50); assert_eq!(page_travel.count, 1); - let page_adventure = client.get_goals_by_tag(&user, &String::from_str(&env, "adventure"), &0, &50); + let page_adventure = + client.get_goals_by_tag(&user, &String::from_str(&env, "adventure"), &0, &50); assert_eq!(page_adventure.count, 1); let page_savings = client.get_goals_by_tag(&user, &String::from_str(&env, "savings"), &0, &50); @@ -5477,7 +5474,7 @@ fn test_tag_index_no_duplicate_goal_ids() { let mut tags = SorobanVec::new(&env); tags.push_back(String::from_str(&env, "Emergency")); - + // Add same tag twice client.add_tags_to_goal(&user, &goal_id, &tags); client.add_tags_to_goal(&user, &goal_id, &tags); diff --git a/savings_goals/src/tests_schedule_exec.rs b/savings_goals/src/tests_schedule_exec.rs index 6c1700b3..b6896f72 100644 --- a/savings_goals/src/tests_schedule_exec.rs +++ b/savings_goals/src/tests_schedule_exec.rs @@ -51,12 +51,7 @@ fn setup(env: &Env) -> (SavingsGoalContractClient, Address) { (client, owner) } -fn make_goal( - env: &Env, - client: &SavingsGoalContractClient, - owner: &Address, - target: i128, -) -> u32 { +fn make_goal(env: &Env, client: &SavingsGoalContractClient, owner: &Address, target: i128) -> u32 { client.create_goal( owner, &String::from_str(env, "Test Goal"), @@ -104,7 +99,10 @@ fn test_single_due_schedule_credits_exactly_once() { assert_eq!(executed.get(0).unwrap(), sched_id); let goal = client.get_goal(&goal_id).unwrap(); - assert_eq!(goal.current_amount, 500, "goal must be credited exactly once"); + assert_eq!( + goal.current_amount, 500, + "goal must be credited exactly once" + ); } /// Calling `execute_due_savings_schedules` a second time within the same ledger @@ -228,7 +226,11 @@ fn test_overflow_near_limit_schedule_skipped() { set_ledger_time(&env, 2, 3_500); let executed = client.execute_due_savings_schedules(); - assert_eq!(executed.len(), 0, "overflow schedule must be silently skipped"); + assert_eq!( + executed.len(), + 0, + "overflow schedule must be silently skipped" + ); let goal = client.get_goal(&goal_id).unwrap(); assert_eq!( @@ -334,8 +336,7 @@ fn test_archived_goal_schedule_skipped_missed_count_not_incremented() { client.add_to_goal(&owner, &goal_id, &500); // Create a schedule while the goal is still active. - let sched_id = - client.create_savings_schedule(&owner, &goal_id, &100, &3_000, &86_400); + let sched_id = client.create_savings_schedule(&owner, &goal_id, &100, &3_000, &86_400); // Archive the goal — removes it from DataKey::Goal storage. client.archive_goal(&owner, &goal_id); @@ -571,15 +572,17 @@ fn test_missed_count_increments_for_skipped_intervals() { // next_due=3000, interval=1000; execute at t=6500. // Intervals skipped: 4000, 5000, 6000 (all ≤ 6500) → 3 missed. // next_due advances to 7000. - let sched_id = - client.create_savings_schedule(&owner, &goal_id, &500, &3_000, &1_000); + let sched_id = client.create_savings_schedule(&owner, &goal_id, &500, &3_000, &1_000); set_ledger_time(&env, 2, 6_500); client.execute_due_savings_schedules(); let sched = client.get_savings_schedule(&sched_id).unwrap(); assert_eq!(sched.missed_count, 3, "three intervals were skipped"); - assert_eq!(sched.next_due, 7_000, "next_due must be at the next future slot"); + assert_eq!( + sched.next_due, 7_000, + "next_due must be at the next future slot" + ); // Only one credit is applied regardless of how many intervals were missed. let goal = client.get_goal(&goal_id).unwrap(); @@ -597,8 +600,7 @@ fn test_missed_count_accumulates_across_multiple_passes() { let goal_id = make_goal(&env, &client, &owner, 100_000); // next_due=2000, interval=1000 - let sched_id = - client.create_savings_schedule(&owner, &goal_id, &100, &2_000, &1_000); + let sched_id = client.create_savings_schedule(&owner, &goal_id, &100, &2_000, &1_000); // Pass 1 at t=5000: // Skipped intervals: 3000, 4000, 5000 (all ≤ 5000) → 3 missed. @@ -615,7 +617,10 @@ fn test_missed_count_accumulates_across_multiple_passes() { set_ledger_time(&env, 3, 7_500); client.execute_due_savings_schedules(); let s2 = client.get_savings_schedule(&sched_id).unwrap(); - assert_eq!(s2.missed_count, 4, "missed_count must accumulate across passes"); + assert_eq!( + s2.missed_count, 4, + "missed_count must accumulate across passes" + ); assert_eq!(s2.next_due, 8_000); // Two executor passes → two credits. From 40ca4db4cca0d36191f0ce6bec2a9b2ad2e3ed90 Mon Sep 17 00:00:00 2001 From: macbook Date: Fri, 19 Jun 2026 10:58:26 +0100 Subject: [PATCH 2/3] add NotInitialized / AlreadyInitialized guard tests across the full init lifecycle --- docs/insurance-bootstrap-guards.md | 46 ++++++ insurance/src/lib.rs | 25 +--- insurance/src/test.rs | 231 ++++++++++++++++++++--------- 3 files changed, 217 insertions(+), 85 deletions(-) create mode 100644 docs/insurance-bootstrap-guards.md diff --git a/docs/insurance-bootstrap-guards.md b/docs/insurance-bootstrap-guards.md new file mode 100644 index 00000000..a9f59a94 --- /dev/null +++ b/docs/insurance-bootstrap-guards.md @@ -0,0 +1,46 @@ +# Insurance Bootstrap Guards + +This note documents the bootstrap safety contract for `insurance`. + +## Initialization + +`Insurance::init(owner)` is single-shot. + +- First call: stores `Initialized = true`, records `Owner = owner`, resets + `PolicyCount`, and creates an empty active-policy index. +- Later calls: return `InsuranceError::AlreadyInitialized`. + +## Current authorization model + +`init` does not currently call `require_auth`. + +That means bootstrap safety relies on: + +- deploying and initializing in a trusted flow +- the `AlreadyInitialized` guard preventing later ownership takeover + +Once initialization succeeds, the stored owner becomes the only contract-level +admin for owner-only operations such as `set_external_ref`. + +## Pre-init behavior + +Before initialization: + +- Mutators return `InsuranceError::NotInitialized`: + - `create_policy` + - `pay_premium` + - `batch_pay_premiums` + - `deactivate_policy` + - `set_external_ref` +- Read paths are deterministic and non-panicking: + - `get_policy` returns `Ok(None)` + - `get_active_policies` returns an empty page + - `get_total_monthly_premium` returns `Ok(0)` + +## Post-init privileges + +After initialization: + +- policy owners may pay and deactivate their own policies +- the stored contract owner may perform owner-only admin updates +- a different address cannot re-run `init` or assume owner-only privileges diff --git a/insurance/src/lib.rs b/insurance/src/lib.rs index 5a1795f4..c01db86e 100644 --- a/insurance/src/lib.rs +++ b/insurance/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use remitwise_common::{ CoverageType, DEFAULT_PAGE_LIMIT, INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD, - MAX_BATCH_SIZE, MAX_PAGE_LIMIT, + MAX_PAGE_LIMIT, }; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, @@ -210,7 +210,7 @@ impl Insurance { fn validate_ext_ref(ext_ref: &core::option::Option) -> Result<(), InsuranceError> { if let Some(r) = ext_ref { - if r.len() == 0 || r.len() > MAX_EXT_REF_LEN { + if r.is_empty() || r.len() > MAX_EXT_REF_LEN { return Err(InsuranceError::InvalidExternalRef); } } @@ -239,7 +239,7 @@ impl Insurance { Self::require_initialized(&env)?; caller.require_auth(); - if name.len() == 0 { + if name.is_empty() { return Err(InsuranceError::InvalidName); } if name.len() > MAX_NAME_LEN { @@ -289,7 +289,7 @@ impl Insurance { id: next_id, owner: caller.clone(), name: name.clone(), - coverage_type: coverage_type.clone(), + coverage_type, monthly_premium, coverage_amount, external_ref: core::option::Option::None, @@ -463,7 +463,7 @@ impl Insurance { .instance() .set(&DataKey::Policy(policy_id), &policy); - let mut active = env + let active = env .storage() .instance() .get::<_, Vec>(&DataKey::ActivePolicies) @@ -490,16 +490,12 @@ impl Insurance { } /// Get a paginated list of active policies for an owner. - /// - /// # Errors - /// - `NotInitialized` if the contract has not been initialized pub fn get_active_policies( env: Env, owner: Address, cursor: u32, limit: u32, ) -> Result { - Self::require_initialized(&env)?; let owner_ids = env .storage() .instance() @@ -542,23 +538,15 @@ impl Insurance { } /// Get a policy by ID. - /// - /// # Errors - /// - `NotInitialized` if the contract has not been initialized pub fn get_policy( env: Env, policy_id: u32, ) -> Result, InsuranceError> { - Self::require_initialized(&env)?; Ok(env.storage().instance().get(&DataKey::Policy(policy_id))) } /// Get the total monthly premium for all active policies owned by an address. - /// - /// # Errors - /// - `NotInitialized` if the contract has not been initialized pub fn get_total_monthly_premium(env: Env, owner: Address) -> Result { - Self::require_initialized(&env)?; let owner_ids = env .storage() .instance() @@ -579,3 +567,6 @@ impl Insurance { Ok(total) } } + +#[cfg(test)] +mod test; diff --git a/insurance/src/test.rs b/insurance/src/test.rs index c3663f86..b70f44bc 100644 --- a/insurance/src/test.rs +++ b/insurance/src/test.rs @@ -1,91 +1,186 @@ -#[cfg(test)] -mod tests { - use crate::*; - use proptest::prelude::*; - use remitwise_common::CoverageType; - use soroban_sdk::testutils::{Address as AddressTrait, Events as _}; - use soroban_sdk::{symbol_short, Env, IntoVal, String, TryFromVal}; - -use super::*; -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, String, -}; - -fn setup() -> (Env, Address, Address) { +use crate::{Insurance, InsuranceClient, InsuranceError}; +use remitwise_common::CoverageType; +use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; + +fn fresh_env() -> (Env, Address) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register_contract(None, Insurance); - let client = InsuranceClient::new(&env, &contract_id); + (env, contract_id) +} + +fn init_contract(client: &InsuranceClient<'_>, env: &Env) -> Address { let owner = Address::generate(&env); - client.init(&owner).unwrap(); - (env, contract_id, owner) + client.init(&owner); + owner } -fn health_str(env: &Env) -> String { String::from_str(env, "health") } -fn life_str(env: &Env) -> String { String::from_str(env, "life") } -fn property_str(env: &Env) -> String { String::from_str(env, "property") } -fn auto_str(env: &Env) -> String { String::from_str(env, "auto") } -fn liability_str(env: &Env) -> String { String::from_str(env, "liability") } +fn policy_name(env: &Env) -> String { + String::from_str(env, "Family Cover") +} + +fn external_ref(env: &Env) -> String { + String::from_str(env, "ext-001") +} +/// `init` is single-shot: the first call succeeds and a second call cannot +/// replace the configured owner. #[test] -fn test_init_success() { - let (env, _, owner) = setup(); - let client = InsuranceClient::new(&env, &env.register_contract(None, Insurance)); - client.init(&owner).unwrap(); +fn test_init_is_single_shot() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + + client.init(&owner); + let second = client.try_init(&attacker); + assert_eq!(second, Err(Ok(InsuranceError::AlreadyInitialized))); + + let policy_owner = Address::generate(&env); + let policy_id = client.create_policy( + &policy_owner, + &policy_name(&env), + &CoverageType::Health, + &100, + &10_000, + ); + + assert_eq!( + client.set_external_ref(&owner, &policy_id, &Some(external_ref(&env))), + true + ); + assert_eq!( + client.try_set_external_ref(&attacker, &policy_id, &None), + Err(Ok(InsuranceError::Unauthorized)) + ); } +/// Every mutating entrypoint rejects pre-init access with `NotInitialized`. #[test] -fn test_create_policy_success() { - let (env, _, owner) = setup(); - let client = InsuranceClient::new(&env, &env.register_contract(None, Insurance)); - client.init(&owner).unwrap(); +fn test_mutators_reject_before_init() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); let caller = Address::generate(&env); - let id = client.create_policy(&caller, &String::from_str(&env, "P1"), &health_str(&env), &5_000_000i128, &50_000_000i128, &None).unwrap(); - assert_eq!(id, 1); - let p = client.get_policy(&id).unwrap(); - assert_eq!(p.monthly_premium, 5_000_000); + let ids = Vec::new(&env); + + assert_eq!( + client.try_create_policy( + &caller, + &policy_name(&env), + &CoverageType::Health, + &100, + &10_000, + ), + Err(Ok(InsuranceError::NotInitialized)) + ); + assert_eq!( + client.try_pay_premium(&caller, &1), + Err(Ok(InsuranceError::NotInitialized)) + ); + assert_eq!( + client.try_batch_pay_premiums(&caller, &ids), + Err(Ok(InsuranceError::NotInitialized)) + ); + assert_eq!( + client.try_deactivate_policy(&caller, &1), + Err(Ok(InsuranceError::NotInitialized)) + ); + assert_eq!( + client.try_set_external_ref(&caller, &1, &Some(external_ref(&env))), + Err(Ok(InsuranceError::NotInitialized)) + ); } +/// Read-only entrypoints stay deterministic before init and never panic. #[test] -fn test_pagination() { - let (env, _, _) = setup(); - let client = InsuranceClient::new(&env, &env.register_contract(None, Insurance)); +fn test_reads_are_safe_before_init() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); let owner = Address::generate(&env); - client.init(&Address::generate(&env)).unwrap(); - for i in 0..10 { - client.create_policy(&owner, &String::from_str(&env, "P"), &health_str(&env), &5_000_000i128, &50_000_000i128, &None).unwrap(); - } - let page = client.get_active_policies(&owner, &0, &5).unwrap(); - assert_eq!(page.items.len(), 5); - assert_eq!(page.count, 5); - assert_eq!(page.next_cursor, 5); + + assert!(client.get_policy(&1).is_none()); + assert_eq!(client.get_total_monthly_premium(&owner), 0); + + let page = client.get_active_policies(&owner, &0, &5); + assert_eq!(page.items, Vec::new(&env)); + assert_eq!(page.next_cursor, 0); + assert_eq!(page.count, 0); } +/// The initialized owner remains the only admin for owner-only mutators. #[test] -fn test_total_premium_isolation() { - let (env, _, _) = setup(); - let client = InsuranceClient::new(&env, &env.register_contract(None, Insurance)); - client.init(&Address::generate(&env)).unwrap(); - let u1 = Address::generate(&env); - let u2 = Address::generate(&env); - client.create_policy(&u1, &String::from_str(&env, "P1"), &health_str(&env), &5_000_000i128, &50_000_000i128, &None).unwrap(); - client.create_policy(&u2, &String::from_str(&env, "P2"), &health_str(&env), &6_000_000i128, &50_000_000i128, &None).unwrap(); - assert_eq!(client.get_total_monthly_premium(&u1).unwrap(), 5_000_000); - assert_eq!(client.get_total_monthly_premium(&u2).unwrap(), 6_000_000); +fn test_initialized_owner_is_only_privileged_owner() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); + let owner = init_contract(&client, &env); + let policy_holder = Address::generate(&env); + let stranger = Address::generate(&env); + let policy_id = client.create_policy( + &policy_holder, + &policy_name(&env), + &CoverageType::Health, + &100, + &10_000, + ); + + assert_eq!( + client.set_external_ref(&owner, &policy_id, &Some(external_ref(&env))), + true + ); + assert_eq!( + client.try_set_external_ref(&stranger, &policy_id, &None), + Err(Ok(InsuranceError::Unauthorized)) + ); + + assert_eq!(client.deactivate_policy(&owner, &policy_id), true); + assert_eq!( + client.try_deactivate_policy(&stranger, &policy_id), + Err(Ok(InsuranceError::Unauthorized)) + ); } +/// `init` currently records no authorization requirement; ownership is +/// established purely by the first successful call. #[test] -fn test_batch_pay() { - let (env, _, _) = setup(); - let client = InsuranceClient::new(&env, &env.register_contract(None, Insurance)); - client.init(&Address::generate(&env)).unwrap(); +fn test_init_authorization_matches_current_model() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); let owner = Address::generate(&env); - let id1 = client.create_policy(&owner, &String::from_str(&env, "P1"), &health_str(&env), &5_000_000i128, &50_000_000i128, &None).unwrap(); - let id2 = client.create_policy(&owner, &String::from_str(&env, "P2"), &health_str(&env), &5_000_000i128, &50_000_000i128, &None).unwrap(); - let mut ids = Vec::new(&env); - ids.push_back(id1); - ids.push_back(id2); - let count = client.batch_pay_premiums(&owner, &ids).unwrap(); - assert_eq!(count, 2); -} \ No newline at end of file + + client.init(&owner); + assert!( + env.auths().is_empty(), + "init currently does not call require_auth on the proposed owner" + ); +} + +/// Once initialized, owner-only and owner-scoped actions still enforce the +/// configured post-bootstrap authorization model. +#[test] +fn test_post_init_authorization_enforced() { + let (env, contract_id) = fresh_env(); + let client = InsuranceClient::new(&env, &contract_id); + let owner = init_contract(&client, &env); + let policy_holder = Address::generate(&env); + let other_user = Address::generate(&env); + let policy_id = client.create_policy( + &policy_holder, + &policy_name(&env), + &CoverageType::Health, + &100, + &10_000, + ); + + assert_eq!( + client.try_pay_premium(&other_user, &policy_id), + Err(Ok(InsuranceError::Unauthorized)) + ); + assert_eq!( + client.try_set_external_ref(&other_user, &policy_id, &Some(external_ref(&env))), + Err(Ok(InsuranceError::Unauthorized)) + ); + assert_eq!( + client.set_external_ref(&owner, &policy_id, &Some(external_ref(&env))), + true + ); +} From fe3e295d1deb4e489c80bdf0d6a0be49a0f72b8a Mon Sep 17 00:00:00 2001 From: macbook Date: Fri, 19 Jun 2026 11:16:34 +0100 Subject: [PATCH 3/3] empty pr --- insurance/src/next_payment_scheduling_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insurance/src/next_payment_scheduling_tests.rs b/insurance/src/next_payment_scheduling_tests.rs index 20de5a8c..de1af0c1 100644 --- a/insurance/src/next_payment_scheduling_tests.rs +++ b/insurance/src/next_payment_scheduling_tests.rs @@ -195,4 +195,4 @@ fn test_batch_event_next_payment_dates_match_each_policy_value() { assert_eq!(p2.next_payment_date, due2 + (2 * PERIOD)); assert_eq!(by_id[0], (id1, p1.next_payment_date)); assert_eq!(by_id[1], (id2, p2.next_payment_date)); -} +} \ No newline at end of file