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`.
1268use 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} ;
1776use soroban_sdk:: { contractevent, Address , Env , String , Vec } ;
1877
78+ // ── Events ────────────────────────────────────────────────────────────────────
79+
1980#[ contractevent( topics = [ "niffyinsure" , "claim_filed" ] ) ]
2081#[ derive( Clone , Debug , Eq , PartialEq ) ]
2182struct 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`.
204365pub 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
223478fn payout ( env : & Env , claim : & Claim ) -> Result < ( ) , Error > {
0 commit comments