Skip to content

Commit 1137c5b

Browse files
authored
Merge branch 'main' into feature/user-data-export
2 parents 87b4b39 + 48f39c2 commit 1137c5b

17 files changed

Lines changed: 1181 additions & 3 deletions

backend/src/claims/claims.controller.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
UseGuards,
77
ParseIntPipe,
88
DefaultValuePipe,
9+
Post,
10+
HttpCode,
11+
HttpStatus,
12+
Body,
913
} from '@nestjs/common';
1014
import {
1115
ApiTags,
@@ -16,6 +20,8 @@ import {
1620
} from '@nestjs/swagger';
1721
import { ClaimsService } from './claims.service';
1822
import { ClaimsListResponseDto, ClaimDetailResponseDto } from './dto/claim.dto';
23+
import { BuildClaimTransactionDto } from './dto/build-claim-transaction.dto';
24+
import { SubmitTransactionDto } from './dto/submit-transaction.dto';
1925
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
2026
import { WalletAddress } from '../auth/decorators/wallet-address.decorator';
2127

@@ -62,4 +68,27 @@ export class ClaimsController {
6268
async getClaim(@Param('id', ParseIntPipe) id: number): Promise<ClaimDetailResponseDto> {
6369
return this.claimsService.getClaimById(id);
6470
}
71+
72+
@Post('build-transaction')
73+
@HttpCode(HttpStatus.OK)
74+
@Throttle({ default: { limit: 10, ttl: 60_000 } })
75+
@ApiOperation({ summary: 'Build unsigned file_claim transaction' })
76+
@ApiResponse({ status: 200, description: 'Unsigned transaction XDR + fee estimates' })
77+
async buildTransaction(@Body() dto: BuildClaimTransactionDto) {
78+
return this.claimsService.buildTransaction({
79+
holder: dto.holder,
80+
policyId: dto.policyId,
81+
amount: BigInt(dto.amount),
82+
details: dto.details,
83+
imageUrls: dto.imageUrls,
84+
});
85+
}
86+
87+
@Post('submit')
88+
@HttpCode(HttpStatus.OK)
89+
@ApiOperation({ summary: 'Submit signed claim transaction' })
90+
@ApiResponse({ status: 200, description: 'Transaction submitted' })
91+
async submitTransaction(@Body() dto: SubmitTransactionDto) {
92+
return this.claimsService.submitTransaction(dto.transactionXdr);
93+
}
6594
}

backend/src/claims/claims.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
22
import { ClaimsController } from './claims.controller';
33
import { ClaimsService } from './claims.service';
44
import { SanitizationService } from './sanitization.service';
5+
import { RpcModule } from '../rpc/rpc.module';
56

67
@Module({
8+
imports: [RpcModule],
79
controllers: [ClaimsController],
810
providers: [ClaimsService, SanitizationService],
911
exports: [ClaimsService],

backend/src/claims/claims.service.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { Prisma } from '@prisma/client';
4-
import { RedisService } from '../cache/redis.service';
5-
import { PrismaService } from '../prisma/prisma.service';
6-
import { SanitizationService } from './sanitization.service';
4+
import { SorobanService } from '../rpc/soroban.service';
75
import {
86
ClaimDetailResponseDto,
97
ClaimMetadataDto,
@@ -42,6 +40,7 @@ export class ClaimsService {
4240
private readonly redis: RedisService,
4341
private readonly sanitization: SanitizationService,
4442
private readonly config: ConfigService,
43+
private readonly soroban: SorobanService,
4544
) {
4645
this.cacheTtl = this.config.get<number>('CACHE_TTL_SECONDS', 60);
4746
this.ipfsGateway = this.config.get<string>('IPFS_GATEWAY', 'https://ipfs.io');
@@ -286,4 +285,29 @@ export class ClaimsService {
286285
await this.redis.delPattern('claims:list:*');
287286
this.logger.log(`Cache invalidated for claim ${claimId || 'all'}`);
288287
}
288+
289+
/**
290+
* Build an unsigned file_claim transaction
291+
*/
292+
async buildTransaction(args: {
293+
holder: string;
294+
policyId: number;
295+
amount: bigint;
296+
details: string;
297+
imageUrls: string[];
298+
}) {
299+
return this.soroban.buildFileClaimTransaction(args);
300+
}
301+
302+
/**
303+
* Submit a signed transaction
304+
*/
305+
async submitTransaction(transactionXdr: string) {
306+
const result = await this.soroban.submitTransaction(transactionXdr);
307+
308+
// Invalidate claims list cache so the new claim appears
309+
await this.invalidateCache();
310+
311+
return result;
312+
}
289313
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import {
3+
IsArray,
4+
IsInt,
5+
IsPositive,
6+
IsString,
7+
Matches,
8+
MaxLength,
9+
Validate,
10+
ValidatorConstraint,
11+
ValidatorConstraintInterface,
12+
} from 'class-validator';
13+
14+
@ValidatorConstraint({ name: 'posIntString', async: false })
15+
class PositiveIntStringConstraint implements ValidatorConstraintInterface {
16+
validate(value: string) {
17+
return /^\d+$/.test(value) && BigInt(value) > BigInt(0);
18+
}
19+
defaultMessage() {
20+
return 'amount must be a positive integer string (stroops)';
21+
}
22+
}
23+
24+
export class BuildClaimTransactionDto {
25+
@ApiProperty({
26+
description: 'Stellar public key of the claimant.',
27+
example: 'GDVOEGATQV4FGUJKDEBEYT5NAPWJ55MEMJVLC5TU7Y74WD73PPAS4TYW',
28+
})
29+
@IsString()
30+
@Matches(/^G[A-Z2-7]{55}$/, {
31+
message: 'holder must be a valid Stellar public key (G...)',
32+
})
33+
holder: string;
34+
35+
@ApiProperty({
36+
description: 'The ID of the policy to claim against.',
37+
example: 1,
38+
})
39+
@IsInt()
40+
@IsPositive()
41+
policyId: number;
42+
43+
@ApiProperty({
44+
description: 'Claim amount in stroops as an integer string.',
45+
example: '500000000',
46+
})
47+
@IsString()
48+
@Validate(PositiveIntStringConstraint)
49+
amount: string;
50+
51+
@ApiProperty({
52+
description: 'Narrative description of the claim.',
53+
example: 'Water damage in the kitchen due to pipe burst.',
54+
})
55+
@IsString()
56+
@MaxLength(1000)
57+
details: string;
58+
59+
@ApiProperty({
60+
description: 'List of IPFS URLs (or CIDs) for evidence images.',
61+
example: ['https://ipfs.io/ipfs/Qm...'],
62+
})
63+
@IsArray()
64+
@IsString({ each: true })
65+
imageUrls: string[];
66+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString, IsNotEmpty } from 'class-validator';
3+
4+
export class SubmitTransactionDto {
5+
@ApiProperty({
6+
description: 'Base64-encoded signed transaction envelope (XDR).',
7+
example: 'AAAAAgAAA...',
8+
})
9+
@IsString()
10+
@IsNotEmpty()
11+
transactionXdr: string;
12+
}

backend/src/rpc/soroban.service.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,117 @@ export class SorobanService {
312312
};
313313
}
314314

315+
/**
316+
* Build unsigned file_claim transaction.
317+
* Signature: file_claim(holder, policy_id, amount, details, image_urls)
318+
*/
319+
async buildFileClaimTransaction(args: {
320+
holder: string;
321+
policyId: number;
322+
amount: bigint;
323+
details: string;
324+
imageUrls: string[];
325+
}): Promise<BuildTransactionResult> {
326+
const server = this.makeServer();
327+
const account = await this.loadAccount(server, args.holder);
328+
const ledgerInfo = await server.getLatestLedger();
329+
330+
const scArgs = [
331+
new Address(args.holder).toScVal(),
332+
nativeToScVal(args.policyId, { type: 'u32' }),
333+
nativeToScVal(args.amount, { type: 'i128' }),
334+
nativeToScVal(args.details, { type: 'string' }),
335+
xdr.ScVal.scvVec(
336+
args.imageUrls.map((url) => nativeToScVal(url, { type: 'string' })),
337+
),
338+
];
339+
340+
const contract = new Contract(this.contractId);
341+
const tx = new TransactionBuilder(account, {
342+
fee: BASE_FEE,
343+
networkPassphrase: this.networkPassphrase,
344+
})
345+
.addOperation(contract.call('file_claim', ...scArgs))
346+
.setTimeout(30)
347+
.build();
348+
349+
const simulation = await server.simulateTransaction(tx);
350+
351+
if (Api.isSimulationError(simulation)) {
352+
const err = simulation as SorobanRpc.Api.SimulateTransactionErrorResponse;
353+
this.mapSimulationError(err.error);
354+
}
355+
356+
const successSim =
357+
simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse;
358+
const assembled = assembleTransaction(tx, successSim);
359+
const unsignedXdr = assembled.build().toEnvelope().toXDR('base64');
360+
361+
const baseFee = BigInt(BASE_FEE);
362+
const resourceFee = BigInt(successSim.minResourceFee ?? '0');
363+
const totalFee = baseFee + resourceFee;
364+
365+
const authRequirements: AuthRequirement[] = [];
366+
for (const authEntry of successSim.result?.auth ?? []) {
367+
const credentials = authEntry.credentials();
368+
if (
369+
credentials.switch().value ===
370+
xdr.SorobanCredentialsType.sorobanCredentialsAddress().value
371+
) {
372+
const addrObj = credentials.address().address();
373+
const stellarAddr = Address.fromScAddress(addrObj);
374+
const isContract =
375+
addrObj.switch().value ===
376+
xdr.ScAddressType.scAddressTypeContract().value;
377+
authRequirements.push({ address: stellarAddr.toString(), isContract });
378+
}
379+
}
380+
381+
if (!authRequirements.some((r) => r.address === args.holder)) {
382+
authRequirements.unshift({ address: args.holder, isContract: false });
383+
}
384+
385+
return {
386+
unsignedXdr,
387+
minResourceFee: successSim.minResourceFee ?? '0',
388+
baseFee: BASE_FEE.toString(),
389+
totalEstimatedFee: totalFee.toString(),
390+
totalEstimatedFeeXlm: SorobanService.stroopsToXlm(totalFee),
391+
authRequirements,
392+
memoConvention:
393+
'NiffyInsure does not use memos for protocol correlation. ' +
394+
'Claim details are embedded in the contract call.',
395+
currentLedger: ledgerInfo.sequence,
396+
};
397+
}
398+
399+
/**
400+
* Submit a signed transaction to the Soroban RPC.
401+
* Expects base64-encoded XDR (envelope).
402+
*/
403+
async submitTransaction(transactionXdr: string): Promise<SorobanRpc.Api.SendTransactionResponse> {
404+
const server = this.makeServer();
405+
const tx = TransactionBuilder.fromEnvelope(transactionXdr, this.networkPassphrase);
406+
407+
try {
408+
const response = await server.sendTransaction(tx);
409+
if (response.status === 'ERROR') {
410+
throw new BadRequestException({
411+
code: 'TRANSACTION_REJECTED',
412+
message: 'The transaction was rejected by the network.',
413+
details: response.errorResultXdr,
414+
});
415+
}
416+
return response;
417+
} catch (err) {
418+
this.logger.error('Transaction submission error', err);
419+
throw new ServiceUnavailableException({
420+
code: 'SUBMISSION_FAILED',
421+
message: 'Failed to submit transaction to the network.',
422+
});
423+
}
424+
}
425+
315426
/**
316427
* Fetch events for the configured contract ID within a ledger range.
317428
*/
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client';
2+
3+
import React, { useEffect, useState } from 'react';
4+
import { useParams, useRouter } from 'next/navigation';
5+
import { PolicyAPI } from '@/lib/api/policy';
6+
import { Policy } from '@/lib/schemas/policy';
7+
import { ClaimWizard } from '@/components/claims/ClaimWizard';
8+
import { Button, Card, CardContent, Skeleton, useToast } from '@/components/ui';
9+
import { AlertCircle, ArrowLeft } from 'lucide-react';
10+
11+
export default function FileClaimPage() {
12+
const params = useParams();
13+
const router = useRouter();
14+
const { toast } = useToast();
15+
const policyId = params.id as string;
16+
17+
const [policy, setPolicy] = useState<Policy | null>(null);
18+
const [isLoading, setIsLoading] = useState(true);
19+
const [error, setError] = useState<string | null>(null);
20+
21+
useEffect(() => {
22+
async function loadPolicy() {
23+
try {
24+
const data = await PolicyAPI.getPolicy(policyId);
25+
setPolicy(data);
26+
} catch (err) {
27+
console.error('Failed to load policy:', err);
28+
setError('Could not load policy details. Please ensure the policy exists and you have access.');
29+
} finally {
30+
setIsLoading(false);
31+
}
32+
}
33+
34+
if (policyId) {
35+
loadPolicy();
36+
}
37+
}, [policyId]);
38+
39+
if (isLoading) {
40+
return (
41+
<div className="container max-w-3xl py-10 space-y-4">
42+
<Skeleton className="h-10 w-48" />
43+
<Skeleton className="h-[400px] w-full" />
44+
</div>
45+
);
46+
}
47+
48+
if (error || !policy) {
49+
return (
50+
<div className="container max-w-3xl py-20">
51+
<Card className="border-destructive/50 bg-destructive/10">
52+
<CardContent className="pt-6 text-center space-y-4">
53+
<AlertCircle className="mx-auto h-12 w-12 text-destructive" />
54+
<div className="space-y-2">
55+
<h2 className="text-xl font-bold">Error Loading Policy</h2>
56+
<p className="text-muted-foreground">{error}</p>
57+
</div>
58+
<Button onClick={() => router.push('/dashboard')} variant="outline">
59+
Back to Dashboard
60+
</Button>
61+
</CardContent>
62+
</Card>
63+
</div>
64+
);
65+
}
66+
67+
return (
68+
<div className="container max-w-4xl py-10 space-y-6">
69+
<div className="flex items-center gap-4">
70+
<Button
71+
variant="ghost"
72+
size="icon"
73+
onClick={() => router.back()}
74+
className="h-9 w-9"
75+
>
76+
<ArrowLeft className="h-5 w-5" />
77+
</Button>
78+
<h1 className="text-3xl font-bold tracking-tight">File Insurance Claim</h1>
79+
</div>
80+
81+
<ClaimWizard
82+
policyId={policyId}
83+
maxCoverage={policy.coverageAmount.toString()}
84+
/>
85+
</div>
86+
);
87+
}

0 commit comments

Comments
 (0)