Skip to content

Commit 24a2967

Browse files
authored
Merge pull request InsurNiffy#760 from devoclan/feature/appeal-mechanism-kyc-whitelist
Feature/appeal mechanism kyc whitelist
2 parents 9159932 + 796ccf5 commit 24a2967

6 files changed

Lines changed: 604 additions & 10 deletions

File tree

contracts/niffyinsure/src/claim.rs

Lines changed: 339 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
// or deadline-plurality approval, which is controlled by the DAO snapshot, not
6767
// the admin. The admin cannot flip a `Rejected` claim to `Approved`.
6868
use crate::{
69-
events, ledger, storage,
69+
events::{self, ClaimEvidenceUpdated, PayoutRecipientWarning},
70+
ledger, storage,
7071
types::{
7172
Claim, ClaimEvidenceEntry, ClaimProcessed, ClaimStatus, ClaimStatusHistoryEntry,
7273
TerminationReason, VoteOption, CLAIM_STATUS_HISTORY_MAX, STRIKE_DEACTIVATION_THRESHOLD,
@@ -830,7 +831,8 @@ pub fn process_claim(env: &Env, claim_id: u64) -> Result<(), Error> {
830831
}
831832
// SAFETY: Rejected and Processing claims are explicitly blocked here.
832833
// No path can circumvent this guard to reach payout().
833-
if claim.status != ClaimStatus::Approved {
834+
// AppealApproved is also accepted — same payout flow as Approved.
835+
if claim.status != ClaimStatus::Approved && claim.status != ClaimStatus::AppealApproved {
834836
return Err(Error::ClaimNotApproved);
835837
}
836838

@@ -1464,3 +1466,338 @@ pub fn disburse_installment(env: &Env, claim_id: u64, amount: i128) -> Result<()
14641466
storage::set_claim(env, &claim);
14651467
Ok(())
14661468
}
1469+
1470+
// ── Appeal mechanism ──────────────────────────────────────────────────────────
1471+
//
1472+
// After a claim is Rejected, the claimant has `APPEAL_OPEN_WINDOW_LEDGERS` to
1473+
// open an appeal. The appeal runs a fresh vote round with a higher quorum
1474+
// requirement (APPEAL_ELEVATED_QUORUM_BPS) and a shorter deadline
1475+
// (APPEAL_VOTE_WINDOW_LEDGERS). Only one appeal per claim is allowed
1476+
// (MAX_APPEALS_PER_CLAIM = 1).
1477+
//
1478+
// State machine during appeal:
1479+
// Rejected → UnderAppeal (open_appeal called within window)
1480+
// UnderAppeal → AppealApproved (vote or finalize; approve wins)
1481+
// UnderAppeal → AppealRejected (vote or finalize; reject wins or no quorum)
1482+
//
1483+
// The elevated quorum for appeal rounds uses APPEAL_ELEVATED_QUORUM_BPS (7500 = 75%).
1484+
// This can be overridden by the admin via `admin_set_elevated_quorum_bps`.
1485+
1486+
/// Quorum basis points used for appeal vote rounds when no elevated quorum is configured.
1487+
/// 75% (7500 / 10_000) is intentionally higher than the default 50% base quorum
1488+
/// to reflect the higher evidentiary bar for reversing a prior rejection.
1489+
pub const APPEAL_ELEVATED_QUORUM_BPS: u32 = 7_500;
1490+
1491+
/// Emitted when a claimant opens an appeal on a rejected claim.
1492+
///
1493+
/// Topic layout: ["niffyinsure", "appeal_opened", claim_id]
1494+
/// Data: { policy_id, claimant, appeal_deadline_ledger, quorum_bps, at_ledger }
1495+
#[contractevent(topics = ["niffyinsure", "appeal_opened"])]
1496+
#[derive(Clone, Debug, Eq, PartialEq)]
1497+
pub struct AppealOpened {
1498+
#[topic]
1499+
pub claim_id: u64,
1500+
pub policy_id: u32,
1501+
pub claimant: Address,
1502+
/// Voting deadline for this appeal round (ledger sequence).
1503+
pub appeal_deadline_ledger: u32,
1504+
/// Quorum basis points required for this appeal round.
1505+
pub quorum_bps: u32,
1506+
pub at_ledger: u32,
1507+
}
1508+
1509+
/// Emitted when an appeal vote round resolves (approved or rejected).
1510+
///
1511+
/// Topic layout: ["niffyinsure", "appeal_resolved", claim_id]
1512+
#[contractevent(topics = ["niffyinsure", "appeal_resolved"])]
1513+
#[derive(Clone, Debug, Eq, PartialEq)]
1514+
pub struct AppealResolved {
1515+
#[topic]
1516+
pub claim_id: u64,
1517+
pub policy_id: u32,
1518+
pub claimant: Address,
1519+
pub outcome: ClaimStatus,
1520+
pub approve_votes: u32,
1521+
pub reject_votes: u32,
1522+
pub at_ledger: u32,
1523+
}
1524+
1525+
/// Emitted when an appeal vote is cast.
1526+
///
1527+
/// Topic layout: ["niffyinsure", "appeal_vote_cast", claim_id]
1528+
#[contractevent(topics = ["niffyinsure", "appeal_vote_cast"])]
1529+
#[derive(Clone, Debug, Eq, PartialEq)]
1530+
pub struct AppealVoteCast {
1531+
#[topic]
1532+
pub claim_id: u64,
1533+
#[topic]
1534+
pub voter: Address,
1535+
pub vote: VoteOption,
1536+
pub at_ledger: u32,
1537+
}
1538+
1539+
/// Claimant-only: open an appeal on a rejected claim.
1540+
///
1541+
/// Preconditions:
1542+
/// - `claim.status == Rejected`
1543+
/// - `now <= claim.appeal_open_deadline_ledger` (within appeal window)
1544+
/// - `claim.appeals_count < MAX_APPEALS_PER_CLAIM` (only one appeal allowed)
1545+
///
1546+
/// Transitions: `Rejected → UnderAppeal`
1547+
/// Resets vote counts, sets `appeal_deadline_ledger`, requires elevated quorum
1548+
/// (`APPEAL_ELEVATED_QUORUM_BPS` or admin-configured `elevated_quorum_bps`).
1549+
///
1550+
/// A fresh voter snapshot is taken at appeal opening so new policy-holders
1551+
/// can participate in the appeal vote (and departed ones cannot).
1552+
pub fn open_appeal(env: &Env, claimant: &Address, claim_id: u64) -> Result<(), Error> {
1553+
storage::assert_claims_not_paused(env);
1554+
1555+
let mut claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
1556+
1557+
// Only the original claimant may open an appeal.
1558+
if claimant != &claim.claimant {
1559+
return Err(Error::NotEligibleVoter);
1560+
}
1561+
1562+
// Claim must be in Rejected status to appeal.
1563+
if claim.status != ClaimStatus::Rejected {
1564+
return Err(Error::ClaimAlreadyTerminal);
1565+
}
1566+
1567+
let now = env.ledger().sequence();
1568+
1569+
// Appeal window: must be called within APPEAL_OPEN_WINDOW_LEDGERS of rejection.
1570+
if now > claim.appeal_open_deadline_ledger {
1571+
return Err(Error::AppealWindowClosed);
1572+
}
1573+
1574+
// Cap: only one appeal allowed per claim.
1575+
if claim.appeals_count >= ledger::MAX_APPEALS_PER_CLAIM {
1576+
return Err(Error::AppealAlreadyUsed);
1577+
}
1578+
1579+
// Reset vote tallies for the appeal round.
1580+
claim.appeal_approve_votes = 0;
1581+
claim.appeal_reject_votes = 0;
1582+
1583+
// Set the appeal voting deadline.
1584+
claim.appeal_deadline_ledger = now
1585+
.checked_add(ledger::APPEAL_VOTE_WINDOW_LEDGERS)
1586+
.ok_or(Error::Overflow)?;
1587+
1588+
claim.appeals_count = claim.appeals_count.saturating_add(1);
1589+
1590+
// Determine quorum for this appeal round. Use the admin-configured elevated quorum
1591+
// when set; fall back to APPEAL_ELEVATED_QUORUM_BPS.
1592+
let appeal_quorum_bps = {
1593+
let configured = storage::get_elevated_quorum_bps(env);
1594+
// elevated_quorum_bps defaults to 7500; if admin hasn't changed it, we use
1595+
// our own constant to be explicit. Both values happen to be the same default.
1596+
configured.max(APPEAL_ELEVATED_QUORUM_BPS)
1597+
};
1598+
1599+
// Snapshot the appeal-round quorum so subsequent admin changes don't affect it.
1600+
storage::set_appeal_claim_quorum_bps(env, claim_id, appeal_quorum_bps);
1601+
1602+
// Take a fresh voter snapshot for the appeal round.
1603+
storage::snapshot_appeal_voters(env, claim_id);
1604+
1605+
// Transition to UnderAppeal.
1606+
let old_status = claim.status.clone();
1607+
claim.status = ClaimStatus::UnderAppeal;
1608+
push_status_transition(&mut claim.status_history, ClaimStatus::UnderAppeal, now);
1609+
1610+
// Re-open the "open claim" slot so finalization bookkeeping is consistent.
1611+
storage::set_open_claim(env, &claim.claimant, claim.policy_id, true);
1612+
1613+
storage::set_claim(env, &claim);
1614+
1615+
crate::events::emit_claim_status_changed(env, claim_id, old_status, ClaimStatus::UnderAppeal);
1616+
1617+
AppealOpened {
1618+
claim_id,
1619+
policy_id: claim.policy_id,
1620+
claimant: claimant.clone(),
1621+
appeal_deadline_ledger: claim.appeal_deadline_ledger,
1622+
quorum_bps: appeal_quorum_bps,
1623+
at_ledger: now,
1624+
}
1625+
.publish(env);
1626+
1627+
Ok(())
1628+
}
1629+
1630+
/// Cast a vote in an active appeal round.
1631+
///
1632+
/// Window check: `now <= claim.appeal_deadline_ledger`.
1633+
/// Only voters present in the appeal snapshot electorate may vote.
1634+
/// Duplicate votes are rejected.
1635+
/// If quorum is reached mid-vote, the appeal resolves immediately.
1636+
pub fn vote_on_appeal(
1637+
env: &Env,
1638+
voter: &Address,
1639+
claim_id: u64,
1640+
vote: &VoteOption,
1641+
) -> Result<ClaimStatus, Error> {
1642+
storage::assert_claims_not_paused(env);
1643+
1644+
let mut claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
1645+
1646+
if claim.status != ClaimStatus::UnderAppeal {
1647+
return Err(Error::ClaimAlreadyTerminal);
1648+
}
1649+
1650+
let now = env.ledger().sequence();
1651+
1652+
// Appeal voting window (inclusive deadline).
1653+
if !ledger::is_claim_voting_open(now, claim.appeal_deadline_ledger) {
1654+
return Err(Error::VotingWindowClosed);
1655+
}
1656+
1657+
// Voter must be in the appeal snapshot.
1658+
if !storage::has_appeal_voters(env, claim_id) {
1659+
return Err(Error::VoterSnapshotExpired);
1660+
}
1661+
let snapshot = storage::get_appeal_voters(env, claim_id);
1662+
if !snapshot.iter().any(|v| v == *voter) {
1663+
return Err(Error::NotEligibleVoter);
1664+
}
1665+
1666+
// Check delegation: delegated voters must cast through their delegate.
1667+
let resolved_target = storage::resolve_vote_delegation_target(env, voter, now)?;
1668+
if resolved_target != *voter {
1669+
return Err(Error::VoteDelegated);
1670+
}
1671+
1672+
// Duplicate appeal vote check.
1673+
if storage::get_appeal_vote(env, claim_id, voter).is_some() {
1674+
return Err(Error::DuplicateVote);
1675+
}
1676+
1677+
storage::set_appeal_vote(env, claim_id, voter, vote);
1678+
1679+
let vote_weight: u32 = if crate::governance_token::governance_token_effective_enabled(env) {
1680+
let balance = storage::get_holder_active_policy_count(env, voter) as i128;
1681+
let cap = storage::get_max_weight_cap(env);
1682+
balance.min(cap).max(1) as u32
1683+
} else {
1684+
1
1685+
};
1686+
1687+
match vote {
1688+
VoteOption::Approve => {
1689+
claim.appeal_approve_votes = claim.appeal_approve_votes.saturating_add(vote_weight)
1690+
}
1691+
VoteOption::Reject => {
1692+
claim.appeal_reject_votes = claim.appeal_reject_votes.saturating_add(vote_weight)
1693+
}
1694+
}
1695+
1696+
AppealVoteCast {
1697+
claim_id,
1698+
voter: voter.clone(),
1699+
vote: vote.clone(),
1700+
at_ledger: now,
1701+
}
1702+
.publish(env);
1703+
1704+
let eligible = snapshot.len();
1705+
let cast = claim.appeal_approve_votes + claim.appeal_reject_votes;
1706+
let quorum_bps = storage::get_appeal_claim_quorum_bps(env, claim_id);
1707+
1708+
let maybe_resolved = resolve_plurality_if_quorum_met(
1709+
claim.appeal_approve_votes,
1710+
claim.appeal_reject_votes,
1711+
cast,
1712+
eligible,
1713+
quorum_bps,
1714+
);
1715+
1716+
if let Some(raw) = maybe_resolved {
1717+
// Map base-claim status to appeal outcome statuses.
1718+
let outcome = if raw == ClaimStatus::Approved {
1719+
ClaimStatus::AppealApproved
1720+
} else {
1721+
ClaimStatus::AppealRejected
1722+
};
1723+
finalize_appeal_outcome(env, &mut claim, outcome, now);
1724+
}
1725+
1726+
let status = claim.status.clone();
1727+
storage::set_claim(env, &claim);
1728+
Ok(status)
1729+
}
1730+
1731+
/// Permissionless keeper: finalize an appeal vote after its deadline passes.
1732+
///
1733+
/// Window check: `now > claim.appeal_deadline_ledger`.
1734+
/// Uses participation quorum; if quorum not met, appeal is rejected (insurer-favored default).
1735+
pub fn finalize_appeal(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
1736+
storage::assert_claims_not_paused(env);
1737+
1738+
let mut claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
1739+
1740+
if claim.status != ClaimStatus::UnderAppeal {
1741+
return Err(Error::ClaimAlreadyTerminal);
1742+
}
1743+
1744+
let now = env.ledger().sequence();
1745+
if ledger::is_claim_voting_open(now, claim.appeal_deadline_ledger) {
1746+
return Err(Error::VotingWindowStillOpen);
1747+
}
1748+
1749+
let eligible = if storage::has_appeal_voters(env, claim_id) {
1750+
storage::get_appeal_voters(env, claim_id).len()
1751+
} else {
1752+
// Snapshot expired — use eligible_voter_count from original filing as fallback.
1753+
claim.eligible_voter_count
1754+
};
1755+
1756+
let cast = claim.appeal_approve_votes + claim.appeal_reject_votes;
1757+
let quorum_bps = storage::get_appeal_claim_quorum_bps(env, claim_id);
1758+
1759+
let outcome = if participation_quorum_met(cast, eligible, quorum_bps)
1760+
&& claim.appeal_approve_votes > claim.appeal_reject_votes
1761+
{
1762+
ClaimStatus::AppealApproved
1763+
} else {
1764+
ClaimStatus::AppealRejected
1765+
};
1766+
1767+
finalize_appeal_outcome(env, &mut claim, outcome, now);
1768+
1769+
let status = claim.status.clone();
1770+
storage::set_claim(env, &claim);
1771+
Ok(status)
1772+
}
1773+
1774+
/// Internal: apply a resolved appeal outcome to the claim record.
1775+
///
1776+
/// Sets `status`, pushes history entry, closes the open-claim slot when terminal,
1777+
/// sets `payout_deadline_ledger` on AppealApproved, and emits `AppealResolved`.
1778+
fn finalize_appeal_outcome(env: &Env, claim: &mut crate::types::Claim, outcome: ClaimStatus, now: u32) {
1779+
let old_status = claim.status.clone();
1780+
claim.status = outcome.clone();
1781+
push_status_transition(&mut claim.status_history, outcome.clone(), now);
1782+
1783+
if outcome == ClaimStatus::AppealApproved && claim.payout_deadline_ledger == 0 {
1784+
claim.payout_deadline_ledger = now.saturating_add(ledger::PAYOUT_TIMEOUT_LEDGERS);
1785+
claim.dispute_deadline_ledger = now.saturating_add(ledger::DEFAULT_DISPUTE_WINDOW_LEDGERS);
1786+
}
1787+
1788+
// Close the open-claim slot on terminal appeal outcomes.
1789+
storage::set_open_claim(env, &claim.claimant, claim.policy_id, false);
1790+
1791+
crate::events::emit_claim_status_changed(env, claim.claim_id, old_status, outcome.clone());
1792+
1793+
AppealResolved {
1794+
claim_id: claim.claim_id,
1795+
policy_id: claim.policy_id,
1796+
claimant: claim.claimant.clone(),
1797+
outcome,
1798+
approve_votes: claim.appeal_approve_votes,
1799+
reject_votes: claim.appeal_reject_votes,
1800+
at_ledger: now,
1801+
}
1802+
.publish(env);
1803+
}

contracts/niffyinsure/src/events.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,31 @@ pub fn emit_policy_transferred(
705705
}
706706
.publish(env);
707707
}
708+
709+
// ── Claim evidence updated event ──────────────────────────────────────────────
710+
711+
/// Emitted by `add_claim_evidence` when claimant replaces evidence before voting.
712+
/// topics: ("niffyinsure", "claim_evidence_updated", claim_id)
713+
#[contractevent(topics = ["niffyinsure", "claim_evidence_updated"])]
714+
#[derive(Clone, Debug, Eq, PartialEq)]
715+
pub struct ClaimEvidenceUpdated {
716+
#[topic]
717+
pub claim_id: u64,
718+
pub policy_id: u32,
719+
pub evidence_hashes: Vec<BytesN<32>>,
720+
pub at_ledger: u32,
721+
}
722+
723+
// ── Payout recipient warning event ────────────────────────────────────────────
724+
725+
/// Emitted when payout is sent to a contract address (phishing risk warning).
726+
/// topics: ("niffyinsure", "payout_recipient_warning", claim_id)
727+
#[contractevent(topics = ["niffyinsure", "payout_recipient_warning"])]
728+
#[derive(Clone, Debug, Eq, PartialEq)]
729+
pub struct PayoutRecipientWarning {
730+
#[topic]
731+
pub claim_id: u64,
732+
pub recipient: Address,
733+
pub asset: Address,
734+
pub at_ledger: u32,
735+
}

0 commit comments

Comments
 (0)