Skip to content

Commit 666df4e

Browse files
authored
Merge pull request #265 from Jambox11/feature/policy-deductible-payout
feat(niffyinsure): policy deductible at bind, net payout, events, tests
2 parents 7fac45b + 1d18588 commit 666df4e

52 files changed

Lines changed: 1159 additions & 24 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/indexer/indexer.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,17 @@ export class IndexerService {
269269
await this.handlePolicyInitiated(tx, dataNative, event);
270270
} else if (mainTopic === 'policy' && subTopic === 'renewed') {
271271
await this.handlePolicyRenewed(tx, dataNative);
272-
} else if (mainTopic === 'claim' && subTopic === 'filed') {
272+
} else if (
273+
(mainTopic === 'claim' && subTopic === 'filed') ||
274+
(mainTopic === 'niffyinsure' && subTopic === 'claim_filed')
275+
) {
273276
await this.handleClaimFiled(tx, dataNative, event);
274277
} else if (mainTopic === 'vote') {
275-
await this.handleVoteCast(tx, topics, dataNative, event);
276-
} else if (mainTopic === 'claim_pd') {
278+
await this.handleVoteCast(tx, topics, dataNative as EventPayload, event);
279+
} else if (
280+
mainTopic === 'claim_pd' ||
281+
(mainTopic === 'niffyinsure' && subTopic === 'claim_paid')
282+
) {
277283
await this.handleClaimProcessed(tx, dataNative, event);
278284
}
279285

backend/src/rpc/soroban.service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ export class SorobanService {
302302
* Build unsigned initiate_policy transaction with simulation-derived footprints.
303303
* Argument ordering matches `contracts/niffyinsure/src/lib.rs` initiate_policy:
304304
* holder, policy_type, region, age_band, coverage_tier, safety_score,
305-
* base_amount, asset, beneficiary (optional payout address).
305+
* base_amount, asset, beneficiary (optional payout address), deductible (optional i128).
306306
*/
307307
async buildInitiatePolicyTransaction(args: {
308308
holder: string;
@@ -314,6 +314,7 @@ export class SorobanService {
314314
baseAmount: bigint;
315315
asset?: string;
316316
beneficiary?: string;
317+
deductible?: bigint | null;
317318
}): Promise<BuildTransactionResult> {
318319
return this.trackRpc('build_initiate_policy', () =>
319320
this._buildInitiatePolicyTransaction(args),
@@ -330,6 +331,7 @@ export class SorobanService {
330331
baseAmount: bigint;
331332
asset?: string;
332333
beneficiary?: string;
334+
deductible?: bigint | null;
333335
}): Promise<BuildTransactionResult> {
334336
const server = this.makeServer();
335337
const account = await this.loadAccount(server, args.holder);
@@ -346,6 +348,14 @@ export class SorobanService {
346348
innerType: 'address',
347349
} as { type: string; innerType: string });
348350

351+
const deductibleScv =
352+
args.deductible == null || args.deductible === undefined
353+
? nativeToScVal(null)
354+
: nativeToScVal(args.deductible, {
355+
type: 'option',
356+
innerType: 'i128',
357+
} as { type: string; innerType: string });
358+
349359
const scArgs = [
350360
new Address(args.holder).toScVal(),
351361
SorobanService.enumVariantToScVal(args.policyType),
@@ -356,6 +366,7 @@ export class SorobanService {
356366
nativeToScVal(args.baseAmount, { type: 'i128' }),
357367
new Address(assetAddress).toScVal(),
358368
beneficiaryScv,
369+
deductibleScv,
359370
];
360371

361372
const contract = new Contract(this.contractId);

backend/src/soroban/golden-vectors.json

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
{ "pos": 5, "name": "safety_score", "scvType": "scvU32", "encoding": "AAAAB3UAAABK" },
2020
{ "pos": 6, "name": "base_amount", "scvType": "scvI128", "encoding": "AAAACQAAAAAAAAAAAAAAAO5rKAA=" },
2121
{ "pos": 7, "name": "asset", "scvType": "scvAddress", "encoding": "AAAAEgAAAAIAAAAA" },
22-
{ "pos": 8, "name": "beneficiary", "scvType": "scvVoid", "encoding": "AAAAAQ==" }
22+
{ "pos": 8, "name": "beneficiary", "scvType": "scvVoid", "encoding": "AAAAAQ==" },
23+
{ "pos": 9, "name": "deductible", "scvType": "scvVoid", "encoding": "AAAAAQ==" }
2324
],
24-
"argCount": 9,
25+
"argCount": 10,
2526
"inputs": {
2627
"holder": "GDVOEGATQV4FGUJKDEBEYT5NAPWJ55MEMJVLC5TU7Y74WD73PPAS4TYW",
2728
"policy_type": "Auto",
@@ -31,7 +32,8 @@
3132
"safety_score": 74,
3233
"base_amount": "1000000000",
3334
"asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
34-
"beneficiary": null
35+
"beneficiary": null,
36+
"deductible": null
3537
}
3638
},
3739
{
@@ -47,9 +49,10 @@
4749
{ "pos": 5, "name": "safety_score", "scvType": "scvU32", "encoding": "AAAAB3UAAAAB" },
4850
{ "pos": 6, "name": "base_amount", "scvType": "scvI128", "encoding": "AAAACQAAAAAAAAAAAAAABN+AAAA=" },
4951
{ "pos": 7, "name": "asset", "scvType": "scvAddress", "encoding": "AAAAEgAAAAIAAAAA" },
50-
{ "pos": 8, "name": "beneficiary", "scvType": "scvVoid", "encoding": "AAAAAQ==" }
52+
{ "pos": 8, "name": "beneficiary", "scvType": "scvVoid", "encoding": "AAAAAQ==" },
53+
{ "pos": 9, "name": "deductible", "scvType": "scvVoid", "encoding": "AAAAAQ==" }
5154
],
52-
"argCount": 9,
55+
"argCount": 10,
5356
"inputs": {
5457
"holder": "GDVOEGATQV4FGUJKDEBEYT5NAPWJ55MEMJVLC5TU7Y74WD73PPAS4TYW",
5558
"policy_type": "Health",
@@ -59,7 +62,8 @@
5962
"safety_score": 1,
6063
"base_amount": "5000000000",
6164
"asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
62-
"beneficiary": null
65+
"beneficiary": null,
66+
"deductible": null
6367
}
6468
},
6569
{
@@ -123,10 +127,10 @@
123127
"negativeVectors": [
124128
{
125129
"id": "initiate_policy__wrong_arg_count",
126-
"description": "Builder must reject calls with fewer than 9 args",
130+
"description": "Builder must reject calls with fewer than 10 args",
127131
"function": "initiate_policy",
128132
"expectError": "ARG_COUNT_MISMATCH",
129-
"badArgCount": 8
133+
"badArgCount": 9
130134
},
131135
{
132136
"id": "initiate_policy__enum_wrong_type",

backend/src/soroban/golden-vectors.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function scvTypeName(val: xdr.ScVal): string {
4141
*
4242
* Argument order MUST match contracts/niffyinsure/src/lib.rs `initiate_policy`:
4343
* holder, policy_type, region, age_band, coverage_tier, safety_score,
44-
* base_amount, asset, beneficiary (Option<Address>)
44+
* base_amount, asset, beneficiary (Option<Address>), deductible (Option<i128>)
4545
*/
4646
function buildInitiatePolicyArgs(inputs: Record<string, unknown>): xdr.ScVal[] {
4747
const ben = inputs['beneficiary'];
@@ -52,6 +52,14 @@ function buildInitiatePolicyArgs(inputs: Record<string, unknown>): xdr.ScVal[] {
5252
type: 'option',
5353
innerType: 'address',
5454
} as { type: string; innerType: string });
55+
const ded = inputs['deductible'];
56+
const deductibleScv =
57+
ded == null || ded === ''
58+
? nativeToScVal(null)
59+
: nativeToScVal(BigInt(String(ded)), {
60+
type: 'option',
61+
innerType: 'i128',
62+
} as { type: string; innerType: string });
5563
return [
5664
new Address(inputs['holder'] as string).toScVal(),
5765
enumVariantToScVal(inputs['policy_type'] as string),
@@ -62,6 +70,7 @@ function buildInitiatePolicyArgs(inputs: Record<string, unknown>): xdr.ScVal[] {
6270
nativeToScVal(BigInt(inputs['base_amount'] as string), { type: 'i128' }),
6371
new Address(inputs['asset'] as string).toScVal(),
6472
beneficiaryScv,
73+
deductibleScv,
6574
];
6675
}
6776

@@ -129,7 +138,7 @@ describe('Soroban argument golden vectors', () => {
129138
// Simulate a builder that omits the last arg (asset)
130139
const inputs = vectors.vectors.find((v) => v.id === 'initiate_policy__basic')!.inputs as Record<string, unknown>;
131140
const full = buildInitiatePolicyArgs(inputs);
132-
const truncated = full.slice(0, 8); // drop beneficiary (9th arg)
141+
const truncated = full.slice(0, 9); // drop deductible (10th arg)
133142
const neg = vectors.negativeVectors.find((n) => n.id === 'initiate_policy__wrong_arg_count')!;
134143
expect(truncated.length).toBe(neg.badArgCount);
135144
expect(truncated.length).not.toBe(

backend/src/soroban/soroban.client.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export async function simulateGeneratePremium(args: {
211211
* resource footprints. Returns the base64 XDR for wallet signing.
212212
*
213213
* Argument ordering matches on-chain initiate_policy: holder, policy_type, region,
214-
* age_band, coverage_tier, safety_score, base_amount, asset, beneficiary.
214+
* age_band, coverage_tier, safety_score, base_amount, asset, beneficiary, deductible.
215215
*
216216
* Multisig: `authRequirements` lists all addresses that must sign the Soroban
217217
* auth entries before submission. Display these to the user before the wallet popup.
@@ -229,6 +229,8 @@ export async function buildInitiatePolicyTransaction(args: {
229229
baseAmount: bigint;
230230
asset?: string;
231231
beneficiary?: string;
232+
/** Optional per-claim deductible (stroops), same asset as premium/payout. */
233+
deductible?: bigint | null;
232234
}): Promise<BuildTransactionResult> {
233235
const server = makeServer();
234236
const account = await loadAccount(server, args.holder);
@@ -246,6 +248,14 @@ export async function buildInitiatePolicyTransaction(args: {
246248
innerType: 'address',
247249
} as { type: string; innerType: string });
248250

251+
const deductibleScv =
252+
args.deductible == null || args.deductible === undefined
253+
? nativeToScVal(null)
254+
: nativeToScVal(args.deductible, {
255+
type: 'option',
256+
innerType: 'i128',
257+
} as { type: string; innerType: string });
258+
249259
const scArgs = [
250260
new Address(args.holder).toScVal(),
251261
enumVariantToScVal(args.policyType),
@@ -256,6 +266,7 @@ export async function buildInitiatePolicyTransaction(args: {
256266
nativeToScVal(args.baseAmount, { type: 'i128' }),
257267
new Address(assetAddress).toScVal(),
258268
beneficiaryScv,
269+
deductibleScv,
259270
];
260271

261272
const contract = new Contract(config.stellar.contractId);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Policy deductible
2+
3+
## Product rule (coverage cap)
4+
5+
- **Coverage cap** applies at **claim filing**: `claim.amount` must be `> 0` and `≤ policy.coverage` (`check_claim_fields`).
6+
- **Deductible** is stored on the policy at **bind** (`initiate_policy`), in the **same asset units** as premium and payout.
7+
- **Deductible does not shrink the filing cap**: the claimant can still file up to the full coverage amount.
8+
- **Payout** uses **gross = `claim.amount`** (approved amount) and **net = gross − deductible_snapshot**, where `deductible_snapshot` is copied from the policy at `file_claim` into `Claim.deductible`.
9+
10+
## Validation
11+
12+
- At bind: `deductible` must be `None`, `Some(0)` (stored as `None`), or `Some(d)` with `0 < d ≤ coverage` (`PolicyError::InvalidDeductible` otherwise).
13+
- `check_policy` also enforces `deductible ≤ coverage` (returns `validate::Error::Overflow` if violated — the `contracterror` enum is at Soroban’s max variant count).
14+
- At payout: if `net ≤ 0`, `process_claim` returns **`validate::Error::ClaimAmountZero`** (again, no spare error discriminant). There is **no** token transfer in that case.
15+
16+
## Events (indexer / UI)
17+
18+
- **`PolicyInitiated`**: includes `deductible: Option<i128>`.
19+
- **`ClaimFiled`** (`niffyinsure` / `claim_filed`): `policy_id`, `claim_amount`, `deductible`, `image_hash`, plus `claim_id` topic.
20+
- **`ClaimProcessed`** (`niffyinsure` / `claim_paid`): `gross_amount`, `deductible`, `amount` (**net** transferred).
21+
22+
## Renewal
23+
24+
- `renew_policy` updates `policy.coverage` to the new `base_amount` and re-runs `check_policy`. If the **existing** deductible exceeds the new coverage, renewal fails with `RenewalError::InvalidDeductible`.
25+
26+
## Tests
27+
28+
```bash
29+
cargo test -p niffyinsure deductible
30+
cargo test -p niffyinsure types_validate::deductible
31+
```

contracts/niffyinsure/src/admin.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,10 @@ fn calculate_protected_balance(env: &Env, asset: &Address) -> i128 {
349349
// Get the policy to check its asset
350350
if let Some(policy) = storage::get_policy(env, &claim.claimant, claim.policy_id) {
351351
if policy.asset == *asset {
352-
protected = protected.saturating_add(claim.amount);
352+
let net = claim.amount.saturating_sub(claim.deductible);
353+
if net > 0 {
354+
protected = protected.saturating_add(net);
355+
}
353356
}
354357
}
355358
}

contracts/niffyinsure/src/claim.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ struct ClaimFiled {
9696
#[topic]
9797
pub claim_id: u64,
9898
pub holder: Address,
99+
pub policy_id: u32,
100+
/// Gross claim amount requested (before deductible at payout).
101+
pub claim_amount: i128,
102+
/// Deductible copied from the policy at filing (for indexer / UI breakdown).
103+
pub deductible: i128,
99104
pub image_hash: u64,
100105
}
101106

@@ -219,6 +224,8 @@ pub fn file_claim(
219224

220225
crate::validate::check_claim_fields(env, amount, policy.coverage, details, image_urls)?;
221226

227+
let deductible_snapshot = policy.deductible.unwrap_or(0);
228+
222229
let duration = storage::get_voting_duration_ledgers(env);
223230
let voting_deadline_ledger = now
224231
.checked_add(duration)
@@ -232,6 +239,7 @@ pub fn file_claim(
232239
policy_id,
233240
claimant: holder.clone(),
234241
amount,
242+
deductible: deductible_snapshot,
235243
asset: policy.asset.clone(),
236244
details: details.clone(),
237245
image_urls: image_urls.clone(),
@@ -256,6 +264,9 @@ pub fn file_claim(
256264
ClaimFiled {
257265
claim_id,
258266
holder: holder.clone(),
267+
policy_id,
268+
claim_amount: amount,
269+
deductible: deductible_snapshot,
259270
image_hash: hash_image_urls(image_urls),
260271
}
261272
.publish(env);
@@ -528,7 +539,17 @@ fn payout(env: &Env, claim: &Claim) -> Result<(), Error> {
528539
return Err(Error::InvalidAsset);
529540
}
530541

531-
if !crate::token::check_balance(env, &policy.asset, claim.amount) {
542+
let gross = claim.amount;
543+
let deductible = claim.deductible;
544+
let net = gross
545+
.checked_sub(deductible)
546+
.ok_or(Error::Overflow)?;
547+
if net <= 0 {
548+
// Enum size capped by Soroban; reuse ClaimAmountZero for "no positive payout after deductible".
549+
return Err(Error::ClaimAmountZero);
550+
}
551+
552+
if !crate::token::check_balance(env, &policy.asset, net) {
532553
return Err(Error::InsufficientTreasury);
533554
}
534555

@@ -542,13 +563,15 @@ fn payout(env: &Env, claim: &Claim) -> Result<(), Error> {
542563
&policy.asset,
543564
&env.current_contract_address(),
544565
&payout_to,
545-
claim.amount,
566+
net,
546567
);
547568

548569
ClaimProcessed {
549570
claim_id: claim.claim_id,
550571
recipient: payout_to,
551-
amount: claim.amount,
572+
gross_amount: gross,
573+
deductible,
574+
amount: net,
552575
}
553576
.publish(env);
554577

contracts/niffyinsure/src/events.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
//! ### clm_filed — ClaimFiledData
2323
//! topics: ("niffyins", "clm_filed", claim_id: u64, holder: Address)
2424
//! ```json
25-
//! { "version": 1, "policy_id": 3, "amount": 5000000, "image_hash": 2864434397, "filed_at": 1234567 }
25+
//! { "version": 1, "policy_id": 3, "amount": 5000000, "deductible": 0, "image_hash": 2864434397, "filed_at": 1234567 }
2626
//! ```
2727
//! - `amount`: stroops (i128)
2828
//! - `image_hash`: FNV-1a u64 hash of concatenated IPFS CIDs

contracts/niffyinsure/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use soroban_sdk::{contract, contractevent, contractimpl, panic_with_error, Addre
2727
#[contract]
2828
pub struct NiffyInsure;
2929
pub use admin::AdminError;
30-
pub use policy::RenewalError;
30+
pub use policy::{PolicyError, RenewalError};
3131

3232
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
3333
#[soroban_sdk::contracterror]
@@ -303,6 +303,7 @@ impl NiffyInsure {
303303
claim_id: c.claim_id,
304304
policy_id: c.policy_id,
305305
amount: c.amount,
306+
deductible: c.deductible,
306307
status: c.status,
307308
filed_at: c.filed_at,
308309
voting_deadline_ledger: c.voting_deadline_ledger,
@@ -372,6 +373,7 @@ impl NiffyInsure {
372373
base_amount: i128,
373374
asset: Address,
374375
beneficiary: Option<Address>,
376+
deductible: Option<i128>,
375377
) -> Result<types::Policy, policy::PolicyError> {
376378
policy::initiate_policy(
377379
&env,
@@ -384,6 +386,7 @@ impl NiffyInsure {
384386
base_amount,
385387
asset,
386388
beneficiary,
389+
deductible,
387390
)
388391
}
389392

@@ -802,6 +805,7 @@ impl NiffyInsure {
802805
start_ledger: 1,
803806
end_ledger,
804807
asset: token,
808+
deductible: None,
805809
beneficiary: None,
806810
terminated_at_ledger: 0,
807811
termination_reason: TerminationReason::None,

0 commit comments

Comments
 (0)