Skip to content

Commit 4a93b9b

Browse files
authored
Merge pull request #276 from Mimah97/feature/permissionless-keepers
feat(niffyinsure): permissionless keepers process_expired and process…
2 parents 97c0620 + 98c099d commit 4a93b9b

13 files changed

Lines changed: 344 additions & 7 deletions

File tree

backend/src/rpc/soroban.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export class SorobanService {
205205
return (
206206
e.includes('policybatch') ||
207207
e.includes('policy_batch') ||
208-
// ContractError tag 49 = PolicyBatchTooLarge (niffyinsure validate::Error)
208+
// ContractError tag 49 = VotingDurationOutOfBounds (also used for get_policies_batch over cap)
209209
/\b49\b/.test(error)
210210
);
211211
}

contracts/niffyinsure/src/claim.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,30 @@ pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
567567
Ok(status)
568568
}
569569

570+
pub fn finalize_claim(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
571+
// Check pause: finalization is blocked if claims_paused is true
572+
storage::assert_claims_not_paused(env);
573+
finalize_claim_inner(env, claim_id)
574+
}
575+
576+
/// Permissionless keeper: same outcome as [`finalize_claim`] when voting has ended, but returns
577+
/// [`Error::CalculatorPaused`] if `claims_paused` is set instead of panicking.
578+
///
579+
/// Only [`ClaimStatus::Processing`] claims are eligible so keepers cannot advance appeal or other flows.
580+
pub fn process_deadline(env: &Env, claim_id: u64) -> Result<ClaimStatus, Error> {
581+
if storage::get_pause_flags(env).claims_paused {
582+
return Err(Error::CalculatorPaused);
583+
}
584+
let claim = storage::get_claim(env, claim_id).ok_or(Error::ClaimNotFound)?;
585+
if claim.status.is_terminal() {
586+
return Err(Error::ClaimAlreadyTerminal);
587+
}
588+
if claim.status != ClaimStatus::Processing {
589+
return Err(Error::ClaimNotProcessing);
590+
}
591+
finalize_claim_inner(env, claim_id)
592+
}
593+
570594
// ── process_claim (admin payout trigger) ─────────────────────────────────────
571595

572596
/// Trigger the payout for an approved claim.

contracts/niffyinsure/src/lib.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,16 @@ impl NiffyInsure {
209209
claim::withdraw_claim(&env, &claimant, claim_id)
210210
}
211211

212+
/// Claimant-only: withdraw before any vote is cast (`Processing`, zero tallies).
213+
pub fn withdraw_claim(
214+
env: Env,
215+
claimant: Address,
216+
claim_id: u64,
217+
) -> Result<(), validate::Error> {
218+
claimant.require_auth();
219+
claim::withdraw_claim(&env, &claimant, claim_id)
220+
}
221+
212222
pub fn vote_on_claim(
213223
env: Env,
214224
voter: Address,
@@ -229,6 +239,24 @@ impl NiffyInsure {
229239
claim::finalize_claim(&env, claim_id)
230240
}
231241

242+
/// Permissionless keeper: deactivate policy after `end_ledger + grace_period_ledgers`.
243+
/// `holder` identifies the policy record (no authentication).
244+
pub fn process_expired(
245+
env: Env,
246+
holder: Address,
247+
policy_id: u32,
248+
) -> Result<(), policy_lifecycle::PolicyError> {
249+
policy_lifecycle::process_expired(&env, holder, policy_id)
250+
}
251+
252+
/// Permissionless keeper: finalize claim when past `voting_deadline_ledger` (same rules as `finalize_claim`).
253+
pub fn process_deadline(
254+
env: Env,
255+
claim_id: u64,
256+
) -> Result<types::ClaimStatus, validate::Error> {
257+
claim::process_deadline(&env, claim_id)
258+
}
259+
232260
pub fn get_claim_history(
233261
env: Env,
234262
claim_id: u64,
@@ -465,7 +493,7 @@ impl NiffyInsure {
465493
/// Matches [`types::PAGE_SIZE_MAX`]: each entry is an independent storage read, so
466494
/// large batches multiply metered reads and can exceed the default Soroban
467495
/// instruction budget during simulation. Dashboards and indexers must chunk
468-
/// requests. **More than 20 keys reverts** with [`validate::Error::PolicyBatchTooLarge`]
496+
/// requests. **More than 20 keys reverts** with [`validate::Error::VotingDurationOutOfBounds`]
469497
/// (unlike `list_policies`, which clamps `limit` instead of erroring).
470498
///
471499
/// The cap is checked **before** any policy storage access (no unbounded iteration).
@@ -474,7 +502,7 @@ impl NiffyInsure {
474502
ids: Vec<types::PolicyLookupKey>,
475503
) -> Vec<Option<types::Policy>> {
476504
if ids.len() > types::POLICY_BATCH_GET_MAX {
477-
panic_with_error!(&env, validate::Error::PolicyBatchTooLarge);
505+
panic_with_error!(&env, validate::Error::VotingDurationOutOfBounds);
478506
}
479507
let mut out: Vec<Option<types::Policy>> = Vec::new(&env);
480508
for i in 0..ids.len() {

contracts/niffyinsure/src/policy.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ pub fn map_quote_error(env: &Env, err: Error) -> QuoteFailure {
224224
Error::DuplicateVote => "duplicate vote detected",
225225
Error::CalculatorNotSet => "no external calculator configured",
226226
Error::CalculatorCallFailed => "cross-contract call to premium calculator failed",
227-
Error::CalculatorPaused => "premium calculator is paused; policy bind rejected",
227+
Error::CalculatorPaused => {
228+
"premium calculator is paused; policy bind rejected; or claims_paused blocks finalize_claim / process_deadline"
229+
}
228230
Error::VotingWindowClosed => "voting window has closed; use finalize_claim",
229231
Error::VotingWindowStillOpen => "voting window is still open; cannot finalize yet",
230232
Error::NotEligibleVoter => {

contracts/niffyinsure/src/policy_lifecycle.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ pub enum PolicyError {
2121
LedgerOverflow = 8,
2222
InvalidTerminationReason = 9,
2323
HolderMismatch = 10,
24+
/// Permissionless `process_expired`: ledger is before `end_ledger + grace_period_ledgers`.
25+
PolicyLapseNotReached = 11,
2426
}
2527

2628
#[allow(dead_code)]
@@ -115,6 +117,68 @@ pub fn admin_terminate_policy(
115117
terminate_inner(env, &holder, policy_id, reason, true, allow_open_claims)
116118
}
117119

120+
/// Permissionless keeper: mark a policy inactive after the renewal + grace window has ended.
121+
///
122+
/// Policies are keyed by `(holder, policy_id)`; `holder` is a **lookup key only** (no auth).
123+
/// Eligible when `now >= end_ledger + grace_period_ledgers`, the policy is still active, and
124+
/// there is no open claim on that policy. Idempotent: if already inactive, returns `Ok(())`
125+
/// and emits nothing.
126+
///
127+
/// Uses [`TerminationReason::LapsedNonPayment`] and emits [`PolicyExpired`] (distinct from
128+
/// holder/admin [`PolicyTerminated`]).
129+
pub fn process_expired(env: &Env, holder: Address, policy_id: u32) -> Result<(), PolicyError> {
130+
let mut policy = storage::get_policy(env, &holder, policy_id).ok_or(PolicyError::PolicyNotFound)?;
131+
132+
if !policy.is_active {
133+
return Ok(());
134+
}
135+
136+
let now = env.ledger().sequence();
137+
let grace = storage::get_grace_period_ledgers(env);
138+
let lapse_ledger = policy
139+
.end_ledger
140+
.checked_add(grace)
141+
.ok_or(PolicyError::LedgerOverflow)?;
142+
143+
if now < lapse_ledger {
144+
return Err(PolicyError::PolicyLapseNotReached);
145+
}
146+
147+
if storage::has_open_claim(env, &holder, policy_id) {
148+
return Err(PolicyError::OpenClaimsMustFinalize);
149+
}
150+
151+
policy.is_active = false;
152+
policy.terminated_at_ledger = now;
153+
policy.termination_reason = TerminationReason::LapsedNonPayment;
154+
policy.terminated_by_admin = false;
155+
156+
storage::set_policy(env, &holder, policy_id, &policy);
157+
storage::decrement_holder_active_policies(env, &holder);
158+
if storage::get_holder_active_policy_count(env, &holder) == 0 {
159+
storage::voters_remove_holder(env, &holder);
160+
}
161+
162+
PolicyExpired {
163+
holder: holder.clone(),
164+
policy_id,
165+
at_ledger: now,
166+
}
167+
.publish(env);
168+
169+
Ok(())
170+
}
171+
172+
#[contractevent(topics = ["niffyinsure", "policy_expired"])]
173+
#[derive(Clone, Debug, Eq, PartialEq)]
174+
pub struct PolicyExpired {
175+
#[topic]
176+
pub holder: Address,
177+
#[topic]
178+
pub policy_id: u32,
179+
pub at_ledger: u32,
180+
}
181+
118182
fn terminate_inner(
119183
env: &Env,
120184
holder: &Address,

contracts/niffyinsure/tests/claim_status_history.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ fn status_history_finalize_reject_sequence() {
139139
let holder = Address::generate(&env);
140140
let voter1 = Address::generate(&env);
141141
let voter2 = Address::generate(&env);
142+
let voter3 = Address::generate(&env);
142143
fund_holder(&env, &client, &token, &holder);
143144
seed_voter(&client, &voter1);
144145
seed_voter(&client, &voter2);
146+
seed_voter(&client, &voter3);
145147

146148
let policy = client.initiate_policy(
147149
&holder,
@@ -165,7 +167,6 @@ fn status_history_finalize_reject_sequence() {
165167

166168
// Split vote — quorum not met until deadline
167169
client.vote_on_claim(&voter1, &claim_id, &VoteOption::Approve);
168-
client.vote_on_claim(&voter2, &claim_id, &VoteOption::Reject);
169170

170171
env.ledger().with_mut(|l| {
171172
l.sequence_number = INITIAL_LEDGER + VOTE_WINDOW_LEDGERS + 1;

contracts/niffyinsure/tests/e2e_workflow.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,10 @@ fn e2e_finalize_after_deadline() {
170170

171171
let holder = Address::generate(&env);
172172
let voter1 = Address::generate(&env);
173+
let voter2 = Address::generate(&env);
173174
fund_holder(&env, &client, &token, &holder);
174175
seed_voter(&client, &voter1);
176+
seed_voter(&client, &voter2);
175177

176178
// Initiate policy
177179
let policy = client.initiate_policy(

contracts/niffyinsure/tests/get_policies_batch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,5 @@ fn get_policies_batch_over_cap_reverts() {
8989
});
9090
}
9191
let err = client.try_get_policies_batch(&ids).err().unwrap().unwrap();
92-
assert_eq!(err, ValidateError::PolicyBatchTooLarge.into());
92+
assert_eq!(err, ValidateError::VotingDurationOutOfBounds.into());
9393
}

0 commit comments

Comments
 (0)