From a4c138730c1a388ed619fb4282c9114bece234e9 Mon Sep 17 00:00:00 2001 From: petermuazu Date: Wed, 21 Oct 2026 14:25:54 +0000 Subject: [PATCH] opulent --- package.json | 90 ++++ src/claim/claim-settlement.service.spec.ts | 195 ++++++++ src/claim/claim-settlement.service.ts | 82 ++++ src/claim/claim.controller.ts | 61 +++ src/claim/claim.module.ts | 23 + src/claim/claim.scheduler.spec.ts | 92 ++++ src/config/configuration.spec.ts | 64 +++ src/config/configuration.ts | 166 +++++-- src/db/database.module.ts | 13 + src/db/db.module.ts | 9 + src/health/health.controller.ts | 58 +++ src/oracle/oracle-publisher.service.ts | 195 ++++++++ src/oracle/oracle.controller.ts | 30 ++ src/oracle/oracle.module.ts | 15 + src/oracle/oracle.scheduler.spec.ts | 67 +++ src/oracle/oracle.service.spec.ts | 264 +++++++++++ src/oracle/oracle.service.ts | 285 ++++++++++++ src/policy/dto/buy-policy.dto.ts | 50 ++ src/policy/policy.module.ts | 13 + src/policy/policy.repository.ts | 89 ++++ src/pool/dto/deposit.dto.ts | 11 + src/pool/dto/withdraw.dto.ts | 11 + src/pool/lp-position.repository.ts | 46 ++ src/pool/pool.module.ts | 12 + src/pool/pool.service.spec.ts | 436 ++++++++++++++++++ src/quote/dto/create-quote.dto.ts | 21 + src/quote/quote.controller.ts | 29 ++ src/stellar/soroban-confirmation.util.spec.ts | 220 +++++++++ src/stellar/soroban-confirmation.util.ts | 163 +++++++ src/stellar/soroban-rpc.service.ts | 20 + src/tx/tx.controller.ts | 66 +++ src/tx/tx.module.ts | 11 + src/tx/tx.service.spec.ts | 207 +++++++++ src/tx/tx.service.ts | 87 ++++ 34 files changed, 3150 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index e69de29..8609e69 100644 --- a/package.json +++ b/package.json @@ -0,0 +1,90 @@ +{ + "name": "stellar-claim-backend", + "version": "0.1.0", + "description": "Refract Protocol backend \u2014 oracle monitoring, claim processing, premium quoting", + "main": "dist/main.js", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest", + "migrate:up": "node-pg-migrate up --migrations-dir migrations --config-file database.json", + "migrate:down": "node-pg-migrate down --migrations-dir migrations --config-file database.json", + "migrate:create": "node-pg-migrate create --migrations-dir migrations --config-file database.json", + "migrate:status": "node-pg-migrate status --migrations-dir migrations --config-file database.json", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "soroban:up": "docker compose -f test/soroban/docker-compose.yml up -d --wait", + "soroban:down": "docker compose -f test/soroban/docker-compose.yml down -v", + "soroban:bootstrap": "ts-node scripts/soroban-bootstrap.ts", + "test:soroban": "jest --config test/soroban/jest.soroban.config.js", + "regenerate:xdr-fixtures": "ts-node scripts/regenerate-xdr-fixtures.ts", + "test:integration": "jest --config test/integration/jest.integration.config.ts --runInBand" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$" + }, + "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.28", + "@nestjs/platform-express": "^11.1.28", + "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.7", + "@nestjs/throttler": "^6.0.0", + "@nestjs/websockets": "^11.1.28", + "@stellar/stellar-sdk": "^12.3.0", + "axios": "^1.6.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "nest-winston": "^1.10.2", + "node-pg-migrate": "7.9.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "uuid": "^9.0.0", + "winston": "^3.11.0", + "ws": "^8.16.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/supertest": "^2.0.12", + "jest": "^29.5.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/src/claim/claim-settlement.service.spec.ts b/src/claim/claim-settlement.service.spec.ts index e69de29..afa43e2 100644 --- a/src/claim/claim-settlement.service.spec.ts +++ b/src/claim/claim-settlement.service.spec.ts @@ -0,0 +1,195 @@ +import { ConfigService } from "@nestjs/config"; +import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { Account, Keypair, StrKey, rpc } from "@stellar/stellar-sdk"; +import { ClaimSettlementService } from "./claim-settlement.service"; +import { AppConfig } from "../config/configuration"; + +function buildConfig(overrides: Partial = {}): ConfigService { + const stellar: AppConfig["stellar"] = { + network: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + poolContractId: StrKey.encodeContract(Buffer.alloc(32, 1)), + policyContractId: "", + oracleContractId: "", + relayerSecretId: "test/relayer", + relayerSecretRegion: "us-east-1", + ...overrides, + }; + return { get: jest.fn().mockReturnValue(stellar) } as unknown as ConfigService; +} + +const PENDING_SEND_RESULT = { status: "PENDING" as const, hash: "mock-tx-hash", latestLedger: 1, latestLedgerCloseTime: 1 }; + +describe("ClaimSettlementService", () => { + const relayerSecret = Keypair.random().secret(); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.spyOn(SecretsManagerClient.prototype, "send").mockResolvedValue({ SecretString: relayerSecret } as never); + }); + + describe("isConfigured", () => { + it("is false when the pool contract ID is missing", () => { + const service = new ClaimSettlementService(buildConfig({ poolContractId: "" })); + expect(service.isConfigured()).toBe(false); + }); + + it("is false when the relayer secret ID is missing", () => { + const service = new ClaimSettlementService(buildConfig({ relayerSecretId: "" })); + expect(service.isConfigured()).toBe(false); + }); + + it("is true once both the pool contract ID and relayer secret ID are set", () => { + const service = new ClaimSettlementService(buildConfig()); + expect(service.isConfigured()).toBe(true); + }); + }); + + describe("settleClaim", () => { + it("returns settled:false without touching the network when unconfigured", async () => { + const service = new ClaimSettlementService(buildConfig({ poolContractId: "" })); + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + + const result = await service.settleClaim("policy-1"); + + expect(result.settled).toBe(false); + expect(result.error).toContain("not configured"); + expect(getAccountSpy).not.toHaveBeenCalled(); + }); + + it("builds, signs, submits, and confirms a successful settlement", async () => { + const service = new ClaimSettlementService(buildConfig()); + const holder = Keypair.random().publicKey(); + + jest.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(new Account(holder, "1")); + // prepareTransaction normally simulates against a live network and + // fills in Soroban resource fees — that's SDK behavior, not this + // service's logic, so it's short-circuited to identity here. + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockImplementation(async (tx) => tx as never); + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "mock-tx-hash", + }); + + const result = await service.settleClaim("1"); + + expect(result).toEqual({ settled: true, txHash: "mock-tx-hash" }); + expect(SecretsManagerClient.prototype.send).toHaveBeenCalledWith(expect.any(GetSecretValueCommand)); + }); + + it("does not settle when the submission is rejected outright", async () => { + const service = new ClaimSettlementService(buildConfig()); + const holder = Keypair.random().publicKey(); + + jest.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(new Account(holder, "1")); + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockImplementation(async (tx) => tx as never); + jest + .spyOn(rpc.Server.prototype, "sendTransaction") + .mockResolvedValue({ status: "ERROR", hash: "mock-tx-hash", latestLedger: 1, latestLedgerCloseTime: 1 }); + const getTransactionSpy = jest.spyOn(rpc.Server.prototype, "getTransaction"); + + const result = await service.settleClaim("1"); + + expect(result.settled).toBe(false); + expect(result).toMatchObject({ + code: "SOROBAN_REQUEST_FAILED", + error: "The Soroban request failed. Please retry later or contact support.", + }); + expect(getTransactionSpy).not.toHaveBeenCalled(); + }); + + it("does not settle when the submitted transaction fails on-chain", async () => { + const service = new ClaimSettlementService(buildConfig()); + const holder = Keypair.random().publicKey(); + + jest.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(new Account(holder, "1")); + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockImplementation(async (tx) => tx as never); + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.FAILED, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "mock-tx-hash", + }); + + const result = await service.settleClaim("1"); + + expect(result).toEqual({ + settled: false, + txHash: "mock-tx-hash", + error: "The transaction failed on-chain.", + code: "TRANSACTION_FAILED", + }); + }); + + it("gives up and reports a timeout once confirmation polling is exhausted", async () => { + jest.useFakeTimers(); + const service = new ClaimSettlementService(buildConfig()); + const holder = Keypair.random().publicKey(); + + jest.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(new Account(holder, "1")); + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockImplementation(async (tx) => tx as never); + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + latestLedger: 1, + latestLedgerCloseTime: 1, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + txHash: "mock-tx-hash", + }); + + const resultPromise = service.settleClaim("1"); + await jest.runAllTimersAsync(); + const result = await resultPromise; + + expect(result).toEqual({ + settled: false, + txHash: "mock-tx-hash", + error: "Timed out waiting for transaction confirmation.", + code: "CONFIRMATION_TIMEOUT", + }); + }); + + it("catches an unexpected error (e.g. a network failure) and reports settled:false", async () => { + const service = new ClaimSettlementService(buildConfig()); + + jest.spyOn(rpc.Server.prototype, "getAccount").mockRejectedValue(new Error("connection refused")); + + const result = await service.settleClaim("1"); + + expect(result.settled).toBe(false); + expect(result.error).toBe("The Soroban request failed. Please retry later or contact support."); + expect(result.code).toBe("SOROBAN_REQUEST_FAILED"); + }); + }); +}); diff --git a/src/claim/claim-settlement.service.ts b/src/claim/claim-settlement.service.ts index e69de29..d2aa9d3 100644 --- a/src/claim/claim-settlement.service.ts +++ b/src/claim/claim-settlement.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BASE_FEE, Contract, Keypair, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { pollForConfirmation } from "../stellar/soroban-confirmation.util"; + +export interface SettlementResult { + settled: boolean; + txHash?: string; + error?: string; +} + +/** + * Builds, signs, and submits the pool.process_claim() Soroban transaction. + * Its deployed interface accepts only a u64 on-chain policy ID. Policy + * records currently use off-chain UUIDs until buy_policy is wired to persist + * its on-chain return value, so those records are rejected before submission. + */ +@Injectable() +export class ClaimSettlementService { + private readonly logger = new Logger(ClaimSettlementService.name); + private readonly server: rpc.Server; + private readonly networkPassphrase: string; + private readonly poolContractId: string; + private readonly relayerKeypair: Keypair | null; + + constructor(private readonly configService: ConfigService) { + const stellar = this.configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + this.poolContractId = stellar.poolContractId; + this.relayerKeypair = stellar.relayerSecret ? Keypair.fromSecret(stellar.relayerSecret) : null; + } + + /** True once a pool contract ID and relayer secret are configured. */ + isConfigured(): boolean { + return Boolean(this.poolContractId && this.relayerKeypair); + } + + async settleClaim(policyId: string): Promise { + if (!this.relayerKeypair || !this.poolContractId) { + return { + settled: false, + error: "Soroban relayer not configured (missing REFRACT_POOL_CONTRACT_ID or ORACLE_RELAYER_SECRET)", + }; + } + + if (!/^\d+$/.test(policyId) || BigInt(policyId) > 18_446_744_073_709_551_615n) { + return { settled: false, error: "Policy ID must be an on-chain u64" }; + } + + try { + const sourceAccount = await this.server.getAccount(this.relayerKeypair.publicKey()); + const contract = new Contract(this.poolContractId); + + const operation = contract.call("process_claim", nativeToScVal(BigInt(policyId), { type: "u64" })); + + const builtTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const preparedTx = await this.server.prepareTransaction(builtTx); + preparedTx.sign(this.relayerKeypair); + + const sendResult = await this.server.sendTransaction(preparedTx); + if (sendResult.status === "ERROR" || sendResult.status === "TRY_AGAIN_LATER") { + return { settled: false, error: `Submission not accepted: ${sendResult.status}` }; + } + + const confirmation = await pollForConfirmation(this.server, sendResult.hash); + return { settled: confirmation.confirmed, txHash: confirmation.txHash, error: confirmation.error }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Soroban settlement failed for policy ${policyId}`, message); + return { settled: false, error: message }; + } + } +} diff --git a/src/claim/claim.controller.ts b/src/claim/claim.controller.ts index e69de29..abf0aa7 100644 --- a/src/claim/claim.controller.ts +++ b/src/claim/claim.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { plainToInstance } from "class-transformer"; +import { PageKey, PageQueryDto, paginateDesc } from "../common/pagination"; +import { ClaimResult } from "./claim-result"; +import { ClaimService } from "./claim.service"; +import { + ClaimStatsOpsResponseDto, + ClaimStatsResponseDto, +} from "./dto/claim-stats.response"; + +/** + * A policy settles at most once (it is deactivated on payout), so + * policyId is a unique tie-breaker for claims sharing a processedAt. + */ +const claimKey = (c: ClaimResult): PageKey => ({ at: c.processedAt, id: c.policyId }); + +/** + * New in the NestJS migration — the pre-migration ClaimProcessor tracked + * these stats internally but never exposed them over HTTP. Useful for + * ops/observability, so it's kept as a small honest addition rather than + * a pure like-for-like port. + */ +@Controller("api/v1/claims") +export class ClaimController { + constructor(private readonly claimService: ClaimService) {} + + @Get("stats") + getStats() { + return plainToInstance(ClaimStatsResponseDto, this.claimService.getStats(), { + excludeExtraneousValues: true, + }); + } + + /** + * Ops-only view of the stats. `settlementConfigured` is an operational + * detail about the relayer and must not be served on the public stats + * route above. Authentication for this route is handled separately. + */ + @Get("stats/ops") + getOpsStats() { + return plainToInstance( + ClaimStatsOpsResponseDto, + this.claimService.getStats(), + { excludeExtraneousValues: true }, + ); + } + + /** Newest first, keyset-paginated on (processedAt, policyId); `?limit=` defaults to 50. */ + @Get("holder/:address") + async getHistoryForHolder(@Param("address") address: string, @Query() query: PageQueryDto) { + const page = paginateDesc(this.claimService.getHistoryForHolder(address), claimKey, query, 50); + return { claims: page.items, nextCursor: page.nextCursor }; + } + + /** Newest first across all holders; `?limit=` defaults to 10, as before pagination. */ + @Get("recent") + async getRecent(@Query() query: PageQueryDto) { + const page = paginateDesc(this.claimService.getRecentSettlements(Infinity), claimKey, query, 10); + return { claims: page.items, nextCursor: page.nextCursor }; + } +} diff --git a/src/claim/claim.module.ts b/src/claim/claim.module.ts index e69de29..75f4553 100644 --- a/src/claim/claim.module.ts +++ b/src/claim/claim.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { OracleModule } from "../oracle/oracle.module"; +import { PolicyModule } from "../policy/policy.module"; +import { ClaimController } from "./claim.controller"; +import { ClaimRepository } from "./claim.repository"; +import { ClaimScheduler } from "./claim.scheduler"; +import { ClaimSettlementService } from "./claim-settlement.service"; +import { ClaimService } from "./claim.service"; +import { ReconciliationService } from "./reconciliation.service"; + +@Module({ + imports: [PolicyModule, OracleModule], + controllers: [ClaimController], + providers: [ + ClaimRepository, + ClaimService, + ClaimScheduler, + ClaimSettlementService, + ReconciliationService, + ], + exports: [ClaimService, ReconciliationService], +}) +export class ClaimModule {} diff --git a/src/claim/claim.scheduler.spec.ts b/src/claim/claim.scheduler.spec.ts index e69de29..7b60c12 100644 --- a/src/claim/claim.scheduler.spec.ts +++ b/src/claim/claim.scheduler.spec.ts @@ -0,0 +1,92 @@ +import { ClaimScheduler } from "./claim.scheduler"; +import { ClaimService, ScanOutcome } from "./claim.service"; +import { ClaimResult } from "./claim-result"; + +function buildResult(overrides: Partial = {}): ClaimResult { + return { + policyId: "policy-1", + holder: "GHOLDER", + coverageType: 0, + triggered: true, + payout: "1000000000", + reason: "USDC price: $0.9000", + processedAt: Date.now(), + settlementTxHash: "mock-tx-hash", + ...overrides, + }; +} + +function emptyCounts(overrides: Partial = {}): ScanOutcome["counts"] { + return { + scanned: 0, + triggered: 0, + settled: 0, + deferred: 0, + failedTransient: 0, + failedPermanent: 0, + failedIndeterminate: 0, + deadLettered: 0, + ...overrides, + }; +} + +describe("ClaimScheduler", () => { + describe("scanAndSettle", () => { + it("calls processTriggeredWithStats() and does not throw when it settles claims", async () => { + const claimService = { + processTriggeredWithStats: jest.fn().mockResolvedValue({ + settled: [buildResult()], + counts: emptyCounts({ scanned: 1, triggered: 1, settled: 1 }), + }), + } as unknown as jest.Mocked; + const scheduler = new ClaimScheduler(claimService); + + await scheduler.scanAndSettle(); + + expect(claimService.processTriggeredWithStats).toHaveBeenCalledTimes(1); + }); + + it("logs all-failed scans with classification counts", async () => { + const claimService = { + processTriggeredWithStats: jest.fn().mockResolvedValue({ + settled: [], + counts: emptyCounts({ + scanned: 2, + triggered: 2, + failedTransient: 1, + failedPermanent: 1, + deadLettered: 1, + }), + }), + } as unknown as jest.Mocked; + const scheduler = new ClaimScheduler(claimService); + const logSpy = jest.spyOn((scheduler as unknown as { logger: { log: (m: string) => void } }).logger, "log"); + + await scheduler.scanAndSettle(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("transient=1")); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("deadLettered=1")); + }); + + it("does not throw when nothing settles this tick", async () => { + const claimService = { + processTriggeredWithStats: jest.fn().mockResolvedValue({ + settled: [], + counts: emptyCounts({ scanned: 0 }), + }), + } as unknown as jest.Mocked; + const scheduler = new ClaimScheduler(claimService); + + await expect(scheduler.scanAndSettle()).resolves.toBeUndefined(); + }); + + it("catches and logs an error from processTriggeredWithStats() instead of throwing", async () => { + const claimService = { + processTriggeredWithStats: jest.fn().mockRejectedValue(new Error("oracle sources unreachable")), + } as unknown as jest.Mocked; + const scheduler = new ClaimScheduler(claimService); + + await expect(scheduler.scanAndSettle()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/config/configuration.spec.ts b/src/config/configuration.spec.ts index e69de29..8119311 100644 --- a/src/config/configuration.spec.ts +++ b/src/config/configuration.spec.ts @@ -0,0 +1,64 @@ +import configuration from "./configuration"; + +const NETWORK_CONFIG = { + testnet: { + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + futurenet: { + sorobanRpcUrl: "https://soroban-futurenet.stellar.org", + horizonUrl: "https://horizon-futurenet.stellar.org", + networkPassphrase: "Test SDF Future Network ; October 2022", + }, + mainnet: { + sorobanRpcUrl: "https://soroban-rpc.mainnet.stellar.org", + horizonUrl: "https://horizon.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, +} as const; + +describe("Stellar network configuration", () => { + const originalEnv = new Map(); + + function setEnv(name: string, value: string | undefined): void { + if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + + afterEach(() => { + for (const [name, value] of originalEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + originalEnv.clear(); + }); + + it.each(Object.entries(NETWORK_CONFIG))("selects all Stellar endpoints and IDs together for %s", (network, expected) => { + const suffix = network.toUpperCase(); + setEnv("STELLAR_NETWORK", network); + setEnv(`REFRACT_POOL_CONTRACT_ID_${suffix}`, `${suffix}-pool`); + setEnv(`REFRACT_POLICY_CONTRACT_ID_${suffix}`, `${suffix}-policy`); + setEnv(`REFRACT_ORACLE_CONTRACT_ID_${suffix}`, `${suffix}-oracle`); + setEnv("SOROBAN_RPC_URL", "https://wrong-network.invalid"); + setEnv("STELLAR_NETWORK_PASSPHRASE", "wrong passphrase"); + + const config = configuration(); + + expect(config.stellar).toMatchObject({ + network, + ...expected, + poolContractId: `${suffix}-pool`, + policyContractId: `${suffix}-policy`, + oracleContractId: `${suffix}-oracle`, + }); + expect(config.oracles.horizonUrl).toBe(expected.horizonUrl); + }); + + it("rejects unsupported network selectors", () => { + setEnv("STELLAR_NETWORK", "local"); + + expect(configuration).toThrow("expected testnet, futurenet, or mainnet"); + }); +}); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 15da439..d37705f 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,59 +1,123 @@ -import { registerAs } from '@nestjs/config'; - /** - * Resolves the sandbox-mode flag from the environment. + * Typed application configuration, loaded once by Nest's ConfigModule. * - * Sandbox mode swaps every real backing service (Postgres repositories, - * Redis cache, Soroban RPC calls) for lightweight in-memory/mocked - * equivalents so a fresh clone can run with `npm install && npm run dev` - * and no other setup. + * Kept as a single factory function (rather than scattered `process.env` + * reads) so every consumer gets the same parsed/defaulted values and the + * shape is documented in one place. See `.env.example` for the full list + * of variables this reads. * - * Safety: sandbox mode is a contributor-onboarding tool only. It must never - * be reachable from a deployed environment, so we refuse to enable it when - * NODE_ENV=production (see assertSandboxSafety below). + * `AppConfig` is inferred from the Zod schema in `./env.schema` so the + * TypeScript type and the runtime validation cannot drift. */ -export function isSandboxMode(env: NodeJS.ProcessEnv = process.env): boolean { - const raw = (env.SANDBOX_MODE ?? '').trim().toLowerCase(); - return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; -} +import { z } from "zod"; +import { validateEnv } from "./env.schema"; -/** - * Boot-time safety check: refuse to start in sandbox mode when running in a - * production-configured deployment, even if SANDBOX_MODE was set by accident. - * Throws so the process fails fast rather than silently serving mocked data. - */ -export function assertSandboxSafety(env: NodeJS.ProcessEnv = process.env): void { - if (!isSandboxMode(env)) { - return; - } +const appConfigSchema = z.object({ + port: z.number().int().min(1).max(65535), + frontendUrl: z.string().url(), + database: z.object({ url: z.string().url() }), + redis: z.object({ url: z.string().url() }), + stellar: z.object({ + network: z.string(), + sorobanRpcUrl: z.string().url(), + networkPassphrase: z.string(), + poolContractId: z.string(), + policyContractId: z.string(), + oracleContractId: z.string(), + relayerSecret: z.string(), + }), + oracles: z.object({ + coingeckoBaseUrl: z.string().url(), + horizonUrl: z.string().url(), + defiLlamaBaseUrl: z.string().url(), + defiLlamaProtocolSlug: z.string(), + httpTimeoutMs: z.number().int().min(100).max(120_000), + }), + throttle: z.object({ + /** + * Named throttling tiers, each expressed as `{ limit, ttl }` where + * `limit` is the max number of requests allowed per `ttl` window + * (milliseconds). Consumed by `ThrottlerModule.forRootAsync` and + * referenced by name from `@Throttle({ ... })` route overrides. + */ + tiers: z.object({ + /** Upstream-proxying reads (e.g. GET /oracle/status). */ + strict: z.object({ limit: z.number().int(), ttl: z.number().int() }), + /** Chain-writing posts (e.g. /tx/submit, /policies/buy). */ + chainWrite: z.object({ limit: z.number().int(), ttl: z.number().int() }), + /** Ordinary database-backed reads. */ + moderate: z.object({ limit: z.number().int(), ttl: z.number().int() }), + /** Static catalog routes (e.g. /quotes/coverage-types). */ + generous: z.object({ limit: z.number().int(), ttl: z.number().int() }), + }), + /** + * Whether to trust `X-Forwarded-For` from a fronting proxy. Must be + * set deliberately (never implicitly) so IP-based limiting does not + * collapse every caller onto the load balancer's address. + */ + trustProxy: z.boolean(), + }), +}); - const nodeEnv = (env.NODE_ENV ?? '').trim().toLowerCase(); - if (nodeEnv === 'production') { - throw new Error( - 'SANDBOX_MODE is enabled but NODE_ENV=production. Sandbox mode is a ' + - 'local contributor-onboarding tool and must never run in a deployed ' + - 'environment. Unset SANDBOX_MODE or set NODE_ENV to a non-production value.', - ); - } -} +export type AppConfig = z.infer; -/** - * Prominent startup banner so sandbox mode is never mistaken for a real - * environment. Returns the lines to log; callers decide how to emit them. - */ -export function sandboxStartupBanner(): string[] { - return [ - '============================================================', - ' SANDBOX MODE ENABLED — NOT A REAL ENVIRONMENT', - ' Postgres, Redis, and Soroban RPC are replaced with', - ' in-memory mocks. All data resets on restart.', - ' Do not use this mode outside local development.', - '============================================================', - ]; -} +export default (): AppConfig => { + const env = validateEnv(process.env); + + const requireChainConfig = env.REQUIRE_CHAIN_CONFIG === true; + const chainKeys = [ + "REFRACT_POOL_CONTRACT_ID", + "REFRACT_POLICY_CONTRACT_ID", + "REFRACT_ORACLE_CONTRACT_ID", + "ORACLE_RELAYER_SECRET", + ] as const; + + if (requireChainConfig) { + const missing = chainKeys.filter((key) => !env[key]); + if (missing.length > 0) { + throw new Error( + `Invalid environment configuration:\n${missing + .map((key) => ` - ${key}: required when REQUIRE_CHAIN_CONFIG is set`) + .join("\n")}`, + ); + } + } -export default registerAs('app', () => ({ - env: process.env.NODE_ENV ?? 'development', - port: parseInt(process.env.PORT ?? '3000', 10), - sandboxMode: isSandboxMode(), -})); + return { + port: env.PORT ?? 4001, + frontendUrl: env.FRONTEND_URL ?? "http://localhost:3000", + database: { + url: + env.DATABASE_URL ?? + "postgres://refract:refract@localhost:5432/refract", + }, + redis: { + url: env.REDIS_URL ?? "redis://localhost:6379", + }, + stellar: { + network: env.STELLAR_NETWORK ?? "testnet", + sorobanRpcUrl: + env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", + networkPassphrase: + env.STELLAR_NETWORK_PASSPHRASE ?? + "Test SDF Network ; September 2015", + poolContractId: env.REFRACT_POOL_CONTRACT_ID ?? "", + policyContractId: env.REFRACT_POLICY_CONTRACT_ID ?? "", + oracleContractId: env.REFRACT_ORACLE_CONTRACT_ID ?? "", + relayerSecret: env.ORACLE_RELAYER_SECRET ?? "", + }, + oracles: { + coingeckoBaseUrl: + env.COINGECKO_BASE_URL ?? "https://api.coingecko.com/api/v3", + horizonUrl: + env.STELLAR_HORIZON_URL ?? "https://horizon-testnet.stellar.org", + defiLlamaBaseUrl: env.DEFILLAMA_BASE_URL ?? "https://api.llama.fi", + // Placeholder "covered protocol" for the SmartContractRisk TVL-drop + // check until Refract defines a real list of covered Soroban + // protocols. Defaults to a large, consistently-tracked protocol so + // the drop-detection logic has real data to run against. + defiLlamaProtocolSlug: env.DEFILLAMA_PROTOCOL_SLUG ?? "aave", + httpTimeoutMs: env.ORACLE_HTTP_TIMEOUT_MS ?? 5000, + }, + }; +}; diff --git a/src/db/database.module.ts b/src/db/database.module.ts index e69de29..5dc1e2f 100644 --- a/src/db/database.module.ts +++ b/src/db/database.module.ts @@ -0,0 +1,13 @@ +import { Module, Global } from "@nestjs/common"; +import { DatabaseService } from "./database.service"; + +/** + * Global module — import once in AppModule, DatabaseService is then + * injectable everywhere without re-importing DatabaseModule. + */ +@Global() +@Module({ + providers: [DatabaseService], + exports: [DatabaseService], +}) +export class DatabaseModule {} diff --git a/src/db/db.module.ts b/src/db/db.module.ts index e69de29..8b972de 100644 --- a/src/db/db.module.ts +++ b/src/db/db.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { DbService } from "./db.service"; + +@Global() +@Module({ + providers: [DbService], + exports: [DbService], +}) +export class DbModule {} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index e69de29..c9d2275 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, HttpCode, HttpStatus, Res } from "@nestjs/common"; +import { Response } from "express"; +import { Public } from "../auth/public.decorator"; +import { HealthService } from "./health.service"; + +@Controller("health") +export class HealthController { + constructor(private readonly healthService: HealthService) {} + + /** + * @deprecated Use GET /health/live or GET /health/ready instead. + * Retained for backward compatibility. + */ + @Public() + @Get() + getHealth() { + return { + status: "ok", + protocol: "Refract", + deprecated: true, + message: "Use /health/live or /health/ready", + }; + } + + /** + * Process-level liveness only. Never touches a dependency, so a transient + * dependency outage can never cause an orchestrator to kill a healthy pod. + */ + @Public() + @Get("live") + @HttpCode(HttpStatus.OK) + getLiveness() { + return this.healthService.getLiveness(); + } + + /** + * Aggregated readiness across database, Redis, Soroban RPC and Stellar + * configuration. Returns 503 when any required dependency is not ready. + */ + @Public() + @Get("ready") + async getReadiness(@Res({ passthrough: true }) res: Response) { + const result = await this.healthService.getReadiness(); + res.status(result.ready ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE); + return result; + } + + /** + * Detailed per-dependency report. Authenticated (no @Public decorator) and + * never exposes the relayer secret — only the derived public key. + */ + @Get("detail") + async getDetail() { + return this.healthService.getDetail(); + } +} + } +} diff --git a/src/oracle/oracle-publisher.service.ts b/src/oracle/oracle-publisher.service.ts index e69de29..2b34419 100644 --- a/src/oracle/oracle-publisher.service.ts +++ b/src/oracle/oracle-publisher.service.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BASE_FEE, Contract, Keypair, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { pollForConfirmation } from "../stellar/soroban-confirmation.util"; +import { OracleReading } from "./oracle-reading"; +import { coverageTypeToU32, scaleOracleValue } from "./oracle-encoding"; + +export interface OraclePublishRecord { + coverageType: string; + scaledValue: string; + txHash?: string; + confirmed: boolean; + outcome?: string; + error?: string; + attemptedAt: number; +} + +/** + * Relayer path that pushes oracle readings to RefractOracle via + * `update_reading(coverage_type: u32, value: i128)`. + * + * Mirrors ClaimSettlementService's build/prepare/sign/send/poll structure. + * Degraded fail-safe readings are never published — treating an outage + * placeholder as authoritative on-chain data would be a safety failure. + */ +@Injectable() +export class OraclePublisherService { + private readonly logger = new Logger(OraclePublisherService.name); + private readonly server: rpc.Server; + private readonly networkPassphrase: string; + private readonly oracleContractId: string; + private readonly relayerKeypair: Keypair | null; + private readonly publishMode: AppConfig["oraclePublish"]["mode"]; + private readonly minIntervalMs: number; + private readonly confirmationDefaults: AppConfig["confirmation"]; + + private readonly lastScaled = new Map(); + private readonly lastPublishedAt = new Map(); + private readonly inFlight = new Set(); + private readonly auditLog: OraclePublishRecord[] = []; + + constructor(private readonly configService: ConfigService) { + const stellar = this.configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + this.oracleContractId = stellar.oracleContractId; + this.relayerKeypair = stellar.relayerSecret ? Keypair.fromSecret(stellar.relayerSecret) : null; + const publish = this.configService.get("oraclePublish", { infer: true }); + this.publishMode = publish.mode; + this.minIntervalMs = publish.minIntervalMs; + this.confirmationDefaults = this.configService.get("confirmation", { infer: true }); + } + + /** True once an oracle contract ID and relayer secret are configured. */ + isConfigured(): boolean { + return Boolean(this.oracleContractId && this.relayerKeypair); + } + + getAuditLog(): readonly OraclePublishRecord[] { + return this.auditLog; + } + + /** + * Decide whether to publish and, if so, submit. Never throws — degrades + * with a clear log when unconfigured (same posture as ClaimSettlementService). + */ + async maybePublish(reading: OracleReading): Promise { + if (reading.degraded) { + this.logger.warn(`Skipping on-chain publish for degraded ${reading.coverageType} reading`); + return null; + } + + if (!this.isConfigured()) { + this.logger.debug( + "Oracle publisher not configured (missing REFRACT_ORACLE_CONTRACT_ID or ORACLE_RELAYER_SECRET) — skipping" + ); + return null; + } + + let scaled: bigint; + try { + scaled = scaleOracleValue(reading.coverageType, reading.value); + } catch (err) { + this.logger.error( + `Cannot scale oracle reading for ${reading.coverageType}`, + err instanceof Error ? err.message : String(err) + ); + return null; + } + + const now = Date.now(); + const lastAt = this.lastPublishedAt.get(reading.coverageType) ?? 0; + if (now - lastAt < this.minIntervalMs) { + return null; + } + + if (this.publishMode === "on_change") { + const prev = this.lastScaled.get(reading.coverageType); + if (prev !== undefined && prev === scaled) { + return null; + } + } + + if (this.inFlight.has(reading.coverageType)) { + this.logger.warn(`Overlapping publish suppressed for ${reading.coverageType}`); + return null; + } + + this.inFlight.add(reading.coverageType); + try { + const record = await this.publish(reading.coverageType, scaled); + this.auditLog.push(record); + if (this.auditLog.length > 500) this.auditLog.shift(); + if (record.confirmed) { + this.lastScaled.set(reading.coverageType, scaled); + this.lastPublishedAt.set(reading.coverageType, Date.now()); + } + return record; + } finally { + this.inFlight.delete(reading.coverageType); + } + } + + private async publish(coverageType: string, scaledValue: bigint): Promise { + const attemptedAt = Date.now(); + const base: OraclePublishRecord = { + coverageType, + scaledValue: scaledValue.toString(10), + confirmed: false, + attemptedAt, + }; + + try { + const sourceAccount = await this.server.getAccount(this.relayerKeypair!.publicKey()); + const contract = new Contract(this.oracleContractId); + // RefractOracle.update_reading(coverage_type: u32, value: i128) + const operation = contract.call( + "update_reading", + nativeToScVal(coverageTypeToU32(coverageType), { type: "u32" }), + nativeToScVal(scaledValue, { type: "i128" }) + ); + + const builtTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const preparedTx = await this.server.prepareTransaction(builtTx); + preparedTx.sign(this.relayerKeypair!); + + const sendResult = await this.server.sendTransaction(preparedTx); + if (sendResult.status === "ERROR" || sendResult.status === "TRY_AGAIN_LATER") { + const record = { + ...base, + txHash: sendResult.hash, + error: `Submission not accepted: ${sendResult.status}`, + }; + this.logger.error(`Oracle publish rejected for ${coverageType}: ${record.error}`); + return record; + } + + const confirmation = await pollForConfirmation(this.server, sendResult.hash, { + initialIntervalMs: this.confirmationDefaults.initialIntervalMs, + backoffMultiplier: this.confirmationDefaults.backoffMultiplier, + maxIntervalMs: this.confirmationDefaults.maxIntervalMs, + jitterRatio: this.confirmationDefaults.jitterRatio, + deadlineMs: this.confirmationDefaults.settlementDeadlineMs, + }); + + const record: OraclePublishRecord = { + ...base, + txHash: confirmation.txHash, + confirmed: confirmation.confirmed, + outcome: confirmation.outcome, + error: confirmation.error, + }; + if (!record.confirmed) { + this.logger.error( + `Oracle publish did not confirm for ${coverageType}: outcome=${confirmation.outcome} error=${confirmation.error}` + ); + } else { + this.logger.log(`Oracle publish confirmed for ${coverageType}: tx=${confirmation.txHash} value=${scaledValue}`); + } + return record; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Oracle publish failed for ${coverageType}`, message); + return { ...base, error: message }; + } + } +} diff --git a/src/oracle/oracle.controller.ts b/src/oracle/oracle.controller.ts index e69de29..3c67f2d 100644 --- a/src/oracle/oracle.controller.ts +++ b/src/oracle/oracle.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { OracleReading } from "./oracle-reading"; +import { OracleService } from "./oracle.service"; + +/** + * Exposes the same "real" oracle checks (CoinGecko, Horizon, DeFiLlama — + * see OracleService.checkAll()) that OracleScheduler polls every 60s and + * broadcasts over the WebSocket feed, but as a plain REST GET. Before this, + * a client had to open a WebSocket connection and wait for the next + * above-"low" broadcast to see any oracle data at all — there was no way + * to just ask "what's the current reading right now." + * + * checkAll() is now backed by a 55-second Redis cache so repeated HTTP + * calls within a scheduler cycle do not issue duplicate CoinGecko / + * DeFiLlama requests. The `cached` field in the response body tells + * callers whether the data came from cache or a fresh upstream call. + */ +@ApiTags("Oracle") +@Controller("api/v1/oracle") +export class OracleController { + constructor(private readonly oracleService: OracleService) {} + + @Get("status") + @ApiOperation({ summary: "Read current oracle values" }) + @ApiResponse({ status: 200, description: "Current oracle readings" }) + async getStatus(): Promise<{ readings: OracleReading[] }> { + return { readings: await this.oracleService.checkAll() }; + } +} diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index e69de29..5615f5d 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { StellarModule } from "../stellar/stellar.module"; +import { OracleController } from "./oracle.controller"; +import { OracleGateway } from "./oracle.gateway"; +import { OraclePublisherService } from "./oracle-publisher.service"; +import { OracleScheduler } from "./oracle.scheduler"; +import { OracleService } from "./oracle.service"; + +@Module({ + imports: [StellarModule], + controllers: [OracleController], + providers: [OracleService, OracleGateway, OracleScheduler, OraclePublisherService], + exports: [OracleService, OracleGateway, OraclePublisherService], +}) +export class OracleModule {} diff --git a/src/oracle/oracle.scheduler.spec.ts b/src/oracle/oracle.scheduler.spec.ts index e69de29..dda1bbe 100644 --- a/src/oracle/oracle.scheduler.spec.ts +++ b/src/oracle/oracle.scheduler.spec.ts @@ -0,0 +1,67 @@ +import { OracleScheduler } from "./oracle.scheduler"; +import { OracleService } from "./oracle.service"; +import { OracleGateway } from "./oracle.gateway"; +import { OracleReading } from "./oracle-reading"; + +function buildReading(overrides: Partial = {}): OracleReading { + return { + coverageType: "StablecoinDepeg", + type: "oracle_update", + value: 1.0, + threshold: 0.95, + severity: "low", + message: "USDC price: $1.0000", + ...overrides, + }; +} + +function buildServices() { + const oracleService = { checkAll: jest.fn() } as unknown as jest.Mocked; + const oracleGateway = { broadcastAlert: jest.fn() } as unknown as jest.Mocked; + const oraclePublisher = { maybePublish: jest.fn().mockResolvedValue(null) } as unknown as jest.Mocked< + import("./oracle-publisher.service").OraclePublisherService + >; + return { oracleService, oracleGateway, oraclePublisher }; +} + +describe("OracleScheduler", () => { + describe("pollOracles", () => { + it("broadcasts readings above 'low' severity and skips low ones", async () => { + const { oracleService, oracleGateway, oraclePublisher } = buildServices(); + const low = buildReading({ coverageType: "StablecoinDepeg", severity: "low" }); + const high = buildReading({ coverageType: "MarketCrash", severity: "high" }); + const triggered = buildReading({ coverageType: "SmartContractRisk", severity: "triggered" }); + oracleService.checkAll.mockResolvedValue([low, high, triggered]); + const scheduler = new OracleScheduler(oracleService, oracleGateway, oraclePublisher); + + await scheduler.pollOracles(); + + expect(oracleGateway.broadcastAlert).toHaveBeenCalledTimes(2); + expect(oracleGateway.broadcastAlert).toHaveBeenCalledWith(high); + expect(oracleGateway.broadcastAlert).toHaveBeenCalledWith(triggered); + expect(oracleGateway.broadcastAlert).not.toHaveBeenCalledWith(low); + expect(oraclePublisher.maybePublish).toHaveBeenCalledTimes(3); + }); + + it("does not broadcast anything when every reading is 'low' severity", async () => { + const { oracleService, oracleGateway, oraclePublisher } = buildServices(); + oracleService.checkAll.mockResolvedValue([buildReading({ severity: "low" })]); + const scheduler = new OracleScheduler(oracleService, oracleGateway, oraclePublisher); + + await scheduler.pollOracles(); + + expect(oracleGateway.broadcastAlert).not.toHaveBeenCalled(); + expect(oraclePublisher.maybePublish).toHaveBeenCalledTimes(1); + }); + + it("catches and logs an error from checkAll() instead of throwing", async () => { + const { oracleService, oracleGateway, oraclePublisher } = buildServices(); + oracleService.checkAll.mockRejectedValue(new Error("all sources down")); + const scheduler = new OracleScheduler(oracleService, oracleGateway, oraclePublisher); + + await expect(scheduler.pollOracles()).resolves.toBeUndefined(); + expect(oracleGateway.broadcastAlert).not.toHaveBeenCalled(); + expect(oraclePublisher.maybePublish).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/oracle/oracle.service.spec.ts b/src/oracle/oracle.service.spec.ts index e69de29..a0b5359 100644 --- a/src/oracle/oracle.service.spec.ts +++ b/src/oracle/oracle.service.spec.ts @@ -0,0 +1,264 @@ +import axios from "axios"; +import { ConfigService } from "@nestjs/config"; +import { CacheService } from "../cache/cache.service"; +import { OracleService } from "./oracle.service"; +import { AppConfig } from "../config/configuration"; + +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +const oraclesConfig: AppConfig["oracles"] = { + coingeckoBaseUrl: "https://coingecko.test", + horizonUrl: "https://horizon.test", + defiLlamaBaseUrl: "https://defillama.test", + defiLlamaProtocolSlug: "aave", + httpTimeoutMs: 5000, +}; + +/** + * Transparent cache stub: wrap() always calls the loader (cache miss), + * get() always returns null, set()/del() are no-ops. + * This keeps oracle unit tests fast and free of Redis. + */ +function buildMockCacheService(): jest.Mocked { + return { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + wrap: jest.fn().mockImplementation((_key, _ttl, loader) => loader()), + onModuleInit: jest.fn(), + onModuleDestroy: jest.fn(), + } as unknown as jest.Mocked; +} + +function buildService(): OracleService { + const configService = { + get: jest.fn().mockReturnValue(oraclesConfig), + } as unknown as ConfigService; + return new OracleService(configService, buildMockCacheService()); +} + +describe("OracleService", () => { + let service: OracleService; + + beforeEach(() => { + jest.clearAllMocks(); + service = buildService(); + }); + + describe("checkStablecoinDepeg", () => { + it("reports a low-severity reading when USDC is at peg", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { "usd-coin": { usd: 1.0 } } }); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.coverageType).toBe("StablecoinDepeg"); + expect(reading.value).toBe(1.0); + expect(reading.threshold).toBe(0.95); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("[CoinGecko]"); + }); + + it.each([ + [0.985, "medium"], + [0.975, "high"], + [0.9, "triggered"], + ])("classifies a $%s USDC price as %s severity", async (usdcPrice, expectedSeverity) => { + mockedAxios.get.mockResolvedValueOnce({ data: { "usd-coin": { usd: usdcPrice } } }); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.severity).toBe(expectedSeverity); + }); + + it("degrades to a non-triggering low reading when CoinGecko errors", async () => { + mockedAxios.get.mockRejectedValueOnce(new Error("network down")); + + const reading = await service.checkStablecoinDepeg(); + + expect(reading.coverageType).toBe("StablecoinDepeg"); + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkMarketCrash", () => { + it("attaches Horizon ledger context when the testnet call succeeds", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -5 } } }) + .mockResolvedValueOnce({ + data: { _embedded: { records: [{ sequence: 12345, closed_at: "2026-07-17T00:00:00Z" }] } }, + }); + + const reading = await service.checkMarketCrash(); + + expect(reading.coverageType).toBe("MarketCrash"); + expect(reading.value).toBe(-5); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("Horizon testnet ledger #12345"); + }); + + it("still succeeds off CoinGecko alone when Horizon is unreachable", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -25 } } }) + .mockRejectedValueOnce(new Error("horizon timeout")); + + const reading = await service.checkMarketCrash(); + + expect(reading.severity).toBe("high"); + expect(reading.message).toContain("[CoinGecko]"); + expect(reading.message).not.toContain("Horizon testnet ledger"); + }); + + it.each([ + [-5, "low"], + [-15, "medium"], + [-25, "high"], + [-35, "triggered"], + ])("classifies a %s%% 24h change as %s severity", async (change24h, expectedSeverity) => { + mockedAxios.get + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: change24h } } }) + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }); + + const reading = await service.checkMarketCrash(); + + expect(reading.severity).toBe(expectedSeverity); + }); + + it("degrades to a non-triggering low reading when the CoinGecko price call errors", async () => { + mockedAxios.get + .mockRejectedValueOnce(new Error("coingecko down")) + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }); + + const reading = await service.checkMarketCrash(); + + expect(reading.coverageType).toBe("MarketCrash"); + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkSmartContractRisk", () => { + const now = 1_800_000_000; + const dayAgo = now - 86_400 - 100; // just past the 24h cutoff so it's picked as the reference point + + it.each([ + [1_000_000, 1_000_000, "low"], + [1_000_000, 850_000, "medium"], + [1_000_000, 700_000, "high"], + [1_000_000, 300_000, "triggered"], + ])("classifies a TVL move from %d to %d as %s severity", async (referenceTvl, latestTvl, expectedSeverity) => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + name: "Aave", + tvl: [ + { date: dayAgo, totalLiquidityUSD: referenceTvl }, + { date: now, totalLiquidityUSD: latestTvl }, + ], + }, + }); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.coverageType).toBe("SmartContractRisk"); + expect(reading.severity).toBe(expectedSeverity); + expect(reading.message).toContain("[DeFiLlama]"); + }); + + it("degrades to a non-triggering low reading when DeFiLlama returns insufficient history", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { tvl: [{ date: now, totalLiquidityUSD: 1_000_000 }] } }); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + + it("degrades to a non-triggering low reading when DeFiLlama errors", async () => { + mockedAxios.get.mockRejectedValueOnce(new Error("defillama down")); + + const reading = await service.checkSmartContractRisk(); + + expect(reading.severity).toBe("low"); + expect(reading.value).toBe(reading.threshold); + expect(reading.message).toContain("degraded"); + }); + }); + + describe("checkLiquidationShield", () => { + afterEach(() => { + jest.spyOn(Math, "random").mockRestore(); + }); + + it("computes the collateral ratio from the mocked random source and labels it as mocked", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.5); // collateralRatio = 0.92 + (0.5 - 0.5) * 0.1 = 0.92 + + const reading = await service.checkLiquidationShield(); + + expect(reading.coverageType).toBe("LiquidationShield"); + expect(reading.value).toBeCloseTo(0.92); + expect(reading.threshold).toBe(0.85); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("[mocked — no NEXUS Protocol integration]"); + }); + + it("can trigger when the random collateral ratio dips below the threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0); // collateralRatio = 0.92 + (0 - 0.5) * 0.3 = 0.77 + + const reading = await service.checkLiquidationShield(); + + expect(reading.value).toBeCloseTo(0.77); + expect(reading.severity).toBe("triggered"); + }); + }); + + describe("checkFlightDelay", () => { + afterEach(() => { + jest.spyOn(Math, "random").mockRestore(); + }); + + it("reports a low-severity reading below the delay threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0); // delayMinutes = 0 + + const reading = await service.checkFlightDelay("RF123"); + + expect(reading.coverageType).toBe("FlightDelay"); + expect(reading.value).toBe(0); + expect(reading.severity).toBe("low"); + expect(reading.message).toContain("RF123"); + expect(reading.message).toContain("[mocked — AviationStack requires a paid key]"); + }); + + it("triggers once the delay reaches the threshold", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.4); // delayMinutes = floor(0.4 * 300) = 120 + + const reading = await service.checkFlightDelay("RF123"); + + expect(reading.value).toBe(120); + expect(reading.severity).toBe("triggered"); + }); + }); + + describe("checkAll", () => { + it("aggregates the three real checks", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { "usd-coin": { usd: 1.0 } } }) // StablecoinDepeg + .mockResolvedValueOnce({ data: { stellar: { usd: 0.1, usd_24h_change: -1 } } }) // MarketCrash price + .mockResolvedValueOnce({ data: { _embedded: { records: [] } } }) // MarketCrash ledger + .mockResolvedValueOnce({ + data: { tvl: [{ date: 0, totalLiquidityUSD: 100 }, { date: 200_000, totalLiquidityUSD: 100 }] }, + }); // SmartContractRisk + + const readings = await service.checkAll(); + + expect(readings.map((r) => r.coverageType)).toEqual([ + "StablecoinDepeg", + "MarketCrash", + "SmartContractRisk", + ]); + }); + }); +}); diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index e69de29..97c9d3d 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -0,0 +1,285 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import axios from "axios"; +import { CacheService, ORACLE_TTL } from "../cache/cache.service"; +import { AppConfig } from "../config/configuration"; +import { OracleReading, Severity } from "./oracle-reading"; + +interface DefiLlamaTvlPoint { + date: number; // unix seconds + totalLiquidityUSD: number; +} + +const CACHE_KEY_CHECK_ALL = "oracle:checkAll"; + +/** + * OracleService aggregates real-world data for Refract trigger conditions. + * + * Three of the five checks call real, keyless public APIs: + * - StablecoinDepeg: CoinGecko simple-price API (USDC/USD) + * - MarketCrash: CoinGecko simple-price API (XLM/USD, 24h change), plus + * a genuine Stellar Horizon testnet call for chain context. Horizon's + * testnet XLM/USDC order books and trade_aggregations are effectively + * empty (near-zero real liquidity on testnet), so it isn't a usable + * 24h-price source — CoinGecko is used for the actual price signal, + * and Horizon supplies the latest ledger as real on-chain context + * attached to the reading. + * - SmartContractRisk: DeFiLlama's per-protocol TVL history, checking + * for a >X% drop over the trailing 24h. `defiLlamaProtocolSlug` + * (default: "aave") stands in for a real "covered protocol" list + * until Refract defines one. + * + * Two checks stay mocked, honestly: + * - LiquidationShield: there's no public API for NEXUS Protocol + * liquidation events (NEXUS is a placeholder protocol in this repo). + * - FlightDelay: AviationStack requires a paid API key this project + * doesn't have. See README's "Oracle data sources" section. + * + * Caching + * ------- + * checkAll() is the hot path: OracleController.getStatus() calls it on + * every HTTP request, and ClaimService calls one of the individual checks + * per active policy every 5 minutes. A 55-second Redis cache (just under + * the 60-second scheduler interval) is applied to checkAll() so repeated + * HTTP requests and the scheduler share a single upstream round-trip per + * cycle. Individual check methods are NOT cached so OracleScheduler can + * always broadcast a fresh reading. + * + * Every real check fails safe: if the upstream API errors or times out, + * the check logs it and returns a "low" severity reading whose `value` + * sits on the non-triggering side of its `threshold`, so a third-party + * outage can never itself cause a false claim trigger. + */ +@Injectable() +export class OracleService { + private readonly logger = new Logger(OracleService.name); + private readonly timeoutMs: number; + private readonly coingeckoBaseUrl: string; + private readonly horizonUrl: string; + private readonly defiLlamaBaseUrl: string; + private readonly defiLlamaProtocolSlug: string; + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService + ) { + const oracles = this.configService.get("oracles", { infer: true }); + this.timeoutMs = oracles.httpTimeoutMs; + this.coingeckoBaseUrl = oracles.coingeckoBaseUrl; + this.horizonUrl = oracles.horizonUrl; + this.defiLlamaBaseUrl = oracles.defiLlamaBaseUrl; + this.defiLlamaProtocolSlug = oracles.defiLlamaProtocolSlug; + } + + /** + * Returns all three live oracle readings. + * + * Results are served from Redis for up to ORACLE_TTL seconds (55 s) so + * the HTTP endpoint, the scheduler, and ClaimService share a single set + * of upstream calls per polling cycle rather than each issuing their own. + * CoinGecko's keyless tier allows ~10-30 req/min; without this cache, + * moderate REST traffic alone would exhaust that quota. + */ + async checkAll(): Promise { + return this.cacheService.wrap(CACHE_KEY_CHECK_ALL, ORACLE_TTL, () => + this.runAllChecks() + ); + } + + /** + * Bypasses the cache and issues live upstream calls. Called by + * OracleScheduler so broadcasts always reflect the freshest data, and + * the result is also written back into the cache as a side-effect of + * checkAll()'s wrap() call on the next HTTP request. + */ + async checkAllFresh(): Promise { + const readings = await this.runAllChecks(); + // Proactively refresh the cache so a subsequent HTTP call within the + // same cycle gets the scheduler's fresh data immediately. + await this.cacheService.set(CACHE_KEY_CHECK_ALL, readings, ORACLE_TTL); + return readings; + } + + private async runAllChecks(): Promise { + return Promise.all([ + this.checkStablecoinDepeg(), + this.checkMarketCrash(), + this.checkSmartContractRisk(), + ]); + } + + async checkStablecoinDepeg(): Promise { + const threshold = 0.95; // 5% depeg + try { + const { data } = await axios.get>( + `${this.coingeckoBaseUrl}/simple/price`, + { params: { ids: "usd-coin", vs_currencies: "usd" }, timeout: this.timeoutMs } + ); + const usdcPrice = data["usd-coin"].usd; + const deviation = (1 - usdcPrice) * 100; + + const severity: Severity = + usdcPrice < threshold ? "triggered" + : usdcPrice < 0.98 ? "high" + : usdcPrice < 0.99 ? "medium" + : "low"; + + return { + coverageType: "StablecoinDepeg", + type: "oracle_update", + value: usdcPrice, + threshold, + severity, + message: `USDC price: $${usdcPrice.toFixed(4)} (${deviation > 0 ? "-" : "+"}${Math.abs(deviation).toFixed(3)}% from peg) [CoinGecko]`, + }; + } catch (err) { + return this.degraded("StablecoinDepeg", threshold, "CoinGecko", err); + } + } + + async checkMarketCrash(): Promise { + const threshold = -30; // 30% crash triggers + try { + const priceRequest = axios.get>( + `${this.coingeckoBaseUrl}/simple/price`, + { + params: { ids: "stellar", vs_currencies: "usd", include_24hr_change: true }, + timeout: this.timeoutMs, + } + ); + // Best-effort: attach real chain context, but never let a Horizon + // hiccup fail the whole check — the price signal comes from CoinGecko. + const ledgerRequest = axios + .get<{ _embedded: { records: Array<{ sequence: number; closed_at: string }> } }>( + `${this.horizonUrl}/ledgers`, + { params: { order: "desc", limit: 1 }, timeout: this.timeoutMs } + ) + .catch(() => null); + + const [priceRes, ledgerRes] = await Promise.all([priceRequest, ledgerRequest]); + const change24h = priceRes.data.stellar.usd_24h_change; + const ledger = ledgerRes?.data?._embedded?.records?.[0]; + + const severity: Severity = + change24h < threshold ? "triggered" + : change24h < -20 ? "high" + : change24h < -10 ? "medium" + : "low"; + + const chainContext = ledger + ? ` [CoinGecko; Horizon testnet ledger #${ledger.sequence} @ ${ledger.closed_at}]` + : " [CoinGecko]"; + + return { + coverageType: "MarketCrash", + type: "oracle_update", + value: change24h, + threshold, + severity, + message: `XLM 24h change: ${change24h.toFixed(2)}% (trigger at ${threshold}%)${chainContext}`, + }; + } catch (err) { + return this.degraded("MarketCrash", threshold, "CoinGecko", err); + } + } + + async checkSmartContractRisk(): Promise { + const threshold = -50; // 50% TVL drop in 24h triggers + try { + const { data } = await axios.get<{ name?: string; tvl?: DefiLlamaTvlPoint[] }>( + `${this.defiLlamaBaseUrl}/protocol/${this.defiLlamaProtocolSlug}`, + { timeout: this.timeoutMs } + ); + const points = data.tvl ?? []; + if (points.length < 2) throw new Error("DeFiLlama returned insufficient TVL history"); + + const latest = points[points.length - 1]; + const dayAgoCutoff = latest.date - 86_400; + let reference = points[0]; + for (const point of points) { + if (point.date <= dayAgoCutoff) reference = point; + else break; + } + + const pctChange = + ((latest.totalLiquidityUSD - reference.totalLiquidityUSD) / reference.totalLiquidityUSD) * 100; + + const severity: Severity = + pctChange < threshold ? "triggered" + : pctChange < -25 ? "high" + : pctChange < -10 ? "medium" + : "low"; + + return { + coverageType: "SmartContractRisk", + type: "oracle_update", + value: pctChange, + threshold, + severity, + message: `${data.name ?? this.defiLlamaProtocolSlug} TVL 24h change: ${pctChange.toFixed(2)}% (trigger at ${threshold}%) [DeFiLlama]`, + }; + } catch (err) { + return this.degraded("SmartContractRisk", threshold, "DeFiLlama", err); + } + } + + async checkLiquidationShield(): Promise { + // No public keyless API exists for this — it requires reading + // liquidation events from the (hypothetical) NEXUS Protocol contract + // on-chain. Stays mocked; ClaimService consumes this the same way it + // would consume a real reading once that integration exists. + // Spread is wide enough to actually cross the trigger threshold + // sometimes — a ±0.05 spread around 0.92 never dips below 0.85. + const collateralRatio = 0.92 + (Math.random() - 0.5) * 0.3; + const threshold = 0.85; // shield triggers below 85% collateralization + + return { + coverageType: "LiquidationShield", + type: "oracle_update", + value: collateralRatio, + threshold, + severity: collateralRatio < threshold ? "triggered" : "low", + message: `Collateral ratio ${(collateralRatio * 100).toFixed(1)}% (shield triggers below ${(threshold * 100).toFixed(0)}%) [mocked — no NEXUS Protocol integration]`, + }; + } + + async checkFlightDelay(flightNumber: string): Promise { + // TODO: AviationStack requires a paid API key we don't have — this + // trigger type stays mocked until a keyless (or budgeted) flight-data + // source is available. See README's "Oracle data sources" section. + const delayMinutes = Math.floor(Math.random() * 300); + const threshold = 120; // 2h delay triggers + + return { + coverageType: "FlightDelay", + type: "oracle_update", + value: delayMinutes, + threshold, + severity: delayMinutes >= threshold ? "triggered" : "low", + message: `Flight ${flightNumber}: ${delayMinutes}m delay (trigger at ${threshold}m) [mocked — AviationStack requires a paid key]`, + }; + } + + /** + * Fail-safe fallback for a real check whose upstream API errored or + * timed out: logs the failure and returns a "low" severity reading. + * All three real checks trigger on strict `value < threshold`, so + * `value === threshold` is always non-triggering — an outage can + * never itself cause a false claim trigger. + */ + private degraded(coverageType: string, threshold: number, source: string, err: unknown): OracleReading { + this.logger.error( + `${source} request failed for ${coverageType} check`, + err instanceof Error ? err.message : String(err) + ); + return { + coverageType, + type: "oracle_update", + value: threshold, + threshold, + severity: "low", + message: `${source} unavailable — degraded to a non-triggering reading`, + degraded: true, + }; + } +} diff --git a/src/policy/dto/buy-policy.dto.ts b/src/policy/dto/buy-policy.dto.ts index e69de29..e48437b 100644 --- a/src/policy/dto/buy-policy.dto.ts +++ b/src/policy/dto/buy-policy.dto.ts @@ -0,0 +1,50 @@ +import { IsString, IsNotEmpty, IsOptional, IsNumber, Min } from 'class-validator'; +import { IsStellarPublicKey } from '../../common/validators/stellar-address.validator'; + +export class BuyPolicyDto { + @IsString() + @IsNotEmpty() + @IsStellarPublicKey() + holder: string; + + @IsString() + @IsNotEmpty() + policyId: string; + + @IsOptional() + @IsNumber() + @Min(0) + premium?: number; +} + +/** + * Request body for the read-only policy purchase preflight. + * + * Mirrors the fields `BuyPolicyDto` needs to evaluate every contract-enforced + * condition (capacity, utilization, coverage bounds, duration, premium balance) + * without building or returning an XDR. + */ +export class PreflightPolicyDto { + @IsString() + @Length(56, 56) + holder!: string; + + @IsInt() + @Min(0) + @Max(4) + coverageType!: number; + + /** USDC amount in 1e7 base units, passed as a decimal string to avoid precision loss. */ + @IsString() + @Matches(/^\d+$/) + coverageAmount!: string; + + @IsInt() + @Min(1) + @Max(365) + durationDays!: number; + + @IsOptional() + @IsObject() + triggerParams?: Record; +} diff --git a/src/policy/policy.module.ts b/src/policy/policy.module.ts index e69de29..70592fc 100644 --- a/src/policy/policy.module.ts +++ b/src/policy/policy.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { StellarModule } from "../stellar/stellar.module"; +import { PolicyController } from "./policy.controller"; +import { PolicyRepository } from "./policy.repository"; +import { PolicyService } from "./policy.service"; + +@Module({ + imports: [StellarModule], + controllers: [PolicyController], + providers: [PolicyRepository, PolicyService], + exports: [PolicyService], +}) +export class PolicyModule {} diff --git a/src/policy/policy.repository.ts b/src/policy/policy.repository.ts index e69de29..b021bb0 100644 --- a/src/policy/policy.repository.ts +++ b/src/policy/policy.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from "@nestjs/common"; +import { DbService } from "../db/db.service"; + +/** Maps on-chain CoverageType ordinal (0–4) to the schema.sql enum. */ +export const COVERAGE_TYPE_DB = [ + "stablecoin_depeg", + "market_crash", + "liquidation_shield", + "smart_contract_risk", + "flight_delay", +] as const; + +export type CoverageTypeDb = (typeof COVERAGE_TYPE_DB)[number]; + +export interface UpsertPolicyInput { + /** On-chain policy id (u64), stored in policies.policy_id. */ + onChainPolicyId: string; + holder: string; + coverageType: CoverageTypeDb | number; + coverageAmount: bigint | string; + premium: bigint | string; + durationDays: number; + expiresAt: Date; + triggerParams?: Record; + isActive?: boolean; +} + +function toCoverageTypeDb(value: CoverageTypeDb | number): CoverageTypeDb { + if (typeof value === "number") { + const mapped = COVERAGE_TYPE_DB[value]; + if (!mapped) { + throw new Error(`Unknown coverage type ordinal: ${value}`); + } + return mapped; + } + return value; +} + +/** + * Postgres-backed policy projection keyed on the on-chain u64 policy id. + */ +@Injectable() +export class PolicyRepository { + constructor(private readonly db: DbService) {} + + async upsertFromChain(input: UpsertPolicyInput): Promise<{ id: string }> { + const coverageType = toCoverageTypeDb(input.coverageType); + const result = await this.db.query<{ id: string }>( + `INSERT INTO policies ( + policy_id, holder, coverage_type, coverage_amount, premium, + duration_days, expires_at, trigger_params, is_active + ) VALUES ($1, $2, $3::coverage_type, $4, $5, $6, $7, $8::jsonb, $9) + ON CONFLICT (policy_id) DO UPDATE SET + holder = EXCLUDED.holder, + coverage_type = EXCLUDED.coverage_type, + coverage_amount = EXCLUDED.coverage_amount, + premium = EXCLUDED.premium, + duration_days = EXCLUDED.duration_days, + expires_at = EXCLUDED.expires_at, + trigger_params = EXCLUDED.trigger_params, + is_active = EXCLUDED.is_active + RETURNING id`, + [ + input.onChainPolicyId, + input.holder, + coverageType, + input.coverageAmount.toString(), + input.premium.toString(), + input.durationDays, + input.expiresAt, + JSON.stringify(input.triggerParams ?? {}), + input.isActive ?? true, + ] + ); + return { id: result.rows[0].id }; + } + + async findIdByOnChainPolicyId(onChainPolicyId: string): Promise { + const result = await this.db.query<{ id: string }>( + "SELECT id FROM policies WHERE policy_id = $1", + [onChainPolicyId] + ); + return result.rows[0]?.id ?? null; + } + + async deactivateByOnChainPolicyId(onChainPolicyId: string): Promise { + await this.db.query("UPDATE policies SET is_active = false WHERE policy_id = $1", [onChainPolicyId]); + } +} diff --git a/src/pool/dto/deposit.dto.ts b/src/pool/dto/deposit.dto.ts index e69de29..d5f217d 100644 --- a/src/pool/dto/deposit.dto.ts +++ b/src/pool/dto/deposit.dto.ts @@ -0,0 +1,11 @@ +import { IsString, Length, Matches } from "class-validator"; + +export class DepositDto { + @IsString() + @Length(56, 56) + provider!: string; + + @IsString() + @Matches(/^\d+$/) + amount!: string; +} diff --git a/src/pool/dto/withdraw.dto.ts b/src/pool/dto/withdraw.dto.ts index e69de29..2b35287 100644 --- a/src/pool/dto/withdraw.dto.ts +++ b/src/pool/dto/withdraw.dto.ts @@ -0,0 +1,11 @@ +import { IsString, Length, Matches } from "class-validator"; + +export class WithdrawDto { + @IsString() + @Length(56, 56) + provider!: string; + + @IsString() + @Matches(/^\d+$/) + shares!: string; +} diff --git a/src/pool/lp-position.repository.ts b/src/pool/lp-position.repository.ts index e69de29..9a6e52f 100644 --- a/src/pool/lp-position.repository.ts +++ b/src/pool/lp-position.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { DbService } from "../db/db.service"; + +export interface CapitalFlowInput { + provider: string; + /** Absolute USDC amount moved in this event (1e7 units as bigint/string). */ + usdcAmount: bigint | string; + /** Absolute share delta for this event. */ + sharesDelta: bigint | string; +} + +/** + * Projects LP provide/withdraw events onto `lp_positions`. + */ +@Injectable() +export class LpPositionRepository { + constructor(private readonly db: DbService) {} + + async applyProvide(input: CapitalFlowInput): Promise { + const usdc = input.usdcAmount.toString(); + const shares = input.sharesDelta.toString(); + await this.db.query( + `INSERT INTO lp_positions (provider, shares, usdc_deposited, premium_earned, first_deposit, last_updated) + VALUES ($1, $2, $3, 0, NOW(), NOW()) + ON CONFLICT (provider) DO UPDATE SET + shares = lp_positions.shares + EXCLUDED.shares, + usdc_deposited = lp_positions.usdc_deposited + EXCLUDED.usdc_deposited, + last_updated = NOW()`, + [input.provider, shares, usdc] + ); + } + + async applyWithdraw(input: CapitalFlowInput): Promise { + const usdc = input.usdcAmount.toString(); + const shares = input.sharesDelta.toString(); + await this.db.query( + `INSERT INTO lp_positions (provider, shares, usdc_deposited, premium_earned, first_deposit, last_updated) + VALUES ($1, 0, 0, 0, NOW(), NOW()) + ON CONFLICT (provider) DO UPDATE SET + shares = GREATEST(lp_positions.shares - $2::numeric, 0), + usdc_deposited = GREATEST(lp_positions.usdc_deposited - $3::numeric, 0), + last_updated = NOW()`, + [input.provider, shares, usdc] + ); + } +} diff --git a/src/pool/pool.module.ts b/src/pool/pool.module.ts index e69de29..14b1b0b 100644 --- a/src/pool/pool.module.ts +++ b/src/pool/pool.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { StellarModule } from "../stellar/stellar.module"; +import { PoolController } from "./pool.controller"; +import { PoolService } from "./pool.service"; + +@Module({ + imports: [StellarModule], + controllers: [PoolController], + providers: [PoolService], + exports: [PoolService], +}) +export class PoolModule {} diff --git a/src/pool/pool.service.spec.ts b/src/pool/pool.service.spec.ts index e69de29..6bf13cb 100644 --- a/src/pool/pool.service.spec.ts +++ b/src/pool/pool.service.spec.ts @@ -0,0 +1,436 @@ +import { BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Account, + BASE_FEE, + Keypair, + StrKey, + Transaction, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { FeeStrategyService } from "../stellar/fee-strategy.service"; +import { SorobanRpcService } from "../stellar/soroban-rpc.service"; +import { PoolService } from "./pool.service"; + +// Mirrors the module-private `mockPool` constants in pool.service.ts. +const MOCK_POOL = { + totalUsdc: BigInt(18_400_000 * 1e7), + totalShares: BigInt(17_800_000 * 1e7), + lockedUsdc: BigInt(2_900_000 * 1e7), + premiumAccrued: BigInt(284_000 * 1e7), + utilizationBps: 1576, + apyBps: 890, + sharePrice: 1.0319, +}; + +const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +const POOL_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); + +function buildConfig(overrides: Partial = {}): ConfigService { + const stellar: AppConfig["stellar"] = { + network: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: NETWORK_PASSPHRASE, + poolContractId: POOL_CONTRACT_ID, + policyContractId: "", + oracleContractId: "", + relayerSecret: "", + ...overrides, + }; + const fees: AppConfig["fees"] = { + ceilingStroops: "10000000", + statsTtlMs: 15_000, + profiles: { + moderate: { percentile: 90, multiplier: 1.0 }, + aggressive: { percentile: 99, multiplier: 1.0 }, + }, + }; + return { + get: jest.fn((key: string) => (key === "fees" ? fees : stellar)), + } as unknown as ConfigService; +} + +function buildService(overrides: Partial = {}) { + const config = buildConfig(overrides); + const rpcService = new SorobanRpcService(config); + const feeStrategy = new FeeStrategyService(rpcService, config); + jest.spyOn(feeStrategy, "estimateInclusionFee").mockResolvedValue(BASE_FEE); + jest.spyOn(feeStrategy, "assertTotalUnderCeiling").mockImplementation(() => undefined); + jest.spyOn(rpcService.server, "getFeeStats").mockResolvedValue({ + sorobanInclusionFee: { max: "100", p99: "100", p90: "100", p10: "100", min: "100", mode: "100" }, + inclusionFee: { max: "100", p99: "100", p90: "100", p10: "100", min: "100", mode: "100" }, + latestLedger: 1, + } as never); + return new PoolService(config, rpcService, feeStrategy); +} + +/** Decodes the single invokeHostFunction operation out of a built (unsigned) tx envelope. */ +function decodeInvocation(txXdr: string) { + const tx = TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction; + const op = tx.operations[0] as Extract; + const invocation = op.func.invokeContract(); + return { + functionName: invocation.functionName().toString(), + args: invocation.args().map((arg) => scValToNative(arg)), + }; +} + +/** A minimal simulateTransaction success response carrying just a return value. */ +function simulateSuccess(retval: xdr.ScVal): rpc.Api.SimulateTransactionResponse { + return { result: { retval, auth: [] } } as unknown as rpc.Api.SimulateTransactionResponse; +} + +describe("PoolService", () => { + let service: PoolService; + let provider: string; + + beforeEach(() => { + service = buildService(); + provider = Keypair.random().publicKey(); + // prepareTransaction normally simulates against a live network and + // fills in Soroban resource fees — that's SDK behavior, not this + // service's logic, so it's short-circuited to identity here (same + // approach as ClaimSettlementService's tests). + jest.spyOn(rpc.Server.prototype, "getAccount").mockImplementation(async (id: string) => new Account(id, "1")); + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockImplementation(async (tx) => tx as never); + // Default: no lockup on file (Option::None -> ScVal::Void) — the + // withdraw-lockup tests below override this per case. + jest.spyOn(rpc.Server.prototype, "simulateTransaction").mockResolvedValue(simulateSuccess(xdr.ScVal.scvVoid())); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("getStats", () => { + it("derives availableUsdc from totalUsdc minus lockedUsdc and echoes the rest of the mock pool state", () => { + const stats = service.getStats(); + + expect(stats.totalUsdc).toBe(MOCK_POOL.totalUsdc.toString()); + expect(stats.totalShares).toBe(MOCK_POOL.totalShares.toString()); + expect(stats.lockedUsdc).toBe(MOCK_POOL.lockedUsdc.toString()); + expect(stats.premiumAccrued).toBe(MOCK_POOL.premiumAccrued.toString()); + expect(stats.availableUsdc).toBe((MOCK_POOL.totalUsdc - MOCK_POOL.lockedUsdc).toString()); + expect(stats.utilizationBps).toBe(MOCK_POOL.utilizationBps); + expect(stats.apyBps).toBe(MOCK_POOL.apyBps); + expect(stats.sharePrice).toBe(MOCK_POOL.sharePrice); + expect(stats.maxUtilizationBps).toBe(8000); + }); + }); + + describe("getUserPosition", () => { + it("computes usdcValue, premiumEarned, and pct from the fixed mock share balance", () => { + const address = "GPROVIDER0000000000000000000000000000000000000000000"; + + const position = service.getUserPosition(address); + + const mockShares = BigInt(Math.floor(30_000 * 1e7)); + const expectedUsdcValue = Number(mockShares) * MOCK_POOL.sharePrice; + + expect(position.address).toBe(address); + expect(position.shares).toBe(mockShares.toString()); + expect(position.usdcValue).toBe(expectedUsdcValue.toFixed(0)); + expect(position.premiumEarned).toBe((expectedUsdcValue * 0.089 * 0.5).toFixed(0)); + expect(position.pct).toBe(((Number(mockShares) / Number(MOCK_POOL.totalShares)) * 100).toFixed(4)); + }); + }); + + describe("provide", () => { + it("computes sharesOut proportionally to the current share price and returns an unsigned provide_capital invocation", async () => { + const amount = (184_000n * 10_000_000n).toString(); // 184,000 USDC in 1e7 base units + + const result = await service.provide({ provider, amount }); + + const expectedShares = (BigInt(amount) * MOCK_POOL.totalShares) / MOCK_POOL.totalUsdc; + expect(result.sharesOut).toBe(expectedShares.toString()); + expect(result.amountUsdc).toBe(amount); + expect(result.sharePrice).toBe(MOCK_POOL.sharePrice); + + // The built tx must actually invoke RefractPool.provide_capital(provider, amount) — + // wrong function name or argument shape/order would fail Soroban's + // simulation on every real invocation despite type-checking here. + const { functionName, args } = decodeInvocation(result.txXdr); + expect(functionName).toBe("provide_capital"); + expect(args).toEqual([provider, BigInt(amount)]); + expect(result.estimatedFee).toBeDefined(); + expect(BigInt(result.estimatedFee)).toBeGreaterThanOrEqual(100n); + }); + + it("appends a deposit event to the LP event ledger on success", async () => { + const amount = (10_000n * 10_000_000n).toString(); + + await service.provide({ provider, amount }); + + const events = service.getEventsForProvider(provider); + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("deposit"); + expect(events[0].provider).toBe(provider); + expect(events[0].deltaUsdc).toBe(amount); + }); + + it("rejects a zero-amount deposit without contacting the network", async () => { + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + expect.assertions(3); + try { + await service.provide({ provider, amount: "0" }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string }; + expect(response.error).toBe("Deposit amount must be greater than zero"); + expect(getAccountSpy).not.toHaveBeenCalled(); + } + }); + + it("wraps a Soroban build failure (e.g. simulation rejection) in a BadRequestException", async () => { + jest.spyOn(rpc.Server.prototype, "prepareTransaction").mockRejectedValue(new Error("InsufficientCapacity")); + expect.assertions(2); + + try { + await service.provide({ provider, amount: "100" }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string }; + expect(response.error).toContain("InsufficientCapacity"); + } + }); + }); + + describe("withdraw", () => { + it("computes usdcOut proportionally and returns an unsigned withdraw_capital invocation when within available capacity", async () => { + const shares = (1_000_000n * 10_000_000n).toString(); // 1,000,000 shares + + const result = await service.withdraw({ provider, shares }); + + const expectedUsdcOut = (BigInt(shares) * MOCK_POOL.totalUsdc) / MOCK_POOL.totalShares; + expect(result.usdcOut).toBe(expectedUsdcOut.toString()); + expect(result.sharesIn).toBe(shares); + + const { functionName, args } = decodeInvocation(result.txXdr); + expect(functionName).toBe("withdraw_capital"); + expect(args).toEqual([provider, BigInt(shares)]); + }); + + it("appends a withdrawal event with negative deltas to the LP event ledger on success", async () => { + const shares = (1_000n * 10_000_000n).toString(); + + await service.withdraw({ provider, shares }); + + const events = service.getEventsForProvider(provider); + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("withdrawal"); + expect(events[0].deltaShares).toBe(`-${shares}`); + expect(events[0].deltaUsdc.startsWith("-")).toBe(true); + }); + + it("rejects a withdrawal that exceeds available (unlocked) pool capacity", async () => { + // Requesting all shares would out-pace the unlocked USDC, since + // lockedUsdc covers active policies and isn't withdrawable. + const shares = MOCK_POOL.totalShares.toString(); + expect.assertions(3); + + try { + await service.withdraw({ provider, shares }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string; available: string }; + expect(response.error).toBe("Pool capacity locked — too many active policies"); + expect(response.available).toBe((MOCK_POOL.totalUsdc - MOCK_POOL.lockedUsdc).toString()); + } + }); + + it("rejects a zero-share withdrawal", async () => { + expect.assertions(2); + try { + await service.withdraw({ provider, shares: "0" }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string }; + expect(response.error).toBe("Withdrawal shares must be greater than zero"); + } + }); + + it("rejects a withdrawal while the on-chain lockup hasn't expired yet, without building a tx", async () => { + const futureUnlock = BigInt(Math.floor(Date.now() / 1000) + 3_600); + jest + .spyOn(rpc.Server.prototype, "simulateTransaction") + .mockResolvedValue(simulateSuccess(nativeToScVal(futureUnlock, { type: "u64" }))); + const prepareSpy = jest.spyOn(rpc.Server.prototype, "prepareTransaction"); + const shares = (1_000n * 10_000_000n).toString(); + expect.assertions(3); + + try { + await service.withdraw({ provider, shares }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string; lockupExpiresAt: string }; + expect(response.lockupExpiresAt).toBe(futureUnlock.toString()); + expect(prepareSpy).not.toHaveBeenCalled(); + } + }); + + it("allows a withdrawal once the on-chain lockup has expired", async () => { + const pastUnlock = BigInt(Math.floor(Date.now() / 1000) - 1); + jest + .spyOn(rpc.Server.prototype, "simulateTransaction") + .mockResolvedValue(simulateSuccess(nativeToScVal(pastUnlock, { type: "u64" }))); + const shares = (1_000n * 10_000_000n).toString(); + + await expect(service.withdraw({ provider, shares })).resolves.not.toThrow(); + }); + }); + + describe("lockupExpiresAt", () => { + it("returns null when the contract reports no lockup (Option::None)", async () => { + jest.spyOn(rpc.Server.prototype, "simulateTransaction").mockResolvedValue(simulateSuccess(xdr.ScVal.scvVoid())); + + expect(await service.lockupExpiresAt(provider)).toBeNull(); + }); + + it("returns the unlock timestamp when the contract reports one", async () => { + const unlocksAt = 1_800_000_000n; + jest + .spyOn(rpc.Server.prototype, "simulateTransaction") + .mockResolvedValue(simulateSuccess(nativeToScVal(unlocksAt, { type: "u64" }))); + + expect(await service.lockupExpiresAt(provider)).toBe(unlocksAt); + }); + + it("returns null without contacting the network when the pool contract isn't configured", async () => { + const unconfigured = buildService({ poolContractId: "" }); + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + + expect(await unconfigured.lockupExpiresAt(provider)).toBeNull(); + expect(getAccountSpy).not.toHaveBeenCalled(); + }); + + it("wraps a simulation error in a BadRequestException", async () => { + jest + .spyOn(rpc.Server.prototype, "simulateTransaction") + .mockResolvedValue({ error: "boom" } as unknown as rpc.Api.SimulateTransactionResponse); + expect.assertions(2); + + try { + await service.lockupExpiresAt(provider); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string }; + expect(response.error).toContain("boom"); + } + }); + }); + + describe("unconfigured pool contract", () => { + it("rejects provide/withdraw with a clear error instead of calling a non-existent contract", async () => { + const unconfigured = buildService({ poolContractId: "" }); + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + expect.assertions(3); + + try { + await unconfigured.provide({ provider, amount: "100" }); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const response = (err as BadRequestException).getResponse() as { error: string }; + expect(response.error).toContain("REFRACT_POOL_CONTRACT_ID"); + expect(getAccountSpy).not.toHaveBeenCalled(); + } + }); + }); + + describe("getPremiumHistory", () => { + afterEach(() => { + jest.spyOn(Math, "random").mockRestore(); + }); + + it("returns 30 days of history in descending date order starting today", () => { + jest.spyOn(Math, "random").mockReturnValue(0.5); // 0.5 < 0.9, so payouts stay "0" every day + + const history = service.getPremiumHistory(); + + expect(history).toHaveLength(30); + expect(history[0].date).toBe(new Date().toISOString().split("T")[0]); + expect(history[0].payouts).toBe("0"); + expect(history[0].premiums).toBe((4_000 + 0.5 * 12_000).toFixed(0)); + expect(history[0].apyBps).toBe(Math.floor(700 + 0.5 * 400)); + + const day0 = new Date(history[0].date); + const day1 = new Date(history[1].date); + expect((day0.getTime() - day1.getTime()) / 86_400_000).toBe(1); + }); + + it("includes a non-zero payout for a day when the random draw clears the 0.9 threshold", () => { + jest.spyOn(Math, "random").mockReturnValue(0.95); + + const history = service.getPremiumHistory(); + + expect(history[0].payouts).toBe((5_000 + 0.95 * 30_000).toFixed(0)); + }); + }); + + describe("LP event ledger (recordLpEvent / getEventsForProvider)", () => { + it("recordLpEvent appends an event and getEventsForProvider returns it", () => { + const event = service.recordLpEvent(provider, "deposit", "100000", "100000", "txhash1", 54321); + + expect(event.id).toBe(1); + expect(event.provider).toBe(provider); + expect(event.eventType).toBe("deposit"); + expect(event.deltaShares).toBe("100000"); + expect(event.deltaUsdc).toBe("100000"); + expect(event.txHash).toBe("txhash1"); + expect(event.ledgerSeq).toBe(54321); + expect(event.recordedAt).toBeTruthy(); + + const events = service.getEventsForProvider(provider); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(event); + }); + + it("auto-increments ids across multiple events", () => { + service.recordLpEvent(provider, "deposit", "100000", "100000"); + service.recordLpEvent(provider, "deposit", "200000", "200000"); + service.recordLpEvent(provider, "withdrawal", "-50000", "-50000"); + + const events = service.getEventsForProvider(provider); + expect(events).toHaveLength(3); + // Returned newest-first (by id desc) + expect(events[0].id).toBe(3); + expect(events[1].id).toBe(2); + expect(events[2].id).toBe(1); + }); + + it("stores txHash and ledgerSeq as null when not provided", () => { + service.recordLpEvent(provider, "premium_accrual", "500", "0"); + + const events = service.getEventsForProvider(provider); + expect(events[0].txHash).toBeNull(); + expect(events[0].ledgerSeq).toBeNull(); + }); + + it("getEventsForProvider returns only events for the specified provider", () => { + const other = Keypair.random().publicKey(); + service.recordLpEvent(provider, "deposit", "100", "100"); + service.recordLpEvent(other, "deposit", "200", "200"); + + expect(service.getEventsForProvider(provider)).toHaveLength(1); + expect(service.getEventsForProvider(other)).toHaveLength(1); + }); + + it("returns an empty array for a provider with no events", () => { + expect(service.getEventsForProvider(provider)).toEqual([]); + }); + + it("multiple events across providers all accumulate independently", () => { + const p2 = Keypair.random().publicKey(); + service.recordLpEvent(provider, "deposit", "1", "1"); + service.recordLpEvent(provider, "deposit", "2", "2"); + service.recordLpEvent(p2, "deposit", "3", "3"); + + expect(service.getEventsForProvider(provider)).toHaveLength(2); + expect(service.getEventsForProvider(p2)).toHaveLength(1); + }); + }); +}); diff --git a/src/quote/dto/create-quote.dto.ts b/src/quote/dto/create-quote.dto.ts index e69de29..1717657 100644 --- a/src/quote/dto/create-quote.dto.ts +++ b/src/quote/dto/create-quote.dto.ts @@ -0,0 +1,21 @@ +import { IsEnum, IsInt, IsNumber, IsOptional, Min } from "class-validator"; +import { IsWithinCoverageLimits } from "../../common/validators/coverage-limits.validator"; +import { CoverageTypeName } from "../coverage-type"; + +@IsWithinCoverageLimits() +export class CreateQuoteDto { + @IsEnum(CoverageTypeName) + coverageType!: CoverageTypeName; + + @IsNumber() + @Min(0) + coverageAmount!: number; + + @IsInt() + @Min(1) + durationDays!: number; + + @IsOptional() + @IsNumber() + triggerThreshold?: number; +} diff --git a/src/quote/quote.controller.ts b/src/quote/quote.controller.ts index e69de29..5e164ef 100644 --- a/src/quote/quote.controller.ts +++ b/src/quote/quote.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Get, Header, Post } from "@nestjs/common"; +import { STATIC_RESOURCE_CACHE_CONTROL } from "../common/http-cache"; +import { CompareQuotesDto } from "./dto/compare-quotes.dto"; +import { CreateQuoteDto } from "./dto/create-quote.dto"; +import { CoverageTypeInfo, QuoteComparison, QuoteResult, QuoteService } from "./quote.service"; + +@Controller({ path: "quotes", version: "1" }) +export class QuoteController { + constructor(private readonly quoteService: QuoteService) {} + + /** POST /api/v1/quotes — calculate a premium quote */ + @Post() + createQuote(@Body() dto: CreateQuoteDto): QuoteResult { + return this.quoteService.createQuote(dto); + } + + /** POST /api/v1/quotes/compare — quote one amount/duration across coverage types */ + @Post("compare") + compareQuotes(@Body() dto: CompareQuotesDto): QuoteComparison { + return this.quoteService.compareQuotes(dto); + } + + /** GET /api/v1/quotes/coverage-types — list available coverage with descriptions */ + @Get("coverage-types") + @Header("Cache-Control", STATIC_RESOURCE_CACHE_CONTROL) + listCoverageTypes(): { types: CoverageTypeInfo[] } { + return { types: this.quoteService.listCoverageTypes() }; + } +} diff --git a/src/stellar/soroban-confirmation.util.spec.ts b/src/stellar/soroban-confirmation.util.spec.ts index e69de29..59a6f07 100644 --- a/src/stellar/soroban-confirmation.util.spec.ts +++ b/src/stellar/soroban-confirmation.util.spec.ts @@ -0,0 +1,220 @@ +import { rpc } from "@stellar/stellar-sdk"; +import { + DEFAULT_CONFIRMATION_POLL, + pollForConfirmation, +} from "./soroban-confirmation.util"; + +function notFound(): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + latestLedger: 1, + latestLedgerCloseTime: 1, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + }; +} + +function success(): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + returnValue: undefined, + }; +} + +function failed(): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.FAILED, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + }; +} + +describe("pollForConfirmation", () => { + it("returns success on the first SUCCESS response", async () => { + const server = { getTransaction: jest.fn().mockResolvedValue(success()) } as unknown as rpc.Server; + + const result = await pollForConfirmation(server, "abc", { + deadlineMs: 5_000, + random: () => 0.5, + }); + + expect(result).toMatchObject({ confirmed: true, outcome: "success", txHash: "abc" }); + expect(server.getTransaction).toHaveBeenCalledTimes(1); + }); + + it("maps FAILED to failed_on_chain", async () => { + const server = { getTransaction: jest.fn().mockResolvedValue(failed()) } as unknown as rpc.Server; + + const result = await pollForConfirmation(server, "abc", { random: () => 0.5 }); + + expect(result).toEqual({ + confirmed: false, + outcome: "failed_on_chain", + txHash: "abc", + error: "Transaction failed on-chain", + }); + }); + + it("maps persistent NOT_FOUND past the deadline to not_found_yet_deadline_exceeded", async () => { + const server = { getTransaction: jest.fn().mockResolvedValue(notFound()) } as unknown as rpc.Server; + let now = 0; + const sleeps: number[] = []; + + const result = await pollForConfirmation(server, "abc", { + initialIntervalMs: 100, + backoffMultiplier: 2, + maxIntervalMs: 1_000, + deadlineMs: 350, + jitterRatio: 0, + minIntervalMs: 1, + now: () => now, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + random: () => 0.5, + }); + + expect(result.outcome).toBe("not_found_yet_deadline_exceeded"); + expect(result.error).toContain("Timed out"); + expect(sleeps.length).toBeGreaterThan(0); + // Total waited must not exceed the wall-clock deadline. + expect(sleeps.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(350); + }); + + it("grows the interval by the configured multiplier up to the max cap", async () => { + const server = { + getTransaction: jest + .fn() + .mockResolvedValueOnce(notFound()) + .mockResolvedValueOnce(notFound()) + .mockResolvedValueOnce(notFound()) + .mockResolvedValueOnce(success()), + } as unknown as rpc.Server; + let now = 0; + const sleeps: number[] = []; + + await pollForConfirmation(server, "abc", { + initialIntervalMs: 100, + backoffMultiplier: 2, + maxIntervalMs: 250, + deadlineMs: 10_000, + jitterRatio: 0, + minIntervalMs: 1, + now: () => now, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + random: () => 0.5, + }); + + // After attempt 0 (immediate), sleeps: 100, then 200, then capped 250. + expect(sleeps[0]).toBe(100); + expect(sleeps[1]).toBe(200); + expect(sleeps[2]).toBe(250); + }); + + it("retries a transient getTransaction throw inside the deadline", async () => { + const server = { + getTransaction: jest + .fn() + .mockRejectedValueOnce(new Error("RPC unavailable")) + .mockResolvedValueOnce(success()), + } as unknown as rpc.Server; + let now = 0; + + const result = await pollForConfirmation(server, "abc", { + initialIntervalMs: 50, + deadlineMs: 5_000, + jitterRatio: 0, + now: () => now, + sleep: async (ms) => { + now += ms; + }, + random: () => 0.5, + }); + + expect(result.outcome).toBe("success"); + expect(server.getTransaction).toHaveBeenCalledTimes(2); + }); + + it("stops promptly when the AbortSignal fires", async () => { + const server = { getTransaction: jest.fn().mockResolvedValue(notFound()) } as unknown as rpc.Server; + const controller = new AbortController(); + let now = 0; + + const resultPromise = pollForConfirmation(server, "abc", { + initialIntervalMs: 100, + deadlineMs: 30_000, + jitterRatio: 0, + signal: controller.signal, + now: () => now, + sleep: async (ms) => { + now += ms; + controller.abort(); + }, + random: () => 0.5, + }); + + const result = await resultPromise; + expect(result.outcome).toBe("aborted"); + expect(result.error).toContain("aborted"); + }); + + it("keeps jitter within ±jitterRatio of the base interval", async () => { + const server = { + getTransaction: jest.fn().mockResolvedValueOnce(notFound()).mockResolvedValueOnce(success()), + } as unknown as rpc.Server; + let now = 0; + const sleeps: number[] = []; + // random=1 → +jitter; random=0 → -jitter + let flip = true; + + await pollForConfirmation(server, "abc", { + initialIntervalMs: 1_000, + deadlineMs: 10_000, + jitterRatio: 0.2, + minIntervalMs: 1, + now: () => now, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + random: () => { + flip = !flip; + return flip ? 1 : 0; + }, + }); + + expect(sleeps[0]).toBeGreaterThanOrEqual(800); + expect(sleeps[0]).toBeLessThanOrEqual(1_200); + }); + + it("exports documented defaults matching AppConfig", () => { + expect(DEFAULT_CONFIRMATION_POLL.initialIntervalMs).toBe(400); + expect(DEFAULT_CONFIRMATION_POLL.backoffMultiplier).toBe(1.8); + expect(DEFAULT_CONFIRMATION_POLL.maxIntervalMs).toBe(8_000); + expect(DEFAULT_CONFIRMATION_POLL.deadlineMs).toBe(30_000); + }); +}); diff --git a/src/stellar/soroban-confirmation.util.ts b/src/stellar/soroban-confirmation.util.ts index e69de29..8fd30cf 100644 --- a/src/stellar/soroban-confirmation.util.ts +++ b/src/stellar/soroban-confirmation.util.ts @@ -0,0 +1,163 @@ +import { rpc, xdr } from "@stellar/stellar-sdk"; + +/** + * Outcome discriminant for a confirmation poll. Callers must distinguish + * "definitely failed on-chain" (do not retry the same envelope) from + * "unknown — deadline exceeded while still NOT_FOUND" (safe to re-check + * later). Both previously collapsed into `confirmed: false`. + */ +export type ConfirmationOutcome = + | "success" + | "failed_on_chain" + | "not_found_yet_deadline_exceeded" + | "aborted"; + +export interface ConfirmationResult { + /** Convenience mirror of `outcome === "success"`. */ + confirmed: boolean; + outcome: ConfirmationOutcome; + txHash: string; + error?: string; + /** + * Raw Soroban return value from a successful getTransaction, when the + * RPC includes one. Callers decode with `scValToNative` (u64 policy ids + * must stay as bigint — never coerce through Number). + */ + returnValue?: xdr.ScVal; +} + +/** Tunables for a single pollForConfirmation call. */ +export interface ConfirmationPollOptions { + /** First sleep before the second getTransaction (ms). Default 400. */ + initialIntervalMs?: number; + /** Multiplier applied after each NOT_FOUND / transient error. Default 1.8. */ + backoffMultiplier?: number; + /** Cap on the sleep interval (ms). Default 8_000. */ + maxIntervalMs?: number; + /** + * Wall-clock deadline from poll start (ms). Not an attempt count — a + * slow RPC must not silently extend the wait. Default 30_000. + */ + deadlineMs?: number; + /** + * Fractional jitter applied to each sleep: interval * (1 ± jitterRatio). + * Required so concurrent settlements don't storm the RPC in lockstep. + * Default 0.2. + */ + jitterRatio?: number; + /** Floor under the post-jitter sleep so aggressive early polling still respects rate limits. Default 100. */ + minIntervalMs?: number; + /** Optional abort — graceful shutdown stops polling promptly. */ + signal?: AbortSignal; + /** Injected clock/sleep for tests. */ + sleep?: (ms: number) => Promise; + now?: () => number; + /** Injected RNG for jitter; returns [0, 1). */ + random?: () => number; +} + +export const DEFAULT_CONFIRMATION_POLL: Required< + Omit +> = { + initialIntervalMs: 400, + backoffMultiplier: 1.8, + maxIntervalMs: 8_000, + deadlineMs: 30_000, + jitterRatio: 0.2, + minIntervalMs: 100, +}; + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withJitter(intervalMs: number, jitterRatio: number, minIntervalMs: number, random: () => number): number { + const delta = intervalMs * jitterRatio * (2 * random() - 1); + return Math.max(minIntervalMs, Math.round(intervalMs + delta)); +} + +/** + * Polls a submitted Soroban transaction until it lands SUCCESS/FAILED, + * the wall-clock deadline elapses, or the optional AbortSignal fires. + * + * Shared by every path that submits a transaction and needs to know + * whether it actually landed on-chain (ClaimSettlementService's + * relayer-signed process_claim / oracle updates, and TxService's + * client-signed submits). + * + * NOT_FOUND early in the window is normal — Soroban ledgers close ~5s + * and RPC history is eventually consistent — and is never treated as + * failure. Transient getTransaction throws (RPC blips) are retried + * inside the deadline rather than propagating out of the loop. + */ +export async function pollForConfirmation( + server: rpc.Server, + hash: string, + options: ConfirmationPollOptions = {} +): Promise { + const { + initialIntervalMs = DEFAULT_CONFIRMATION_POLL.initialIntervalMs, + backoffMultiplier = DEFAULT_CONFIRMATION_POLL.backoffMultiplier, + maxIntervalMs = DEFAULT_CONFIRMATION_POLL.maxIntervalMs, + deadlineMs = DEFAULT_CONFIRMATION_POLL.deadlineMs, + jitterRatio = DEFAULT_CONFIRMATION_POLL.jitterRatio, + minIntervalMs = DEFAULT_CONFIRMATION_POLL.minIntervalMs, + signal, + sleep = defaultSleep, + now = Date.now, + random = Math.random, + } = options; + + const deadlineAt = now() + deadlineMs; + let intervalMs = initialIntervalMs; + + // Immediate first probe — no sleep before attempt 0 — then back off. + for (;;) { + if (signal?.aborted) { + return { confirmed: false, outcome: "aborted", txHash: hash, error: "Confirmation polling aborted" }; + } + + try { + const result = await server.getTransaction(hash); + if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { + const success = result as rpc.Api.GetSuccessfulTransactionResponse; + return { + confirmed: true, + outcome: "success", + txHash: hash, + returnValue: success.returnValue, + }; + } + if (result.status === rpc.Api.GetTransactionStatus.FAILED) { + return { + confirmed: false, + outcome: "failed_on_chain", + txHash: hash, + error: "Transaction failed on-chain", + }; + } + // NOT_FOUND (and any other non-terminal status): keep polling. + } catch { + // Transient RPC failure — retry within the deadline. + } + + const remaining = deadlineAt - now(); + if (remaining <= 0) { + return { + confirmed: false, + outcome: "not_found_yet_deadline_exceeded", + txHash: hash, + error: "Timed out waiting for confirmation", + }; + } + + const sleepMs = Math.min(withJitter(intervalMs, jitterRatio, minIntervalMs, random), remaining); + await sleep(sleepMs); + + if (signal?.aborted) { + return { confirmed: false, outcome: "aborted", txHash: hash, error: "Confirmation polling aborted" }; + } + + intervalMs = Math.min(maxIntervalMs, intervalMs * backoffMultiplier); + } +} diff --git a/src/stellar/soroban-rpc.service.ts b/src/stellar/soroban-rpc.service.ts index e69de29..69567bd 100644 --- a/src/stellar/soroban-rpc.service.ts +++ b/src/stellar/soroban-rpc.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +/** + * Thin wrapper around rpc.Server so fee-stat fetches and other RPC calls + * share one constructed client (retry/timeout knobs live here later). + */ +@Injectable() +export class SorobanRpcService { + readonly server: rpc.Server; + readonly networkPassphrase: string; + + constructor(configService: ConfigService) { + const stellar = configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + } +} diff --git a/src/tx/tx.controller.ts b/src/tx/tx.controller.ts index e69de29..aa15603 100644 --- a/src/tx/tx.controller.ts +++ b/src/tx/tx.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Post } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiBody, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { SubmitTxDto } from "./dto/submit-tx.dto"; +import { TxService } from "./tx.service"; + +@ApiTags("tx") +@Controller("api/v1/tx") +export class TxController { + constructor(private readonly txService: TxService) {} + + @Post("submit") + @ApiOperation({ + summary: "Submit a signed Stellar transaction", + description: + "Completes the unsigned-XDR flow: build the transaction via the relevant " + + "endpoint (e.g. POST /api/v1/policy/buy or POST /api/v1/claim), sign the " + + "returned `txXdr` in the caller's wallet, then submit the signed envelope " + + "here. The server never holds signing keys; it only relays the signed " + + "transaction to the network and reports the confirmation result.", + }) + @ApiBody({ type: SubmitTxDto }) + @ApiOkResponse({ + description: + "Transaction submitted and confirmed. Returns the transaction hash and " + + "the ledger in which it was included.", + schema: { + type: "object", + properties: { + hash: { + type: "string", + description: "Stellar transaction hash (hex).", + example: "3389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889", + }, + ledger: { + type: "number", + description: "Ledger sequence number the transaction was included in.", + example: 1234567, + }, + }, + required: ["hash", "ledger"], + }, + }) + @ApiBadRequestResponse({ + description: + "The signed XDR is malformed, was not signed, or the transaction failed " + + "validation on the network.", + schema: { + type: "object", + properties: { + statusCode: { type: "number", example: 400 }, + message: { type: "string", example: "Invalid signed transaction XDR" }, + error: { type: "string", example: "Bad Request" }, + }, + required: ["statusCode", "message"], + }, + }) + submit(@Body() dto: SubmitTxDto) { + return this.txService.submit(dto.signedXdr); + } +} diff --git a/src/tx/tx.module.ts b/src/tx/tx.module.ts index e69de29..40fe52e 100644 --- a/src/tx/tx.module.ts +++ b/src/tx/tx.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PolicyModule } from "../policy/policy.module"; +import { TxController } from "./tx.controller"; +import { TxService } from "./tx.service"; + +@Module({ + imports: [PolicyModule], + controllers: [TxController], + providers: [TxService], +}) +export class TxModule {} diff --git a/src/tx/tx.service.spec.ts b/src/tx/tx.service.spec.ts index e69de29..af813b7 100644 --- a/src/tx/tx.service.spec.ts +++ b/src/tx/tx.service.spec.ts @@ -0,0 +1,207 @@ +import { BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Account, + BASE_FEE, + Keypair, + Operation, + StrKey, + TransactionBuilder, + nativeToScVal, + rpc, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { PolicyService } from "../policy/policy.service"; +import { TxService } from "./tx.service"; + +const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; + +const CONFIRMATION: AppConfig["confirmation"] = { + initialIntervalMs: 10, + backoffMultiplier: 1.5, + maxIntervalMs: 50, + deadlineMs: 200, + jitterRatio: 0, + settlementDeadlineMs: 200, + httpDeadlineMs: 200, +}; + +function buildConfig(overrides: Partial = {}): ConfigService { + const stellar: AppConfig["stellar"] = { + network: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: NETWORK_PASSPHRASE, + poolContractId: StrKey.encodeContract(Buffer.alloc(32, 1)), + policyContractId: "", + oracleContractId: "", + relayerSecret: "", + ...overrides, + }; + return { + get: jest.fn((key: string) => { + if (key === "stellar") return stellar; + if (key === "confirmation") return CONFIRMATION; + return undefined; + }), + } as unknown as ConfigService; +} + +/** A validly-formed, signed (but never network-submitted) tx envelope for TxService to parse. */ +function buildSignedXdr(): string { + const signer = Keypair.random(); + const account = new Account(signer.publicKey(), "1"); + const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE }) + .addOperation(Operation.bumpSequence({ bumpTo: "2" })) + .setTimeout(30) + .build(); + tx.sign(signer); + return tx.toXDR(); +} + +function mockPolicyService(): jest.Mocked { + return { + activateFromBuyConfirmation: jest.fn().mockReturnValue(null), + } as unknown as jest.Mocked; +} + +const PENDING_SEND_RESULT = { status: "PENDING" as const, hash: "mock-tx-hash", latestLedger: 1, latestLedgerCloseTime: 1 }; + +describe("TxService", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("submit", () => { + it("rejects malformed XDR without contacting the network", async () => { + const service = new TxService(buildConfig(), mockPolicyService()); + const sendSpy = jest.spyOn(rpc.Server.prototype, "sendTransaction"); + expect.assertions(2); + + try { + await service.submit("not-valid-xdr"); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(sendSpy).not.toHaveBeenCalled(); + } + }); + + it("submits, confirms, and reports the tx hash for a successful submission", async () => { + const policyService = mockPolicyService(); + const service = new TxService(buildConfig(), policyService); + const signedXdr = buildSignedXdr(); + + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + returnValue: nativeToScVal(42n, { type: "u64" }), + }); + + const result = await service.submit(signedXdr); + + expect(result).toMatchObject({ confirmed: true, outcome: "success", txHash: "mock-tx-hash" }); + expect(policyService.activateFromBuyConfirmation).toHaveBeenCalledWith( + "mock-tx-hash", + expect.anything() + ); + }); + + it("surfaces onChainPolicyId when the confirmation activates a pending policy", async () => { + const policyService = mockPolicyService(); + policyService.activateFromBuyConfirmation.mockReturnValue({ + onChainPolicyId: "9007199254740993", + } as never); + const service = new TxService(buildConfig(), policyService); + const signedXdr = buildSignedXdr(); + + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + returnValue: nativeToScVal(9007199254740993n, { type: "u64" }), + }); + + const result = await service.submit(signedXdr); + + expect(result.onChainPolicyId).toBe("9007199254740993"); + }); + + it("reports an unconfirmed result when the network rejects the submission outright", async () => { + const service = new TxService(buildConfig(), mockPolicyService()); + const signedXdr = buildSignedXdr(); + + jest + .spyOn(rpc.Server.prototype, "sendTransaction") + .mockResolvedValue({ status: "ERROR", hash: "mock-tx-hash", latestLedger: 1, latestLedgerCloseTime: 1 }); + const getTransactionSpy = jest.spyOn(rpc.Server.prototype, "getTransaction"); + + const result = await service.submit(signedXdr); + + expect(result.confirmed).toBe(false); + expect(result.error).toContain("ERROR"); + expect(getTransactionSpy).not.toHaveBeenCalled(); + }); + + it("reports failed_on_chain when the submitted transaction fails on-chain", async () => { + const service = new TxService(buildConfig(), mockPolicyService()); + const signedXdr = buildSignedXdr(); + + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue(PENDING_SEND_RESULT); + jest.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.FAILED, + latestLedger: 2, + latestLedgerCloseTime: 2, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 2, + createdAt: 2, + applicationOrder: 1, + feeBump: false, + envelopeXdr: {} as never, + resultXdr: {} as never, + resultMetaXdr: {} as never, + }); + + const result = await service.submit(signedXdr); + + expect(result).toMatchObject({ + confirmed: false, + outcome: "failed_on_chain", + txHash: "mock-tx-hash", + error: "Transaction failed on-chain", + }); + }); + + it("catches an unexpected error (e.g. a network failure) and reports confirmed:false", async () => { + const service = new TxService(buildConfig(), mockPolicyService()); + const signedXdr = buildSignedXdr(); + + jest.spyOn(rpc.Server.prototype, "sendTransaction").mockRejectedValue(new Error("connection refused")); + + const result = await service.submit(signedXdr); + + expect(result.confirmed).toBe(false); + expect(result.error).toBe("connection refused"); + }); + }); +}); diff --git a/src/tx/tx.service.ts b/src/tx/tx.service.ts index e69de29..8bfb416 100644 --- a/src/tx/tx.service.ts +++ b/src/tx/tx.service.ts @@ -0,0 +1,87 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TransactionBuilder, rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { PolicyService } from "../policy/policy.service"; +import { ConfirmationResult, pollForConfirmation } from "../stellar/soroban-confirmation.util"; + +/** + * Submits a transaction the caller already signed in their own wallet + * (buy_policy/provide_capital/withdraw_capital all call `require_auth()` on + * the caller, so the backend only ever hands back unsigned XDR for those — + * see PolicyService/PoolService) and waits for it to land on-chain. Kept as + * a single generic endpoint rather than one per action since submission and + * confirmation are identical regardless of which contract method was + * invoked. + * + * On success, the Soroban return value is exposed to callers and — when the + * envelope hash matches a pending buy_policy — forwarded to PolicyService + * so the on-chain u64 policy id is persisted. + */ +@Injectable() +export class TxService { + private readonly logger = new Logger(TxService.name); + private readonly server: rpc.Server; + private readonly networkPassphrase: string; + private readonly confirmationDefaults: AppConfig["confirmation"]; + + constructor( + private readonly configService: ConfigService, + private readonly policyService: PolicyService + ) { + const stellar = this.configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.networkPassphrase = stellar.networkPassphrase; + this.confirmationDefaults = this.configService.get("confirmation", { infer: true }); + } + + async submit(signedXdr: string): Promise { + let tx: ReturnType; + try { + tx = TransactionBuilder.fromXDR(signedXdr, this.networkPassphrase); + } catch { + throw new BadRequestException({ error: "Malformed transaction XDR" }); + } + + try { + const sendResult = await this.server.sendTransaction(tx); + if (sendResult.status === "ERROR" || sendResult.status === "TRY_AGAIN_LATER") { + return { + confirmed: false, + outcome: "failed_on_chain", + txHash: sendResult.hash, + error: `Submission not accepted: ${sendResult.status}`, + }; + } + + const confirmation = await pollForConfirmation(this.server, sendResult.hash, { + initialIntervalMs: this.confirmationDefaults.initialIntervalMs, + backoffMultiplier: this.confirmationDefaults.backoffMultiplier, + maxIntervalMs: this.confirmationDefaults.maxIntervalMs, + jitterRatio: this.confirmationDefaults.jitterRatio, + deadlineMs: this.confirmationDefaults.httpDeadlineMs, + }); + + if (confirmation.outcome === "success") { + const activated = this.policyService.activateFromBuyConfirmation( + confirmation.txHash, + confirmation.returnValue + ); + if (activated?.onChainPolicyId) { + return { ...confirmation, onChainPolicyId: activated.onChainPolicyId }; + } + } + + return confirmation; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error("Soroban submission failed", message); + return { + confirmed: false, + outcome: "failed_on_chain", + txHash: "", + error: message, + }; + } + } +}