Skip to content

Commit 5e32b33

Browse files
authored
Merge pull request #210 from Sendi0011/feature/insurance-contract-read-layer
feat(insurance): add Soroban contract read layer and REST API routes
2 parents 054f5f6 + e71442e commit 5e32b33

File tree

5 files changed

+283
-82
lines changed

5 files changed

+283
-82
lines changed

app/api/insurance/[id]/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getPolicy } from "@/lib/contracts/insurance";
3+
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
4+
5+
// GET /api/insurance/:id
6+
export async function GET(
7+
request: NextRequest,
8+
{ params }: { params: { id: string } }
9+
) {
10+
if (!validateAuth(request)) {
11+
return unauthorizedResponse();
12+
}
13+
14+
try {
15+
const policy = await getPolicy(params.id);
16+
return NextResponse.json({ policy });
17+
} catch (error: unknown) {
18+
if (
19+
typeof error === "object" &&
20+
error !== null &&
21+
(error as { code?: string }).code === "NOT_FOUND"
22+
) {
23+
return NextResponse.json({ error: "Policy not found" }, { status: 404 });
24+
}
25+
26+
console.error("[GET /api/insurance/[id]]", error);
27+
return NextResponse.json(
28+
{ error: "Failed to fetch policy from contract" },
29+
{ status: 502 }
30+
);
31+
}
32+
}

app/api/insurance/route.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getActivePolicies } from "@/lib/contracts/insurance";
3+
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
4+
5+
// GET /api/insurance
6+
// Returns active policies for the authenticated owner.
7+
// Query param: ?owner=G... (Stellar account address)
8+
export async function GET(request: NextRequest) {
9+
if (!validateAuth(request)) {
10+
return unauthorizedResponse();
11+
}
12+
13+
const { searchParams } = new URL(request.url);
14+
const owner = searchParams.get("owner");
15+
16+
if (!owner) {
17+
return NextResponse.json(
18+
{ error: "Missing required query parameter: owner" },
19+
{ status: 400 }
20+
);
21+
}
22+
23+
try {
24+
const policies = await getActivePolicies(owner);
25+
return NextResponse.json({ policies });
26+
} catch (error: unknown) {
27+
const err = error as { code?: string };
28+
29+
if (err.code === "INVALID_ADDRESS") {
30+
return NextResponse.json({ error: "Invalid Stellar address" }, { status: 400 });
31+
}
32+
33+
console.error("[GET /api/insurance]", error);
34+
return NextResponse.json(
35+
{ error: "Failed to fetch policies from contract" },
36+
{ status: 502 }
37+
);
38+
}
39+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getTotalMonthlyPremium } from "@/lib/contracts/insurance";
3+
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
4+
5+
// GET /api/insurance/total-premium?owner=G...
6+
export async function GET(request: NextRequest) {
7+
if (!validateAuth(request)) {
8+
return unauthorizedResponse();
9+
}
10+
11+
const { searchParams } = new URL(request.url);
12+
const owner = searchParams.get("owner");
13+
14+
if (!owner) {
15+
return NextResponse.json(
16+
{ error: "Missing required query parameter: owner" },
17+
{ status: 400 }
18+
);
19+
}
20+
21+
try {
22+
const totalMonthlyPremium = await getTotalMonthlyPremium(owner);
23+
return NextResponse.json({ totalMonthlyPremium });
24+
} catch (error: unknown) {
25+
const err = error as { code?: string };
26+
27+
if (err.code === "INVALID_ADDRESS") {
28+
return NextResponse.json({ error: "Invalid Stellar address" }, { status: 400 });
29+
}
30+
31+
console.error("[GET /api/insurance/total-premium]", error);
32+
return NextResponse.json(
33+
{ error: "Failed to fetch total premium from contract" },
34+
{ status: 502 }
35+
);
36+
}
37+
}

lib/auth.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NextRequest } from "next/server";
2+
3+
/**
4+
* Validates the Authorization header.
5+
* Expects: Authorization: Bearer <token>
6+
* The token is checked against the AUTH_SECRET env variable.
7+
*/
8+
export function validateAuth(request: NextRequest): boolean {
9+
const authHeader = request.headers.get("authorization") ?? "";
10+
const token = authHeader.startsWith("Bearer ")
11+
? authHeader.slice(7).trim()
12+
: null;
13+
14+
if (!token) return false;
15+
return token === process.env.AUTH_SECRET;
16+
}
17+
18+
export function unauthorizedResponse() {
19+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
20+
status: 401,
21+
headers: { "Content-Type": "application/json" },
22+
});
23+
}

lib/contracts/insurance.ts

Lines changed: 152 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,154 @@
1-
import { Server, Networks, Account, TransactionBuilder, Operation, BASE_FEE, StrKey } from '@stellar/stellar-sdk'
2-
3-
const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon-testnet.stellar.org'
4-
const NETWORK_PASSPHRASE = process.env.NETWORK_PASSPHRASE || Networks.TESTNET
5-
const server = new Server(HORIZON_URL)
6-
7-
function validatePublicKey(pk: string) {
8-
try {
9-
return StrKey.isValidEd25519PublicKey(pk)
10-
} catch (e) {
11-
return false
1+
import {
2+
Contract,
3+
Horizon,
4+
nativeToScVal,
5+
scValToNative,
6+
xdr,
7+
} from "@stellar/stellar-sdk";
8+
9+
// ── Config ──────────────────────────────────────────────────────────────────
10+
const SOROBAN_RPC_URL =
11+
process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";
12+
const INSURANCE_CONTRACT_ID = process.env.INSURANCE_CONTRACT_ID ?? "";
13+
const NETWORK_PASSPHRASE =
14+
process.env.STELLAR_NETWORK_PASSPHRASE ??
15+
"Test SDF Network ; September 2015";
16+
17+
// ── Types ────────────────────────────────────────────────────────────────────
18+
export interface Policy {
19+
id: string;
20+
name: string;
21+
coverageType: string;
22+
monthlyPremium: number;
23+
coverageAmount: number;
24+
active: boolean;
25+
nextPaymentDate: string;
1226
}
27+
28+
// ── RPC Client ───────────────────────────────────────────────────────────────
29+
function getRpcServer() {
30+
const { rpc } = require("@stellar/stellar-sdk") as {
31+
rpc: {
32+
Server: new (url: string) => {
33+
simulateTransaction: (tx: unknown) => Promise<unknown>;
34+
};
35+
};
36+
};
37+
return new rpc.Server(SOROBAN_RPC_URL);
1338
}
14-
15-
async function loadAccount(accountId: string) {
16-
if (!validatePublicKey(accountId)) throw new Error('invalid-account')
17-
return await server.loadAccount(accountId)
18-
}
19-
20-
export async function buildCreatePolicyTx(owner: string, name: string, coverageType: string, monthlyPremium: number, coverageAmount: number) {
21-
if (!validatePublicKey(owner)) throw new Error('invalid-owner')
22-
if (!name || typeof name !== 'string') throw new Error('invalid-name')
23-
if (!coverageType || typeof coverageType !== 'string') throw new Error('invalid-coverageType')
24-
if (!(monthlyPremium > 0)) throw new Error('invalid-monthlyPremium')
25-
if (!(coverageAmount > 0)) throw new Error('invalid-coverageAmount')
26-
27-
const acctResp = await loadAccount(owner)
28-
const source = new Account(owner, acctResp.sequence)
29-
30-
const txBuilder = new TransactionBuilder(source, {
31-
fee: BASE_FEE,
32-
networkPassphrase: NETWORK_PASSPHRASE,
33-
})
34-
35-
txBuilder.addOperation(Operation.manageData({ name: 'policy:name', value: name.slice(0, 64) }))
36-
txBuilder.addOperation(Operation.manageData({ name: 'policy:coverageType', value: coverageType.slice(0, 64) }))
37-
txBuilder.addOperation(Operation.manageData({ name: 'policy:monthlyPremium', value: String(monthlyPremium) }))
38-
txBuilder.addOperation(Operation.manageData({ name: 'policy:coverageAmount', value: String(coverageAmount) }))
39-
40-
const tx = txBuilder.setTimeout(300).build()
41-
return tx.toXDR()
42-
}
43-
44-
export async function buildPayPremiumTx(caller: string, policyId: string) {
45-
if (!validatePublicKey(caller)) throw new Error('invalid-caller')
46-
if (!policyId) throw new Error('invalid-policyId')
47-
48-
const acctResp = await loadAccount(caller)
49-
const source = new Account(caller, acctResp.sequence)
50-
51-
const txBuilder = new TransactionBuilder(source, {
52-
fee: BASE_FEE,
53-
networkPassphrase: NETWORK_PASSPHRASE,
54-
})
55-
56-
txBuilder.addOperation(Operation.manageData({ name: `policy:pay:${policyId}`, value: '1' }))
57-
58-
const tx = txBuilder.setTimeout(300).build()
59-
return tx.toXDR()
60-
}
61-
62-
export async function buildDeactivatePolicyTx(caller: string, policyId: string) {
63-
if (!validatePublicKey(caller)) throw new Error('invalid-caller')
64-
if (!policyId) throw new Error('invalid-policyId')
65-
66-
const acctResp = await loadAccount(caller)
67-
const source = new Account(caller, acctResp.sequence)
68-
69-
const txBuilder = new TransactionBuilder(source, {
70-
fee: BASE_FEE,
71-
networkPassphrase: NETWORK_PASSPHRASE,
72-
})
73-
74-
txBuilder.addOperation(Operation.manageData({ name: `policy:deactivate:${policyId}`, value: '1' }))
75-
76-
const tx = txBuilder.setTimeout(300).build()
77-
return tx.toXDR()
78-
}
79-
80-
export default {
81-
buildCreatePolicyTx,
82-
buildPayPremiumTx,
83-
buildDeactivatePolicyTx,
84-
}
39+
40+
// ── Raw contract call helper ─────────────────────────────────────────────────
41+
async function callContractView(
42+
method: string,
43+
args: xdr.ScVal[]
44+
): Promise<xdr.ScVal> {
45+
const { Transaction, TransactionBuilder, Account, BASE_FEE } = await import(
46+
"@stellar/stellar-sdk"
47+
);
48+
49+
const contract = new Contract(INSURANCE_CONTRACT_ID);
50+
const server = getRpcServer();
51+
52+
// A "view" simulation doesn't need a real source account funded on-chain;
53+
// we use a well-known testnet account as the simulation source.
54+
const sourceAccount = new Account(
55+
"GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN",
56+
"0"
57+
);
58+
59+
const tx = new TransactionBuilder(sourceAccount, {
60+
fee: BASE_FEE,
61+
networkPassphrase: NETWORK_PASSPHRASE,
62+
})
63+
.addOperation(contract.call(method, ...args))
64+
.setTimeout(30)
65+
.build();
66+
67+
const result = (await server.simulateTransaction(tx)) as {
68+
result?: { retval: xdr.ScVal };
69+
error?: string;
70+
};
71+
72+
if (result.error) {
73+
throw new Error(`Soroban simulation error: ${result.error}`);
74+
}
75+
76+
if (!result.result?.retval) {
77+
throw new Error(`No return value from contract method: ${method}`);
78+
}
79+
80+
return result.result.retval;
81+
}
82+
83+
// ── Mapper ───────────────────────────────────────────────────────────────────
84+
function mapScValToPolicy(raw: unknown): Policy {
85+
// The contract is expected to return a map/struct with these keys.
86+
// scValToNative converts Soroban ScVal → plain JS object.
87+
const obj = scValToNative(raw as xdr.ScVal) as Record<string, unknown>;
88+
89+
return {
90+
id: String(obj.id),
91+
name: String(obj.name),
92+
coverageType: String(obj.coverage_type ?? obj.coverageType),
93+
monthlyPremium: Number(obj.monthly_premium ?? obj.monthlyPremium),
94+
coverageAmount: Number(obj.coverage_amount ?? obj.coverageAmount),
95+
active: Boolean(obj.active),
96+
nextPaymentDate: String(obj.next_payment_date ?? obj.nextPaymentDate),
97+
};
98+
}
99+
100+
// ── Public API ────────────────────────────────────────────────────────────────
101+
102+
/**
103+
* Fetch a single policy by ID.
104+
* Throws a { code: 'NOT_FOUND' } error if the contract returns nothing.
105+
*/
106+
export async function getPolicy(id: string): Promise<Policy> {
107+
const scVal = await callContractView("get_policy", [
108+
nativeToScVal(id, { type: "string" }),
109+
]);
110+
111+
const native = scValToNative(scVal);
112+
if (!native) {
113+
const err = new Error(`Policy not found: ${id}`) as Error & {
114+
code: string;
115+
};
116+
err.code = "NOT_FOUND";
117+
throw err;
118+
}
119+
120+
return mapScValToPolicy(scVal);
121+
}
122+
123+
/**
124+
* Fetch all active policies for a given Stellar account address (owner).
125+
*/
126+
export async function getActivePolicies(owner: string): Promise<Policy[]> {
127+
if (!owner || !/^G[A-Z0-9]{55}$/.test(owner)) {
128+
throw Object.assign(new Error("Invalid Stellar address"), { code: "INVALID_ADDRESS" });
129+
}
130+
131+
const scVal = await callContractView("get_active_policies", [
132+
nativeToScVal(owner, { type: "address" }),
133+
]);
134+
135+
const list = scValToNative(scVal) as unknown[];
136+
if (!Array.isArray(list)) return [];
137+
138+
return list.map(mapScValToPolicy);
139+
}
140+
141+
/**
142+
* Returns the sum of monthly premiums for all active policies of the owner.
143+
*/
144+
export async function getTotalMonthlyPremium(owner: string): Promise<number> {
145+
if (!owner || !/^G[A-Z0-9]{55}$/.test(owner)) {
146+
throw Object.assign(new Error("Invalid Stellar address"), { code: "INVALID_ADDRESS" });
147+
}
148+
149+
const scVal = await callContractView("get_total_monthly_premium", [
150+
nativeToScVal(owner, { type: "address" }),
151+
]);
152+
153+
return Number(scValToNative(scVal));
154+
}

0 commit comments

Comments
 (0)