Skip to content

Commit fbb0fa7

Browse files
authored
Merge pull request #163 from abdegenius/feat/appeal-window
feat: added window appeal
2 parents 3754826 + 4ba3e2f commit fbb0fa7

5 files changed

Lines changed: 117 additions & 10 deletions

File tree

contracts/niffyinsure/src/claim.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ pub fn file_claim(
218218
approve_votes: 0,
219219
reject_votes: 0,
220220
filed_at: now,
221+
appeal_open_deadline_ledger: 0,
222+
appeals_count: 0,
223+
appeal_deadline_ledger: 0,
224+
appeal_approve_votes: 0,
225+
appeal_reject_votes: 0,
221226
};
222227

223228
storage::set_claim(env, &claim);
@@ -283,10 +288,16 @@ pub fn vote_on_claim(
283288
// Auto-finalize on majority.
284289
let total = snapshot.len();
285290
let majority = total / 2 + 1;
291+
let newly_rejected;
286292
if claim.approve_votes >= majority {
287293
claim.status = ClaimStatus::Approved;
294+
newly_rejected = false;
288295
} else if claim.reject_votes >= majority {
289296
claim.status = ClaimStatus::Rejected;
297+
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
298+
newly_rejected = true;
299+
} else {
300+
newly_rejected = false;
290301
}
291302

292303
let newly_rejected = claim.status == ClaimStatus::Rejected;
@@ -329,12 +340,16 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
329340
return Err(Error::VotingWindowStillOpen);
330341
}
331342

332-
claim.status = if claim.approve_votes > claim.reject_votes {
333-
ClaimStatus::Approved
343+
let newly_rejected;
344+
if claim.approve_votes > claim.reject_votes {
345+
claim.status = ClaimStatus::Approved;
346+
newly_rejected = false;
334347
} else {
335348
// Tie or reject plurality → Rejected (insurer wins tie).
336-
ClaimStatus::Rejected
337-
};
349+
claim.status = ClaimStatus::Rejected;
350+
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
351+
newly_rejected = true;
352+
}
338353

339354
let newly_rejected = claim.status == ClaimStatus::Rejected;
340355

@@ -505,6 +520,8 @@ fn payout(env: &Env, claim: &Claim) -> Result<(), Error> {
505520
Ok(())
506521
}
507522

523+
// ── Public read helpers ───────────────────────────────────────────────────────
524+
508525
pub fn get_claim(env: &Env, claim_id: u64) -> Result<Claim, Error> {
509526
storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)
510527
}

contracts/niffyinsure/src/ledger.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ pub const RATE_LIMIT_WINDOW_LEDGERS: u32 = LEDGERS_PER_DAY; // 17_280
8787
/// Quote validity: how many ledgers a `generate_premium` result stays valid.
8888
pub const QUOTE_TTL_LEDGERS: u32 = 100;
8989

90+
/// Appeal open window: how many ledgers after rejection a claimant may open an appeal.
91+
/// ~3 days. Anchored at the ledger that produced the Rejected status.
92+
pub const APPEAL_OPEN_WINDOW_LEDGERS: u32 = 3 * LEDGERS_PER_DAY; // 51_840
93+
94+
/// Appeal vote window: how many ledgers voters have to vote on an appeal.
95+
/// ~7 days (same duration as the base claim vote window).
96+
pub const APPEAL_VOTE_WINDOW_LEDGERS: u32 = 7 * LEDGERS_PER_DAY; // 120_960
97+
98+
/// Hard cap on appeals per claim. Prevents infinite ping-pong.
99+
/// Claimants get exactly one appeal after a Rejected outcome.
100+
pub const MAX_APPEALS_PER_CLAIM: u32 = 1;
101+
90102
// ── Core window helpers ───────────────────────────────────────────────────────
91103

92104
/// Returns `true` if `now` falls in the half-open interval `[start, end)`.

contracts/niffyinsure/src/storage.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub enum DataKey {
3939
ClaimVoters(u64),
4040
/// Last ledger at which `holder` filed a claim (rate-limit anchor).
4141
LastClaimLedger(Address),
42+
/// (claim_id, voter_address) -> VoteOption for appeal round; immutable after first write.
43+
AppealVote(u64, Address),
4244
}
4345

4446
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -453,3 +455,19 @@ pub fn get_last_claim_ledger(env: &Env, holder: &Address) -> Option<u32> {
453455
.persistent()
454456
.get(&DataKey::LastClaimLedger(holder.clone()))
455457
}
458+
459+
// ── Appeal vote (persistent) ──────────────────────────────────────────────────
460+
461+
pub fn set_appeal_vote(env: &Env, claim_id: u64, voter: &Address, vote: &VoteOption) {
462+
let key = DataKey::AppealVote(claim_id, voter.clone());
463+
env.storage().persistent().set(&key, vote);
464+
env.storage()
465+
.persistent()
466+
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
467+
}
468+
469+
pub fn get_appeal_vote(env: &Env, claim_id: u64, voter: &Address) -> Option<VoteOption> {
470+
env.storage()
471+
.persistent()
472+
.get(&DataKey::AppealVote(claim_id, voter.clone()))
473+
}

contracts/niffyinsure/src/types.rs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,28 @@ pub const STRIKE_DEACTIVATION_THRESHOLD: u32 = 3;
3535
// Conversion: 1 ledger ≈ 5 s on Stellar Mainnet (Protocol 20+).
3636
// See: https://developers.stellar.org/docs/learn/fundamentals/stellar-consensus-protocol
3737
pub use crate::ledger::{
38-
LEDGERS_PER_DAY, LEDGERS_PER_HOUR, LEDGERS_PER_MIN, LEDGERS_PER_WEEK, POLICY_DURATION_LEDGERS,
38+
APPEAL_OPEN_WINDOW_LEDGERS, APPEAL_VOTE_WINDOW_LEDGERS, LEDGERS_PER_DAY, LEDGERS_PER_HOUR,
39+
LEDGERS_PER_MIN, LEDGERS_PER_WEEK, MAX_APPEALS_PER_CLAIM, POLICY_DURATION_LEDGERS,
3940
QUOTE_TTL_LEDGERS, RATE_LIMIT_WINDOW_LEDGERS, RENEWAL_WINDOW_LEDGERS, SECS_PER_LEDGER,
4041
VOTE_WINDOW_LEDGERS,
4142
};
4243

44+
// ── Strike / rejection constants ──────────────────────────────────────────────
45+
46+
/// Number of rejected claims that automatically deactivates a policy.
47+
///
48+
/// This is a **compile-time constant**, not a runtime admin parameter. Admin
49+
/// cannot flip it post-deployment, which prevents governance gaming where a
50+
/// large voter bloc rejects claims to deactivate rival policies.
51+
///
52+
/// **Legal review:** Before changing this value, consult legal counsel on
53+
/// whether automatic policy cancellation triggers regulatory requirements
54+
/// (e.g., notice periods, appeal rights).
55+
///
56+
/// **Appeal interaction:** Deactivation triggered by reaching this threshold
57+
/// can be reversed by a successful appeal that decrements strikes back below it.
58+
pub const STRIKE_DEACTIVATION_THRESHOLD: u32 = 3;
59+
4360
// ── Enums ─────────────────────────────────────────────────────────────────────
4461

4562
#[contracttype]
@@ -76,10 +93,19 @@ pub enum CoverageType {
7693

7794
/// Claim lifecycle state machine.
7895
///
79-
/// Transitions:
80-
/// Processing → Approved (majority approve vote or deadline plurality)
81-
/// Processing → Rejected (majority reject vote or deadline plurality/tie)
82-
/// Approved → Paid (admin calls process_claim)
96+
/// Base-flow transitions:
97+
/// Processing → Approved (majority approve vote or deadline plurality)
98+
/// Processing → Rejected (majority reject vote or deadline plurality/tie)
99+
/// Approved → Paid (admin calls process_claim)
100+
///
101+
/// Appeal-flow transitions (requires Rejected status + open appeal window):
102+
/// Rejected → UnderAppeal (claimant calls open_appeal within window)
103+
/// UnderAppeal → AppealApproved (majority approve appeal vote or deadline)
104+
/// UnderAppeal → AppealRejected (majority reject appeal vote or deadline)
105+
/// AppealApproved → Paid (admin calls process_claim — same as Approved)
106+
///
107+
/// Terminal states (no further transitions): Paid, Rejected (after appeal window
108+
/// closes), AppealApproved (→ Paid only), AppealRejected.
83109
#[contracttype]
84110
#[derive(Clone, PartialEq, Eq, Debug)]
85111
pub enum ClaimStatus {
@@ -88,13 +114,23 @@ pub enum ClaimStatus {
88114
Approved,
89115
Paid,
90116
Rejected,
117+
/// Claimant has opened an appeal; fresh vote round in progress.
118+
UnderAppeal,
119+
/// Appeal vote resolved in claimant's favour; awaits admin payout.
120+
AppealApproved,
121+
/// Appeal vote rejected; claim is permanently closed.
122+
AppealRejected,
91123
}
92124

93125
impl ClaimStatus {
94126
pub fn is_terminal(&self) -> bool {
95127
matches!(
96128
self,
97-
ClaimStatus::Approved | ClaimStatus::Paid | ClaimStatus::Rejected
129+
ClaimStatus::Approved
130+
| ClaimStatus::Paid
131+
| ClaimStatus::Rejected
132+
| ClaimStatus::AppealApproved
133+
| ClaimStatus::AppealRejected
98134
)
99135
}
100136
}
@@ -234,6 +270,18 @@ pub struct Claim {
234270
pub reject_votes: u32,
235271
/// Ledger sequence at which this claim was filed (voting window anchor).
236272
pub filed_at: u32,
273+
// ── Appeal fields ────────────────────────────────────────────────────────
274+
/// Ledger by which `open_appeal` must be called (0 if never rejected).
275+
/// Set to `rejected_at + APPEAL_OPEN_WINDOW_LEDGERS` when status → Rejected.
276+
pub appeal_open_deadline_ledger: u32,
277+
/// How many appeals have been opened for this claim (cap = MAX_APPEALS_PER_CLAIM).
278+
pub appeals_count: u32,
279+
/// Voting deadline for the current appeal round (0 if no appeal open).
280+
pub appeal_deadline_ledger: u32,
281+
/// Approve votes cast in the current appeal round.
282+
pub appeal_approve_votes: u32,
283+
/// Reject votes cast in the current appeal round.
284+
pub appeal_reject_votes: u32,
237285
}
238286

239287
#[contracttype]

contracts/niffyinsure/src/validate.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ pub enum Error {
5353
VotingWindowStillOpen = 40,
5454
NotEligibleVoter = 41,
5555
RateLimitExceeded = 42,
56+
/// Appeal open window has passed; claimant can no longer appeal this claim.
57+
AppealWindowClosed = 43,
58+
/// An appeal is already open for this claim.
59+
AppealAlreadyOpen = 44,
60+
/// Claim has reached the maximum allowed appeals per claim.
61+
MaxAppealsReached = 45,
62+
/// Claim is not in Rejected status; cannot open an appeal.
63+
ClaimNotRejected = 46,
64+
/// No appeal is currently open; cannot vote on or finalize appeal.
65+
AppealNotOpen = 47,
66+
/// Appeal voting window is still open; cannot finalize appeal yet.
67+
AppealWindowStillOpen = 48,
5668
}
5769

5870
pub fn check_policy(policy: &Policy) -> Result<(), Error> {

0 commit comments

Comments
 (0)