Skip to content

Commit 9c4049b

Browse files
authored
Merge pull request #244 from aji70/feat/governance-voting-duration-ledgers
feat(governance): configurable vote duration and per-claim deadlines
2 parents e274e14 + e7de977 commit 9c4049b

21 files changed

Lines changed: 416 additions & 45 deletions

File tree

backend/src/dto/policy.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export interface ClaimSummaryDto {
3333
approve_votes: number;
3434
/** Number of reject votes cast by policyholders. */
3535
reject_votes: number;
36+
/**
37+
* On-chain voting deadline ledger (inclusive); same as contract `voting_deadline_ledger`.
38+
* @example 1250000
39+
*/
40+
voting_deadline_ledger?: number;
3641
/**
3742
* Link to the full claim resource.
3843
* @example "/claims/42"
@@ -164,6 +169,7 @@ export function toClaimSummaryDto(c: Claim): ClaimSummaryDto {
164169
status: c.status,
165170
approve_votes: c.approve_votes,
166171
reject_votes: c.reject_votes,
172+
voting_deadline_ledger: c.voting_deadline_ledger,
167173
_link: `/claims/${c.claim_id}`,
168174
};
169175
}

backend/src/openapi/spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ export const openapiSpec = {
334334
status: { type: "string", enum: ["Processing", "Approved", "Rejected"] },
335335
approve_votes: { type: "integer", example: 3 },
336336
reject_votes: { type: "integer", example: 1 },
337+
voting_deadline_ledger: {
338+
type: "integer",
339+
description: "Last inclusive ledger for voting; frozen at filing.",
340+
example: 1_250_000,
341+
},
337342
_link: { type: "string", example: "/claims/42" },
338343
},
339344
},

backend/src/types/policy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,7 @@ export interface Claim {
4646
status: ClaimStatus;
4747
approve_votes: number;
4848
reject_votes: number;
49+
/** Last ledger inclusive for voting; frozen at claim filing (matches contract). */
50+
voting_deadline_ledger?: number;
51+
filed_at_ledger?: number;
4952
}

contracts/niffyinsure/src/claim.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ pub fn file_claim(
205205

206206
crate::validate::check_claim_fields(env, amount, policy.coverage, details, image_urls)?;
207207

208+
let duration = storage::get_voting_duration_ledgers(env);
209+
let voting_deadline_ledger = now
210+
.checked_add(duration)
211+
.ok_or(Error::Overflow)?;
212+
208213
let claim_id = storage::next_claim_id(env);
209214
let claim = Claim {
210215
claim_id,
@@ -215,7 +220,7 @@ pub fn file_claim(
215220
details: details.clone(),
216221
image_urls: image_urls.clone(),
217222
status: ClaimStatus::Processing,
218-
voting_deadline_ledger: now.saturating_add(ledger::VOTE_WINDOW_LEDGERS),
223+
voting_deadline_ledger,
219224
approve_votes: 0,
220225
reject_votes: 0,
221226
filed_at: now,
@@ -244,7 +249,7 @@ pub fn file_claim(
244249

245250
/// Cast a vote on a pending claim.
246251
///
247-
/// Window check: `now < filed_at + VOTE_WINDOW_LEDGERS` (via `ledger::is_vote_open`).
252+
/// Window check: `now <= claim.voting_deadline_ledger` (inclusive; see `ledger::is_claim_voting_open`).
248253
/// Returns the updated `ClaimStatus` after tallying.
249254
pub fn vote_on_claim(
250255
env: &Env,
@@ -261,9 +266,9 @@ pub fn vote_on_claim(
261266
return Err(Error::ClaimAlreadyTerminal);
262267
}
263268

264-
// Voting window check.
269+
// Voting window: use per-claim deadline frozen at filing (not current admin config).
265270
let now = env.ledger().sequence();
266-
if !ledger::is_vote_open(now, claim.filed_at, ledger::VOTE_WINDOW_LEDGERS) {
271+
if !ledger::is_claim_voting_open(now, claim.voting_deadline_ledger) {
267272
return Err(Error::VotingWindowClosed);
268273
}
269274

@@ -319,7 +324,7 @@ pub fn vote_on_claim(
319324

320325
/// Finalize a claim after the voting deadline has passed.
321326
///
322-
/// Window check: `now >= filed_at + VOTE_WINDOW_LEDGERS` (via `ledger::is_vote_deadline_passed`).
327+
/// Window check: `now > claim.voting_deadline_ledger` (see `ledger::is_claim_past_voting_deadline`).
323328
/// Plurality wins; tie resolves to Rejected.
324329
pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
325330
// Check pause: finalization is blocked if claims_paused is true
@@ -332,7 +337,7 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
332337
}
333338

334339
let now = env.ledger().sequence();
335-
if !ledger::is_vote_deadline_passed(now, claim.filed_at, ledger::VOTE_WINDOW_LEDGERS) {
340+
if !ledger::is_claim_past_voting_deadline(now, claim.voting_deadline_ledger) {
336341
return Err(Error::VotingWindowStillOpen);
337342
}
338343

contracts/niffyinsure/src/ledger.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,24 @@ pub const LEDGERS_PER_WEEK: u32 = 120_960;
7373
pub const POLICY_DURATION_LEDGERS: u32 = 30 * LEDGERS_PER_DAY; // 518_400
7474

7575
/// Voting window: ~7 days from claim filing.
76-
/// Votes are accepted while `now < filed_at + VOTE_WINDOW_LEDGERS`.
76+
/// Default value for [`crate::storage::get_voting_duration_ledgers`] and historical
77+
/// behaviour: new claims use `voting_deadline_ledger = filed_at + duration` where
78+
/// `duration` defaults to this constant until an admin sets another value in bounds.
7779
pub const VOTE_WINDOW_LEDGERS: u32 = 7 * LEDGERS_PER_DAY; // 120_960
7880

81+
/// Minimum allowed `voting_duration_ledgers` (admin config).
82+
///
83+
/// **17_280 ledgers (~1 day at nominal 5 s/ledger)** — guarantees at least one full
84+
/// nominal day so voters in all timezones have a reasonable window; shorter windows
85+
/// risk disenfranchisement and operational mistakes.
86+
pub const MIN_VOTING_DURATION_LEDGERS: u32 = LEDGERS_PER_DAY;
87+
88+
/// Maximum allowed `voting_duration_ledgers` (admin config).
89+
///
90+
/// **967_680 ledgers (~8 weeks)** — caps how long approved-but-unpaid claim flows and
91+
/// voter duty can stretch; longer windows require a contract upgrade and broader review.
92+
pub const MAX_VOTING_DURATION_LEDGERS: u32 = 8 * LEDGERS_PER_WEEK;
93+
7994
/// Renewal window: holder may renew starting this many ledgers before expiry.
8095
/// Renewal is accepted while `end - RENEWAL_WINDOW_LEDGERS <= now < end`.
8196
pub const RENEWAL_WINDOW_LEDGERS: u32 = 3 * LEDGERS_PER_DAY; // 51_840
@@ -144,7 +159,11 @@ pub fn is_in_renewal_window(now: u32, end: u32, window: u32) -> bool {
144159
///
145160
/// Votes are accepted while `now < filed_at + vote_window`.
146161
/// At `now == filed_at + vote_window` the window is closed.
162+
///
163+
/// **Note:** On-chain claim voting uses [`is_claim_voting_open`] with the stored
164+
/// `voting_deadline_ledger` instead. This helper remains for unit tests and docs.
147165
#[inline]
166+
#[allow(dead_code)]
148167
pub fn is_vote_open(now: u32, filed_at: u32, vote_window: u32) -> bool {
149168
let deadline = filed_at.saturating_add(vote_window);
150169
now < deadline
@@ -154,10 +173,43 @@ pub fn is_vote_open(now: u32, filed_at: u32, vote_window: u32) -> bool {
154173
///
155174
/// `finalize_claim` may be called once `now >= filed_at + vote_window`.
156175
#[inline]
176+
#[allow(dead_code)]
157177
pub fn is_vote_deadline_passed(now: u32, filed_at: u32, vote_window: u32) -> bool {
158178
!is_vote_open(now, filed_at, vote_window)
159179
}
160180

181+
/// Validates admin-supplied voting duration before it is written to instance storage.
182+
#[inline]
183+
pub fn validate_voting_duration_ledgers(v: u32) -> Result<(), crate::validate::Error> {
184+
if v < MIN_VOTING_DURATION_LEDGERS || v > MAX_VOTING_DURATION_LEDGERS {
185+
return Err(crate::validate::Error::VotingDurationOutOfBounds);
186+
}
187+
Ok(())
188+
}
189+
190+
// ── Per-claim voting deadline (stored on `Claim::voting_deadline_ledger`) ─────
191+
192+
/// Returns `true` while votes are accepted for this claim.
193+
///
194+
/// **Inclusive deadline:** `voting_deadline_ledger` is the **last** ledger in which a
195+
/// vote may be included (matches product requirement: vote at deadline ledger succeeds;
196+
/// one ledger later reverts).
197+
///
198+
/// This differs from [`is_vote_open`], which uses a half-open window on
199+
/// `filed_at + vote_window` (exclusive end). Always use this helper (and the stored
200+
/// `voting_deadline_ledger`) for claim voting — never recompute the deadline from the
201+
/// current global duration config.
202+
#[inline]
203+
pub fn is_claim_voting_open(now: u32, voting_deadline_ledger: u32) -> bool {
204+
now <= voting_deadline_ledger
205+
}
206+
207+
/// Returns `true` when [`finalize_claim`] may run (voting period fully ended).
208+
#[inline]
209+
pub fn is_claim_past_voting_deadline(now: u32, voting_deadline_ledger: u32) -> bool {
210+
now > voting_deadline_ledger
211+
}
212+
161213
/// Returns `true` if the rate-limit window has elapsed since `last_filed_at`.
162214
///
163215
/// A new claim may be filed once `now >= last_filed_at + rate_limit_window`.
@@ -352,4 +404,30 @@ mod tests {
352404
fn approx_secs_remaining_zero_when_expired() {
353405
assert_eq!(approx_secs_remaining(100, 100), 0);
354406
}
407+
408+
// ── is_claim_voting_open (inclusive deadline) ─────────────────────────────
409+
410+
#[test]
411+
fn claim_vote_open_at_deadline_ledger() {
412+
assert!(is_claim_voting_open(200, 200));
413+
}
414+
415+
#[test]
416+
fn claim_vote_closed_one_ledger_after_deadline() {
417+
assert!(!is_claim_voting_open(201, 200));
418+
}
419+
420+
#[test]
421+
fn claim_past_deadline_strictly_after_deadline() {
422+
assert!(!is_claim_past_voting_deadline(200, 200));
423+
assert!(is_claim_past_voting_deadline(201, 200));
424+
}
425+
426+
#[test]
427+
fn validate_voting_duration_bounds() {
428+
assert!(validate_voting_duration_ledgers(MIN_VOTING_DURATION_LEDGERS).is_ok());
429+
assert!(validate_voting_duration_ledgers(MAX_VOTING_DURATION_LEDGERS).is_ok());
430+
assert!(validate_voting_duration_ledgers(MIN_VOTING_DURATION_LEDGERS - 1).is_err());
431+
assert!(validate_voting_duration_ledgers(MAX_VOTING_DURATION_LEDGERS + 1).is_err());
432+
}
355433
}

contracts/niffyinsure/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ struct AllowedAssetUpdated {
4242
pub allowed: bool,
4343
}
4444

45+
#[contractevent(topics = ["niffyinsure", "voting_duration_updated"])]
46+
#[derive(Clone, Debug, Eq, PartialEq)]
47+
struct VotingDurationUpdated {
48+
pub old_ledgers: u32,
49+
pub new_ledgers: u32,
50+
}
51+
4552
#[contractevent(topics = ["niffyinsure", "pause_toggled"])]
4653
#[derive(Clone, Debug, Eq, PartialEq)]
4754
struct PauseToggled {
@@ -67,6 +74,7 @@ impl NiffyInsure {
6774
storage::set_token(&env, &token);
6875
storage::set_multiplier_table(&env, &premium::default_multiplier_table(&env));
6976
storage::set_allowed_asset(&env, &token, true);
77+
storage::set_voting_duration_ledgers(&env, ledger::VOTE_WINDOW_LEDGERS);
7078
Ok(())
7179
}
7280

@@ -137,6 +145,7 @@ impl NiffyInsure {
137145
40 => validate::Error::VotingWindowStillOpen,
138146
41 => validate::Error::NotEligibleVoter,
139147
42 => validate::Error::RateLimitExceeded,
148+
49 => validate::Error::VotingDurationOutOfBounds,
140149
_ => validate::Error::ClaimNotApproved,
141150
};
142151
policy::map_quote_error(&env, err)
@@ -235,6 +244,7 @@ impl NiffyInsure {
235244
amount: c.amount,
236245
status: c.status,
237246
filed_at: c.filed_at,
247+
voting_deadline_ledger: c.voting_deadline_ledger,
238248
});
239249
}
240250
id = id.saturating_add(1);

contracts/niffyinsure/src/policy.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ pub fn map_quote_error(env: &Env, err: Error) -> QuoteFailure {
185185
Error::ClaimNotRejected => "claim is not in rejected status; cannot open appeal",
186186
Error::AppealNotOpen => "no appeal is currently open",
187187
Error::AppealWindowStillOpen => "appeal voting window is still open; cannot finalize yet",
188+
Error::VotingDurationOutOfBounds => {
189+
"voting duration ledgers outside allowed min/max; see contract docs"
190+
}
188191
};
189192
QuoteFailure {
190193
code: err as u32,

contracts/niffyinsure/src/storage.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use soroban_sdk::{contracttype, Address, Env, Vec};
22

3+
use crate::ledger;
34
use crate::types::{Claim, MultiplierTable, Policy, VoteOption};
45

56
// ── TTL constants ─────────────────────────────────────────────────────────────
@@ -129,6 +130,23 @@ pub fn get_treasury(env: &Env) -> Address {
129130
.unwrap_or_else(|| env.current_contract_address())
130131
}
131132

133+
// ── Governance: claim voting duration (instance) ─────────────────────────────
134+
135+
pub fn set_voting_duration_ledgers(env: &Env, ledgers: u32) {
136+
env.storage()
137+
.instance()
138+
.set(&DataKey::VoteDurLedgers, &ledgers);
139+
}
140+
141+
/// Configured duration added at each `file_claim` to compute `voting_deadline_ledger`.
142+
/// Defaults to [`ledger::VOTE_WINDOW_LEDGERS`] when unset (pre-migration deployments).
143+
pub fn get_voting_duration_ledgers(env: &Env) -> u32 {
144+
env.storage()
145+
.instance()
146+
.get(&DataKey::VoteDurLedgers)
147+
.unwrap_or(ledger::VOTE_WINDOW_LEDGERS)
148+
}
149+
132150
// ── External calculator address ───────────────────────────────────────────────
133151

134152
pub fn set_calc_address(env: &Env, addr: &Address) {

contracts/niffyinsure/src/types.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ pub const SAFETY_SCORE_MAX: u32 = 100;
3535
// See: https://developers.stellar.org/docs/learn/fundamentals/stellar-consensus-protocol
3636
pub use crate::ledger::{
3737
APPEAL_OPEN_WINDOW_LEDGERS, APPEAL_VOTE_WINDOW_LEDGERS, LEDGERS_PER_DAY, LEDGERS_PER_HOUR,
38-
LEDGERS_PER_MIN, LEDGERS_PER_WEEK, MAX_APPEALS_PER_CLAIM, POLICY_DURATION_LEDGERS,
39-
QUOTE_TTL_LEDGERS, RATE_LIMIT_WINDOW_LEDGERS, RENEWAL_WINDOW_LEDGERS, SECS_PER_LEDGER,
40-
VOTE_WINDOW_LEDGERS,
38+
LEDGERS_PER_MIN, LEDGERS_PER_WEEK, MAX_APPEALS_PER_CLAIM, MAX_VOTING_DURATION_LEDGERS,
39+
MIN_VOTING_DURATION_LEDGERS, POLICY_DURATION_LEDGERS, QUOTE_TTL_LEDGERS,
40+
RATE_LIMIT_WINDOW_LEDGERS, RENEWAL_WINDOW_LEDGERS, SECS_PER_LEDGER, VOTE_WINDOW_LEDGERS,
4141
};
4242

4343
// ── Strike / rejection constants ──────────────────────────────────────────────
@@ -219,6 +219,8 @@ pub struct ClaimSummary {
219219
pub amount: i128,
220220
pub status: ClaimStatus,
221221
pub filed_at: u32,
222+
/// Same field as `Claim::voting_deadline_ledger` — authoritative for UI / indexers.
223+
pub voting_deadline_ledger: u32,
222224
}
223225

224226
// ── Premium engine structs ────────────────────────────────────────────────────
@@ -300,8 +302,10 @@ pub struct Policy {
300302

301303
/// On-chain claim record.
302304
///
303-
/// `filed_at` is the ledger sequence at which the claim was filed. It anchors
304-
/// the voting deadline: votes are accepted while `now < filed_at + VOTE_WINDOW_LEDGERS`.
305+
/// `filed_at` is the ledger sequence at which the claim was filed.
306+
/// `voting_deadline_ledger` is set at filing as `filed_at + voting_duration_ledgers`
307+
/// (using the instance config **at filing time**). Votes are accepted on ledgers
308+
/// `now <= voting_deadline_ledger` (inclusive); finalization requires `now > voting_deadline_ledger`.
305309
#[contracttype]
306310
#[derive(Clone)]
307311
pub struct Claim {

contracts/niffyinsure/src/validate.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ pub enum Error {
6565
AppealNotOpen = 47,
6666
/// Appeal voting window is still open; cannot finalize appeal yet.
6767
AppealWindowStillOpen = 48,
68+
/// Admin `set_voting_duration_ledgers` value outside allowed [min, max] range.
69+
VotingDurationOutOfBounds = 49,
6870
}
6971

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

0 commit comments

Comments
 (0)