Skip to content

Commit 3754826

Browse files
authored
Merge pull request #160 from abdegenius/feat/reject-claim
feat: added rejection claim feature
2 parents 25a245d + 4920e06 commit 3754826

27 files changed

Lines changed: 1336 additions & 16 deletions

.claude/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(find /home/ljtwp/Desktop/drips/niff -type f -name *.sol -o -name *.rs -o -name *.ts -o -name *.tsx -o -name *.js -o -name *.jsx)",
5+
"Bash(cargo test:*)",
6+
"Bash(cargo fmt:*)",
7+
"Bash(cargo clippy:*)"
8+
]
9+
}
10+
}

contracts/niffyinsure/src/claim.rs

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,74 @@
99
// a terminal status (`Approved` / `Rejected`), so policy termination can block
1010
// or audit in-flight claims. Until `file_claim` ships, admins may use
1111
// `admin_set_open_claim_count` in tests or break-glass ops only.
12+
//
13+
// ── Rejection side-effects ─────────────────────────────────────────────────────
14+
//
15+
// When a claim reaches `ClaimStatus::Rejected` (via majority vote or deadline
16+
// finalization), `on_reject` is called to apply the following deterministic,
17+
// trustless consequences:
18+
//
19+
// 1. `StrikeIncremented` event — increments the policy's `strike_count`
20+
// and emits the new total so indexers can surface it to holders.
21+
// 2. `PolicyDeactivated` event — emitted if `strike_count` reaches
22+
// `STRIKE_DEACTIVATION_THRESHOLD`. The policy is set `is_active = false`
23+
// and the voter registry is updated in the same ledger.
24+
// 3. `ClaimRejected` event — authoritative rejection signal for indexers.
25+
// Carries vote tallies so the UI can explain the outcome without querying
26+
// separate storage.
27+
//
28+
// ── Guarantee: reject NEVER invokes payout ────────────────────────────────────
29+
//
30+
// `on_reject` performs no token transfers. The only token transfer in this
31+
// module is inside `payout`, which is exclusively called from `process_claim`.
32+
// `process_claim` guards on `claim.status == ClaimStatus::Approved`; a
33+
// `Rejected` claim will receive `Error::ClaimNotApproved` before any transfer
34+
// is attempted.
35+
//
36+
// ── Permanent auditability ────────────────────────────────────────────────────
37+
//
38+
// Rejected claim records are stored in `persistent` storage with TTL
39+
// extensions and remain readable indefinitely via `get_claim`. The `details`
40+
// field holds a brief description (≤ 256 chars); full allegation narratives
41+
// must NOT be stored on-chain — use IPFS/off-chain storage and reference via
42+
// `image_urls` or an off-chain indexer.
43+
//
44+
// ── Appeal window interaction ─────────────────────────────────────────────────
45+
//
46+
// Appeals are not implemented in this version. If added:
47+
// - Auto-deactivation in `on_reject` should be conditional on
48+
// `env.ledger().sequence() > appeal_deadline_ledger`.
49+
// - A new `ClaimStatus::Appealed` would require composing cleanly with
50+
// the existing terminal-state checks (`is_terminal()`).
51+
// - The `PolicyDeactivated` and `StrikeIncremented` events carry enough
52+
// context for an appeal system to reverse their effects off-chain.
53+
//
54+
// ── Governance risk documentation ─────────────────────────────────────────────
55+
//
56+
// Admin override path: the admin can call `admin_terminate_policy` with
57+
// `allow_open_claims = true`, which can terminate a policy while a claim is
58+
// in `Processing`. In that scenario the claim vote can still complete, but
59+
// `on_reject` will find `policy.is_active = false` and skip the deactivation
60+
// branch (policy already inactive). The `StrikeIncremented` and
61+
// `ClaimRejected` events still fire for auditability.
62+
//
63+
// Premium-extraction attack: an attacker cannot extract premiums via the
64+
// rejection path because `process_claim` is gated on `Approved` status. The
65+
// only way to get an `Approved` claim processed is through legitimate majority
66+
// or deadline-plurality approval, which is controlled by the DAO snapshot, not
67+
// the admin. The admin cannot flip a `Rejected` claim to `Approved`.
1268
use crate::{
1369
ledger, storage,
14-
types::{Claim, ClaimProcessed, ClaimStatus, VoteOption},
70+
types::{
71+
Claim, ClaimProcessed, ClaimStatus, TerminationReason, VoteOption,
72+
STRIKE_DEACTIVATION_THRESHOLD,
73+
},
1574
validate::Error,
1675
};
1776
use soroban_sdk::{contractevent, Address, Env, String, Vec};
1877

78+
// ── Events ────────────────────────────────────────────────────────────────────
79+
1980
#[contractevent(topics = ["niffyinsure", "claim_filed"])]
2081
#[derive(Clone, Debug, Eq, PartialEq)]
2182
struct ClaimFiled {
@@ -24,6 +85,78 @@ struct ClaimFiled {
2485
pub holder: Address,
2586
}
2687

88+
/// Emitted as the authoritative rejection signal. Indexers must consume this
89+
/// event (not poll storage) to drive user-facing messaging. The vote tallies
90+
/// are included so the UI can explain the outcome (e.g., "rejected 4–1").
91+
///
92+
/// Topic layout: ["niffyinsure", "claim_rejected", claim_id]
93+
/// Data: { policy_id, claimant, reject_votes, approve_votes, at_ledger }
94+
///
95+
/// NOTE: This event is NEVER emitted on the approve path. Its presence
96+
/// unambiguously signals rejection.
97+
#[contractevent(topics = ["niffyinsure", "claim_rejected"])]
98+
#[derive(Clone, Debug, Eq, PartialEq)]
99+
pub struct ClaimRejected {
100+
#[topic]
101+
pub claim_id: u64,
102+
pub policy_id: u32,
103+
pub claimant: Address,
104+
pub reject_votes: u32,
105+
pub approve_votes: u32,
106+
/// Ledger at which the claim was finalized as rejected.
107+
pub at_ledger: u32,
108+
}
109+
110+
/// Emitted every time a rejection increments the policy's strike counter.
111+
/// Indexers should use this event to notify holders of accumulating strikes
112+
/// before the threshold triggers deactivation.
113+
///
114+
/// Topic layout: ["niffyinsure", "strike_incremented", holder, policy_id]
115+
/// Data: { claim_id, strike_count }
116+
///
117+
/// `strike_count` is the NEW total after this increment (1-indexed).
118+
#[contractevent(topics = ["niffyinsure", "strike_incremented"])]
119+
#[derive(Clone, Debug, Eq, PartialEq)]
120+
pub struct StrikeIncremented {
121+
#[topic]
122+
pub holder: Address,
123+
#[topic]
124+
pub policy_id: u32,
125+
pub claim_id: u64,
126+
/// New cumulative strike count for this policy after this rejection.
127+
pub strike_count: u32,
128+
}
129+
130+
/// Emitted when a policy is automatically deactivated because its
131+
/// `strike_count` reached `STRIKE_DEACTIVATION_THRESHOLD`.
132+
///
133+
/// Topic layout: ["niffyinsure", "policy_deactivated", holder, policy_id]
134+
/// Data: { reason_code, at_ledger }
135+
///
136+
/// `reason_code` values:
137+
/// 1 = ExcessiveRejections (strike threshold reached)
138+
///
139+
/// CENTRALIZATION NOTE: This event is emitted by the claims engine
140+
/// deterministically — no admin key is involved. An admin cannot prevent or
141+
/// reverse this deactivation via `process_claim` or any other entrypoint.
142+
/// The only admin avenue is `admin_terminate_policy` (which terminates before
143+
/// the threshold is reached) or a future contract upgrade.
144+
///
145+
/// APPEAL NOTE: If appeals are added, this event should be treated as
146+
/// "pending deactivation" until the appeal window closes, not as an
147+
/// immediate final state.
148+
#[contractevent(topics = ["niffyinsure", "policy_deactivated"])]
149+
#[derive(Clone, Debug, Eq, PartialEq)]
150+
pub struct PolicyDeactivated {
151+
#[topic]
152+
pub holder: Address,
153+
#[topic]
154+
pub policy_id: u32,
155+
/// 1 = ExcessiveRejections
156+
pub reason_code: u32,
157+
pub at_ledger: u32,
158+
}
159+
27160
// ── file_claim ────────────────────────────────────────────────────────────────
28161

29162
/// File a new claim against an active policy.
@@ -156,12 +289,22 @@ pub fn vote_on_claim(
156289
claim.status = ClaimStatus::Rejected;
157290
}
158291

292+
let newly_rejected = claim.status == ClaimStatus::Rejected;
293+
159294
if claim.status.is_terminal() {
160295
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
161296
}
162297

163298
let status = claim.status.clone();
164299
storage::set_claim(env, &claim);
300+
301+
// Apply rejection side-effects after the claim record is persisted.
302+
// on_reject emits ClaimRejected, StrikeIncremented, and (if threshold
303+
// reached) PolicyDeactivated. It never transfers tokens.
304+
if newly_rejected {
305+
on_reject(env, &claim);
306+
}
307+
165308
Ok(status)
166309
}
167310

@@ -193,20 +336,40 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
193336
ClaimStatus::Rejected
194337
};
195338

339+
let newly_rejected = claim.status == ClaimStatus::Rejected;
340+
196341
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
197342
let status = claim.status.clone();
198343
storage::set_claim(env, &claim);
344+
345+
// Apply rejection side-effects after the claim record is persisted.
346+
if newly_rejected {
347+
on_reject(env, &claim);
348+
}
349+
199350
Ok(status)
200351
}
201352

202353
// ── process_claim (admin payout trigger) ─────────────────────────────────────
203354

355+
/// Trigger the payout for an approved claim.
356+
///
357+
/// INVARIANT: This function is the ONLY code path that transfers payout
358+
/// tokens. It is unconditionally gated on `claim.status == Approved`.
359+
/// A `Rejected` claim will never reach `payout()` — the guard below returns
360+
/// `Error::ClaimNotApproved` before any transfer is attempted.
361+
///
362+
/// This invariant is enforced structurally: `on_reject` does not call
363+
/// `payout`, and there is no entrypoint that transitions a `Rejected` claim
364+
/// to `Approved`.
204365
pub fn process_claim(env: &Env, claim_id: u64) -> Result<(), Error> {
205366
let mut claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
206367

207368
if claim.status == ClaimStatus::Paid {
208369
return Err(Error::AlreadyPaid);
209370
}
371+
// SAFETY: Rejected and Processing claims are explicitly blocked here.
372+
// No path can circumvent this guard to reach payout().
210373
if claim.status != ClaimStatus::Approved {
211374
return Err(Error::ClaimNotApproved);
212375
}
@@ -218,6 +381,98 @@ pub fn process_claim(env: &Env, claim_id: u64) -> Result<(), Error> {
218381
Ok(())
219382
}
220383

384+
// ── on_reject (centralized rejection side-effects) ────────────────────────────
385+
386+
/// Apply all side-effects that must occur when a claim is rejected.
387+
///
388+
/// Called by both `vote_on_claim` (majority auto-finalize) and
389+
/// `finalize_claim` (deadline resolution). Must be called AFTER the claim
390+
/// record has been persisted with `ClaimStatus::Rejected`.
391+
///
392+
/// Side-effects (in emission order):
393+
/// 1. `ClaimRejected` — indexer signal; always emitted.
394+
/// 2. `StrikeIncremented` — policy strike counter incremented; always
395+
/// emitted even if the policy is already inactive (auditability).
396+
/// 3. `PolicyDeactivated` — emitted only when `strike_count` reaches
397+
/// `STRIKE_DEACTIVATION_THRESHOLD` AND the policy is currently active.
398+
///
399+
/// NO TOKEN TRANSFERS occur in this function.
400+
///
401+
/// If the policy record cannot be found (e.g., it was manually terminated and
402+
/// subsequently evicted from storage), `ClaimRejected` is still emitted and
403+
/// the function returns without error. Strike and deactivation events require
404+
/// the policy record.
405+
fn on_reject(env: &Env, claim: &Claim) {
406+
let now = env.ledger().sequence();
407+
408+
// ── 1. ClaimRejected ─────────────────────────────────────────────────────
409+
//
410+
// Emit first so indexers always see a ClaimRejected before any policy
411+
// side-effect events, establishing a clear causal ordering.
412+
ClaimRejected {
413+
claim_id: claim.claim_id,
414+
policy_id: claim.policy_id,
415+
claimant: claim.claimant.clone(),
416+
reject_votes: claim.reject_votes,
417+
approve_votes: claim.approve_votes,
418+
at_ledger: now,
419+
}
420+
.publish(env);
421+
422+
// ── 2. StrikeIncremented + (optional) PolicyDeactivated ──────────────────
423+
//
424+
// Best-effort: if the policy record is missing (manual termination + TTL
425+
// eviction), skip strike and deactivation. ClaimRejected has already fired.
426+
let Some(mut policy) = storage::get_policy(env, &claim.claimant, claim.policy_id) else {
427+
return;
428+
};
429+
430+
policy.strike_count = policy.strike_count.saturating_add(1);
431+
432+
StrikeIncremented {
433+
holder: claim.claimant.clone(),
434+
policy_id: claim.policy_id,
435+
claim_id: claim.claim_id,
436+
strike_count: policy.strike_count,
437+
}
438+
.publish(env);
439+
440+
// ── 3. PolicyDeactivated ─────────────────────────────────────────────────
441+
//
442+
// Deactivate only if the policy is currently active AND the strike count
443+
// has reached the threshold. A policy already deactivated (e.g., by the
444+
// admin or a prior threshold breach) is not touched again — no double
445+
// deactivation.
446+
if policy.strike_count >= STRIKE_DEACTIVATION_THRESHOLD && policy.is_active {
447+
policy.is_active = false;
448+
policy.terminated_at_ledger = now;
449+
policy.termination_reason = TerminationReason::ExcessiveRejections;
450+
policy.terminated_by_admin = false;
451+
452+
// Persist policy state change before emitting the event so any
453+
// re-entrant read sees the correct state.
454+
storage::set_policy(env, &claim.claimant, claim.policy_id, &policy);
455+
456+
// Update voter registry: decrement active count and remove from the
457+
// live voter list if this was the holder's last active policy.
458+
storage::decrement_holder_active_policies(env, &claim.claimant);
459+
if storage::get_holder_active_policy_count(env, &claim.claimant) == 0 {
460+
storage::voters_remove_holder(env, &claim.claimant);
461+
}
462+
463+
PolicyDeactivated {
464+
holder: claim.claimant.clone(),
465+
policy_id: claim.policy_id,
466+
reason_code: 1, // 1 = ExcessiveRejections
467+
at_ledger: now,
468+
}
469+
.publish(env);
470+
} else {
471+
// Strike did not trigger deactivation — persist the incremented count.
472+
storage::set_policy(env, &claim.claimant, claim.policy_id, &policy);
473+
}
474+
}
475+
221476
// ── Internal helpers ──────────────────────────────────────────────────────────
222477

223478
fn payout(env: &Env, claim: &Claim) -> Result<(), Error> {

contracts/niffyinsure/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ impl NiffyInsure {
468468
terminated_at_ledger: 0,
469469
termination_reason: TerminationReason::None,
470470
terminated_by_admin: false,
471+
strike_count: 0,
471472
};
472473
env.storage().persistent().set(
473474
&storage::DataKey::Policy(holder.clone(), policy_id),

contracts/niffyinsure/src/policy.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ pub fn initiate_policy(
272272
terminated_at_ledger: 0,
273273
termination_reason: crate::types::TerminationReason::None,
274274
terminated_by_admin: false,
275+
strike_count: 0,
275276
};
276277

277278
validate::check_policy(&policy).map_err(|_| PolicyError::PolicyValidation)?;

contracts/niffyinsure/src/policy_lifecycle.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ pub fn initiate_policy(
6666
terminated_at_ledger: 0,
6767
termination_reason: TerminationReason::None,
6868
terminated_by_admin: false,
69+
strike_count: 0,
6970
};
7071

7172
validate::check_policy(&policy).map_err(|e| match e {
@@ -212,5 +213,10 @@ fn termination_reason_tag(reason: TerminationReason) -> u32 {
212213
TerminationReason::FraudOrMisrepresentation => 4,
213214
TerminationReason::RegulatoryAction => 5,
214215
TerminationReason::AdminOverride => 6,
216+
// 7 = ExcessiveRejections: set by the claims engine via on_reject,
217+
// not by the policy-lifecycle termination flow. Included here for
218+
// completeness; PolicyTerminated is not normally emitted for this
219+
// reason — PolicyDeactivated (from claim.rs) is the canonical event.
220+
TerminationReason::ExcessiveRejections => 7,
215221
}
216222
}

0 commit comments

Comments
 (0)