diff --git a/docs/EVENTS.md b/docs/EVENTS.md index cf04cb0c..3e04e8c7 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -51,6 +51,7 @@ env.events().publish((contract_name, event_category), event_data); **Emitted by:** `pay_bill`, `batch_pay_bills` **Data Structure:** + ```rust pub struct BillPaidEvent { pub bill_id: u32, // ID of paid bill @@ -68,6 +69,7 @@ pub struct BillPaidEvent { **Emitted by:** `cancel_bill` **Data Structure:** + ```rust pub struct BillCancelledEvent { pub bill_id: u32, // ID of cancelled bill @@ -84,6 +86,7 @@ pub struct BillCancelledEvent { **Emitted by:** `set_external_ref` **Data Structure:** + ```rust pub struct BillExternalRefUpdatedEvent { pub bill_id: u32, // ID of updated bill @@ -100,6 +103,7 @@ pub struct BillExternalRefUpdatedEvent { **Emitted by:** `archive_paid_bills` **Data Structure:** + ```rust pub struct BillsArchivedEvent { pub count: u32, // Number of bills archived @@ -115,6 +119,7 @@ pub struct BillsArchivedEvent { **Emitted by:** `restore_bill` **Data Structure:** + ```rust pub struct BillRestoredEvent { pub bill_id: u32, // ID of restored bill @@ -136,6 +141,7 @@ pub struct BillRestoredEvent { **Action Symbol:** `"upgraded"` **Data Structure:** + ```rust pub struct VersionUpgradeEvent { pub previous_version: u32, // Previous contract version @@ -149,6 +155,7 @@ pub struct VersionUpgradeEvent { **Emitted by:** `create_bill_schedule` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // New schedule ID @@ -162,6 +169,7 @@ pub tuple ( **Emitted by:** `modify_bill_schedule` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Modified schedule ID @@ -174,6 +182,7 @@ pub tuple ( **Emitted by:** `cancel_bill_schedule` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Cancelled schedule ID @@ -186,6 +195,7 @@ pub tuple ( **Emitted by:** `execute_due_bill_schedules` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Executed schedule ID @@ -198,6 +208,7 @@ pub tuple ( **Emitted by:** `execute_due_bill_schedules` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Schedule ID @@ -218,6 +229,7 @@ pub tuple ( **Secondary Topic:** `("savings", SavingsEvent::GoalCreated)` **Data Structure:** + ```rust pub struct GoalCreatedEvent { pub goal_id: u32, // Unique goal ID @@ -232,6 +244,7 @@ pub struct GoalCreatedEvent { ``` **Example Event:** + ```json { "goal_id": 1, @@ -251,6 +264,7 @@ pub struct GoalCreatedEvent { **Secondary Topic:** `("savings", SavingsEvent::FundsAdded)` **Data Structure:** + ```rust pub struct FundsAddedEvent { pub goal_id: u32, // Goal ID @@ -262,6 +276,7 @@ pub struct FundsAddedEvent { ``` **Example Event:** + ```json { "goal_id": 1, @@ -273,33 +288,35 @@ pub struct FundsAddedEvent { ``` ### Event: Goal Completed - - **Topic:** `"completed"` (primary) + +**Topic:** `"completed"` (primary) **Secondary Topic:** `("savings", SavingsEvent::GoalCompleted)` - - **Data Structure:** - ```rust - pub struct GoalCompletedEvent { - pub goal_id: u32, // Goal ID - pub owner: Address, // Goal owner - pub amount: i128, // Final contribution amount - pub new_total: i128, // Total amount reached - pub name: String, // Goal name - pub timestamp: u64, // Event timestamp - } - ``` - - **Example Event:** - ```json - { - "goal_id": 1, - "owner": "G...", - "amount": 5000, - "new_total": 50000, - "name": "Emergency Fund", - "timestamp": 1234567860 - } - ``` + +**Data Structure:** + +```rust +pub struct GoalCompletedEvent { + pub goal_id: u32, // Goal ID + pub owner: Address, // Goal owner + pub amount: i128, // Final contribution amount + pub new_total: i128, // Total amount reached + pub name: String, // Goal name + pub timestamp: u64, // Event timestamp +} +``` + +**Example Event:** + +```json +{ + "goal_id": 1, + "owner": "G...", + "amount": 5000, + "new_total": 50000, + "name": "Emergency Fund", + "timestamp": 1234567860 +} +``` ### `GoalCompletedEvent` (`SavingsEvent::GoalCompleted`) @@ -311,7 +328,7 @@ pub struct FundsAddedEvent { **Fields:** | Field | Type | Description | -|----------------|----------|------------------------------------------| +| -------------- | -------- | ---------------------------------------- | | `goal_id` | `u32` | Unique identifier of the completed goal | | `name` | `String` | Name of the savings goal | | `final_amount` | `i128` | Total amount at time of completion | @@ -324,37 +341,40 @@ subsequent contributions. Downstream indexers and notification services can rely on receiving this event at most once per goal. ### Event: Funds Withdrawn - - **Topic:** `"withdrawn"` (primary) - **Secondary Topic:** `("savings", SavingsEvent::FundsWithdrawn)` - - **Data Structure:** - ```rust - pub struct FundsWithdrawnEvent { - pub goal_id: u32, // Goal ID - pub owner: Address, // Goal owner - pub amount: i128, // Amount withdrawn - pub new_total: i128, // New total remaining in goal - pub timestamp: u64, // Event timestamp - } - ``` - - **Example Event:** - ```json - { - "goal_id": 1, - "owner": "G...", - "amount": 2000, - "new_total": 13000, - "timestamp": 1234567900 - } - ``` + +**Topic:** `"withdrawn"` (primary) +**Secondary Topic:** `("savings", SavingsEvent::FundsWithdrawn)` + +**Data Structure:** + +```rust +pub struct FundsWithdrawnEvent { + pub goal_id: u32, // Goal ID + pub owner: Address, // Goal owner + pub amount: i128, // Amount withdrawn + pub new_total: i128, // New total remaining in goal + pub timestamp: u64, // Event timestamp +} +``` + +**Example Event:** + +```json +{ + "goal_id": 1, + "owner": "G...", + "amount": 2000, + "new_total": 13000, + "timestamp": 1234567900 +} +``` ### Event: Goal Locked/Unlocked **Topic:** `("savings", SavingsEvent::GoalLocked)` or `("savings", SavingsEvent::GoalUnlocked)` **Data Structure:** + ```rust pub struct GoalLockEvent { pub goal_id: u32, // Goal ID @@ -368,6 +388,7 @@ pub struct GoalLockEvent { **Topic:** `("savings", SavingsEvent::ScheduleCreated)` **Data Structure:** + ```rust pub struct SavingsScheduleCreatedEvent { pub schedule_id: u32, // Schedule ID @@ -384,6 +405,7 @@ pub struct SavingsScheduleCreatedEvent { **Topic:** `("savings", "upgraded")` **Data Structure:** + ```rust pub struct VersionUpgradeEvent { pub previous_version: u32, @@ -404,6 +426,7 @@ pub struct VersionUpgradeEvent { **Secondary Topic:** `("insure", InsuranceEvent::PolicyCreated)` **Data Structure:** + ```rust pub struct PolicyCreatedEvent { pub policy_id: u32, // Unique policy ID @@ -416,6 +439,7 @@ pub struct PolicyCreatedEvent { ``` **Example Event:** + ```json { "policy_id": 1, @@ -433,6 +457,7 @@ pub struct PolicyCreatedEvent { **Secondary Topic:** `("Remitwise", EventCategory::Transaction, EventPriority::Low, "paid")` **Data Structure:** + ```rust pub struct PremiumPaidEvent { pub policy_id: u32, // Policy ID @@ -444,11 +469,13 @@ pub struct PremiumPaidEvent { ``` **Cadence Rule:** + - Fixed 30-day cadence (`30 * 86_400` seconds). - `pay_premium` and `batch_pay_premiums` both advance per-policy due dates. - Late payments always produce `next_payment_date` strictly in the future. **Example Event:** + ```json { "policy_id": 1, @@ -465,6 +492,7 @@ pub struct PremiumPaidEvent { **Secondary Topic:** `("insure", InsuranceEvent::PolicyDeactivated)` **Data Structure:** + ```rust pub struct PolicyDeactivatedEvent { pub policy_id: u32, // Policy ID @@ -473,11 +501,27 @@ pub struct PolicyDeactivatedEvent { } ``` +### Event: Policy Reactivated + +**Topic:** `"reactivated"` (primary) +**Secondary Topic:** `("insure", InsuranceEvent::PolicyReactivated)` + +**Data Structure:** + +```rust +pub struct PolicyReactivatedEvent { + pub policy_id: u32, // Policy ID + pub name: String, // Policy name + pub timestamp: u64, // Event timestamp +} +``` + ### Event: Premium Schedule Created **Topic:** `("insure", InsuranceEvent::ScheduleCreated)` **Data Structure:** + ```rust pub struct PremiumScheduleCreatedEvent { pub schedule_id: u32, // Schedule ID @@ -493,6 +537,7 @@ pub struct PremiumScheduleCreatedEvent { **Topic:** `("insure", InsuranceEvent::ScheduleExecuted)` **Data Structure:** + ```rust pub struct ScheduleExecutedEvent { pub schedule_id: u32, // Schedule ID @@ -507,6 +552,7 @@ pub struct ScheduleExecutedEvent { **Topic:** `("insure", InsuranceEvent::ScheduleMissed)` **Data Structure:** + ```rust pub struct ScheduleMissedEvent { pub schedule_id: u32, // Schedule ID @@ -521,6 +567,7 @@ pub struct ScheduleMissedEvent { **Topic:** `("insure", "upgraded")` **Data Structure:** + ```rust pub struct VersionUpgradeEvent { pub previous_version: u32, @@ -541,6 +588,7 @@ pub struct VersionUpgradeEvent { **Secondary Topic:** `("split", SplitEvent::Initialized)` **Data Structure:** + ```rust pub struct SplitInitializedEvent { pub spending_percent: u32, // Spending allocation percentage (0-100) @@ -554,6 +602,7 @@ pub struct SplitInitializedEvent { **Constraint:** `spending_percent + savings_percent + bills_percent + insurance_percent == 100` **Example Event:** + ```json { "spending_percent": 50, @@ -570,6 +619,7 @@ pub struct SplitInitializedEvent { **Secondary Topic:** `("split", SplitEvent::Calculated)` **Data Structure:** + ```rust pub struct SplitCalculatedEvent { pub total_amount: i128, // Total amount to split @@ -584,6 +634,7 @@ pub struct SplitCalculatedEvent { **Constraint:** `spending_amount + savings_amount + bills_amount + insurance_amount == total_amount` **Example Event:** + ```json { "total_amount": 10000, @@ -607,6 +658,7 @@ Both entrypoints call the shared `emit_distribution_completed` helper so categor amounts cannot drift between the legacy and request-hash-bound paths. **Data Structure:** + ```rust pub struct DistributionCompletedEvent { pub from: Address, // Payer address @@ -622,6 +674,7 @@ pub struct DistributionCompletedEvent { **Constraint:** `spending_amount + savings_amount + bills_amount + insurance_amount == total_amount` **Example Event:** + ```json { "from": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", @@ -645,6 +698,7 @@ pub struct DistributionCompletedEvent { **Topic:** `("schedule", ScheduleEvent::Created)` **Data Structure:** + ```rust pub struct RemittanceScheduleCreatedEvent { pub schedule_id: u32, // Schedule ID @@ -661,6 +715,7 @@ pub struct RemittanceScheduleCreatedEvent { **Topic:** `("schedule", ScheduleEvent::Executed)` **Data Structure:** + ```rust pub struct ScheduleExecutedEvent { pub schedule_id: u32, // Schedule ID @@ -674,6 +729,7 @@ pub struct ScheduleExecutedEvent { **Topic:** `("schedule", ScheduleEvent::Modified)` **Data Structure:** + ```rust pub struct ScheduleModifiedEvent { pub schedule_id: u32, // Schedule ID @@ -688,6 +744,7 @@ pub struct ScheduleModifiedEvent { **Topic:** `("schedule", ScheduleEvent::Cancelled)` **Data Structure:** + ```rust pub struct ScheduleCancelledEvent { pub schedule_id: u32, // Schedule ID @@ -700,6 +757,7 @@ pub struct ScheduleCancelledEvent { **Topic:** `("split", "sch_exec")` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Schedule ID that was executed @@ -712,6 +770,7 @@ pub tuple ( and indexing purposes. The actual distribution is still performed separately via `distribute_usdc`. **Security Notes:** + - Emitted only after `last_executed` is set, ensuring idempotency. - Emitted even if the schedule is one-off and becomes inactive after execution. - Permissionless executor ensures this event is auditable on-chain without requiring owner authorization. @@ -721,6 +780,7 @@ and indexing purposes. The actual distribution is still performed separately via **Topic:** `("split", "sch_miss")` **Data Structure:** + ```rust pub tuple ( pub schedule_id: u32, // Schedule ID @@ -733,6 +793,7 @@ pub tuple ( not executed for 3 intervals, `missed_count = 3` and this event is emitted. **Drift Handling:** + - Indicates delayed executor runs or network congestion. - Allows off-chain systems to detect and respond to drift. - `next_due` is advanced past all missed intervals, so the schedule "catches up." @@ -748,6 +809,7 @@ not executed for 3 intervals, `missed_count = 3` and this event is emitted. **Topic:** `("split", "upgraded")` **Data Structure:** + ```rust pub struct VersionUpgradeEvent { pub previous_version: u32, @@ -767,6 +829,7 @@ pub struct VersionUpgradeEvent { **Topic:** `("family", "member_added")` **Data Structure:** + ```rust pub struct MemberAddedEvent { pub member: Address, // Member address @@ -781,6 +844,7 @@ pub struct MemberAddedEvent { **Topic:** `("family", "limit_updated")` **Data Structure:** + ```rust pub struct SpendingLimitUpdatedEvent { pub member: Address, // Member address @@ -795,6 +859,7 @@ pub struct SpendingLimitUpdatedEvent { **Topic:** `("family", "tx_proposed")` **Data Structure:** + ```rust pub struct TransactionProposedEvent { pub tx_id: u64, // Transaction ID @@ -809,6 +874,7 @@ pub struct TransactionProposedEvent { **Topic:** `("family", "tx_executed")` **Data Structure:** + ```rust pub struct TransactionExecutedEvent { pub tx_id: u64, // Transaction ID @@ -822,6 +888,7 @@ pub struct TransactionExecutedEvent { **Topic:** `("family", "emergency_on")` **Data Structure:** + ```rust pub struct EmergencyModeEvent { pub activated_by: Address, // Address that activated emergency mode @@ -834,6 +901,7 @@ pub struct EmergencyModeEvent { **Topic:** `("family", "emergency_off")` **Data Structure:** + ```rust pub struct EmergencyModeEvent { pub deactivated_by: Address, // Address that deactivated emergency mode @@ -853,6 +921,7 @@ duplicate signer, non-member signer, oversized signer list, paused wallet, negative spending limit, etc.) returns an `Error` and emits nothing. **Data Structure:** + ```rust pub struct MultisigConfiguredEvent { pub tx_type: TransactionType, // Which TransactionType bucket was configured @@ -892,14 +961,14 @@ state transition in the wallet. `execute_remittance_flow_signed` validates a caller-supplied `request_hash` against `compute_request_hash` before executing the fan-out. The hash binds: -| Field | Description | -|-------|-------------| -| `operation` | Fixed symbol `flow` | -| `nonce` | Per-executor sequential counter | -| `amount` | Total remittance amount | -| `deadline` | Ledger timestamp upper bound (see deadline window doc) | -| `goal_id` | Savings goal ID from instance storage (`GOAL_ID`, default `1`) | -| `bill_id` | Bill ID from instance storage (`BILL_ID`, default `1`) | +| Field | Description | +| ----------- | ----------------------------------------------------------------- | +| `operation` | Fixed symbol `flow` | +| `nonce` | Per-executor sequential counter | +| `amount` | Total remittance amount | +| `deadline` | Ledger timestamp upper bound (see deadline window doc) | +| `goal_id` | Savings goal ID from instance storage (`GOAL_ID`, default `1`) | +| `bill_id` | Bill ID from instance storage (`BILL_ID`, default `1`) | | `policy_id` | Insurance policy ID from instance storage (`POL_ID`, default `1`) | Relayers must query the orchestrator for the current `goal_id` / `bill_id` / @@ -914,6 +983,7 @@ Relayers must query the orchestrator for the current `goal_id` / `bill_id` / **Trigger:** Emitted when a remittance flow execution begins after passing validation checks **Data Structure:** + ```rust pub struct FlowStartedEvent { pub executor: Address, // Address executing the flow @@ -922,6 +992,7 @@ pub struct FlowStartedEvent { ``` **Example Event:** + ```json { "executor": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", @@ -936,6 +1007,7 @@ pub struct FlowStartedEvent { **Trigger:** Emitted when a remittance flow completes successfully **Data Structure:** + ```rust pub struct FlowCompletedEvent { pub executor: Address, // Address that executed the flow @@ -944,6 +1016,7 @@ pub struct FlowCompletedEvent { ``` **Example Event:** + ```json { "executor": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", @@ -958,6 +1031,7 @@ pub struct FlowCompletedEvent { **Trigger:** Emitted when a remittance flow execution fails **Data Structure:** + ```rust pub struct FlowFailedEvent { pub executor: Address, // Address that attempted the flow @@ -966,6 +1040,7 @@ pub struct FlowFailedEvent { ``` **Example Event:** + ```json { "executor": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", @@ -974,6 +1049,7 @@ pub struct FlowFailedEvent { ``` **Error Codes:** + ```rust pub enum OrchestratorError { Unauthorized = 1, @@ -999,6 +1075,7 @@ pub enum OrchestratorError { **Trigger:** Emitted when the orchestrator contract is successfully initialized **Data Structure:** + ```rust pub struct InitCompletedEvent { pub caller: Address, // Address that initialized the contract @@ -1006,6 +1083,7 @@ pub struct InitCompletedEvent { ``` **Example Event:** + ```json { "caller": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" @@ -1019,6 +1097,7 @@ pub struct InitCompletedEvent { **Trigger:** Emitted when the contract version is upgraded by the owner **Data Structure:** + ```rust pub struct VersionUpgradeEvent { pub previous_version: u32, // Previous contract version @@ -1027,14 +1106,15 @@ pub struct VersionUpgradeEvent { ``` **Example Event:** -```json + +````json { "previous_version": 1, "new_version": 2 } ```## Reporting Contract -**Contract Name:** `reporting` +**Contract Name:** `reporting` **Primary Topic Prefix:** `"reporting"` ### Event: Report Generated @@ -1049,13 +1129,14 @@ pub struct ReportGeneratedEvent { pub period_key: u64, // Period identifier pub timestamp: u64, // Event timestamp } -``` +```` ### Event: Report Stored **Topic:** `("reporting", ReportEvent::ReportStored)` **Data Structure:** + ```rust pub struct ReportStoredEvent { pub user: Address, // User address @@ -1070,6 +1151,7 @@ pub struct ReportStoredEvent { **Topic:** `("reporting", ReportEvent::AddressesConfigured)` **Data Structure:** + ```rust pub struct AddressesConfiguredEvent { pub configured_by: Address, // Address that configured @@ -1082,6 +1164,7 @@ pub struct AddressesConfiguredEvent { **Topic:** `("reporting", ReportEvent::ReportsArchived)` **Data Structure:** + ```rust pub struct ReportsArchivedEvent { pub count: u32, // Number of reports archived @@ -1098,6 +1181,7 @@ pub struct ReportsArchivedEvent { Each contract maintains a version number that can be queried via `get_version()`. Version changes are emitted as upgrade events. **Current Versions:** + - Bill Payments: v1 - Savings Goals: v1 - Insurance: v1 @@ -1109,12 +1193,14 @@ Each contract maintains a version number that can be queried via `get_version()` ### Event Format Stability **Backward Compatibility Guarantees:** + - Event topics (primary and secondary) are immutable - Event data structures are append-only (new fields added at the end) - Existing fields maintain their type and position - Deprecated fields are marked but not removed **Breaking Changes:** + - Major version bumps indicate potential event schema changes - Indexers should monitor `upgraded` events for version changes - Contract upgrades are announced via `set_version()` calls @@ -1127,6 +1213,7 @@ that need one aggregate record instead of one event per item. **Topic:** `("Remitwise", EventCategory::, EventPriority::Low, "batch")` **Encoded Topic Values:** + - `EventCategory::Transaction` = `0` - `EventCategory::State` = `1` - `EventCategory::Alert` = `2` @@ -1146,6 +1233,7 @@ responsible for applying any business-level batch-size limits before emitting. ### Migration Path When upgrading contracts: + 1. New event types are added with new topic symbols 2. Old event types continue to be emitted for backward compatibility 3. Indexers can subscribe to both old and new topics during transition period @@ -1174,23 +1262,23 @@ cargo test --workspace events_schema_test Per-contract: -| Contract | Test module | -|----------|-------------| -| `bill_payments` | [bill_payments/src/events_schema_test.rs](bill_payments/src/events_schema_test.rs) | -| `family_wallet` | [family_wallet/src/events_schema_test.rs](family_wallet/src/events_schema_test.rs) | +| Contract | Test module | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `bill_payments` | [bill_payments/src/events_schema_test.rs](bill_payments/src/events_schema_test.rs) | +| `family_wallet` | [family_wallet/src/events_schema_test.rs](family_wallet/src/events_schema_test.rs) | | `remittance_split` | [remittance_split/src/events_schema_test.rs](remittance_split/src/events_schema_test.rs) | -| `reporting` | [reporting/src/events_schema_test.rs](reporting/src/events_schema_test.rs) | -| `savings_goals` | [savings_goals/src/events_schema_test.rs](savings_goals/src/events_schema_test.rs) | -| `orchestrator` | [orchestrator/src/events_schema_test.rs](orchestrator/src/events_schema_test.rs) | -| `remitwise-common` | [remitwise-common/src/lib.rs](remitwise-common/src/lib.rs) | +| `reporting` | [reporting/src/events_schema_test.rs](reporting/src/events_schema_test.rs) | +| `savings_goals` | [savings_goals/src/events_schema_test.rs](savings_goals/src/events_schema_test.rs) | +| `orchestrator` | [orchestrator/src/events_schema_test.rs](orchestrator/src/events_schema_test.rs) | +| `remitwise-common` | [remitwise-common/src/lib.rs](remitwise-common/src/lib.rs) | A failing schema test is the signal that **a change is breaking for indexers**. The required workflow is: 1. Bump the contract's major version. 2. Update `EVENTS.md` to document the old and new shapes side-by-side. -3. Update the test to reflect the new schema *as a separate commit on top - of the version bump*, so reviewers can audit the diff in isolation. +3. Update the test to reflect the new schema _as a separate commit on top + of the version bump_, so reviewers can audit the diff in isolation. 4. Coordinate with downstream indexer owners before the upgrade event is emitted on mainnet. @@ -1201,6 +1289,7 @@ The required workflow is: ### Recommended Indexing Strategy 1. **Subscribe to all contract topics:** + ``` "Remitwise", "savings", "insure", "split", "family", "orchestrator", "reporting" ``` @@ -1223,8 +1312,8 @@ The required workflow is: ```javascript function parseEvent(topics, data) { const [primary, ...secondary] = topics; - - switch(primary) { + + switch (primary) { case "Remitwise": return parseBillPaymentEvent(secondary, data); case "savings": diff --git a/insurance/src/lib.rs b/insurance/src/lib.rs index ae5ab398..e14c589c 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_PAGE_LIMIT, SNAPSHOT_KEY, SNAPSHOT_VERSION, + MAX_PAGE_LIMIT, SNAPSHOT_KEY, SNAPSHOT_VERSION, clamp_limit, }; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, @@ -35,12 +35,14 @@ pub enum InsuranceError { UnsupportedCombination = 9, InvalidExternalRef = 10, MaxPoliciesReached = 11, + /// Returned by `reactivate_policy` when the target policy is already active. + PolicyAlreadyActive = 12, /// Returned by `deactivate_policy` when the target policy is already inactive. /// Distinct from `PolicyInactive` (which signals a caller trying to act *on* /// an inactive policy) — `PolicyAlreadyInactive` signals that the *deactivation /// itself* is a no-op because the policy was never active (or was already /// deactivated by a prior call). - PolicyAlreadyInactive = 12, + PolicyAlreadyInactive = 13, } // ───────────────────────────────────────────────────────────────────────────── @@ -170,6 +172,14 @@ pub struct PolicyDeactivatedEvent { pub timestamp: u64, } +#[contracttype] +#[derive(Clone)] +pub struct PolicyReactivatedEvent { + pub policy_id: u32, + pub name: String, + pub timestamp: u64, +} + #[contracttype] pub enum DataKey { Owner, @@ -246,6 +256,52 @@ impl Insurance { .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); } + /// Add a policy ID to the active index. + /// + /// Ensures the active index does not exceed `MAX_POLICIES` and avoids + /// duplicating an ID that is already present. Returns `MaxPoliciesReached` + /// if the index is full. + fn add_active_policy(env: &Env, policy_id: u32) -> Result<(), InsuranceError> { + let mut active = env + .storage() + .instance() + .get::<_, Vec>(&DataKey::ActivePolicies) + .ok_or(InsuranceError::NotInitialized)?; + // If already present, do nothing (prevents duplication) + for id in active.iter() { + if id == policy_id { + return Ok(()); + } + } + if active.len() >= MAX_POLICIES { + return Err(InsuranceError::MaxPoliciesReached); + } + active.push_back(policy_id); + env.storage() + .instance() + .set(&DataKey::ActivePolicies, &active); + Ok(()) + } + + /// Remove a policy ID from the active index. + fn remove_active_policy(env: &Env, policy_id: u32) -> Result<(), InsuranceError> { + let active = env + .storage() + .instance() + .get::<_, Vec>(&DataKey::ActivePolicies) + .ok_or(InsuranceError::NotInitialized)?; + let mut new_active = Vec::new(&env); + for id in active.iter() { + if id != policy_id { + new_active.push_back(id); + } + } + env.storage() + .instance() + .set(&DataKey::ActivePolicies, &new_active); + Ok(()) + } + fn get_owner(env: &Env) -> Result { env.storage() .instance() @@ -331,6 +387,8 @@ impl Insurance { return Err(InsuranceError::UnsupportedCombination); } + // Reserve a slot in the active index and ensure we don't exceed capacity. + // `add_active_policy` also prevents duplication. let mut active = env .storage() .instance() @@ -361,16 +419,10 @@ impl Insurance { next_payment_date: now + THIRTY_DAYS_SECS, }; - env.storage() - .instance() - .set(&DataKey::Policy(next_id), &policy); - env.storage() - .instance() - .set(&DataKey::PolicyCount, &next_id); - active.push_back(next_id); - env.storage() - .instance() - .set(&DataKey::ActivePolicies, &active); + env.storage().instance().set(&DataKey::Policy(next_id), &policy); + env.storage().instance().set(&DataKey::PolicyCount, &next_id); + // Add to active index (helper enforces no-dup and capacity) + Self::add_active_policy(&env, next_id)?; let mut owner_ids = env .storage() @@ -545,24 +597,9 @@ impl Insurance { } policy.active = false; - env.storage() - .instance() - .set(&DataKey::Policy(policy_id), &policy); - - let active = env - .storage() - .instance() - .get::<_, Vec>(&DataKey::ActivePolicies) - .ok_or(InsuranceError::NotInitialized)?; - let mut new_active = Vec::new(&env); - for id in active.iter() { - if id != policy_id { - new_active.push_back(id); - } - } - env.storage() - .instance() - .set(&DataKey::ActivePolicies, &new_active); + env.storage().instance().set(&DataKey::Policy(policy_id), &policy); + // Remove from active index (helper) + Self::remove_active_policy(&env, policy_id)?; env.events().publish( (symbol_short!("deactive"), symbol_short!("policy")), @@ -575,6 +612,50 @@ impl Insurance { Ok(true) } + /// Reactivate a previously deactivated policy. + /// + /// Authorization: callable by the policy owner or contract owner. + /// Reactivation sets `active = true`, refreshes `next_payment_date` and + /// re-inserts the policy ID into the `ActivePolicies` index without + /// duplicating an existing entry. If the active index is full this + /// returns `MaxPoliciesReached`. + pub fn reactivate_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)?; + let owner = Self::get_owner(&env)?; + if caller != policy.owner && caller != owner { + return Err(InsuranceError::Unauthorized); + } + if policy.active { + return Err(InsuranceError::PolicyAlreadyActive); + } + + // Refresh payment cadence to the next logical due date relative to now. + let now = env.ledger().timestamp(); + policy.next_payment_date = Self::advance_next_payment_date(policy.next_payment_date, now); + policy.active = true; + env.storage().instance().set(&DataKey::Policy(policy_id), &policy); + + // Attempt to add to the active index; helper enforces capacity/dup. + Self::add_active_policy(&env, policy_id)?; + + env.events().publish( + (symbol_short!("reactivated"), symbol_short!("policy")), + PolicyReactivatedEvent { + policy_id, + name: policy.name, + timestamp: now, + }, + ); + Ok(true) + } + /// Get a paginated list of active policies for an owner. /// /// # Errors @@ -631,6 +712,57 @@ impl Insurance { }) } + /// Get a paginated list of deactivated policies for an owner. + /// + /// Mirrors the shape and semantics of `get_active_policies` but filters + /// for policies where `active == false`. `limit` is normalized via + /// `remitwise_common::clamp_limit`. + pub fn get_deactivated_policies( + env: Env, + owner: Address, + cursor: u32, + limit: u32, + ) -> Result { + Self::require_initialized(&env)?; + + let owner_ids = env + .storage() + .instance() + .get::<_, Vec>(&DataKey::OwnerPolicies(owner)) + .unwrap_or_else(|| Vec::new(&env)); + + let mut items = Vec::new(&env); + let mut next_cursor = 0u32; + + let lim = clamp_limit(limit); + + for id in owner_ids.iter() { + if id > cursor { + if let Some(p) = env + .storage() + .instance() + .get::<_, Policy>(&DataKey::Policy(id)) + { + if !p.active { + if items.len() < lim { + items.push_back(id); + } else { + next_cursor = id; + break; + } + } + } + } + } + + let count = items.len(); + Ok(PolicyPage { + items, + next_cursor, + count, + }) + } + /// Get a policy by ID. /// /// # Errors diff --git a/insurance/src/test.rs b/insurance/src/test.rs index bfe055d3..289a030a 100644 --- a/insurance/src/test.rs +++ b/insurance/src/test.rs @@ -483,6 +483,114 @@ mod tests { ); } + // ── reactivate_policy tests ─────────────────────────────────────────── + + #[test] + fn test_reactivate_policy_by_owner_success() { + let env = Env::default(); + env.mock_all_auths(); + let (c, _contract_owner) = setup_with_owner(&env); + let owner = Address::generate(&env); + let pid = c.create_policy( + &owner, + &n(&env, "P"), + &CoverageType::Health, + &5_000_000i128, + &50_000_000i128, + ); + + // Deactivate then reactivate + c.deactivate_policy(&owner, &pid); + let p = c.get_policy(&pid).unwrap(); + let old_next = p.next_payment_date; + + assert!(c.reactivate_policy(&owner, &pid)); + + let p2 = c.get_policy(&pid).unwrap(); + assert!(p2.active, "policy should be active after reactivation"); + // Next payment date should have been refreshed forward + assert!(p2.next_payment_date > old_next); + + let page = c.get_active_policies(&owner, &0, &10); + assert_eq!(page.count, 1); + assert_eq!(page.items.len(), 1); + } + + #[test] + fn test_reactivate_policy_already_active() { + let env = Env::default(); + env.mock_all_auths(); + let (c, _contract_owner) = setup_with_owner(&env); + let owner = Address::generate(&env); + let pid = c.create_policy( + &owner, + &n(&env, "P"), + &CoverageType::Health, + &5_000_000i128, + &50_000_000i128, + ); + + assert_eq!( + c.try_reactivate_policy(&owner, &pid) + .unwrap_err() + .unwrap(), + InsuranceError::PolicyAlreadyActive + ); + } + + #[test] + fn test_reactivate_policy_max_reached() { + let env = Env::default(); + env.mock_all_auths(); + let (c, _contract_owner) = setup_with_owner(&env); + let owner = Address::generate(&env); + let pid = c.create_policy( + &owner, + &n(&env, "P"), + &CoverageType::Health, + &5_000_000i128, + &50_000_000i128, + ); + + // Deactivate so we can attempt to reactivate + c.deactivate_policy(&owner, &pid); + + // Fill the active index to MAX_POLICIES + let mut full = Vec::new(&env); + for i in 1..=MAX_POLICIES { + full.push_back(i); + } + env.storage().instance().set(&DataKey::ActivePolicies, &full); + + assert_eq!( + c.try_reactivate_policy(&owner, &pid) + .unwrap_err() + .unwrap(), + InsuranceError::MaxPoliciesReached + ); + } + + #[test] + fn test_get_deactivated_policies_pagination() { + let env = Env::default(); + env.mock_all_auths(); + let (c, _contract_owner) = setup_with_owner(&env); + let owner = Address::generate(&env); + + let p1 = c.create_policy(&owner, &n(&env, "P1"), &CoverageType::Health, &5_000_000i128, &50_000_000i128); + let p2 = c.create_policy(&owner, &n(&env, "P2"), &CoverageType::Health, &5_000_000i128, &50_000_000i128); + let p3 = c.create_policy(&owner, &n(&env, "P3"), &CoverageType::Health, &5_000_000i128, &50_000_000i128); + let p4 = c.create_policy(&owner, &n(&env, "P4"), &CoverageType::Health, &5_000_000i128, &50_000_000i128); + + // Deactivate a subset + c.deactivate_policy(&owner, &p2); + c.deactivate_policy(&owner, &p4); + + let page = c.get_deactivated_policies(&owner, &0, &10); + assert_eq!(page.count, 2); + assert_eq!(page.items.len(), 2); + } + // ── set_external_ref ────────────────────────────────────────────────────── /// Success path: contract owner can attach a valid external reference.