Skip to content

Commit 7ce3590

Browse files
authored
Merge pull request #246 from favourawaku/feat/rolling-claim-cap
feat(niffyinsure): rolling claim cap per policy (ledger window)
2 parents e082c1f + 4f3c5f9 commit 7ce3590

37 files changed

Lines changed: 1300 additions & 10 deletions

contracts/niffyinsure/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ crate-type = ["cdylib", "rlib"]
1313
# Never enable in production WASM builds.
1414
testutils = ["soroban-sdk/testutils"]
1515
experimental = []
16+
# Opt-in: `quarantine/events_integration_stale.rs` targets a pre-`contractevent` topic layout; keep off in CI until migrated.
17+
legacy-event-schema-tests = []
1618
# Reserves governance-token storage keys and optional admin entrypoints. No mint/transfer.
1719
# MVP / default builds must ship with this disabled.
1820
governance-token = []

contracts/niffyinsure/quarantine/events_integration_stale.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
//! **Quarantined** (not built as a `tests/*.rs` target): expects legacy `niffyins` / `adm_paus`
2-
//! topics and an older Soroban `events().all()` shape. Update to current `#[contractevent]`
3-
//! topics (`niffyinsure`, `pause_toggled`, etc.) before moving back to `tests/events.rs`.
1+
#![cfg(all(test, feature = "legacy-event-schema-tests"))]
2+
//! **Quarantined** (not built as a `tests/*.rs` target unless wired via `[[test]]`): expects legacy
3+
//! `niffyins` / `adm_paus` topics and an older Soroban `events().all()` shape. Update to current
4+
//! `#[contractevent]` topics (`niffyinsure`, `pause_toggled`, etc.) before moving back to
5+
//! `tests/events.rs`.
46
//!
5-
//! Event shape regression tests.
7+
//! Disabled by default: the contract now emits `niffyinsure` `contractevent` topics.
8+
//! Opt in with `cargo test --features legacy-event-schema-tests` after adding a `[[test]]` path if needed.
69
//!
710
//! Each test asserts the exact topic layout and payload fields for a lifecycle
811
//! path. If an event struct changes shape, these tests fail CI intentionally —
@@ -14,8 +17,6 @@
1417
//! A wrong field value means the emitter is passing incorrect data.
1518
//! A wrong topic count means the topic layout changed (breaking for indexers).
1619
17-
#![cfg(test)]
18-
1920
use niffyinsure::{
2021
events::{
2122
AdminAcceptedData, AdminCancelledData, AdminProposedData, AssetAllowlistedData,

contracts/niffyinsure/src/admin.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub enum AdminError {
3737
AssetNotAllowlisted = 108,
3838
/// Sweep would violate protected balance constraints.
3939
ProtectedBalanceViolation = 109,
40+
/// Rolling claim cap outside allowed bounds.
41+
RollingClaimCapOutOfBounds = 110,
42+
/// Rolling claim window length outside allowed bounds.
43+
RollingClaimWindowOutOfBounds = 111,
4044
}
4145

4246
#[contractevent(topics = ["niffyinsure", "admin_proposed"])]

contracts/niffyinsure/src/claim.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
// or deadline-plurality approval, which is controlled by the DAO snapshot, not
6767
// the admin. The admin cannot flip a `Rejected` claim to `Approved`.
6868
use crate::{
69-
ledger, storage,
69+
ledger, rolling_claim_cap, storage,
7070
types::{
7171
Claim, ClaimProcessed, ClaimStatus, ClaimStatusHistoryEntry, TerminationReason, VoteOption,
7272
CLAIM_STATUS_HISTORY_MAX, STRIKE_DEACTIVATION_THRESHOLD,

contracts/niffyinsure/src/lib.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
#![allow(clippy::too_many_arguments)]
33

44
pub mod admin;
5+
pub mod events;
56
mod calculator;
67
mod claim;
7-
pub mod events;
88
mod governance_token;
99
mod ledger;
10+
mod rolling_claim_cap;
1011
mod policy;
1112
mod policy_lifecycle;
1213
pub mod premium;
@@ -659,6 +660,51 @@ impl NiffyInsure {
659660
pub fn get_pause_flags(env: Env) -> storage::PauseFlags {
660661
storage::get_pause_flags(&env)
661662
}
663+
664+
// ── Rolling claim cap (ledger-window cumulative paid per policy) ─────────
665+
666+
/// Global rolling cap on **paid** claim amounts per policy per ledger window (gross `claim.amount`).
667+
/// `i128::MAX` means effectively uncapped.
668+
pub fn get_rolling_claim_cap(env: Env) -> i128 {
669+
storage::get_rolling_claim_cap(&env)
670+
}
671+
672+
/// Ledger length of each rolling window bucket (aligned to `ledger_sequence / window`).
673+
pub fn get_rolling_claim_window_ledgers(env: Env) -> u32 {
674+
storage::get_rolling_claim_window_ledgers(&env)
675+
}
676+
677+
/// Remaining amount that can be **filed** this window before hitting the cap (`0` if at/over cap).
678+
/// Indexers can combine with cap and `get_rolling_claim_state` for full UI.
679+
pub fn get_rolling_claim_remaining(
680+
env: Env,
681+
holder: Address,
682+
policy_id: u32,
683+
) -> i128 {
684+
let now = env.ledger().sequence();
685+
rolling_claim_cap::remaining_under_cap(&env, &holder, policy_id, now)
686+
}
687+
688+
/// Raw rolling state for `(holder, policy_id)` if present (window bucket + cumulative paid).
689+
pub fn get_rolling_claim_state(
690+
env: Env,
691+
holder: Address,
692+
policy_id: u32,
693+
) -> Option<types::RollingClaimWindowState> {
694+
storage::get_rolling_claim_state(&env, &holder, policy_id)
695+
}
696+
697+
/// Admin: set rolling claim cap. Bounded unless `i128::MAX` (uncapped). Emits `ClaimCapUpdated`.
698+
pub fn set_rolling_claim_cap(env: Env, new_cap: i128) -> Result<(), AdminError> {
699+
let _admin = admin::require_admin(&env);
700+
rolling_claim_cap::try_set_cap(&env, new_cap)
701+
}
702+
703+
/// Admin: set rolling window length in ledgers. Emits `RollingClaimWindowLedgersUpdated`.
704+
pub fn set_rolling_claim_window_ledgers(env: Env, window_ledgers: u32) -> Result<(), AdminError> {
705+
let _admin = admin::require_admin(&env);
706+
rolling_claim_cap::try_set_window_ledgers(&env, window_ledgers)
707+
}
662708
}
663709

664710
/// Governance token: reserved entrypoints only when built with `--features governance-token`.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ pub enum DataKey {
3030
ActivePolicyCount(Address),
3131
/// Optional per-transaction cap for emergency sweep operations (i128).
3232
SweepCap,
33+
/// Max total **paid** claim amount per policy per rolling ledger window (gross `claim.amount`).
34+
RollingClaimCap,
35+
/// Ledger length of each rolling window (bucket alignment uses current ledger sequence).
36+
RollingClaimWindowLedgers,
3337
// ── Reserved: future governance token (`governance_token` module) ────────
3438
/// Runtime toggle: only meaningful when crate is built with `governance-token`.
3539
/// Unset or `false` in MVP; no token logic runs unless feature + flag align.
@@ -536,3 +540,53 @@ pub fn get_appeal_vote(env: &Env, claim_id: u64, voter: &Address) -> Option<Vote
536540
.persistent()
537541
.get(&DataKey::AppealVote(claim_id, voter.clone()))
538542
}
543+
544+
// ── Rolling claim cap (instance + persistent) ─────────────────────────────────
545+
546+
pub fn set_rolling_claim_cap(env: &Env, cap: i128) {
547+
env.storage().instance().set(&DataKey::RollingClaimCap, &cap);
548+
}
549+
550+
pub fn get_rolling_claim_cap(env: &Env) -> i128 {
551+
env.storage()
552+
.instance()
553+
.get(&DataKey::RollingClaimCap)
554+
.unwrap_or(i128::MAX)
555+
}
556+
557+
pub fn set_rolling_claim_window_ledgers(env: &Env, w: u32) {
558+
env.storage()
559+
.instance()
560+
.set(&DataKey::RollingClaimWindowLedgers, &w);
561+
}
562+
563+
pub fn get_rolling_claim_window_ledgers(env: &Env) -> u32 {
564+
env.storage()
565+
.instance()
566+
.get(&DataKey::RollingClaimWindowLedgers)
567+
.unwrap_or(1_000_000)
568+
}
569+
570+
pub fn get_rolling_claim_state(
571+
env: &Env,
572+
holder: &Address,
573+
policy_id: u32,
574+
) -> Option<RollingClaimWindowState> {
575+
env.storage().persistent().get(&DataKey::RollingClaimState(
576+
holder.clone(),
577+
policy_id,
578+
))
579+
}
580+
581+
pub fn set_rolling_claim_state(
582+
env: &Env,
583+
holder: &Address,
584+
policy_id: u32,
585+
state: &RollingClaimWindowState,
586+
) {
587+
let key = DataKey::RollingClaimState(holder.clone(), policy_id);
588+
env.storage().persistent().set(&key, state);
589+
env.storage()
590+
.persistent()
591+
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
592+
}

contracts/niffyinsure/src/types.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,18 @@ pub struct Claim {
387387
pub status_history: Vec<ClaimStatusHistoryEntry>,
388388
}
389389

390+
/// Per-policy rolling window accumulator for **paid** claim amounts (same ledger window for all policies).
391+
///
392+
/// `window_start` is the first ledger of the bucket: `floor(now / window_len) * window_len`.
393+
/// `cumulative_paid` resets when the bucket changes. Indexers can derive **remaining** as
394+
/// `min(rolling_claim_cap - cumulative_paid, policy.coverage)` for UX (cap is global).
395+
#[contracttype]
396+
#[derive(Clone, Debug, Eq, PartialEq)]
397+
pub struct RollingClaimWindowState {
398+
pub window_start: u32,
399+
pub cumulative_paid: i128,
400+
}
401+
390402
#[contracttype]
391403
#[derive(Clone, Debug, Eq, PartialEq)]
392404
pub struct PremiumQuoteLineItem {

contracts/niffyinsure/test_snapshots/add_voter_and_remove_voter.1.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,30 @@
284284
]
285285
}
286286
},
287+
{
288+
"key": {
289+
"vec": [
290+
{
291+
"symbol": "RollingClaimCap"
292+
}
293+
]
294+
},
295+
"val": {
296+
"i128": "170141183460469231731687303715884105727"
297+
}
298+
},
299+
{
300+
"key": {
301+
"vec": [
302+
{
303+
"symbol": "RollingClaimWindowLedgers"
304+
}
305+
]
306+
},
307+
"val": {
308+
"u32": 1000000
309+
}
310+
},
287311
{
288312
"key": {
289313
"vec": [

0 commit comments

Comments
 (0)