Skip to content

Commit 714cb65

Browse files
authored
Merge pull request #268 from Mimah97/feature/quorum-bps-governance
feat(niffyinsure): configurable quorum_bps with per-claim snapshot
2 parents 62e1840 + ecacfe1 commit 714cb65

18 files changed

Lines changed: 440 additions & 54 deletions

File tree

contracts/niffyinsure/src/claim.rs

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,50 @@ fn push_status_transition(
8888
}
8989
}
9090

91+
// ── Participation quorum (see also `types` lifecycle docs) ───────────────────
92+
//
93+
// Let `E` = eligible voters (snapshot length at `file_claim`), `C` = cast ballots =
94+
// `approve_votes + reject_votes`, `Q` = quorum basis points **for this claim**
95+
// (instance `quorum_bps` copied into persistent `ClaimQuorumBps(claim_id)` at filing).
96+
// Admin changes to instance `quorum_bps` do **not** alter `Q` for claims already in
97+
// `Processing`.
98+
//
99+
// Required minimum cast votes:
100+
// R = ceil(E * Q / 10_000) → R = (E * Q + 9_999) / 10_000 (u32; E = 0 ⇒ R = 0)
101+
//
102+
// **Quorum met** iff `C >= R`. If met, outcome is **plurality**: Approved when
103+
// `approve_votes > reject_votes`, else Rejected (insurer wins ties).
104+
// If the voting deadline passes with `C < R`, the claim is **Rejected** (no quorum).
105+
fn required_cast_for_quorum(eligible: u32, quorum_bps: u32) -> u32 {
106+
if eligible == 0 {
107+
return 0;
108+
}
109+
let numer = (eligible as u64).saturating_mul(quorum_bps as u64);
110+
numer.div_ceil(10_000) as u32
111+
}
112+
113+
fn participation_quorum_met(cast_votes: u32, eligible: u32, quorum_bps: u32) -> bool {
114+
cast_votes >= required_cast_for_quorum(eligible, quorum_bps)
115+
}
116+
117+
/// If participation quorum is satisfied, returns Some(Approved|Rejected) by plurality.
118+
fn resolve_plurality_if_quorum_met(
119+
approve_votes: u32,
120+
reject_votes: u32,
121+
cast_votes: u32,
122+
eligible: u32,
123+
quorum_bps: u32,
124+
) -> Option<ClaimStatus> {
125+
if !participation_quorum_met(cast_votes, eligible, quorum_bps) {
126+
return None;
127+
}
128+
if approve_votes > reject_votes {
129+
Some(ClaimStatus::Approved)
130+
} else {
131+
Some(ClaimStatus::Rejected)
132+
}
133+
}
134+
91135
// ── Events ────────────────────────────────────────────────────────────────────
92136

93137
#[contractevent(topics = ["niffyinsure", "claim_filed"])]
@@ -259,6 +303,7 @@ pub fn file_claim(
259303
storage::set_claim(env, &claim);
260304
storage::set_open_claim(env, holder, policy_id, true);
261305
storage::snapshot_claim_voters(env, claim_id);
306+
storage::set_claim_quorum_bps(env, claim_id, storage::get_quorum_bps(env));
262307
storage::set_last_claim_ledger(env, holder, now);
263308

264309
let mut evidence_hashes: Vec<BytesN<32>> = Vec::new(env);
@@ -331,14 +376,21 @@ pub fn vote_on_claim(
331376
VoteOption::Reject => claim.reject_votes += 1,
332377
}
333378

334-
// Auto-finalize on majority.
335-
let total = snapshot.len();
336-
let majority = total / 2 + 1;
337-
if claim.approve_votes >= majority {
338-
claim.status = ClaimStatus::Approved;
339-
} else if claim.reject_votes >= majority {
340-
claim.status = ClaimStatus::Rejected;
341-
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
379+
let eligible = snapshot.len() as u32;
380+
let cast = claim.approve_votes + claim.reject_votes;
381+
let quorum_bps = storage::get_claim_quorum_bps(env, claim_id);
382+
if let Some(res) = resolve_plurality_if_quorum_met(
383+
claim.approve_votes,
384+
claim.reject_votes,
385+
cast,
386+
eligible,
387+
quorum_bps,
388+
) {
389+
let rejected = res == ClaimStatus::Rejected;
390+
claim.status = res;
391+
if rejected {
392+
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
393+
}
342394
}
343395

344396
if claim.status != status_before {
@@ -380,7 +432,8 @@ pub fn refresh_snapshot(env: &Env, claim_id: u64) -> Result<(), Error> {
380432
/// Finalize a claim after the voting deadline has passed.
381433
///
382434
/// Window check: `now > claim.voting_deadline_ledger` (see `ledger::is_claim_past_voting_deadline`).
383-
/// Plurality wins; tie resolves to Rejected.
435+
/// Uses the **participation quorum** and per-claim `quorum_bps` snapshot (see module helpers).
436+
/// If quorum is met, plurality decides; if not, **Rejected** (no quorum).
384437
pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
385438
// Check pause: finalization is blocked if claims_paused is true
386439
storage::assert_claims_not_paused(env);
@@ -398,10 +451,20 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
398451

399452
let status_before = claim.status.clone();
400453

401-
if claim.approve_votes > claim.reject_votes {
402-
claim.status = ClaimStatus::Approved;
454+
let voters = storage::get_claim_voters(env, claim_id);
455+
let eligible = voters.len() as u32;
456+
let cast = claim.approve_votes + claim.reject_votes;
457+
let quorum_bps = storage::get_claim_quorum_bps(env, claim_id);
458+
459+
if participation_quorum_met(cast, eligible, quorum_bps) {
460+
if claim.approve_votes > claim.reject_votes {
461+
claim.status = ClaimStatus::Approved;
462+
} else {
463+
claim.status = ClaimStatus::Rejected;
464+
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
465+
}
403466
} else {
404-
// Tie or reject plurality → Rejected (insurer wins tie).
467+
// Below minimum participation — no quorum (insurer-favored default).
405468
claim.status = ClaimStatus::Rejected;
406469
claim.appeal_open_deadline_ledger = now.saturating_add(ledger::APPEAL_OPEN_WINDOW_LEDGERS);
407470
}

contracts/niffyinsure/src/events.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
//! ```
6363
//! - `allowed`: 1 = added to allowlist, 0 = removed
6464
//!
65+
//! ### quorum_updated — (contract `niffyinsure` topic namespace)
66+
//! On-chain topics: `["niffyinsure", "quorum_updated"]` with payload `{ old_bps, new_bps }`.
67+
//! Emitted by `admin_set_quorum_bps`. Does not alter `quorum_bps` already snapshotted on open claims.
68+
//!
6569
//! ### adm_prop — AdminProposedData
6670
//! topics: ("niffyins", "adm_prop", old_admin: Address, new_admin: Address)
6771
//! ```json

contracts/niffyinsure/src/lib.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ struct VotingDurationUpdated {
5151
pub new_ledgers: u32,
5252
}
5353

54+
#[contractevent(topics = ["niffyinsure", "quorum_updated"])]
55+
#[derive(Clone, Debug, Eq, PartialEq)]
56+
struct QuorumUpdated {
57+
pub old_bps: u32,
58+
pub new_bps: u32,
59+
}
60+
5461
#[contractevent(topics = ["niffyinsure", "pause_toggled"])]
5562
#[derive(Clone, Debug, Eq, PartialEq)]
5663
struct PauseToggled {
@@ -77,6 +84,7 @@ impl NiffyInsure {
7784
storage::set_multiplier_table(&env, &premium::default_multiplier_table(&env));
7885
storage::set_allowed_asset(&env, &token, true);
7986
storage::set_voting_duration_ledgers(&env, ledger::VOTE_WINDOW_LEDGERS);
87+
storage::set_quorum_bps(&env, types::DEFAULT_QUORUM_BPS);
8088
Ok(())
8189
}
8290

@@ -233,6 +241,31 @@ impl NiffyInsure {
233241
Ok(())
234242
}
235243

244+
/// Participation quorum in basis points (1–10_000). Applies to **new** claims only;
245+
/// each claim stores a snapshot at `file_claim` so `Processing` claims keep their `quorum_bps`.
246+
pub fn get_quorum_bps(env: Env) -> u32 {
247+
storage::get_quorum_bps(&env)
248+
}
249+
250+
/// Basis points snapshot for this claim (immutable after filing).
251+
pub fn get_claim_quorum_bps(env: Env, claim_id: u64) -> u32 {
252+
storage::get_claim_quorum_bps(&env, claim_id)
253+
}
254+
255+
pub fn admin_set_quorum_bps(env: Env, quorum_bps: u32) -> Result<(), validate::Error> {
256+
let admin = storage::get_admin(&env);
257+
admin.require_auth();
258+
validate::validate_quorum_bps(quorum_bps)?;
259+
let old = storage::get_quorum_bps(&env);
260+
storage::set_quorum_bps(&env, quorum_bps);
261+
QuorumUpdated {
262+
old_bps: old,
263+
new_bps: quorum_bps,
264+
}
265+
.publish(&env);
266+
Ok(())
267+
}
268+
236269
// ── Grace period ──────────────────────────────────────────────────────────
237270

238271
/// Admin-only: set the grace period (in ledgers) after nominal expiry during

contracts/niffyinsure/src/storage.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,12 @@ pub enum DataKey {
7676
AppealVote(u64, Address),
7777
/// Configurable voting window in ledgers (set by admin via set_voting_duration_ledgers).
7878
VoteDurLedgers,
79+
/// Participation quorum in basis points (1–10_000). New claims snapshot this at filing.
80+
QuorumBps,
7981
/// Configurable grace period in ledgers after nominal expiry for late renewals.
8082
GracePeriodLedgers,
83+
/// Per-claim snapshot of `QuorumBps` at `file_claim` time (immutable for that claim).
84+
ClaimQuorumBps(u64),
8185
}
8286

8387
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -173,6 +177,38 @@ pub fn get_voting_duration_ledgers(env: &Env) -> u32 {
173177
.unwrap_or(ledger::VOTE_WINDOW_LEDGERS)
174178
}
175179

180+
// ── Claim voting quorum (instance + per-claim snapshot) ───────────────────────
181+
182+
pub fn set_quorum_bps(env: &Env, bps: u32) {
183+
env.storage().instance().set(&DataKey::QuorumBps, &bps);
184+
}
185+
186+
/// Current instance quorum (basis points). Defaults to [`crate::types::DEFAULT_QUORUM_BPS`].
187+
pub fn get_quorum_bps(env: &Env) -> u32 {
188+
env.storage()
189+
.instance()
190+
.get(&DataKey::QuorumBps)
191+
.unwrap_or(crate::types::DEFAULT_QUORUM_BPS)
192+
}
193+
194+
pub fn set_claim_quorum_bps(env: &Env, claim_id: u64, bps: u32) {
195+
let key = DataKey::ClaimQuorumBps(claim_id);
196+
env.storage().persistent().set(&key, &bps);
197+
env.storage()
198+
.persistent()
199+
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
200+
}
201+
202+
/// Quorum basis points frozen for this claim at filing. Missing key ⇒ legacy claim:
203+
/// use [`crate::types::DEFAULT_QUORUM_BPS`] so admin quorum changes never retroactively
204+
/// alter `Processing` claims that predate per-claim snapshots.
205+
pub fn get_claim_quorum_bps(env: &Env, claim_id: u64) -> u32 {
206+
env.storage()
207+
.persistent()
208+
.get(&DataKey::ClaimQuorumBps(claim_id))
209+
.unwrap_or(crate::types::DEFAULT_QUORUM_BPS)
210+
}
211+
176212
// ── Grace period (instance) ───────────────────────────────────────────────────
177213

178214
pub fn set_grace_period_ledgers(env: &Env, ledgers: u32) {

contracts/niffyinsure/src/types.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ pub use crate::ledger::{
5858
/// can be reversed by a successful appeal that decrements strikes back below it.
5959
pub const STRIKE_DEACTIVATION_THRESHOLD: u32 = 3;
6060

61+
// ── Claim voting quorum (basis points) ────────────────────────────────────────
62+
63+
/// Default participation quorum when instance `QuorumBps` is unset, and fallback for
64+
/// claims filed before per-claim quorum snapshots existed.
65+
pub const DEFAULT_QUORUM_BPS: u32 = 5000;
66+
67+
/// Admin `quorum_bps` must satisfy `QUORUM_BPS_MIN <= quorum_bps <= QUORUM_BPS_MAX`.
68+
pub const QUORUM_BPS_MIN: u32 = 1;
69+
pub const QUORUM_BPS_MAX: u32 = 10_000;
70+
71+
/// One full turn-out / 100% weight in bps (used in the quorum formula below).
72+
pub const QUORUM_BPS_DENOMINATOR: u32 = 10_000;
73+
6174
// ── Enums ─────────────────────────────────────────────────────────────────────
6275

6376
#[contracttype]
@@ -95,8 +108,8 @@ pub enum CoverageTier {
95108
/// Claim lifecycle state machine.
96109
///
97110
/// Base-flow transitions:
98-
/// Processing → Approved (majority approve vote or deadline plurality)
99-
/// Processing → Rejected (majority reject vote or deadline plurality/tie)
111+
/// Processing → Approved (participation quorum met + more approve than reject votes cast)
112+
/// Processing → Rejected (participation quorum met + reject wins or tie; or deadline with no quorum)
100113
/// Approved → Paid (admin calls process_claim)
101114
///
102115
/// Appeal-flow transitions (requires Rejected status + open appeal window):

contracts/niffyinsure/src/validate.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ pub enum Error {
6262
VoterSnapshotExpired = 51,
6363
}
6464

65+
pub fn validate_quorum_bps(bps: u32) -> Result<(), Error> {
66+
use crate::types::{QUORUM_BPS_MAX, QUORUM_BPS_MIN};
67+
if !(QUORUM_BPS_MIN..=QUORUM_BPS_MAX).contains(&bps) {
68+
// Reuse bounded-config error code (Soroban `contracterror` caps variant count).
69+
return Err(Error::VotingDurationOutOfBounds);
70+
}
71+
Ok(())
72+
}
73+
6574
pub fn check_policy(policy: &Policy) -> Result<(), Error> {
6675
if policy.coverage <= 0 {
6776
return Err(Error::ZeroCoverage);

contracts/niffyinsure/tests/claim_status_history.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,14 @@ fn status_history_finalize_reject_sequence() {
156156
&None,
157157
);
158158

159+
// 100% participation required so a 1–1 split does not auto-finalize before the deadline.
160+
client.admin_set_quorum_bps(&10_000u32);
161+
159162
let details = String::from_str(&env, "reject path");
160163
let ev = common::empty_evidence(&env);
161164
let claim_id = client.file_claim(&holder, &policy.policy_id, &50_000, &details, &ev);
162165

163-
// Split vote — no majority until deadline
166+
// Split vote — quorum not met until deadline
164167
client.vote_on_claim(&voter1, &claim_id, &VoteOption::Approve);
165168
client.vote_on_claim(&voter2, &claim_id, &VoteOption::Reject);
166169

contracts/niffyinsure/tests/e2e_workflow.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,14 @@ fn e2e_finalize_after_deadline() {
188188
);
189189
let policy_id = policy.policy_id;
190190

191+
// Require all eligible voters to cast before quorum counts (so one ballot stays Processing).
192+
client.admin_set_quorum_bps(&10_000u32);
193+
191194
let details = String::from_str(&env, "Claim for review");
192195
let ev = common::empty_evidence(&env);
193196
let claim_id = client.file_claim(&holder, &policy_id, &100_000, &details, &ev);
194197

195-
// Vote once (not enough for majority)
198+
// Vote once — participation quorum not satisfied yet
196199
client.vote_on_claim(&voter1, &claim_id, &VoteOption::Approve);
197200

198201
let claim = client.get_claim(&claim_id);
@@ -205,9 +208,9 @@ fn e2e_finalize_after_deadline() {
205208
// Finalize after deadline
206209
client.finalize_claim(&claim_id);
207210

208-
// Verify claim is Rejected (tie/partial = reject)
211+
// Below required participation at deadline → no quorum → Rejected
209212
let claim = client.get_claim(&claim_id);
210-
assert!(claim.status == ClaimStatus::Rejected || claim.status == ClaimStatus::Approved);
213+
assert_eq!(claim.status, ClaimStatus::Rejected);
211214
}
212215

213216
// ── Pause Behavior Tests ───────────────────────────────────────────────────────

0 commit comments

Comments
 (0)