Skip to content

Commit 10339ed

Browse files
committed
feat(niffyinsure): PolicyExpired event, process_expired, renew Lapsed outcome
- Emit PolicyExpired (holder, policy_id, expiry_ledger, reported_at_ledger) once per term - Instance storage dedupe keyed by holder + policy_id + end_ledger term - process_expired keeper: NotFound / NotYetExpired / idempotent Ok - renew_policy: Ok(Lapsed) when expired so Soroban does not roll back emission - RenewPolicyOutcome enum (Renewed | Lapsed); PolicyError::Expired reserved - Read API get_pol_exp_evt_end_ledger (32-char export limit) - Tests for keeper, renew path, natural initiate + expiry Made-with: Cursor
1 parent 6b09b0a commit 10339ed

5 files changed

Lines changed: 547 additions & 2 deletions

File tree

contracts/niffyinsure/src/lib.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, Vec};
2626
#[contract]
2727
pub struct NiffyInsure;
2828
pub use admin::AdminError;
29+
pub use policy::PolicyError;
2930

3031
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
3132
#[soroban_sdk::contracterror]
@@ -368,6 +369,48 @@ impl NiffyInsure {
368369
storage::get_active_policy_count(&env, &holder)
369370
}
370371

372+
/// If set, the `end_ledger` for which a [`policy::PolicyExpired`] event was already recorded
373+
/// (one row per policy). Indexers may use this with `get_policy` for idempotency checks.
374+
/// Name is shortened to satisfy the 32-char Soroban export limit.
375+
pub fn get_pol_exp_evt_end_ledger(
376+
env: Env,
377+
holder: Address,
378+
policy_id: u32,
379+
) -> Option<u32> {
380+
storage::get_policy_expired_event_end_ledger(&env, &holder, policy_id)
381+
}
382+
383+
/// Keeper hook: when `ledger_sequence >= policy.end_ledger`, emit [`policy::PolicyExpired`]
384+
/// once per policy term (see `policy` module docs for notification delay). Reverts if the
385+
/// policy does not exist or is not yet expired.
386+
pub fn process_expired(
387+
env: Env,
388+
holder: Address,
389+
policy_id: u32,
390+
) -> Result<(), policy::PolicyError> {
391+
policy::process_expired(&env, holder, policy_id)
392+
}
393+
394+
/// Renew before `end_ledger` (renewal window). If already expired, emits [`policy::PolicyExpired`]
395+
/// when due and returns [`types::RenewPolicyOutcome::Lapsed`] in **`Ok`** (see type docs).
396+
pub fn renew_policy(
397+
env: Env,
398+
holder: Address,
399+
policy_id: u32,
400+
age_band: types::AgeBand,
401+
coverage_type: types::CoverageType,
402+
safety_score: u32,
403+
) -> Result<types::RenewPolicyOutcome, policy::PolicyError> {
404+
policy::renew_policy(
405+
&env,
406+
holder,
407+
policy_id,
408+
age_band,
409+
coverage_type,
410+
safety_score,
411+
)
412+
}
413+
371414
pub fn terminate_policy(
372415
env: Env,
373416
holder: Address,

contracts/niffyinsure/src/policy.rs

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use crate::{
22
ledger, premium, storage, token,
3-
types::{AgeBand, CoverageType, Policy, PolicyType, PremiumQuote, RegionTier, RiskInput},
3+
types::{
4+
AgeBand, CoverageType, Policy, PolicyType, PremiumQuote, RegionTier, RiskInput,
5+
STRIKE_DEACTIVATION_THRESHOLD,
6+
},
47
validate::{self, Error},
58
};
69
use soroban_sdk::{contracterror, contractevent, contracttype, Address, Env, String};
@@ -40,6 +43,18 @@ pub enum PolicyError {
4043
NotFound = 111,
4144
/// Policy is already active.
4245
AlreadyActive = 112,
46+
/// Keeper `process_expired`: policy is not yet at `end_ledger`.
47+
NotYetExpired = 113,
48+
/// `renew_policy` called before the renewal window opens.
49+
NotInRenewalWindow = 114,
50+
/// `renew_policy`: policy is inactive (terminated or deactivated).
51+
PolicyInactive = 115,
52+
/// `renew_policy`: an open claim exists for this policy.
53+
OpenClaimBlocksRenewal = 116,
54+
/// `renew_policy`: strike count blocks renewal (see `STRIKE_DEACTIVATION_THRESHOLD`).
55+
TooManyStrikesForRenewal = 117,
56+
/// Reserved / legacy: expired renewals now return [`crate::types::RenewPolicyOutcome::Lapsed`] `Ok`.
57+
Expired = 118,
4358
}
4459

4560
#[contracttype]
@@ -69,7 +84,6 @@ pub struct PolicyInitiated {
6984
/// Event emitted by `renew_policy`.
7085
#[contractevent]
7186
#[derive(Clone, Debug)]
72-
#[allow(dead_code)]
7387
pub struct PolicyRenewed {
7488
#[topic]
7589
pub holder: Address,
@@ -79,6 +93,26 @@ pub struct PolicyRenewed {
7993
pub new_end_ledger: u32,
8094
}
8195

96+
/// Emitted at most once per `(holder, policy_id, end_ledger)` term when expiry is detected.
97+
///
98+
/// **Timing:** `reported_at_ledger` is the ledger of the transaction that observes expiry.
99+
/// It may be **strictly greater** than `expiry_ledger` if no call ran exactly at expiry
100+
/// (keeper delay is normal). Indexers and notification services should **deduplicate on
101+
/// `policy_id`** (and holder) and must not assume the event fires on the expiry ledger itself.
102+
///
103+
/// **`renew_policy` on an expired policy:** the call returns [`crate::types::RenewPolicyOutcome::Lapsed`]
104+
/// in **`Ok`** (not `Err`) so this event and idempotency storage are not rolled back.
105+
#[contractevent(topics = ["niffyinsure", "policy_expired"])]
106+
#[derive(Clone, Debug, Eq, PartialEq)]
107+
pub struct PolicyExpired {
108+
#[topic]
109+
pub holder: Address,
110+
#[topic]
111+
pub policy_id: u32,
112+
pub expiry_ledger: u32,
113+
pub reported_at_ledger: u32,
114+
}
115+
82116
pub fn generate_premium(
83117
env: &Env,
84118
region: RegionTier,
@@ -306,3 +340,143 @@ pub fn initiate_policy(
306340

307341
Ok(policy)
308342
}
343+
344+
/// Emit [`PolicyExpired`] if `now >= end_ledger` and we have not yet recorded an event for this term.
345+
pub fn publish_policy_expired_if_due(env: &Env, policy: &Policy, now: u32) {
346+
if !ledger::is_expired(now, policy.end_ledger) {
347+
return;
348+
}
349+
if storage::get_policy_expired_event_end_ledger(env, &policy.holder, policy.policy_id)
350+
== Some(policy.end_ledger)
351+
{
352+
return;
353+
}
354+
PolicyExpired {
355+
holder: policy.holder.clone(),
356+
policy_id: policy.policy_id,
357+
expiry_ledger: policy.end_ledger,
358+
reported_at_ledger: now,
359+
}
360+
.publish(env);
361+
storage::set_policy_expired_event_end_ledger(
362+
env,
363+
&policy.holder,
364+
policy.policy_id,
365+
policy.end_ledger,
366+
);
367+
storage::bump_instance(env);
368+
}
369+
370+
/// Keeper entrypoint: observe expiry for indexers / notification pipelines.
371+
///
372+
/// Reverts with [`PolicyError::NotYetExpired`] if `now < end_ledger`. If expiry was already
373+
/// notified for this policy term, succeeds without emitting a duplicate event.
374+
pub fn process_expired(env: &Env, holder: Address, policy_id: u32) -> Result<(), PolicyError> {
375+
storage::bump_instance(env);
376+
let policy = storage::get_policy(env, &holder, policy_id).ok_or(PolicyError::NotFound)?;
377+
let now = env.ledger().sequence();
378+
if !ledger::is_expired(now, policy.end_ledger) {
379+
return Err(PolicyError::NotYetExpired);
380+
}
381+
publish_policy_expired_if_due(env, &policy, now);
382+
Ok(())
383+
}
384+
385+
/// Extend policy duration after premium payment (renewal window only).
386+
///
387+
/// If the policy is already expired, records [`PolicyExpired`] if not yet recorded for this
388+
/// term, then returns [`crate::types::RenewPolicyOutcome::Lapsed`] in **`Ok`** so storage and
389+
/// events persist (an `Err` would roll back the contract invocation).
390+
#[allow(clippy::too_many_arguments)]
391+
pub fn renew_policy(
392+
env: &Env,
393+
holder: Address,
394+
policy_id: u32,
395+
age_band: AgeBand,
396+
coverage_type: CoverageType,
397+
safety_score: u32,
398+
) -> Result<crate::types::RenewPolicyOutcome, PolicyError> {
399+
storage::assert_bind_not_paused(env);
400+
holder.require_auth();
401+
402+
let mut policy = storage::get_policy(env, &holder, policy_id).ok_or(PolicyError::NotFound)?;
403+
let now = env.ledger().sequence();
404+
405+
if ledger::is_expired(now, policy.end_ledger) {
406+
publish_policy_expired_if_due(env, &policy, now);
407+
return Ok(crate::types::RenewPolicyOutcome::Lapsed);
408+
}
409+
410+
if !policy.is_active {
411+
return Err(PolicyError::PolicyInactive);
412+
}
413+
414+
if storage::has_open_claim(env, &holder, policy_id) {
415+
return Err(PolicyError::OpenClaimBlocksRenewal);
416+
}
417+
418+
if policy.strike_count >= STRIKE_DEACTIVATION_THRESHOLD {
419+
return Err(PolicyError::TooManyStrikesForRenewal);
420+
}
421+
422+
if !ledger::is_in_renewal_window(
423+
now,
424+
policy.end_ledger,
425+
ledger::RENEWAL_WINDOW_LEDGERS,
426+
) {
427+
return Err(PolicyError::NotInRenewalWindow);
428+
}
429+
430+
if safety_score > 100 {
431+
return Err(PolicyError::InvalidRiskScore);
432+
}
433+
434+
if !storage::is_allowed_asset(env, &policy.asset) {
435+
return Err(PolicyError::AssetNotAllowed);
436+
}
437+
438+
let input = RiskInput {
439+
region: policy.region.clone(),
440+
age_band: age_band.clone(),
441+
coverage: coverage_type,
442+
safety_score,
443+
};
444+
445+
let quote =
446+
crate::calculator::compute_quote(env, &input, policy.coverage, false, QUOTE_TTL_LEDGERS)
447+
.map_err(|e| match e {
448+
Error::CalculatorPaused => PolicyError::ContractPaused,
449+
Error::CalculatorCallFailed | Error::CalculatorNotSet => PolicyError::PremiumOverflow,
450+
_ => PolicyError::PremiumOverflow,
451+
})?;
452+
453+
let premium_amount = quote.total_premium;
454+
if premium_amount <= 0 {
455+
return Err(PolicyError::InvalidPremium);
456+
}
457+
458+
token::collect_premium(env, &holder, &policy.asset, premium_amount);
459+
460+
let new_end = policy
461+
.end_ledger
462+
.checked_add(ledger::POLICY_DURATION_LEDGERS)
463+
.ok_or(PolicyError::LedgerOverflow)?;
464+
465+
policy.premium = premium_amount;
466+
policy.end_ledger = new_end;
467+
468+
validate::check_policy(&policy).map_err(|_| PolicyError::PolicyValidation)?;
469+
470+
storage::set_policy(env, &holder, policy_id, &policy);
471+
472+
PolicyRenewed {
473+
version: POLICY_EVENT_VERSION,
474+
holder: holder.clone(),
475+
policy_id,
476+
premium: premium_amount,
477+
new_end_ledger: new_end,
478+
}
479+
.publish(env);
480+
481+
Ok(crate::types::RenewPolicyOutcome::Renewed(policy))
482+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub enum DataKey {
4949
AppealVote(u64, Address),
5050
/// Rolling paid total for `(holder, policy_id)` within the stored `window_start` bucket.
5151
RollingClaimState(Address, u32),
52+
/// `end_ledger` for which a `PolicyExpired` event was already emitted (one row per policy).
53+
PolicyExpiredEventEndLedger(Address, u32),
5254
}
5355

5456
// ── Instance bump ─────────────────────────────────────────────────────────────
@@ -545,3 +547,29 @@ pub fn set_rolling_claim_state(
545547
.persistent()
546548
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
547549
}
550+
551+
// ── Policy expiry notification (instance) ─────────────────────────────────────
552+
553+
/// Last `end_ledger` for which `PolicyExpired` was emitted for this policy term.
554+
pub fn get_policy_expired_event_end_ledger(
555+
env: &Env,
556+
holder: &Address,
557+
policy_id: u32,
558+
) -> Option<u32> {
559+
env.storage().instance().get(&DataKey::PolicyExpiredEventEndLedger(
560+
holder.clone(),
561+
policy_id,
562+
))
563+
}
564+
565+
pub fn set_policy_expired_event_end_ledger(
566+
env: &Env,
567+
holder: &Address,
568+
policy_id: u32,
569+
end_ledger: u32,
570+
) {
571+
env.storage().instance().set(
572+
&DataKey::PolicyExpiredEventEndLedger(holder.clone(), policy_id),
573+
&end_ledger,
574+
);
575+
}

contracts/niffyinsure/src/types.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,19 @@ pub struct Policy {
298298
pub strike_count: u32,
299299
}
300300

301+
/// Return value of [`crate::policy::renew_policy`].
302+
///
303+
/// When the policy is already at or past `end_ledger`, the call **succeeds** with [`Lapsed`](Self::Lapsed)
304+
/// so that [`crate::policy::PolicyExpired`] and idempotency storage are committed (a failed `Result::Err`
305+
/// invocation would roll those writes back).
306+
#[contracttype]
307+
#[derive(Clone)]
308+
pub enum RenewPolicyOutcome {
309+
Renewed(Policy),
310+
/// Ledger is at or after `end_ledger`; expiry notice recorded if due; no premium taken.
311+
Lapsed,
312+
}
313+
301314
/// On-chain claim record.
302315
///
303316
/// `filed_at` is the ledger sequence at which the claim was filed. It anchors

0 commit comments

Comments
 (0)