Skip to content

Commit b0ccc38

Browse files
committed
feat(claims): withdraw_claim before votes, Withdrawn status, rate-limit restore
- Add ClaimStatus::Withdrawn (terminal); block finalize/process payout - withdraw_claim: claimant auth, Processing + zero tallies, open-claim clear - Persist per-claim rate-limit anchor in ClaimRateLimitPrev; restore on withdraw - Clean anchor when claim leaves Processing via vote/finalize/payout - Emit claim_withdrawn event; document for indexers in events.rs - Reuse validate errors: NotEligibleVoter (wrong claimant), ClaimAlreadyTerminal - Tests: success, post-vote reject, unauthorized, rate-limit refile, no finalize/pay - Frontend: Withdrawn in schemas, board badge, vote tally, i18n, claims.mdx Made-with: Cursor
1 parent 1867b81 commit b0ccc38

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
//
@@ -143,6 +143,19 @@ struct ClaimFiled {
143143
pub image_hash: u64,
144144
}
145145

146+
/// Emitted when the claimant withdraws before any vote is cast.
147+
///
148+
/// Topic layout: ["niffyinsure", "claim_withdrawn", claim_id]
149+
#[contractevent(topics = ["niffyinsure", "claim_withdrawn"])]
150+
#[derive(Clone, Debug, Eq, PartialEq)]
151+
pub struct ClaimWithdrawn {
152+
#[topic]
153+
pub claim_id: u64,
154+
pub policy_id: u32,
155+
pub claimant: Address,
156+
pub at_ledger: u32,
157+
}
158+
146159
/// Emitted as the authoritative rejection signal. Indexers must consume this
147160
/// event (not poll storage) to drive user-facing messaging. The vote tallies
148161
/// are included so the UI can explain the outcome (e.g., "rejected 4–1").
@@ -254,8 +267,11 @@ pub fn file_claim(
254267
return Err(Error::DuplicateOpenClaim);
255268
}
256269

270+
// Anchor for restoring per-holder rate limit if claimant later withdraws (see `withdraw_claim`).
271+
let rate_limit_anchor_before_filing = storage::get_last_claim_ledger(env, holder);
272+
257273
// Rate-limit check.
258-
if let Some(last) = storage::get_last_claim_ledger(env, holder) {
274+
if let Some(last) = rate_limit_anchor_before_filing {
259275
if !ledger::is_rate_limit_elapsed(now, last, ledger::RATE_LIMIT_WINDOW_LEDGERS) {
260276
return Err(Error::RateLimitExceeded);
261277
}
@@ -297,6 +313,7 @@ pub fn file_claim(
297313
storage::snapshot_claim_voters(env, claim_id);
298314
storage::set_claim_quorum_bps(env, claim_id, storage::get_quorum_bps(env));
299315
storage::set_last_claim_ledger(env, holder, now);
316+
storage::set_claim_rate_limit_prev(env, claim_id, rate_limit_anchor_before_filing);
300317

301318
ClaimFiled {
302319
claim_id,
@@ -308,6 +325,60 @@ pub fn file_claim(
308325
Ok(claim_id)
309326
}
310327

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

313384
/// Cast a vote on a pending claim.
@@ -383,6 +454,10 @@ pub fn vote_on_claim(
383454
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
384455
}
385456

457+
if status_before == ClaimStatus::Processing && claim.status != ClaimStatus::Processing {
458+
storage::remove_claim_rate_limit_prev(env, claim_id);
459+
}
460+
386461
let status = claim.status.clone();
387462
storage::set_claim(env, &claim);
388463

@@ -445,6 +520,11 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
445520
let newly_rejected = claim.status == ClaimStatus::Rejected;
446521

447522
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
523+
524+
if status_before == ClaimStatus::Processing && claim.status != ClaimStatus::Processing {
525+
storage::remove_claim_rate_limit_prev(env, claim_id);
526+
}
527+
448528
let status = claim.status.clone();
449529
storage::set_claim(env, &claim);
450530

@@ -485,6 +565,7 @@ pub fn process_claim(env: &Env, claim_id: u64) -> Result<(), Error> {
485565
claim.status = ClaimStatus::Paid;
486566
push_status_transition(&mut claim.status_history, ClaimStatus::Paid, now);
487567
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
568+
storage::remove_claim_rate_limit_prev(env, claim_id);
488569
storage::set_claim(env, &claim);
489570
Ok(())
490571
}

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
@@ -197,6 +197,16 @@ impl NiffyInsure {
197197
claim::file_claim(&env, &holder, policy_id, amount, &details, &image_urls)
198198
}
199199

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

contracts/niffyinsure/src/policy.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,18 @@ pub fn map_quote_error(env: &Env, err: Error) -> QuoteFailure {
181181
Error::TooManyImageUrls => "too many image URLs supplied",
182182
Error::ImageUrlTooLong => "image URL exceeds maximum length",
183183
Error::ReasonTooLong => "termination reason exceeds maximum length",
184-
Error::ClaimAlreadyTerminal => "claim already reached a terminal status",
184+
Error::ClaimAlreadyTerminal => {
185+
"claim already terminal, or withdrawal blocked (voting started or not Processing)"
186+
}
185187
Error::DuplicateVote => "duplicate vote detected",
186188
Error::CalculatorNotSet => "no external calculator configured",
187189
Error::CalculatorCallFailed => "cross-contract call to premium calculator failed",
188190
Error::CalculatorPaused => "premium calculator is paused; policy bind rejected",
189191
Error::VotingWindowClosed => "voting window has closed; use finalize_claim",
190192
Error::VotingWindowStillOpen => "voting window is still open; cannot finalize yet",
191-
Error::NotEligibleVoter => "caller is not in the claim voter snapshot",
193+
Error::NotEligibleVoter => {
194+
"caller is not in the claim voter snapshot, or is not the claimant for withdraw_claim"
195+
}
192196
Error::RateLimitExceeded => "claim rate-limit: wait before filing another claim",
193197
Error::AppealWindowClosed => "appeal window has closed",
194198
Error::AppealAlreadyOpen => "an appeal is already open for this claim",

contracts/niffyinsure/src/storage.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ pub enum DataKey {
6060
GracePeriodLedgers,
6161
/// Per-claim snapshot of `QuorumBps` at `file_claim` time (immutable for that claim).
6262
ClaimQuorumBps(u64),
63+
/// Value of `LastClaimLedger(claimant)` **before** this claim's filing updated it.
64+
/// Removed when the claim leaves `Processing` without withdraw, or consumed by `withdraw_claim`.
65+
ClaimRateLimitPrev(u64),
6366
}
6467

6568
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -541,6 +544,41 @@ pub fn get_last_claim_ledger(env: &Env, holder: &Address) -> Option<u32> {
541544
.get(&DataKey::LastClaimLedger(holder.clone()))
542545
}
543546

547+
pub fn remove_last_claim_ledger(env: &Env, holder: &Address) {
548+
let key = DataKey::LastClaimLedger(holder.clone());
549+
if env.storage().persistent().has(&key) {
550+
env.storage().persistent().remove(&key);
551+
}
552+
}
553+
554+
/// Snapshot `LastClaimLedger` before filing (only written when `prev` is `Some`).
555+
pub fn set_claim_rate_limit_prev(env: &Env, claim_id: u64, prev: Option<u32>) {
556+
if let Some(ledger) = prev {
557+
let key = DataKey::ClaimRateLimitPrev(claim_id);
558+
env.storage().persistent().set(&key, &ledger);
559+
env.storage()
560+
.persistent()
561+
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
562+
}
563+
}
564+
565+
pub fn remove_claim_rate_limit_prev(env: &Env, claim_id: u64) {
566+
let key = DataKey::ClaimRateLimitPrev(claim_id);
567+
if env.storage().persistent().has(&key) {
568+
env.storage().persistent().remove(&key);
569+
}
570+
}
571+
572+
/// Read and remove the rate-limit restore snapshot for `claim_id` (withdraw path).
573+
pub fn take_claim_rate_limit_prev(env: &Env, claim_id: u64) -> Option<u32> {
574+
let key = DataKey::ClaimRateLimitPrev(claim_id);
575+
let v: Option<u32> = env.storage().persistent().get(&key);
576+
if env.storage().persistent().has(&key) {
577+
env.storage().persistent().remove(&key);
578+
}
579+
v
580+
}
581+
544582
// ── Sweep cap (instance) ──────────────────────────────────────────────────────
545583

546584
/// 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
@@ -109,6 +109,7 @@ pub enum CoverageTier {
109109
/// Base-flow transitions:
110110
/// Processing → Approved (participation quorum met + more approve than reject votes cast)
111111
/// Processing → Rejected (participation quorum met + reject wins or tie; or deadline with no quorum)
112+
/// Processing → Withdrawn (claimant calls `withdraw_claim` before any vote is cast)
112113
/// Approved → Paid (admin calls process_claim)
113114
///
114115
/// Appeal-flow transitions (requires Rejected status + open appeal window):
@@ -118,7 +119,7 @@ pub enum CoverageTier {
118119
/// AppealApproved → Paid (admin calls process_claim — same as Approved)
119120
///
120121
/// Terminal states (no further transitions): Paid, Rejected (after appeal window
121-
/// closes), AppealApproved (→ Paid only), AppealRejected.
122+
/// closes), AppealApproved (→ Paid only), AppealRejected, Withdrawn.
122123
#[contracttype]
123124
#[derive(Clone, PartialEq, Eq, Debug)]
124125
pub enum ClaimStatus {
@@ -133,6 +134,8 @@ pub enum ClaimStatus {
133134
AppealApproved,
134135
/// Appeal vote rejected; claim is permanently closed.
135136
AppealRejected,
137+
/// Claimant withdrew before voting began; record kept for audit; no payout.
138+
Withdrawn,
136139
}
137140

138141
impl ClaimStatus {
@@ -144,6 +147,7 @@ impl ClaimStatus {
144147
| ClaimStatus::Rejected
145148
| ClaimStatus::AppealApproved
146149
| ClaimStatus::AppealRejected
150+
| ClaimStatus::Withdrawn
147151
)
148152
}
149153
}

contracts/niffyinsure/tests/types_validate.rs

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

238+
#[test]
239+
fn withdrawn_claim_is_terminal() {
240+
let env = Env::default();
241+
let c = dummy_claim(&env, 1_000_000, ClaimStatus::Withdrawn);
242+
assert_eq!(check_claim_open(&c), Err(Error::ClaimAlreadyTerminal));
243+
}
244+
238245
// ── Enum coherence ────────────────────────────────────────────────────────────
239246

240247
#[test]
@@ -249,4 +256,5 @@ fn claim_status_terminal_flags() {
249256
assert!(ClaimStatus::Approved.is_terminal());
250257
assert!(ClaimStatus::Paid.is_terminal());
251258
assert!(ClaimStatus::Rejected.is_terminal());
259+
assert!(ClaimStatus::Withdrawn.is_terminal());
252260
}

0 commit comments

Comments
 (0)