|
| 1 | +//! Rolling per-policy claim cap over a **ledger-anchored** window. |
| 2 | +//! |
| 3 | +//! # What is counted |
| 4 | +//! Only **paid** amounts (when `process_claim` succeeds) add to `cumulative_paid`. |
| 5 | +//! At `file_claim` we require `cumulative_paid + new_amount <= cap` for the **current** |
| 6 | +//! window bucket, so at most one open claim per policy (`DuplicateOpenClaim`) keeps the |
| 7 | +//! check consistent with paid totals. |
| 8 | +//! |
| 9 | +//! # Deductible / net vs gross (product note) |
| 10 | +//! This MVP applies the cap to **gross** on-chain `claim.amount` (the same field used for |
| 11 | +//! payout). If a deductible or net-of-deductible payout is introduced later, explicitly |
| 12 | +//! define whether the rolling accumulator uses gross filed amount, net paid amount, or both. |
| 13 | +//! |
| 14 | +//! # Cap / window changes |
| 15 | +//! Admin updates apply to **future** `file_claim` calls only. `process_claim` does not |
| 16 | +//! re-validate the cap — in-flight approved claims pay even if the cap was lowered after filing. |
| 17 | +
|
| 18 | +use soroban_sdk::{contractevent, Address, Env}; |
| 19 | + |
| 20 | +use crate::{ |
| 21 | + admin::AdminError, |
| 22 | + storage, |
| 23 | + types::RollingClaimWindowState, |
| 24 | + validate::Error, |
| 25 | +}; |
| 26 | + |
| 27 | +/// Minimum rolling cap (when admin configures a finite cap). |
| 28 | +pub const MIN_ROLLING_CLAIM_CAP: i128 = 1; |
| 29 | +/// Upper bound to avoid absurd configuration (adjust per asset decimals in production). |
| 30 | +pub const MAX_ROLLING_CLAIM_CAP: i128 = 9_999_999_999_999_999; |
| 31 | + |
| 32 | +pub const MIN_ROLLING_WINDOW_LEDGERS: u32 = 100; |
| 33 | +pub const MAX_ROLLING_WINDOW_LEDGERS: u32 = 100_000_000; |
| 34 | + |
| 35 | +#[contractevent(topics = ["niffyinsure", "claim_cap_updated"])] |
| 36 | +#[derive(Clone, Debug, Eq, PartialEq)] |
| 37 | +pub struct ClaimCapUpdated { |
| 38 | + pub old_cap: i128, |
| 39 | + pub new_cap: i128, |
| 40 | +} |
| 41 | + |
| 42 | +#[contractevent(topics = ["niffyinsure", "rolling_claim_window_updated"])] |
| 43 | +#[derive(Clone, Debug, Eq, PartialEq)] |
| 44 | +pub struct RollingClaimWindowLedgersUpdated { |
| 45 | + pub old_window_ledgers: u32, |
| 46 | + pub new_window_ledgers: u32, |
| 47 | +} |
| 48 | + |
| 49 | +#[inline] |
| 50 | +fn window_bucket_start(now: u32, window_len: u32) -> u32 { |
| 51 | + if window_len == 0 { |
| 52 | + return 0; |
| 53 | + } |
| 54 | + now.saturating_div(window_len).saturating_mul(window_len) |
| 55 | +} |
| 56 | + |
| 57 | +/// Initialise defaults at contract `initialize` (effectively uncapped until admin sets a cap). |
| 58 | +pub fn init_defaults(env: &Env) { |
| 59 | + storage::set_rolling_claim_cap(env, i128::MAX); |
| 60 | + storage::set_rolling_claim_window_ledgers(env, 1_000_000); |
| 61 | +} |
| 62 | + |
| 63 | +fn sync_state_to_ledger( |
| 64 | + env: &Env, |
| 65 | + holder: &Address, |
| 66 | + policy_id: u32, |
| 67 | + now: u32, |
| 68 | +) -> RollingClaimWindowState { |
| 69 | + let wlen = storage::get_rolling_claim_window_ledgers(env); |
| 70 | + let expected_start = window_bucket_start(now, wlen); |
| 71 | + match storage::get_rolling_claim_state(env, holder, policy_id) { |
| 72 | + Some(s) if s.window_start == expected_start => s, |
| 73 | + _ => RollingClaimWindowState { |
| 74 | + window_start: expected_start, |
| 75 | + cumulative_paid: 0, |
| 76 | + }, |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +fn persist_state( |
| 81 | + env: &Env, |
| 82 | + holder: &Address, |
| 83 | + policy_id: u32, |
| 84 | + state: &RollingClaimWindowState, |
| 85 | +) { |
| 86 | + storage::set_rolling_claim_state(env, holder, policy_id, state); |
| 87 | +} |
| 88 | + |
| 89 | +/// Validate before accepting a new claim amount. |
| 90 | +pub fn check_file_claim( |
| 91 | + env: &Env, |
| 92 | + holder: &Address, |
| 93 | + policy_id: u32, |
| 94 | + amount: i128, |
| 95 | + now: u32, |
| 96 | +) -> Result<(), Error> { |
| 97 | + let cap = storage::get_rolling_claim_cap(env); |
| 98 | + if cap == i128::MAX { |
| 99 | + return Ok(()); |
| 100 | + } |
| 101 | + let state = sync_state_to_ledger(env, holder, policy_id, now); |
| 102 | + let sum = state |
| 103 | + .cumulative_paid |
| 104 | + .checked_add(amount) |
| 105 | + .ok_or(Error::Overflow)?; |
| 106 | + if sum > cap { |
| 107 | + return Err(Error::RollingClaimCapExceeded); |
| 108 | + } |
| 109 | + // Persist rolled state if we reset the bucket (so storage matches reads). |
| 110 | + persist_state(env, holder, policy_id, &state); |
| 111 | + Ok(()) |
| 112 | +} |
| 113 | + |
| 114 | +/// Add a successful payout to the rolling accumulator (no cap check — in-flight safety). |
| 115 | +pub fn record_claim_paid(env: &Env, holder: &Address, policy_id: u32, amount: i128, now: u32) { |
| 116 | + let mut state = sync_state_to_ledger(env, holder, policy_id, now); |
| 117 | + state.cumulative_paid = state.cumulative_paid.saturating_add(amount); |
| 118 | + persist_state(env, holder, policy_id, &state); |
| 119 | +} |
| 120 | + |
| 121 | +/// Remaining headroom under the rolling cap for this policy/window (ignores per-claim coverage). |
| 122 | +pub fn remaining_under_cap(env: &Env, holder: &Address, policy_id: u32, now: u32) -> i128 { |
| 123 | + let cap = storage::get_rolling_claim_cap(env); |
| 124 | + if cap == i128::MAX { |
| 125 | + return i128::MAX; |
| 126 | + } |
| 127 | + let state = sync_state_to_ledger(env, holder, policy_id, now); |
| 128 | + cap.saturating_sub(state.cumulative_paid).max(0) |
| 129 | +} |
| 130 | + |
| 131 | +pub fn try_set_cap(env: &Env, new_cap: i128) -> Result<(), AdminError> { |
| 132 | + if new_cap != i128::MAX && (new_cap < MIN_ROLLING_CLAIM_CAP || new_cap > MAX_ROLLING_CLAIM_CAP) |
| 133 | + { |
| 134 | + return Err(AdminError::RollingClaimCapOutOfBounds); |
| 135 | + } |
| 136 | + let old = storage::get_rolling_claim_cap(env); |
| 137 | + storage::set_rolling_claim_cap(env, new_cap); |
| 138 | + storage::bump_instance(env); |
| 139 | + ClaimCapUpdated { old_cap: old, new_cap }.publish(env); |
| 140 | + Ok(()) |
| 141 | +} |
| 142 | + |
| 143 | +pub fn try_set_window_ledgers(env: &Env, new_window: u32) -> Result<(), AdminError> { |
| 144 | + if new_window < MIN_ROLLING_WINDOW_LEDGERS || new_window > MAX_ROLLING_WINDOW_LEDGERS { |
| 145 | + return Err(AdminError::RollingClaimWindowOutOfBounds); |
| 146 | + } |
| 147 | + let old = storage::get_rolling_claim_window_ledgers(env); |
| 148 | + storage::set_rolling_claim_window_ledgers(env, new_window); |
| 149 | + storage::bump_instance(env); |
| 150 | + RollingClaimWindowLedgersUpdated { |
| 151 | + old_window_ledgers: old, |
| 152 | + new_window_ledgers: new_window, |
| 153 | + } |
| 154 | + .publish(env); |
| 155 | + Ok(()) |
| 156 | +} |
0 commit comments