Skip to content

Commit 97c0620

Browse files
authored
Merge pull request #272 from Mimah97/feature/claims-withdrawal-reopen
Feature/claims withdrawal reopen
2 parents 714cb65 + b0ccc38 commit 97c0620

16 files changed

Lines changed: 301 additions & 10 deletions

File tree

contracts/niffyinsure/src/claim.rs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77
// Open claim accounting: `storage::OpenClaimCount(holder, policy_id)` must be
88
// incremented when a claim enters `Processing` and decremented when it reaches
9-
// a terminal status (`Approved` / `Rejected`), so policy termination can block
9+
// a terminal status (`Approved` / `Rejected` / `Withdrawn`), 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.
1212
//
@@ -148,6 +148,19 @@ struct ClaimFiled {
148148
pub image_hash: u64,
149149
}
150150

151+
/// Emitted when the claimant withdraws before any vote is cast.
152+
///
153+
/// Topic layout: ["niffyinsure", "claim_withdrawn", claim_id]
154+
#[contractevent(topics = ["niffyinsure", "claim_withdrawn"])]
155+
#[derive(Clone, Debug, Eq, PartialEq)]
156+
pub struct ClaimWithdrawn {
157+
#[topic]
158+
pub claim_id: u64,
159+
pub policy_id: u32,
160+
pub claimant: Address,
161+
pub at_ledger: u32,
162+
}
163+
151164
/// Emitted as the authoritative rejection signal. Indexers must consume this
152165
/// event (not poll storage) to drive user-facing messaging. The vote tallies
153166
/// are included so the UI can explain the outcome (e.g., "rejected 4–1").
@@ -259,8 +272,11 @@ pub fn file_claim(
259272
return Err(Error::DuplicateOpenClaim);
260273
}
261274

275+
// Anchor for restoring per-holder rate limit if claimant later withdraws (see `withdraw_claim`).
276+
let rate_limit_anchor_before_filing = storage::get_last_claim_ledger(env, holder);
277+
262278
// Rate-limit check.
263-
if let Some(last) = storage::get_last_claim_ledger(env, holder) {
279+
if let Some(last) = rate_limit_anchor_before_filing {
264280
if !ledger::is_rate_limit_elapsed(now, last, ledger::RATE_LIMIT_WINDOW_LEDGERS) {
265281
return Err(Error::RateLimitExceeded);
266282
}
@@ -305,6 +321,7 @@ pub fn file_claim(
305321
storage::snapshot_claim_voters(env, claim_id);
306322
storage::set_claim_quorum_bps(env, claim_id, storage::get_quorum_bps(env));
307323
storage::set_last_claim_ledger(env, holder, now);
324+
storage::set_claim_rate_limit_prev(env, claim_id, rate_limit_anchor_before_filing);
308325

309326
let mut evidence_hashes: Vec<BytesN<32>> = Vec::new(env);
310327
for e in evidence.iter() {
@@ -324,6 +341,60 @@ pub fn file_claim(
324341
Ok(claim_id)
325342
}
326343

344+
// ── withdraw_claim ────────────────────────────────────────────────────────────
345+
346+
/// Claimant-only: withdraw a claim before any ballot is cast.
347+
///
348+
/// Allowed only while `status == Processing` and `approve_votes + reject_votes == 0`.
349+
/// Sets status to [`ClaimStatus::Withdrawn`], clears the open-claim flag, restores the
350+
/// holder's claim **rate-limit anchor** to its value before this claim was filed (see
351+
/// `storage::ClaimRateLimitPrev`), and emits [`ClaimWithdrawn`].
352+
///
353+
/// **Rate limit vs open-claim cap:** Withdrawal does **not** consume the per-policy
354+
/// "one open claim" slot once complete (open flag cleared). The per-holder time spacing
355+
/// between **successful** `file_claim` calls is reverted to the pre-filing anchor so a
356+
/// mistaken filing does not force the holder to wait another full window before refiling.
357+
pub fn withdraw_claim(env: &Env, claimant: &Address, claim_id: u64) -> Result<(), Error> {
358+
storage::assert_claims_not_paused(env);
359+
360+
let mut claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
361+
362+
if claimant != &claim.claimant {
363+
return Err(Error::NotEligibleVoter);
364+
}
365+
366+
if claim.status != ClaimStatus::Processing {
367+
return Err(Error::ClaimAlreadyTerminal);
368+
}
369+
370+
if claim.approve_votes != 0 || claim.reject_votes != 0 {
371+
return Err(Error::ClaimAlreadyTerminal);
372+
}
373+
374+
let now = env.ledger().sequence();
375+
claim.status = ClaimStatus::Withdrawn;
376+
push_status_transition(&mut claim.status_history, ClaimStatus::Withdrawn, now);
377+
378+
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
379+
380+
match storage::take_claim_rate_limit_prev(env, claim_id) {
381+
Some(ledger) => storage::set_last_claim_ledger(env, &claim.claimant, ledger),
382+
None => storage::remove_last_claim_ledger(env, &claim.claimant),
383+
}
384+
385+
storage::set_claim(env, &claim);
386+
387+
ClaimWithdrawn {
388+
claim_id,
389+
policy_id: claim.policy_id,
390+
claimant: claimant.clone(),
391+
at_ledger: now,
392+
}
393+
.publish(env);
394+
395+
Ok(())
396+
}
397+
327398
// ── vote_on_claim ─────────────────────────────────────────────────────────────
328399

329400
/// Cast a vote on a pending claim.
@@ -403,6 +474,10 @@ pub fn vote_on_claim(
403474
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
404475
}
405476

477+
if status_before == ClaimStatus::Processing && claim.status != ClaimStatus::Processing {
478+
storage::remove_claim_rate_limit_prev(env, claim_id);
479+
}
480+
406481
let status = claim.status.clone();
407482
storage::set_claim(env, &claim);
408483

@@ -476,6 +551,11 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
476551
let newly_rejected = claim.status == ClaimStatus::Rejected;
477552

478553
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
554+
555+
if status_before == ClaimStatus::Processing && claim.status != ClaimStatus::Processing {
556+
storage::remove_claim_rate_limit_prev(env, claim_id);
557+
}
558+
479559
let status = claim.status.clone();
480560
storage::set_claim(env, &claim);
481561

@@ -516,6 +596,7 @@ pub fn process_claim(env: &Env, claim_id: u64) -> Result<(), Error> {
516596
claim.status = ClaimStatus::Paid;
517597
push_status_transition(&mut claim.status_history, ClaimStatus::Paid, now);
518598
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
599+
storage::remove_claim_rate_limit_prev(env, claim_id);
519600
storage::set_claim(env, &claim);
520601
Ok(())
521602
}

contracts/niffyinsure/src/events.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
//! ```
4848
//! - `amount`: stroops (i128)
4949
//!
50+
//! ### claim_withdrawn — on-chain `niffyinsure` namespace
51+
//! Contract topics: `["niffyinsure", "claim_withdrawn", claim_id]` with payload
52+
//! `{ policy_id, claimant, at_ledger }`. Emitted when the claimant withdraws before any vote.
53+
//! Indexers should surface `Withdrawn` distinctly on the claims board.
54+
//!
5055
//! ## Admin / config events (namespace: "niffyins")
5156
//!
5257
//! ### tbl_upd — PremiumTableUpdatedData

contracts/niffyinsure/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ impl NiffyInsure {
199199
claim::file_claim(&env, &holder, policy_id, amount, &details, &evidence)
200200
}
201201

202+
/// Claimant-only: withdraw before any vote is cast (`Processing`, zero tallies).
203+
pub fn withdraw_claim(
204+
env: Env,
205+
claimant: Address,
206+
claim_id: u64,
207+
) -> Result<(), validate::Error> {
208+
claimant.require_auth();
209+
claim::withdraw_claim(&env, &claimant, claim_id)
210+
}
211+
202212
pub fn vote_on_claim(
203213
env: Env,
204214
voter: Address,

contracts/niffyinsure/src/policy.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,18 @@ pub fn map_quote_error(env: &Env, err: Error) -> QuoteFailure {
218218
Error::TooManyImageUrls => "too many image URLs supplied",
219219
Error::ImageUrlTooLong => "image URL exceeds maximum length",
220220
Error::ReasonTooLong => "termination reason exceeds maximum length",
221-
Error::ClaimAlreadyTerminal => "claim already reached a terminal status",
221+
Error::ClaimAlreadyTerminal => {
222+
"claim already terminal, or withdrawal blocked (voting started or not Processing)"
223+
}
222224
Error::DuplicateVote => "duplicate vote detected",
223225
Error::CalculatorNotSet => "no external calculator configured",
224226
Error::CalculatorCallFailed => "cross-contract call to premium calculator failed",
225227
Error::CalculatorPaused => "premium calculator is paused; policy bind rejected",
226228
Error::VotingWindowClosed => "voting window has closed; use finalize_claim",
227229
Error::VotingWindowStillOpen => "voting window is still open; cannot finalize yet",
228-
Error::NotEligibleVoter => "caller is not in the claim voter snapshot",
230+
Error::NotEligibleVoter => {
231+
"caller is not in the claim voter snapshot, or is not the claimant for withdraw_claim"
232+
}
229233
Error::RateLimitExceeded => "claim rate-limit: wait before filing another claim",
230234
Error::VotingDurationOutOfBounds => {
231235
"voting duration ledgers outside allowed min/max; see contract docs"

contracts/niffyinsure/src/storage.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ pub enum DataKey {
8282
GracePeriodLedgers,
8383
/// Per-claim snapshot of `QuorumBps` at `file_claim` time (immutable for that claim).
8484
ClaimQuorumBps(u64),
85+
/// Value of `LastClaimLedger(claimant)` **before** this claim's filing updated it.
86+
/// Removed when the claim leaves `Processing` without withdraw, or consumed by `withdraw_claim`.
87+
ClaimRateLimitPrev(u64),
8588
}
8689

8790
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -584,6 +587,41 @@ pub fn get_last_claim_ledger(env: &Env, holder: &Address) -> Option<u32> {
584587
.get(&DataKey::LastClaimLedger(holder.clone()))
585588
}
586589

590+
pub fn remove_last_claim_ledger(env: &Env, holder: &Address) {
591+
let key = DataKey::LastClaimLedger(holder.clone());
592+
if env.storage().persistent().has(&key) {
593+
env.storage().persistent().remove(&key);
594+
}
595+
}
596+
597+
/// Snapshot `LastClaimLedger` before filing (only written when `prev` is `Some`).
598+
pub fn set_claim_rate_limit_prev(env: &Env, claim_id: u64, prev: Option<u32>) {
599+
if let Some(ledger) = prev {
600+
let key = DataKey::ClaimRateLimitPrev(claim_id);
601+
env.storage().persistent().set(&key, &ledger);
602+
env.storage()
603+
.persistent()
604+
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
605+
}
606+
}
607+
608+
pub fn remove_claim_rate_limit_prev(env: &Env, claim_id: u64) {
609+
let key = DataKey::ClaimRateLimitPrev(claim_id);
610+
if env.storage().persistent().has(&key) {
611+
env.storage().persistent().remove(&key);
612+
}
613+
}
614+
615+
/// Read and remove the rate-limit restore snapshot for `claim_id` (withdraw path).
616+
pub fn take_claim_rate_limit_prev(env: &Env, claim_id: u64) -> Option<u32> {
617+
let key = DataKey::ClaimRateLimitPrev(claim_id);
618+
let v: Option<u32> = env.storage().persistent().get(&key);
619+
if env.storage().persistent().has(&key) {
620+
env.storage().persistent().remove(&key);
621+
}
622+
v
623+
}
624+
587625
// ── Sweep cap (instance) ──────────────────────────────────────────────────────
588626

589627
/// Set optional per-transaction cap for emergency sweep operations.

contracts/niffyinsure/src/types.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ pub enum CoverageTier {
110110
/// Base-flow transitions:
111111
/// Processing → Approved (participation quorum met + more approve than reject votes cast)
112112
/// Processing → Rejected (participation quorum met + reject wins or tie; or deadline with no quorum)
113+
/// Processing → Withdrawn (claimant calls `withdraw_claim` before any vote is cast)
113114
/// Approved → Paid (admin calls process_claim)
114115
///
115116
/// Appeal-flow transitions (requires Rejected status + open appeal window):
@@ -119,7 +120,7 @@ pub enum CoverageTier {
119120
/// AppealApproved → Paid (admin calls process_claim — same as Approved)
120121
///
121122
/// Terminal states (no further transitions): Paid, Rejected (after appeal window
122-
/// closes), AppealApproved (→ Paid only), AppealRejected.
123+
/// closes), AppealApproved (→ Paid only), AppealRejected, Withdrawn.
123124
#[contracttype]
124125
#[derive(Clone, PartialEq, Eq, Debug)]
125126
pub enum ClaimStatus {
@@ -134,6 +135,8 @@ pub enum ClaimStatus {
134135
AppealApproved,
135136
/// Appeal vote rejected; claim is permanently closed.
136137
AppealRejected,
138+
/// Claimant withdrew before voting began; record kept for audit; no payout.
139+
Withdrawn,
137140
}
138141

139142
impl ClaimStatus {
@@ -145,6 +148,7 @@ impl ClaimStatus {
145148
| ClaimStatus::Rejected
146149
| ClaimStatus::AppealApproved
147150
| ClaimStatus::AppealRejected
151+
| ClaimStatus::Withdrawn
148152
)
149153
}
150154
}

contracts/niffyinsure/tests/types_validate.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ fn rejected_claim_is_terminal() {
306306
assert_eq!(check_claim_open(&c), Err(Error::ClaimAlreadyTerminal));
307307
}
308308

309+
#[test]
310+
fn withdrawn_claim_is_terminal() {
311+
let env = Env::default();
312+
let c = dummy_claim(&env, 1_000_000, ClaimStatus::Withdrawn);
313+
assert_eq!(check_claim_open(&c), Err(Error::ClaimAlreadyTerminal));
314+
}
315+
309316
// ── Enum coherence ────────────────────────────────────────────────────────────
310317

311318
#[test]
@@ -320,4 +327,5 @@ fn claim_status_terminal_flags() {
320327
assert!(ClaimStatus::Approved.is_terminal());
321328
assert!(ClaimStatus::Paid.is_terminal());
322329
assert!(ClaimStatus::Rejected.is_terminal());
330+
assert!(ClaimStatus::Withdrawn.is_terminal());
323331
}

0 commit comments

Comments
 (0)