Skip to content

Commit ec7daac

Browse files
authored
Merge pull request #124 from unrealtim-tech/feat-initiate-policy
fix: initiate policy, coverage bind, premium recording, and voter registry rules
2 parents d4862b2 + 61cfd34 commit ec7daac

3 files changed

Lines changed: 311 additions & 6 deletions

File tree

contracts/niffyinsure/src/lib.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
mod claim;
44
mod policy;
5-
#[allow(dead_code)] // used by policy.rs once feat/policy-lifecycle lands
65
mod premium;
76
mod storage;
87
mod token;
@@ -70,8 +69,57 @@ impl NiffyInsure {
7069
}
7170

7271
// ── Policy domain ────────────────────────────────────────────────────
73-
// generate_premium, initiate_policy, renew_policy, terminate_policy
74-
// implemented in policy.rs — issue: feat/policy-lifecycle
72+
73+
/// Turn an accepted quote into an enforceable on-chain policy.
74+
///
75+
/// Authenticates the holder, computes premium, transfers payment,
76+
/// persists the policy, updates the DAO voter registry, and emits
77+
/// a versioned `PolicyInitiated` event for NestJS indexers.
78+
pub fn initiate_policy(
79+
env: Env,
80+
holder: Address,
81+
policy_type: types::PolicyType,
82+
region: types::RegionTier,
83+
coverage: i128,
84+
age: u32,
85+
risk_score: u32,
86+
) -> Result<types::Policy, policy::PolicyError> {
87+
policy::initiate_policy(&env, holder, policy_type, region, coverage, age, risk_score)
88+
}
89+
90+
/// Read-only: retrieve a persisted policy by (holder, policy_id).
91+
pub fn get_policy(env: Env, holder: Address, policy_id: u32) -> Option<types::Policy> {
92+
storage::get_policy(&env, &holder, policy_id)
93+
}
94+
95+
/// Read-only: number of active policies for a holder (= vote weight).
96+
pub fn get_active_policy_count(env: Env, holder: Address) -> u32 {
97+
storage::get_active_policy_count(&env, &holder)
98+
}
99+
100+
// ── Admin / pause ────────────────────────────────────────────────────
101+
102+
/// Admin-only: pause the contract (blocks initiate_policy and future
103+
/// mutating entrypoints).
104+
pub fn pause(env: Env, admin: Address) {
105+
admin.require_auth();
106+
let stored_admin = storage::get_admin(&env);
107+
assert!(admin == stored_admin, "only admin can pause");
108+
storage::set_paused(&env, true);
109+
}
110+
111+
/// Admin-only: unpause the contract.
112+
pub fn unpause(env: Env, admin: Address) {
113+
admin.require_auth();
114+
let stored_admin = storage::get_admin(&env);
115+
assert!(admin == stored_admin, "only admin can unpause");
116+
storage::set_paused(&env, false);
117+
}
118+
119+
/// Read-only: check if the contract is paused.
120+
pub fn is_paused(env: Env) -> bool {
121+
storage::is_paused(&env)
122+
}
75123

76124
// ── Claim domain ─────────────────────────────────────────────────────
77125
// file_claim, vote_on_claim

contracts/niffyinsure/src/policy.rs

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
use crate::{
22
premium,
3-
types::{PolicyType, PremiumQuote, RegionTier},
3+
storage,
4+
token,
5+
types::{Policy, PolicyType, PremiumQuote, RegionTier},
6+
validate,
47
};
5-
use soroban_sdk::{contracterror, contracttype, Env, String};
8+
use soroban_sdk::{contractevent, contracterror, contracttype, Address, Env, String};
69

710
/// How long a quote stays valid (in ledgers) from generation time.
811
pub const QUOTE_TTL_LEDGERS: u32 = 100;
912

13+
/// Default policy duration in ledgers (~30 days at 5s/ledger ≈ 518_400).
14+
pub const POLICY_DURATION_LEDGERS: u32 = 518_400;
15+
16+
/// Current event schema version for PolicyInitiated.
17+
pub const POLICY_EVENT_VERSION: u32 = 1;
18+
1019
#[contracterror]
1120
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
1221
#[repr(u32)]
@@ -17,13 +26,65 @@ pub enum QuoteError {
1726
ArithmeticOverflow = 4,
1827
}
1928

29+
/// Errors specific to policy initiation and lifecycle.
30+
#[contracterror]
31+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
32+
#[repr(u32)]
33+
pub enum PolicyError {
34+
/// Contract is paused by admin.
35+
ContractPaused = 100,
36+
/// A policy with this (holder, policy_id) already exists.
37+
DuplicatePolicyId = 101,
38+
/// Coverage must be > 0.
39+
InvalidCoverage = 102,
40+
/// Computed premium is zero or negative (should not happen with valid inputs).
41+
InvalidPremium = 103,
42+
/// Premium computation overflowed.
43+
PremiumOverflow = 104,
44+
/// Policy duration would overflow ledger sequence.
45+
LedgerOverflow = 105,
46+
/// Policy struct failed internal validation.
47+
PolicyValidation = 106,
48+
/// Caller is not authorized (require_auth failed or wrong signer).
49+
Unauthorized = 107,
50+
/// Age out of range (1..=120).
51+
InvalidAge = 108,
52+
/// Risk score out of range (1..=10).
53+
InvalidRiskScore = 109,
54+
}
55+
2056
#[contracttype]
2157
#[derive(Clone, Debug, Eq, PartialEq)]
2258
pub struct QuoteFailure {
2359
pub code: u32,
2460
pub message: String,
2561
}
2662

63+
/// Versioned event emitted by `initiate_policy`.
64+
///
65+
/// NestJS indexers subscribe to this event to render dashboards without
66+
/// scanning entire storage. The `version` field allows the indexer consumer
67+
/// to be versioned alongside contract releases.
68+
///
69+
/// Topic fields (`holder`) are indexed for efficient subscription filtering.
70+
/// Data fields are serialised as a map in the event body.
71+
#[contractevent]
72+
#[derive(Clone, Debug)]
73+
pub struct PolicyInitiated {
74+
/// Schema version; currently 1.
75+
#[topic]
76+
pub holder: Address,
77+
pub version: u32,
78+
pub policy_id: u32,
79+
pub premium: i128,
80+
pub asset: Address,
81+
pub policy_type: PolicyType,
82+
pub region: RegionTier,
83+
pub coverage: i128,
84+
pub start_ledger: u32,
85+
pub end_ledger: u32,
86+
}
87+
2788
pub fn generate_premium(
2889
env: &Env,
2990
policy_type: PolicyType,
@@ -78,3 +139,117 @@ pub fn map_quote_error(env: &Env, err: QuoteError) -> QuoteFailure {
78139
message: String::from_str(env, message),
79140
}
80141
}
142+
143+
/// Turns an accepted quote into an enforceable on-chain policy.
144+
///
145+
/// # Auth
146+
/// `holder.require_auth()` — only the policyholder may initiate.
147+
///
148+
/// # Flow
149+
/// 1. Check contract is not paused.
150+
/// 2. Authenticate the holder.
151+
/// 3. Validate inputs (age, risk_score, coverage).
152+
/// 4. Compute premium via `premium::compute_premium_checked`.
153+
/// 5. Allocate a unique per-holder `policy_id` (idempotent: if a client
154+
/// retries after a failed tx the counter is only bumped on success).
155+
/// 6. Transfer premium from holder → contract address.
156+
/// 7. Persist the `Policy` struct with `is_active = true`.
157+
/// 8. Update voter registry (add holder, increment active-policy count).
158+
/// 9. Emit versioned `PolicyInitiated` event for NestJS indexers.
159+
///
160+
/// All durable writes happen **after** the premium transfer so that a failed
161+
/// transfer leaves zero partial state (no policy, no voter entry).
162+
pub fn initiate_policy(
163+
env: &Env,
164+
holder: Address,
165+
policy_type: PolicyType,
166+
region: RegionTier,
167+
coverage: i128,
168+
age: u32,
169+
risk_score: u32,
170+
) -> Result<Policy, PolicyError> {
171+
// 1. Pause guard
172+
if storage::is_paused(env) {
173+
return Err(PolicyError::ContractPaused);
174+
}
175+
176+
// 2. Authenticate the holder
177+
holder.require_auth();
178+
179+
// 3. Input validation
180+
if age == 0 || age > 120 {
181+
return Err(PolicyError::InvalidAge);
182+
}
183+
if risk_score == 0 || risk_score > 10 {
184+
return Err(PolicyError::InvalidRiskScore);
185+
}
186+
if coverage <= 0 {
187+
return Err(PolicyError::InvalidCoverage);
188+
}
189+
190+
// 4. Compute premium (smallest units / stroops)
191+
let premium_amount = premium::compute_premium_checked(&policy_type, &region, age, risk_score)
192+
.ok_or(PolicyError::PremiumOverflow)?;
193+
if premium_amount <= 0 {
194+
return Err(PolicyError::InvalidPremium);
195+
}
196+
197+
// 5. Allocate unique per-holder policy_id
198+
let policy_id = storage::next_policy_id(env, &holder);
199+
200+
// Enforce uniqueness (defensive — next_policy_id is monotonic, but guard
201+
// against any future code path that might manually set an id).
202+
if storage::has_policy(env, &holder, policy_id) {
203+
return Err(PolicyError::DuplicatePolicyId);
204+
}
205+
206+
// 6. Premium transfer: holder → contract address
207+
// Done BEFORE any durable writes so failure leaves no partial state.
208+
let token_addr = storage::get_token(env);
209+
let contract_addr = env.current_contract_address();
210+
token::transfer(env, &token_addr, &holder, &contract_addr, premium_amount);
211+
212+
// 7. Build and validate policy struct
213+
let current_ledger = env.ledger().sequence();
214+
let end_ledger = current_ledger
215+
.checked_add(POLICY_DURATION_LEDGERS)
216+
.ok_or(PolicyError::LedgerOverflow)?;
217+
218+
let policy = Policy {
219+
holder: holder.clone(),
220+
policy_id,
221+
policy_type: policy_type.clone(),
222+
region: region.clone(),
223+
premium: premium_amount,
224+
coverage,
225+
is_active: true,
226+
start_ledger: current_ledger,
227+
end_ledger,
228+
};
229+
230+
// Run structural validation (coverage > 0, premium > 0, ledger window).
231+
validate::check_policy(&policy).map_err(|_| PolicyError::PolicyValidation)?;
232+
233+
// 8. Persist policy
234+
storage::set_policy(env, &holder, policy_id, &policy);
235+
236+
// 9. Update voter registry
237+
storage::add_voter(env, &holder);
238+
239+
// 10. Emit versioned PolicyInitiated event
240+
PolicyInitiated {
241+
version: POLICY_EVENT_VERSION,
242+
policy_id,
243+
holder: holder.clone(),
244+
premium: premium_amount,
245+
asset: token_addr,
246+
policy_type,
247+
region,
248+
coverage,
249+
start_ledger: current_ledger,
250+
end_ledger,
251+
}
252+
.publish(env);
253+
254+
Ok(policy)
255+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use soroban_sdk::{contracttype, Address, Env};
1+
use soroban_sdk::{contracttype, Address, Env, Vec};
22

33
#[contracttype]
44
pub enum DataKey {
@@ -15,6 +15,10 @@ pub enum DataKey {
1515
Voters,
1616
/// Global monotonic claim id counter
1717
ClaimCounter,
18+
/// Contract pause flag (bool). Missing ≡ not paused.
19+
Paused,
20+
/// Per-holder active policy count; used for weighted voting.
21+
ActivePolicyCount(Address),
1822
}
1923

2024
pub fn set_admin(env: &Env, admin: &Address) {
@@ -80,3 +84,81 @@ pub fn has_policy(env: &Env, holder: &Address, policy_id: u32) -> bool {
8084
.persistent()
8185
.has(&DataKey::Policy(holder.clone(), policy_id))
8286
}
87+
88+
// ── Pause flag ───────────────────────────────────────────────────────────────
89+
90+
pub fn set_paused(env: &Env, paused: bool) {
91+
env.storage().instance().set(&DataKey::Paused, &paused);
92+
}
93+
94+
pub fn is_paused(env: &Env) -> bool {
95+
env.storage()
96+
.instance()
97+
.get(&DataKey::Paused)
98+
.unwrap_or(false)
99+
}
100+
101+
// ── Policy persistence ───────────────────────────────────────────────────────
102+
103+
pub fn set_policy(env: &Env, holder: &Address, policy_id: u32, policy: &crate::types::Policy) {
104+
env.storage()
105+
.persistent()
106+
.set(&DataKey::Policy(holder.clone(), policy_id), policy);
107+
}
108+
109+
pub fn get_policy(env: &Env, holder: &Address, policy_id: u32) -> Option<crate::types::Policy> {
110+
env.storage()
111+
.persistent()
112+
.get(&DataKey::Policy(holder.clone(), policy_id))
113+
}
114+
115+
// ── Voter registry ───────────────────────────────────────────────────────────
116+
//
117+
// Vote-weight semantics: **one-policy-one-vote**.
118+
// Each active policy grants exactly one vote. A holder with N active policies
119+
// has N votes in claim governance. `ActivePolicyCount(holder)` tracks this.
120+
// `Voters` is a deduplicated Vec<Address> of holders with ≥1 active policy;
121+
// it is used for quorum denominator calculation. `vote_on_claim` multiplies
122+
// each ballot by the holder's `ActivePolicyCount` at vote time.
123+
124+
pub fn get_voters(env: &Env) -> Vec<Address> {
125+
env.storage()
126+
.instance()
127+
.get(&DataKey::Voters)
128+
.unwrap_or_else(|| Vec::new(env))
129+
}
130+
131+
pub fn set_voters(env: &Env, voters: &Vec<Address>) {
132+
env.storage().instance().set(&DataKey::Voters, voters);
133+
}
134+
135+
/// Add `holder` to the voter set (if not already present) and increment their
136+
/// active-policy count by 1.
137+
pub fn add_voter(env: &Env, holder: &Address) {
138+
let mut voters = get_voters(env);
139+
// Check membership — linear scan is acceptable for DAO-scale voter sets.
140+
let mut found = false;
141+
for v in voters.iter() {
142+
if v == *holder {
143+
found = true;
144+
break;
145+
}
146+
}
147+
if !found {
148+
voters.push_back(holder.clone());
149+
}
150+
set_voters(env, &voters);
151+
152+
// Increment active policy count.
153+
let key = DataKey::ActivePolicyCount(holder.clone());
154+
let count: u32 = env.storage().instance().get(&key).unwrap_or(0);
155+
env.storage().instance().set(&key, &(count + 1));
156+
}
157+
158+
/// Returns the number of active policies for `holder` (vote weight).
159+
pub fn get_active_policy_count(env: &Env, holder: &Address) -> u32 {
160+
env.storage()
161+
.instance()
162+
.get(&DataKey::ActivePolicyCount(holder.clone()))
163+
.unwrap_or(0)
164+
}

0 commit comments

Comments
 (0)