Skip to content

Commit 8177967

Browse files
committed
feat(contract): make generate_premium a pure quote path
Return PremiumQuote with total_premium, optional line_items, and valid_until_ledger. Add QuoteError and quote_error_message for API simulation. No persistent writes on quote path; tests assert counters and policy map unchanged. Document MVP quote events and caching. Made-with: Cursor
1 parent 9c6b44b commit 8177967

9 files changed

Lines changed: 555 additions & 9 deletions

contracts/niffyinsure/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ Record the contract ID and the SHA-256 from `make sha` in your release notes so
5757
| `stellar contract deploy` fails | Wrong CLI version | `stellar --version` must be ≥ 21 |
5858
| Tests fail with `no_std` errors | Running `cargo test --target wasm32` | Tests run on native; omit `--target` flag |
5959

60+
## Quote behavior (`generate_premium`)
61+
62+
- `generate_premium` is a quote-only entrypoint: it does not increment `claim_id`, mutate policy state, or transfer funds.
63+
- The response is a structured `PremiumQuote` with `total_premium`, optional `line_items` (for UX), and `valid_until_ledger`.
64+
- MVP does not emit quote events to reduce event spam, avoid accidental PII leakage, and stay within Soroban payload limits.
65+
- Validation failures return typed error codes; API layers can map these using `quote_error_message(code)` for support-friendly messages.
66+
- Off-chain quote caches must enforce `valid_until_ledger`: if admin-adjustable multipliers are introduced later, stale cached quotes must be discarded and re-simulated before bind.
67+
6068
## Module map
6169

6270
```

contracts/niffyinsure/src/lib.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,52 @@ impl NiffyInsure {
2323
storage::set_token(&env, &token);
2424
}
2525

26+
/// Pure quote path: reads config and computes premium only.
27+
/// This entrypoint intentionally performs no persistent writes.
28+
pub fn generate_premium(
29+
env: Env,
30+
policy_type: types::PolicyType,
31+
region: types::RegionTier,
32+
age: u32,
33+
risk_score: u32,
34+
include_breakdown: bool,
35+
) -> Result<types::PremiumQuote, policy::QuoteError> {
36+
policy::generate_premium(
37+
&env,
38+
policy_type,
39+
region,
40+
age,
41+
risk_score,
42+
include_breakdown,
43+
)
44+
}
45+
46+
/// Converts quote failure codes to support-friendly messages for API layers.
47+
pub fn quote_error_message(env: Env, code: u32) -> policy::QuoteFailure {
48+
let err = match code {
49+
1 => policy::QuoteError::InvalidAge,
50+
2 => policy::QuoteError::InvalidRiskScore,
51+
3 => policy::QuoteError::InvalidQuoteTtl,
52+
_ => policy::QuoteError::ArithmeticOverflow,
53+
};
54+
policy::map_quote_error(&env, err)
55+
}
56+
57+
/// Read-only helper for monitoring state in tests / ops tooling.
58+
pub fn get_claim_counter(env: Env) -> u64 {
59+
storage::get_claim_counter(&env)
60+
}
61+
62+
/// Read-only helper for monitoring state in tests / ops tooling.
63+
pub fn get_policy_counter(env: Env, holder: Address) -> u32 {
64+
storage::get_policy_counter(&env, &holder)
65+
}
66+
67+
/// Read-only helper for monitoring state in tests / ops tooling.
68+
pub fn has_policy(env: Env, holder: Address, policy_id: u32) -> bool {
69+
storage::has_policy(&env, &holder, policy_id)
70+
}
71+
2672
// ── Policy domain ────────────────────────────────────────────────────
2773
// generate_premium, initiate_policy, renew_policy, terminate_policy
2874
// implemented in policy.rs — issue: feat/policy-lifecycle
Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,80 @@
1-
// Policy lifecycle methods will be implemented here and exposed via
2-
// NiffyInsure contractimpl in lib.rs.
3-
//
4-
// Planned public functions:
5-
// generate_premium(env, policy_type, age, risk_score) -> i128
6-
// initiate_policy(env, holder, policy_id, policy_type, coverage, age, risk_score)
7-
// renew_policy(env, holder, policy_id)
8-
// terminate_policy(env, holder, policy_id, reason)
1+
use crate::{
2+
premium,
3+
types::{PolicyType, PremiumQuote, RegionTier},
4+
};
5+
use soroban_sdk::{contracterror, contracttype, Env, String};
6+
7+
/// How long a quote stays valid (in ledgers) from generation time.
8+
pub const QUOTE_TTL_LEDGERS: u32 = 100;
9+
10+
#[contracterror]
11+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
12+
#[repr(u32)]
13+
pub enum QuoteError {
14+
InvalidAge = 1,
15+
InvalidRiskScore = 2,
16+
InvalidQuoteTtl = 3,
17+
ArithmeticOverflow = 4,
18+
}
19+
20+
#[contracttype]
21+
#[derive(Clone, Debug, Eq, PartialEq)]
22+
pub struct QuoteFailure {
23+
pub code: u32,
24+
pub message: String,
25+
}
26+
27+
pub fn generate_premium(
28+
env: &Env,
29+
policy_type: PolicyType,
30+
region: RegionTier,
31+
age: u32,
32+
risk_score: u32,
33+
include_breakdown: bool,
34+
) -> Result<PremiumQuote, QuoteError> {
35+
if age == 0 || age > 120 {
36+
return Err(QuoteError::InvalidAge);
37+
}
38+
if risk_score == 0 || risk_score > 10 {
39+
return Err(QuoteError::InvalidRiskScore);
40+
}
41+
if QUOTE_TTL_LEDGERS == 0 {
42+
return Err(QuoteError::InvalidQuoteTtl);
43+
}
44+
45+
let total = premium::compute_premium_checked(&policy_type, &region, age, risk_score)
46+
.ok_or(QuoteError::ArithmeticOverflow)?;
47+
48+
let line_items = if include_breakdown {
49+
Some(
50+
premium::build_line_items(env, &policy_type, &region, age, risk_score)
51+
.ok_or(QuoteError::ArithmeticOverflow)?,
52+
)
53+
} else {
54+
None
55+
};
56+
57+
let current_ledger = env.ledger().sequence();
58+
let valid_until_ledger = current_ledger
59+
.checked_add(QUOTE_TTL_LEDGERS)
60+
.ok_or(QuoteError::ArithmeticOverflow)?;
61+
62+
Ok(PremiumQuote {
63+
total_premium: total,
64+
line_items,
65+
valid_until_ledger,
66+
})
67+
}
68+
69+
pub fn map_quote_error(env: &Env, err: QuoteError) -> QuoteFailure {
70+
let message = match err {
71+
QuoteError::InvalidAge => "invalid age: expected 1..=120",
72+
QuoteError::InvalidRiskScore => "invalid risk_score: expected 1..=10",
73+
QuoteError::InvalidQuoteTtl => "quote ttl misconfigured: contact support",
74+
QuoteError::ArithmeticOverflow => "pricing arithmetic overflow: contact support",
75+
};
76+
QuoteFailure {
77+
code: err as u32,
78+
message: String::from_str(env, message),
79+
}
80+
}

contracts/niffyinsure/src/premium.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use crate::types::{PolicyType, RegionTier};
1+
use crate::types::{PolicyType, PremiumQuoteLineItem, RegionTier};
2+
use soroban_sdk::{Env, String, Vec};
23

34
/// Base annual premium in stroops (1 XLM = 10_000_000 stroops).
45
#[allow(dead_code)]
@@ -32,3 +33,91 @@ pub fn compute_premium(
3233
};
3334
BASE * (type_factor + region_factor + age_factor + risk_score as i128) / 10
3435
}
36+
37+
#[allow(dead_code)]
38+
pub fn type_factor(policy_type: &PolicyType) -> i128 {
39+
match policy_type {
40+
PolicyType::Auto => 15,
41+
PolicyType::Health => 20,
42+
PolicyType::Property => 10,
43+
}
44+
}
45+
46+
#[allow(dead_code)]
47+
pub fn region_factor(region: &RegionTier) -> i128 {
48+
match region {
49+
RegionTier::Low => 8,
50+
RegionTier::Medium => 10,
51+
RegionTier::High => 14,
52+
}
53+
}
54+
55+
#[allow(dead_code)]
56+
pub fn age_factor(age: u32) -> i128 {
57+
if age < 25 {
58+
15
59+
} else if age > 60 {
60+
13
61+
} else {
62+
10
63+
}
64+
}
65+
66+
#[allow(dead_code)]
67+
pub fn compute_premium_checked(
68+
policy_type: &PolicyType,
69+
region: &RegionTier,
70+
age: u32,
71+
risk_score: u32,
72+
) -> Option<i128> {
73+
let tf = type_factor(policy_type);
74+
let rf = region_factor(region);
75+
let af = age_factor(age);
76+
let raw = tf
77+
.checked_add(rf)?
78+
.checked_add(af)?
79+
.checked_add(risk_score as i128)?;
80+
BASE.checked_mul(raw)?.checked_div(10)
81+
}
82+
83+
#[allow(dead_code)]
84+
pub fn build_line_items(
85+
env: &Env,
86+
policy_type: &PolicyType,
87+
region: &RegionTier,
88+
age: u32,
89+
risk_score: u32,
90+
) -> Option<Vec<PremiumQuoteLineItem>> {
91+
let tf = type_factor(policy_type);
92+
let rf = region_factor(region);
93+
let af = age_factor(age);
94+
let rsk = risk_score as i128;
95+
96+
let base_type = BASE.checked_mul(tf)?.checked_div(10)?;
97+
let base_region = BASE.checked_mul(rf)?.checked_div(10)?;
98+
let base_age = BASE.checked_mul(af)?.checked_div(10)?;
99+
let base_risk = BASE.checked_mul(rsk)?.checked_div(10)?;
100+
101+
let mut items = Vec::new(env);
102+
items.push_back(PremiumQuoteLineItem {
103+
component: String::from_str(env, "type"),
104+
factor: tf,
105+
amount: base_type,
106+
});
107+
items.push_back(PremiumQuoteLineItem {
108+
component: String::from_str(env, "region"),
109+
factor: rf,
110+
amount: base_region,
111+
});
112+
items.push_back(PremiumQuoteLineItem {
113+
component: String::from_str(env, "age"),
114+
factor: af,
115+
amount: base_age,
116+
});
117+
items.push_back(PremiumQuoteLineItem {
118+
component: String::from_str(env, "risk_score"),
119+
factor: rsk,
120+
amount: base_risk,
121+
});
122+
Some(items)
123+
}

contracts/niffyinsure/src/storage.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,23 @@ pub fn next_claim_id(env: &Env) -> u64 {
6060
env.storage().instance().set(&DataKey::ClaimCounter, &next);
6161
next
6262
}
63+
64+
pub fn get_claim_counter(env: &Env) -> u64 {
65+
env.storage()
66+
.instance()
67+
.get(&DataKey::ClaimCounter)
68+
.unwrap_or(0u64)
69+
}
70+
71+
pub fn get_policy_counter(env: &Env, holder: &Address) -> u32 {
72+
env.storage()
73+
.persistent()
74+
.get(&DataKey::PolicyCounter(holder.clone()))
75+
.unwrap_or(0u32)
76+
}
77+
78+
pub fn has_policy(env: &Env, holder: &Address, policy_id: u32) -> bool {
79+
env.storage()
80+
.persistent()
81+
.has(&DataKey::Policy(holder.clone(), policy_id))
82+
}

contracts/niffyinsure/src/types.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,24 @@ pub struct Claim {
150150
pub approve_votes: u32,
151151
pub reject_votes: u32,
152152
}
153+
154+
/// Premium quote line item for UX display.
155+
#[contracttype]
156+
#[derive(Clone)]
157+
pub struct PremiumQuoteLineItem {
158+
pub component: String,
159+
pub factor: i128,
160+
pub amount: i128,
161+
}
162+
163+
/// Structured quote response returned by `generate_premium`.
164+
///
165+
/// Field names and ordering are kept stable for SDK bindings consumed by
166+
/// backend simulation services.
167+
#[contracttype]
168+
#[derive(Clone)]
169+
pub struct PremiumQuote {
170+
pub total_premium: i128,
171+
pub line_items: Option<Vec<PremiumQuoteLineItem>>,
172+
pub valid_until_ledger: u32,
173+
}

0 commit comments

Comments
 (0)