Skip to content

Commit adf0f77

Browse files
committed
feat(claim-voting): implement DAO voting mechanism
- Snapshot voter eligibility at claim-filing time (DataKey::ClaimVoters) Fairness rationale documented inline: holders who terminate mid-vote retain their ballot; late joiners cannot influence existing claims. - Votes are immutable after first cast (DataKey::Vote written once). Prevents last-minute flip attacks; tally reconciliation is O(1). - Running approve_votes / reject_votes updated transactionally inside vote_on_claim; no O(n) scan at finalization. - Auto-finalization on simple majority inside vote_on_claim. finalize_claim() settles by plurality after vote_deadline; tie Rejected. - Events: ClaimFiled and VoteLogged carry enough data for the Next.js claim detail timeline. ClaimSettled emitted on state transition. - ContractError enum with typed error codes; require_auth() on claimant and voter prevents address spoofing. - Pause guard on file_claim and vote_on_claim via DataKey::Paused. - test_seed_policy / test_remove_voter helpers gated behind testutils feature. - 16 integration tests covering: non-voter rejection, double-vote, vote-flip, majority approve/reject, finalize after deadline, tie, snapshot isolation, mid-vote termination, adversarial bloat, events.
1 parent 731c6f4 commit adf0f77

8 files changed

Lines changed: 759 additions & 26 deletions

File tree

contracts/niffyinsure/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ publish = false
88
[lib]
99
crate-type = ["cdylib", "rlib"]
1010

11+
[features]
12+
# Enables test-only contract entrypoints (test_seed_policy, test_remove_voter).
13+
# Never enable in production WASM builds.
14+
testutils = ["soroban-sdk/testutils"]
15+
1116
[dependencies]
1217
soroban-sdk = { version = "=23.5.3", features = [] }
1318

1419
[dev-dependencies]
1520
soroban-sdk = { version = "=23.5.3", features = ["testutils"] }
16-

contracts/niffyinsure/src/claim.rs

Lines changed: 290 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,290 @@
1-
// Claim lifecycle and DAO voting will be implemented here.
2-
//
3-
// Planned public functions:
4-
// file_claim(env, policy_id, amount, details, image_urls)
5-
// vote_on_claim(env, voter, claim_id, vote)
1+
/// Claim lifecycle and DAO voting.
2+
///
3+
/// # Voter eligibility: snapshot model
4+
///
5+
/// At `file_claim` time the contract captures the live `Voters` Vec into
6+
/// `DataKey::ClaimVoters(claim_id)`. Only addresses in that snapshot may vote
7+
/// on the claim. This means:
8+
///
9+
/// - A holder who terminates their policy *after* filing retains their vote
10+
/// right — they were a member when the loss event occurred.
11+
/// - A holder who joins *after* filing cannot vote — they had no stake at the
12+
/// time of the event.
13+
/// - **UI copy alignment**: "Your vote stands even if your policy lapses before
14+
/// the voting deadline."
15+
///
16+
/// # Vote immutability
17+
///
18+
/// Votes are immutable after first cast (`DataKey::Vote(claim_id, voter)` is
19+
/// written once and never overwritten). This prevents last-minute flip attacks
20+
/// and keeps tally reconciliation trivial.
21+
///
22+
/// # Tally consistency
23+
///
24+
/// `approve_votes` / `reject_votes` on the `Claim` struct are incremented
25+
/// immediately after the per-voter record is written. Soroban contracts are
26+
/// single-threaded so there is no race; the counters always equal the count of
27+
/// `Vote(claim_id, *)` entries for each option. Finalization is O(1).
28+
///
29+
/// # Pause interaction
30+
///
31+
/// Both `file_claim` and `vote_on_claim` panic with `ContractError::Paused`
32+
/// when the contract is paused. Existing votes and tallies are unaffected.
33+
use soroban_sdk::{contracttype, panic_with_error, symbol_short, Address, Env, String, Vec};
34+
35+
use crate::{
36+
storage::{
37+
self, get_claim_voters, is_eligible_voter, next_claim_id, snapshot_voters_for_claim,
38+
},
39+
types::{Claim, ClaimStatus, VoteOption, VOTE_WINDOW_LEDGERS},
40+
validate::{check_claim_fields, check_claim_open},
41+
};
42+
43+
// ── Contract-level error codes ────────────────────────────────────────────────
44+
45+
#[contracttype]
46+
#[derive(Copy, Clone, Debug, PartialEq)]
47+
#[repr(u32)]
48+
pub enum ContractError {
49+
/// Contract is administratively paused.
50+
Paused = 1,
51+
/// Caller is not the policy holder.
52+
NotPolicyHolder = 2,
53+
/// Policy does not exist or is not active.
54+
PolicyNotActive = 3,
55+
/// Claim amount is zero or exceeds coverage.
56+
InvalidClaimAmount = 4,
57+
/// Claim details or URL fields violate size limits.
58+
InvalidClaimFields = 5,
59+
/// Claim does not exist.
60+
ClaimNotFound = 6,
61+
/// Claim is already in a terminal state.
62+
ClaimTerminal = 7,
63+
/// Voting window has closed (current ledger > vote_deadline).
64+
VotingClosed = 8,
65+
/// Caller is not in the voter snapshot for this claim.
66+
NotEligibleVoter = 9,
67+
/// Voter has already cast a ballot on this claim (immutable).
68+
AlreadyVoted = 10,
69+
/// Claim is still within the voting window; cannot finalize yet.
70+
VotingStillOpen = 11,
71+
}
72+
73+
// ── Internal helpers ──────────────────────────────────────────────────────────
74+
75+
fn load_claim(env: &Env, claim_id: u64) -> Claim {
76+
env.storage()
77+
.persistent()
78+
.get(&storage::DataKey::Claim(claim_id))
79+
.unwrap_or_else(|| panic_with_error!(env, ContractError::ClaimNotFound))
80+
}
81+
82+
fn save_claim(env: &Env, claim: &Claim) {
83+
env.storage()
84+
.persistent()
85+
.set(&storage::DataKey::Claim(claim.claim_id), claim);
86+
}
87+
88+
/// Simple majority: strictly more than half of snapshot_size.
89+
/// If snapshot_size is 0 (no voters at filing) the claim cannot reach majority
90+
/// and will be rejected at finalization.
91+
fn majority(snapshot_size: u32) -> u32 {
92+
snapshot_size / 2 + 1
93+
}
94+
95+
// ── Public entrypoints ────────────────────────────────────────────────────────
96+
97+
/// File a new insurance claim against an active policy.
98+
///
99+
/// # Authentication
100+
/// `claimant` must authorize this call (`claimant.require_auth()`). The
101+
/// address must match `policy.holder`; mismatched addresses are rejected before
102+
/// any storage write.
103+
///
104+
/// # Events emitted
105+
/// `ClaimFiled { claim_id, policy_id, claimant, amount, vote_deadline, snapshot_size }`
106+
pub fn file_claim(
107+
env: &Env,
108+
claimant: Address,
109+
policy_id: u32,
110+
amount: i128,
111+
details: String,
112+
image_urls: Vec<String>,
113+
) -> u64 {
114+
// Pause guard
115+
if storage::is_paused(env) {
116+
panic_with_error!(env, ContractError::Paused);
117+
}
118+
119+
// Authenticate the claimant — cannot be spoofed by a mismatched address
120+
claimant.require_auth();
121+
122+
// Load and validate the policy
123+
let policy: crate::types::Policy = env
124+
.storage()
125+
.persistent()
126+
.get(&storage::DataKey::Policy(claimant.clone(), policy_id))
127+
.unwrap_or_else(|| panic_with_error!(env, ContractError::PolicyNotActive));
128+
129+
if !policy.is_active || env.ledger().sequence() >= policy.end_ledger {
130+
panic_with_error!(env, ContractError::PolicyNotActive);
131+
}
132+
133+
// Validate claim fields
134+
check_claim_fields(env, amount, policy.coverage, &details, &image_urls)
135+
.unwrap_or_else(|_| panic_with_error!(env, ContractError::InvalidClaimFields));
136+
137+
// Assign claim id and snapshot voters
138+
let claim_id = next_claim_id(env);
139+
snapshot_voters_for_claim(env, claim_id);
140+
let snapshot = get_claim_voters(env, claim_id);
141+
let snapshot_size = snapshot.len();
142+
143+
let vote_deadline = env.ledger().sequence() + VOTE_WINDOW_LEDGERS;
144+
145+
let claim = Claim {
146+
claim_id,
147+
policy_id,
148+
claimant: claimant.clone(),
149+
amount,
150+
details,
151+
image_urls,
152+
status: ClaimStatus::Processing,
153+
approve_votes: 0,
154+
reject_votes: 0,
155+
vote_deadline,
156+
snapshot_size,
157+
};
158+
save_claim(env, &claim);
159+
160+
// Emit ClaimFiled event — enough data for the Next.js claim detail page
161+
env.events().publish(
162+
(symbol_short!("claim"), symbol_short!("filed")),
163+
(claim_id, policy_id, claimant, amount, vote_deadline, snapshot_size),
164+
);
165+
166+
claim_id
167+
}
168+
169+
/// Cast a ballot on an open claim.
170+
///
171+
/// # Authentication
172+
/// `voter` must authorize this call (`voter.require_auth()`).
173+
///
174+
/// # Eligibility
175+
/// `voter` must appear in the snapshot taken at `file_claim` time. Addresses
176+
/// not in the snapshot are rejected immediately to prevent storage bloat /
177+
/// griefing.
178+
///
179+
/// # Immutability
180+
/// A voter may cast exactly one ballot. Attempting to vote again panics with
181+
/// `ContractError::AlreadyVoted`.
182+
///
183+
/// # Auto-finalization
184+
/// After recording the vote the function checks whether a simple majority has
185+
/// been reached. If so, the claim is immediately transitioned to
186+
/// `Approved` or `Rejected` and a `ClaimSettled` event is emitted.
187+
///
188+
/// # Events emitted
189+
/// `VoteLogged { claim_id, voter, vote, approve_votes, reject_votes, snapshot_size }`
190+
/// Optionally: `ClaimSettled { claim_id, status }` on majority reached.
191+
pub fn vote_on_claim(env: &Env, voter: Address, claim_id: u64, vote: VoteOption) {
192+
// Pause guard
193+
if storage::is_paused(env) {
194+
panic_with_error!(env, ContractError::Paused);
195+
}
196+
197+
// Authenticate — require_auth prevents address spoofing
198+
voter.require_auth();
199+
200+
// Load claim and verify it is still open
201+
let mut claim = load_claim(env, claim_id);
202+
check_claim_open(&claim)
203+
.unwrap_or_else(|_| panic_with_error!(env, ContractError::ClaimTerminal));
204+
205+
// Voting window check
206+
if env.ledger().sequence() > claim.vote_deadline {
207+
panic_with_error!(env, ContractError::VotingClosed);
208+
}
209+
210+
// Eligibility: voter must be in the snapshot (early rejection prevents map spam)
211+
if !is_eligible_voter(env, claim_id, &voter) {
212+
panic_with_error!(env, ContractError::NotEligibleVoter);
213+
}
214+
215+
// Duplicate vote check (immutable ballot)
216+
let vote_key = storage::DataKey::Vote(claim_id, voter.clone());
217+
if env.storage().persistent().has(&vote_key) {
218+
panic_with_error!(env, ContractError::AlreadyVoted);
219+
}
220+
221+
// Record the ballot — written once, never overwritten
222+
env.storage().persistent().set(&vote_key, &vote);
223+
224+
// Update running tallies transactionally
225+
match &vote {
226+
VoteOption::Approve => claim.approve_votes += 1,
227+
VoteOption::Reject => claim.reject_votes += 1,
228+
}
229+
230+
// Emit VoteLogged — sufficient for the claim detail timeline in Next.js
231+
env.events().publish(
232+
(symbol_short!("vote"), symbol_short!("logged")),
233+
(
234+
claim_id,
235+
voter.clone(),
236+
vote,
237+
claim.approve_votes,
238+
claim.reject_votes,
239+
claim.snapshot_size,
240+
),
241+
);
242+
243+
// Auto-finalize on simple majority
244+
let threshold = majority(claim.snapshot_size);
245+
if claim.approve_votes >= threshold {
246+
claim.status = ClaimStatus::Approved;
247+
env.events().publish(
248+
(symbol_short!("claim"), symbol_short!("settled")),
249+
(claim_id, ClaimStatus::Approved),
250+
);
251+
} else if claim.reject_votes >= threshold {
252+
claim.status = ClaimStatus::Rejected;
253+
env.events().publish(
254+
(symbol_short!("claim"), symbol_short!("settled")),
255+
(claim_id, ClaimStatus::Rejected),
256+
);
257+
}
258+
259+
save_claim(env, &claim);
260+
}
261+
262+
/// Finalize a claim after the voting deadline has passed without a majority.
263+
///
264+
/// Compares tallies and sets status to Approved or Rejected based on plurality.
265+
/// If tallies are equal the claim is Rejected (tie goes to insurer).
266+
///
267+
/// Can be called by anyone after `vote_deadline` — no auth required.
268+
pub fn finalize_claim(env: &Env, claim_id: u64) {
269+
let mut claim = load_claim(env, claim_id);
270+
check_claim_open(&claim)
271+
.unwrap_or_else(|_| panic_with_error!(env, ContractError::ClaimTerminal));
272+
273+
if env.ledger().sequence() <= claim.vote_deadline {
274+
panic_with_error!(env, ContractError::VotingStillOpen);
275+
}
276+
277+
claim.status = if claim.approve_votes > claim.reject_votes {
278+
ClaimStatus::Approved
279+
} else {
280+
// Tie or majority reject → Rejected
281+
ClaimStatus::Rejected
282+
};
283+
284+
env.events().publish(
285+
(symbol_short!("claim"), symbol_short!("settled")),
286+
(claim_id, claim.status.clone()),
287+
);
288+
289+
save_claim(env, &claim);
290+
}

contracts/niffyinsure/src/lib.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ mod token;
99
pub mod types;
1010
pub mod validate;
1111

12-
use soroban_sdk::{contract, contractimpl, Address, Env};
12+
use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec};
13+
14+
use crate::types::VoteOption;
1315

1416
#[contract]
1517
pub struct NiffyInsure;
@@ -28,10 +30,74 @@ impl NiffyInsure {
2830
// implemented in policy.rs — issue: feat/policy-lifecycle
2931

3032
// ── Claim domain ─────────────────────────────────────────────────────
31-
// file_claim, vote_on_claim
32-
// implemented in claim.rs — issue: feat/claim-voting
33+
34+
/// File a new claim against an active policy.
35+
/// `claimant` must authorize; must match the policy holder address.
36+
pub fn file_claim(
37+
env: Env,
38+
claimant: Address,
39+
policy_id: u32,
40+
amount: i128,
41+
details: String,
42+
image_urls: Vec<String>,
43+
) -> u64 {
44+
claim::file_claim(&env, claimant, policy_id, amount, details, image_urls)
45+
}
46+
47+
/// Cast an immutable ballot on an open claim.
48+
/// `voter` must authorize; must be in the claim's voter snapshot.
49+
pub fn vote_on_claim(env: Env, voter: Address, claim_id: u64, vote: VoteOption) {
50+
claim::vote_on_claim(&env, voter, claim_id, vote)
51+
}
52+
53+
/// Settle a claim after the voting deadline without a majority.
54+
/// Permissionless — anyone may call once `vote_deadline` has passed.
55+
pub fn finalize_claim(env: Env, claim_id: u64) {
56+
claim::finalize_claim(&env, claim_id)
57+
}
3358

3459
// ── Admin / treasury ─────────────────────────────────────────────────
3560
// drain
3661
// implemented in token.rs — issue: feat/admin
62+
63+
// ── Test-only helpers ─────────────────────────────────────────────────
64+
// These are NOT part of the production ABI; they exist solely to let
65+
// integration tests seed state without the full policy-lifecycle feature.
66+
// Gated behind the `testutils` feature so they are excluded from WASM builds.
67+
68+
/// Seed a policy record and register the holder as a voter.
69+
#[cfg(feature = "testutils")]
70+
pub fn test_seed_policy(
71+
env: Env,
72+
holder: Address,
73+
policy_id: u32,
74+
coverage: i128,
75+
end_ledger: u32,
76+
) {
77+
use crate::types::{Policy, PolicyType, RegionTier};
78+
let policy = Policy {
79+
holder: holder.clone(),
80+
policy_id,
81+
policy_type: PolicyType::Auto,
82+
region: RegionTier::Medium,
83+
premium: 10_000_000,
84+
coverage,
85+
is_active: true,
86+
start_ledger: 1,
87+
end_ledger,
88+
};
89+
env.storage()
90+
.persistent()
91+
.set(&storage::DataKey::Policy(holder.clone(), policy_id), &policy);
92+
storage::add_voter(&env, &holder);
93+
}
94+
95+
/// Remove a holder from the live voter set (simulates policy termination).
96+
#[cfg(feature = "testutils")]
97+
pub fn test_remove_voter(env: Env, holder: Address) {
98+
storage::remove_voter(&env, &holder);
99+
}
37100
}
101+
102+
// Re-export error type so tests can reference it without the module path.
103+
pub use claim::ContractError;

0 commit comments

Comments
 (0)