Skip to content

Commit e0eaeee

Browse files
authored
Merge pull request #173 from GoodnessJohn/feat/policy-renewal
feat: implement ledger-bounded policy renewal with premium recalc, cl…
2 parents 7207a69 + 0aed800 commit e0eaeee

7 files changed

Lines changed: 1154 additions & 3 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import {
2+
IsString,
3+
IsNotEmpty,
4+
IsInt,
5+
IsPositive,
6+
IsOptional,
7+
Min,
8+
Max,
9+
IsIn,
10+
} from 'class-validator';
11+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
12+
import { Type } from 'class-transformer';
13+
import type { PolicyTypeEnum, RegionTierEnum } from '../../rpc/soroban.service';
14+
15+
export class BuildRenewalTransactionDto {
16+
@ApiProperty({ description: 'Stellar address of the policyholder', example: 'GABC...' })
17+
@IsString()
18+
@IsNotEmpty()
19+
holder!: string;
20+
21+
@ApiProperty({ description: 'Per-holder monotonic policy ID (u32)', example: 1 })
22+
@Type(() => Number)
23+
@IsInt()
24+
@IsPositive()
25+
policy_id!: number;
26+
27+
/**
28+
* Age is required to deterministically recalculate the renewal premium using
29+
* the same on-chain formula as policy initiation. Must match the original
30+
* policy parameters to avoid premium manipulation.
31+
*/
32+
@ApiProperty({ description: 'Policyholder age (must match original policy)', example: 35 })
33+
@Type(() => Number)
34+
@IsInt()
35+
@Min(18)
36+
@Max(120)
37+
age!: number;
38+
39+
/**
40+
* Risk score [0–100] used in the premium formula. Must be provided by the
41+
* caller and validated against the original policy to prevent replay attacks
42+
* where a lower risk score is substituted to reduce the renewal premium.
43+
*/
44+
@ApiProperty({ description: 'Risk score 0–100 (must match original policy)', example: 5 })
45+
@Type(() => Number)
46+
@IsInt()
47+
@Min(0)
48+
@Max(100)
49+
risk_score!: number;
50+
51+
/**
52+
* Optional override for renewal duration in ledgers.
53+
* Defaults to POLICY_DURATION_LEDGERS (~1 year) if omitted.
54+
*/
55+
@ApiPropertyOptional({ description: 'Renewal duration in ledgers (default: ~1 year)', example: 6307200 })
56+
@IsOptional()
57+
@Type(() => Number)
58+
@IsInt()
59+
@IsPositive()
60+
duration_ledgers?: number;
61+
62+
/**
63+
* SEP-41 asset contract ID for premium payment.
64+
* Must match the asset bound to the original policy (assetContractId).
65+
* Omit to use the policy's existing asset.
66+
*/
67+
@ApiPropertyOptional({ description: 'SEP-41 asset contract ID (defaults to policy asset)' })
68+
@IsOptional()
69+
@IsString()
70+
asset?: string;
71+
}
72+
73+
export class RenewalQuoteResponseDto {
74+
@ApiProperty({ description: 'Renewal premium in stroops (i128 as string)', example: '50000000' })
75+
premiumStroops!: string;
76+
77+
@ApiProperty({ description: 'Renewal premium in XLM', example: '5.0000000' })
78+
premiumXlm!: string;
79+
80+
@ApiProperty({ description: 'Previous policy expiry ledger', example: 1000000 })
81+
previousEndLedger!: number;
82+
83+
@ApiProperty({ description: 'New policy expiry ledger after renewal', example: 7307200 })
84+
newEndLedger!: number;
85+
86+
@ApiProperty({ description: 'Current ledger at time of quote', example: 950000 })
87+
currentLedger!: number;
88+
89+
@ApiProperty({ description: 'Ledger at which the renewal window opened', example: 879040 })
90+
windowOpenLedger!: number;
91+
92+
@ApiProperty({ description: 'Exclusive ledger at which the renewal window closes', example: 1017280 })
93+
windowCloseLedger!: number;
94+
95+
@ApiProperty({ description: 'Whether the premium was computed on-chain or via local fallback' })
96+
premiumSource!: 'simulation' | 'local_fallback';
97+
}
98+
99+
export class BuildRenewalTransactionResponseDto extends RenewalQuoteResponseDto {
100+
@ApiProperty({ description: 'Base64-encoded unsigned transaction XDR for wallet signing' })
101+
unsignedXdr!: string;
102+
103+
@ApiProperty({ description: 'Minimum resource fee in stroops' })
104+
minResourceFee!: string;
105+
106+
@ApiProperty({ description: 'Base fee in stroops' })
107+
baseFee!: string;
108+
109+
@ApiProperty({ description: 'Total estimated fee in stroops' })
110+
totalEstimatedFee!: string;
111+
112+
@ApiProperty({ description: 'Total estimated fee in XLM' })
113+
totalEstimatedFeeXlm!: string;
114+
115+
@ApiProperty({ description: 'Addresses that must sign Soroban auth entries' })
116+
authRequirements!: Array<{ address: string; isContract: boolean }>;
117+
118+
@ApiProperty({ description: 'Memo convention note' })
119+
memoConvention!: string;
120+
}
121+
122+
/**
123+
* PolicyRenewed event payload — emitted exactly once per successful renewal.
124+
* Structured for off-chain indexing and timeline UIs.
125+
*
126+
* EVENT INTEGRITY:
127+
* - Emitted only after the unsigned transaction is successfully assembled.
128+
* - Contains previousEndLedger and newEndLedger for timeline reconstruction.
129+
* - termVersion allows UIs to detect term changes between renewal cycles.
130+
* - premiumPaidStroops enables cumulative premium tracking with checked arithmetic.
131+
*/
132+
export interface PolicyRenewedEvent {
133+
/** Composite policy key: holderAddress:policyId */
134+
policyCompositeId: string;
135+
holderAddress: string;
136+
policyId: number;
137+
/** Ledger at which the previous term expired (inclusive end of old term). */
138+
previousEndLedger: number;
139+
/** Ledger at which the new term expires (inclusive end of new term). */
140+
newEndLedger: number;
141+
/** Renewal premium in stroops (i128 as string) — use BigInt for arithmetic. */
142+
premiumPaidStroops: string;
143+
/** Term version hash or identifier, if the policy terms changed at renewal. */
144+
termVersion: string | null;
145+
/** Ledger at which the renewal was requested (for audit trail). */
146+
renewalRequestedAtLedger: number;
147+
/** ISO-8601 wall-clock timestamp of the renewal request (approximate). */
148+
renewalRequestedAt: string;
149+
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Module } from '@nestjs/common';
22
import { PolicyController } from './policy.controller';
33
import { PolicyService } from './policy.service';
4+
import { RenewalController } from './renewal.controller';
5+
import { RenewalService } from './renewal.service';
46
import { RpcModule } from '../rpc/rpc.module';
57

68
@Module({
79
imports: [RpcModule],
8-
controllers: [PolicyController],
9-
providers: [PolicyService],
10+
controllers: [PolicyController, RenewalController],
11+
providers: [PolicyService, RenewalService],
12+
exports: [RenewalService],
1013
})
1114
export class PolicyModule {}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Ledger-bounded renewal window constants.
3+
*
4+
* RENEWAL WINDOW SEMANTICS
5+
* ─────────────────────────────────────────────────────────────────────────────
6+
* A renewal is valid when the current ledger falls within the half-open interval:
7+
*
8+
* [policy.endLedger - RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY,
9+
* policy.endLedger + RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY)
10+
*
11+
* Inclusive lower bound:
12+
* currentLedger >= policy.endLedger - RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY
13+
* Renewals attempted before this ledger are rejected with RENEWAL_TOO_EARLY.
14+
*
15+
* Exclusive upper bound:
16+
* currentLedger < policy.endLedger + RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY
17+
* Renewals attempted at or after this ledger are rejected with RENEWAL_TOO_LATE.
18+
* The upper bound is exclusive so that a policy expiring at ledger N and a
19+
* grace period of G means the last valid renewal ledger is N+G-1.
20+
*
21+
* LEDGER TIME ASSUMPTIONS
22+
* ─────────────────────────────────────────────────────────────────────────────
23+
* Stellar closes a ledger approximately every 5 seconds (SECONDS_PER_LEDGER).
24+
* This is a statistical average; individual ledger close times vary ±1–2 s.
25+
*
26+
* SERVER-CLIENT SKEW CONSIDERATIONS
27+
* ─────────────────────────────────────────────────────────────────────────────
28+
* The backend fetches the authoritative ledger sequence from the Soroban RPC
29+
* (server.getLatestLedger()) and uses that for all window checks.
30+
*
31+
* Frontend countdown UIs should:
32+
* 1. Fetch currentLedger from GET /health or a dedicated /ledger endpoint.
33+
* 2. Compute estimated wall-clock time as:
34+
* remainingLedgers * SECONDS_PER_LEDGER seconds
35+
* 3. Add a UI buffer of ~2 ledgers (~10 s) to account for RPC propagation
36+
* lag and the time between the user clicking "Renew" and the backend
37+
* receiving the request.
38+
* 4. Never rely on Date.now() alone — ledger sequence is the canonical clock.
39+
*
40+
* The backend does NOT add any skew buffer; it enforces the window exactly as
41+
* defined by the constants below. Frontends should open the renewal UI slightly
42+
* before RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY to absorb network latency.
43+
*/
44+
45+
/** Approximate ledger close time in seconds (Stellar mainnet/testnet average). */
46+
export const SECONDS_PER_LEDGER = 5;
47+
48+
/**
49+
* Standard policy duration in ledgers (~1 year at 5 s/ledger).
50+
* 365 days × 24 h × 3600 s / 5 s = 6,307,200 ledgers.
51+
*/
52+
export const POLICY_DURATION_LEDGERS = 6_307_200;
53+
54+
/**
55+
* How many ledgers before policy.endLedger the renewal window opens.
56+
* Inclusive lower bound: currentLedger >= endLedger - RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY.
57+
*
58+
* Default: 120,960 ledgers ≈ 7 days (120,960 × 5 s = 604,800 s).
59+
*/
60+
export const RENEWAL_OPEN_LEDGERS_BEFORE_EXPIRY = 120_960;
61+
62+
/**
63+
* How many ledgers after policy.endLedger the grace window closes.
64+
* Exclusive upper bound: currentLedger < endLedger + RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY.
65+
*
66+
* Default: 17,280 ledgers ≈ 1 day (17,280 × 5 s = 86,400 s).
67+
* The last valid renewal ledger is endLedger + RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY - 1.
68+
*/
69+
export const RENEWAL_GRACE_LEDGERS_AFTER_EXPIRY = 17_280;
70+
71+
/**
72+
* Claim states that block renewal.
73+
*
74+
* OPEN-CLAIM RULE (product specification):
75+
* A policy with a claim in PENDING status (on-chain: "Processing") cannot be
76+
* renewed until the claim is finalized (APPROVED, PAID, or REJECTED).
77+
*
78+
* Rationale: allowing renewal while a claim is unresolved creates ambiguity
79+
* about which policy term covers the outstanding liability. The contract
80+
* enforces this on-chain; the backend mirrors it to provide early rejection
81+
* with a clear error code before building an unsigned transaction.
82+
*
83+
* APPROVED claims are NOT blocking: the claim has been decided; only payment
84+
* disbursement is pending, which does not affect the new policy term.
85+
*/
86+
export const BLOCKING_CLAIM_STATUSES = ['PENDING'] as const;
87+
export type BlockingClaimStatus = (typeof BLOCKING_CLAIM_STATUSES)[number];
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
2+
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
3+
import { Throttle } from '@nestjs/throttler';
4+
import { RenewalService } from './renewal.service';
5+
import { BuildRenewalTransactionDto } from './dto/renewal.dto';
6+
7+
@ApiTags('Policy')
8+
@Controller('policy')
9+
export class RenewalController {
10+
constructor(private readonly renewalService: RenewalService) {}
11+
12+
/**
13+
* POST /api/policy/renewal/quote
14+
*
15+
* Returns a renewal premium quote and window metadata without building a
16+
* transaction. Safe to call repeatedly — no side effects.
17+
*
18+
* Errors: POLICY_NOT_FOUND, POLICY_INACTIVE, RENEWAL_TOO_EARLY,
19+
* RENEWAL_TOO_LATE, OPEN_CLAIM_BLOCKS_RENEWAL
20+
*/
21+
@Post('renewal/quote')
22+
@HttpCode(HttpStatus.OK)
23+
@Throttle({ default: { limit: 30, ttl: 60_000 } })
24+
@ApiOperation({ summary: 'Get renewal premium quote and window metadata' })
25+
@ApiResponse({ status: 200, description: 'Renewal quote with window ledgers and premium' })
26+
@ApiResponse({ status: 400, description: 'Window violation or open claim' })
27+
@ApiResponse({ status: 404, description: 'Policy not found' })
28+
@ApiResponse({ status: 429, description: 'Rate limited' })
29+
async quoteRenewal(@Body() dto: BuildRenewalTransactionDto) {
30+
return this.renewalService.quoteRenewal(dto);
31+
}
32+
33+
/**
34+
* POST /api/policy/renewal/build-transaction
35+
*
36+
* Returns unsigned XDR for Freighter / wallet-kit to sign.
37+
* Emits a PolicyRenewed event exactly once on success.
38+
* Rate-limited (10 req/min) to protect Soroban RPC quotas.
39+
*
40+
* Errors: POLICY_NOT_FOUND, POLICY_INACTIVE, RENEWAL_TOO_EARLY,
41+
* RENEWAL_TOO_LATE, OPEN_CLAIM_BLOCKS_RENEWAL, PREMIUM_OVERFLOW,
42+
* ACCOUNT_NOT_FOUND, CONTRACT_NOT_DEPLOYED, SIMULATION_FAILED
43+
*/
44+
@Post('renewal/build-transaction')
45+
@HttpCode(HttpStatus.OK)
46+
@Throttle({ default: { limit: 10, ttl: 60_000 } })
47+
@ApiOperation({ summary: 'Build unsigned renew_policy transaction' })
48+
@ApiResponse({ status: 200, description: 'Unsigned XDR + fee estimates + renewal metadata' })
49+
@ApiResponse({ status: 400, description: 'Window violation, open claim, or simulation error' })
50+
@ApiResponse({ status: 404, description: 'Policy not found' })
51+
@ApiResponse({ status: 429, description: 'Rate limited — protects RPC quotas' })
52+
@ApiResponse({ status: 503, description: 'Contract not deployed or RPC unavailable' })
53+
async buildRenewalTransaction(@Body() dto: BuildRenewalTransactionDto) {
54+
return this.renewalService.buildRenewalTransaction(dto);
55+
}
56+
}

0 commit comments

Comments
 (0)