From ccc433f571bd2c236730428c2ef74de85caaa7a3 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Wed, 21 Oct 2026 13:39:31 +0000 Subject: [PATCH 1/2] fix: restore files emptied from backend source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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/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 | 14 + src/pool/dto/withdraw.dto.ts | 14 + src/pool/lp-position.repository.ts | 46 ++ src/pool/pool.module.ts | 12 + src/pool/pool.service.spec.ts | 436 ++++++++++++++++++ src/pool/pool.service.ts | 366 +++++++++++++++ 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, 3407 insertions(+) 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/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..da25b44 100644 --- a/src/pool/dto/deposit.dto.ts +++ b/src/pool/dto/deposit.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length, Matches } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +export class DepositDto { + @ApiProperty({ description: "Stellar address of the liquidity provider", minLength: 56, maxLength: 56 }) + @IsString() + @Length(56, 56) + provider!: string; + + @ApiProperty({ description: "Deposit amount in USDC base units (1 USDC = 10^7)", pattern: "^\\d+$" }) + @IsString() + @Matches(/^\d+$/) + amount!: string; +} diff --git a/src/pool/dto/withdraw.dto.ts b/src/pool/dto/withdraw.dto.ts index e69de29..a64e2f0 100644 --- a/src/pool/dto/withdraw.dto.ts +++ b/src/pool/dto/withdraw.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length, Matches } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +export class WithdrawDto { + @ApiProperty({ description: "Stellar address of the liquidity provider", minLength: 56, maxLength: 56 }) + @IsString() + @Length(56, 56) + provider!: string; + + @ApiProperty({ description: "Pool shares to withdraw in base units", pattern: "^\\d+$" }) + @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/pool/pool.service.ts b/src/pool/pool.service.ts index e69de29..8870a8d 100644 --- a/src/pool/pool.service.ts +++ b/src/pool/pool.service.ts @@ -0,0 +1,366 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Account, + BASE_FEE, + Keypair, + TransactionBuilder, + rpc, + xdr, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { + decodeLockupExpiresAt, + decodePoolState, + decodeProviderPosition, + encodeLockupExpiresAt, + encodeProvideCapital, + encodeProviderPosition, + encodeWithdrawCapital, + poolOperation, +} from "../stellar/contracts/refract-pool"; +import { DepositDto } from "./dto/deposit.dto"; +import { WithdrawDto } from "./dto/withdraw.dto"; + +// Mock pool state — replaced by a Postgres-backed (pool_snapshots table) +// read in a later PR that wires the app onto src/db/schema.sql. +const mockPool = { + totalUsdc: BigInt(18_400_000 * 1e7), + totalShares: BigInt(17_800_000 * 1e7), + lockedUsdc: BigInt(2_900_000 * 1e7), // locked covering active policies + premiumAccrued: BigInt(284_000 * 1e7), + utilizationBps: 1576, // 15.76% + apyBps: 890, // 8.9% from premiums + sharePrice: 1.0319, +}; + +// Short TTL for the per-address position read: a stale share balance shown +// next to a withdrawal quote is the same class of bug this read fixes, so +// keep it brief while still shielding the RPC provider from fan-out. +const POSITION_CACHE_TTL_MS = 5_000; + +// Stellar ed25519 public keys are StrKey-encoded as a 56-char base32 string +// beginning with "G". Validating before any RPC call keeps the endpoint from +// being used to fan garbage at the RPC provider. +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +export interface PoolStats { + totalUsdc: string; + totalShares: string; + lockedUsdc: string; + premiumAccrued: string; + availableUsdc: string; + utilizationBps: number; + apyBps: number; + sharePrice: number; + maxUtilizationBps: number; +} + +export interface PremiumHistoryEntry { + date: string; + premiums: string; + payouts: string; + apyBps: number; +} + +export interface UserPosition { + address: string; + shares: string; + usdcValue: string; + premiumEarned: string; + pct: string; + lockupExpiresAt: string | null; + readAt: string; +} + +interface CachedPosition { + value: UserPosition; + expiresAt: number; +} + +@Injectable() +export class PoolService { + private readonly server: rpc.Server; + private readonly networkPassphrase: string; + private readonly poolContractId: string; + private readonly positionCache = new Map(); + private readonly positionInFlight = new Map>(); + + 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; + } + + /** + * Builds an unsigned, simulation-prepared invocation of the deployed + * RefractPool contract for `provider` to sign in their own wallet — + * provide_capital()/withdraw_capital() both call `require_auth()` on the + * provider, so unlike ClaimSettlementService's relayer-signed flow, the + * server can never sign this itself. + */ + private async buildUnsignedInvoke( + sourcePublicKey: string, + method: "provide_capital" | "withdraw_capital", + args: xdr.ScVal[], + ): Promise { + if (!this.poolContractId) { + throw new BadRequestException({ error: "Pool contract not configured (missing REFRACT_POOL_CONTRACT_ID)" }); + } + try { + const sourceAccount = await this.server.getAccount(sourcePublicKey); + const operation = poolOperation(this.poolContractId, method, args); + + const builtTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + // Simulates against the live contract and fills in Soroban resource + // fees/footprint — surfaces contract-level rejections (e.g. + // InsufficientCapacity, CapitalLocked) as part of building the tx, + // rather than only after the caller signs and submits it. + const preparedTx = await this.server.prepareTransaction(builtTx); + return preparedTx.toXDR(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new BadRequestException({ error: `Failed to build Soroban transaction: ${message}` }); + } + } + + /** + * Simulates a read-only contract invocation. Uses a throwaway source + * account (as onChainCoverageBounds does) rather than the provider's own + * account: view simulations never touch the network, so requiring the + * provider account to exist would spuriously fail for an unfunded + * provider that has never interacted with the pool. + */ + private async simulateRead(method: string, args: xdr.ScVal[]): Promise { + const sourceAccount = new Account(Keypair.random().publicKey(), "0"); + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(poolOperation(this.poolContractId, method, args)) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(sim.error); + } + return sim.result!.retval; + } + + /** + * Reads RefractPool.lockup_expires_at(provider) via simulation — no + * signature or submission needed, this never changes state. Returns + * null if `provider` has never deposited (never locked) or the pool + * contract isn't configured yet, matching the contract's own Option. + */ + async lockupExpiresAt(provider: string): Promise { + if (!this.poolContractId) { + return null; + } + try { + const retval = await this.simulateRead("lockup_expires_at", encodeLockupExpiresAt(provider)); + return decodeLockupExpiresAt(retval); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new BadRequestException({ error: `Failed to read lockup status: ${message}` }); + } + } + + getStats(): PoolStats { + return { + totalUsdc: mockPool.totalUsdc.toString(), + totalShares: mockPool.totalShares.toString(), + lockedUsdc: mockPool.lockedUsdc.toString(), + premiumAccrued: mockPool.premiumAccrued.toString(), + availableUsdc: (mockPool.totalUsdc - mockPool.lockedUsdc).toString(), + utilizationBps: mockPool.utilizationBps, + apyBps: mockPool.apyBps, + sharePrice: mockPool.sharePrice, + maxUtilizationBps: 8000, + }; + } + + /** + * Reads the provider's real share balance and premium entitlement from + * the pool contract, consolidating position, lockup expiry and premium + * into a single response so the frontend needs one call instead of two. + * + * Honest semantics, matching lockupExpiresAt: + * - unconfigured contract -> explicit zero-position (no RPC attempted) + * - provider with no stake -> explicit zero-position (distinguishable + * from an error, which still surfaces as a BadRequestException) + * - simulation failure -> BadRequestException + * + * All arithmetic is BigInt fixed-point (shares are i128); no floats. + */ + async getUserPosition(address: string): Promise { + if (!STELLAR_ADDRESS_RE.test(address)) { + throw new BadRequestException({ error: "Invalid Stellar address" }); + } + + const cached = this.positionCache.get(address); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + // Single-flight: concurrent requests for the same address share one RPC + // round-trip instead of each fanning out to the provider. + const inFlight = this.positionInFlight.get(address); + if (inFlight) { + return inFlight; + } + + const pending = this.readUserPosition(address) + .then((value) => { + this.positionCache.set(address, { value, expiresAt: Date.now() + POSITION_CACHE_TTL_MS }); + return value; + }) + .finally(() => { + this.positionInFlight.delete(address); + }); + + this.positionInFlight.set(address, pending); + return pending; + } + + private async readUserPosition(address: string): Promise { + const readAt = new Date().toISOString(); + + if (!this.poolContractId) { + return this.zeroPosition(address, readAt); + } + + try { + const [positionRetval, stateRetval, lockupExpiresAt] = await Promise.all([ + this.simulateRead("provider_position", encodeProviderPosition(address)), + this.simulateRead("pool_state", []), + this.lockupExpiresAt(address), + ]); + + const position = decodeProviderPosition(positionRetval); + const state = decodePoolState(stateRetval); + + const shares = position.shares; + const totalShares = state.totalShares; + + // usdcValue = shares * totalUsdc / totalShares, all BigInt fixed-point. + // Guard division by zero when the pool holds no shares yet. + const usdcValue = totalShares > 0n ? (shares * state.totalUsdc) / totalShares : 0n; + const premiumEarned = position.premiumEarned; + const pct = + totalShares > 0n + ? ((shares * 1_000_000n) / totalShares).toString() + : "0"; + + return { + address, + shares: shares.toString(), + usdcValue: usdcValue.toString(), + premiumEarned: premiumEarned.toString(), + pct, + lockupExpiresAt: lockupExpiresAt === null ? null : lockupExpiresAt.toString(), + readAt, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new BadRequestException({ error: `Failed to read user position: ${message}` }); + } + } + + private zeroPosition(address: string, readAt: string): UserPosition { + return { + address, + shares: "0", + usdcValue: "0", + premiumEarned: "0", + pct: "0", + lockupExpiresAt: null, + readAt, + }; + } + + async provide(dto: DepositDto) { + const { provider, amount } = dto; + const amountBn = BigInt(amount); + if (amountBn <= 0n) { + throw new BadRequestException({ error: "Deposit amount must be greater than zero" }); + } + const sharesOut = (amountBn * mockPool.totalShares) / mockPool.totalUsdc; + + const txXdr = await this.buildUnsignedInvoke( + provider, + "provide_capital", + encodeProvideCapital(provider, amountBn), + ); + + return { + provider, + amountUsdc: amount, + sharesOut: sharesOut.toString(), + sharePrice: mockPool.sharePrice, + txXdr, + message: "Sign and submit to provide capital to Refract risk pool", + }; + } + + async withdraw(dto: WithdrawDto) { + const { provider, shares } = dto; + const sharesBn = BigInt(shares); + if (sharesBn <= 0n) { + throw new BadRequestException({ error: "Withdrawal shares must be greater than zero" }); + } + + // Fails fast with a clear message instead of letting the caller + // discover the lockup only once buildUnsignedInvoke's simulation + // rejects it with a raw contract error string. + const lockupExpiresAt = await this.lockupExpiresAt(provider); + if (lockupExpiresAt !== null && BigInt(Math.floor(Date.now() / 1000)) < lockupExpiresAt) { + throw new BadRequestException({ + error: `Withdrawals are locked until ${new Date(Number(lockupExpiresAt) * 1000).toISOString()}`, + lockupExpiresAt: lockupExpiresAt.toString(), + }); + } + + const usdcOut = (sharesBn * mockPool.totalUsdc) / mockPool.totalShares; + const available = mockPool.totalUsdc - mockPool.lockedUsdc; + + if (usdcOut > available) { + throw new BadRequestException({ + error: "Pool capacity locked — too many active policies", + available: available.toString(), + }); + } + + const txXdr = await this.buildUnsignedInvoke( + provider, + "withdraw_capital", + encodeWithdrawCapital(provider, sharesBn), + ); + + return { + provider, + sharesIn: shares, + usdcOut: usdcOut.toString(), + sharePrice: mockPool.sharePrice, + txXdr, + }; + } + + getPremiumHistory(): PremiumHistoryEntry[] { + return Array.from({ length: 30 }, (_, i) => ({ + date: new Date(Date.now() - i * 86400000).toISOString().split("T")[0], + premiums: (4_000 + Math.random() * 12_000).toFixed(0), + payouts: Math.random() > 0.9 ? (5_000 + Math.random() * 20_000).toFixed(0) : "0", + apyBps: 890, + })); + } +} 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, + }; + } + } +} From b447ace9aa51275ccc5158fe76787a903ce06995 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Wed, 21 Oct 2026 14:57:22 +0000 Subject: [PATCH 2/2] fluffy --- package-lock.json | 473 +++++++++++++----- package.json | 67 ++- src/app.module.ts | 188 ++----- src/cache/cache.module.ts | 19 +- src/cache/cache.service.ts | 113 +++-- src/claim/claim.repository.ts | 62 ++- src/claim/claim.scheduler.ts | 45 +- src/claim/claim.service.ts | 2 +- src/claim/dto/claim-stats.response.ts | 17 + src/claim/ops-claim.controller.ts | 145 +----- src/common/coverage-catalog.ts | 1 + src/common/filters/all-exceptions.filter.ts | 2 +- src/common/guards/ops-auth.guard.ts | 6 +- .../validators/coverage-limits.validator.ts | 18 +- .../validators/stellar-address.validator.ts | 4 +- src/config/configuration.ts | 258 ++++++++-- src/config/test-stellar.ts | 2 + src/db/db.module.ts | 29 +- src/digest/digest.scheduler.ts | 124 ----- src/health/health.controller.ts | 35 +- src/health/health.module.ts | 17 +- src/health/health.service.ts | 36 ++ src/oracle/oracle-reading.ts | 3 + src/oracle/oracle.gateway.ts | 24 +- src/oracle/oracle.module.ts | 8 +- src/oracle/oracle.scheduler.ts | 56 +-- src/oracle/oracle.service.ts | 19 + src/policy/dto/buy-policy.dto.ts | 77 ++- src/policy/policy.controller.ts | 6 +- src/policy/policy.service.spec.ts | 76 ++- src/policy/policy.service.ts | 94 +++- src/pool/pool.service.ts | 12 +- src/quote/dto/create-quote.dto.ts | 2 +- src/stellar/contracts/refract-pool.ts | 70 +++ src/stellar/soroban-confirmation.util.ts | 4 +- src/stellar/transaction-submitter.ts | 3 + src/tx/tx.service.spec.ts | 11 +- 37 files changed, 1230 insertions(+), 898 deletions(-) create mode 100644 src/claim/dto/claim-stats.response.ts delete mode 100644 src/digest/digest.scheduler.ts create mode 100644 src/health/health.service.ts create mode 100644 src/stellar/contracts/refract-pool.ts diff --git a/package-lock.json b/package-lock.json index 1d67c2b..6875645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,10 @@ "@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", "@prometheus-io/client": "^0.16.1", - "@stellar/stellar-sdk": "^16.3.0", + "@stellar/stellar-sdk": "^12.3.0", "axios": "^1.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -2976,6 +2977,20 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.7.1.tgz", + "integrity": "sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/websockets": { "version": "11.1.28", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.28.tgz", @@ -2999,15 +3014,6 @@ } } }, - "node_modules/@noble/ed25519": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", - "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3546,72 +3552,42 @@ } }, "node_modules/@stellar/js-xdr": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.0.0", - "pnpm": ">=9.0.0" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" }, - "node_modules/@stellar/stellar-sdk": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", - "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", + "node_modules/@stellar/stellar-base": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", + "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", "license": "Apache-2.0", "dependencies": { - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "4.0.0", - "axios": "1.18.0", + "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", - "bignumber.js": "^11.1.4", + "bignumber.js": "^9.1.2", "buffer": "^6.0.3", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "optionalDependencies": { + "sodium-native": "^4.1.1" } }, - "node_modules/@stellar/stellar-sdk/node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", + "node_modules/@stellar/stellar-sdk": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", + "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "license": "Apache-2.0", "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" + "@stellar/stellar-base": "^12.1.1", + "axios": "^1.7.7", + "bignumber.js": "^9.1.2", + "eventsource": "^2.0.2", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" } }, "node_modules/@tokenizer/inflate": { @@ -3890,7 +3866,7 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3900,7 +3876,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4996,6 +4972,21 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -5390,6 +5381,50 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.11.0.tgz", + "integrity": "sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.6.tgz", + "integrity": "sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", + "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -5433,10 +5468,13 @@ } }, "node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -5682,6 +5720,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -6292,6 +6348,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6828,24 +6901,12 @@ } }, "node_modules/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=12.0.0" } }, "node_modules/execa": { @@ -7126,15 +7187,6 @@ } } }, - "node_modules/feaxios": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -7283,6 +7335,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7717,6 +7784,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7984,6 +8063,18 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8062,28 +8153,31 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-retry-allowed": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-unicode-supported": { @@ -8099,6 +8193,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -10322,6 +10422,15 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10493,6 +10602,15 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -10600,6 +10718,19 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/require-addon": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.3.0.tgz", + "integrity": "sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10951,12 +11082,49 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11085,16 +11253,14 @@ "node": ">=8" } }, - "node_modules/smol-toml": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", - "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" + "node_modules/sodium-native": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", + "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "require-addon": "^1.1.0" } }, "node_modules/source-map": { @@ -11715,6 +11881,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11755,6 +11935,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -11944,6 +12130,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11993,6 +12185,20 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -12056,7 +12262,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -12157,6 +12363,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12400,6 +12612,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/package.json b/package.json index 8609e69..8a9fc91 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { - "name": "stellar-claim-backend", + "name": "refract-protocol-backend", "version": "0.1.0", - "description": "Refract Protocol backend \u2014 oracle monitoring, claim processing, premium quoting", - "main": "dist/main.js", "private": true, + "main": "dist/main.js", "scripts": { "build": "nest build", "start": "nest start", @@ -11,18 +10,13 @@ "start:prod": "node dist/main", "lint": "eslint \"src/**/*.ts\"", "test": "jest", + "test:cov": "jest --coverage", + "test:integration": "jest --config test/integration/jest.integration.config.ts --runInBand", "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" + "regenerate:xdr-fixtures": "ts-node scripts/regenerate-xdr-fixtures.ts" }, "jest": { "preset": "ts-jest", @@ -31,6 +25,7 @@ "testRegex": ".*\\.spec\\.ts$" }, "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.1142.0", "@nestjs/common": "^11.1.28", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.28", @@ -40,6 +35,7 @@ "@nestjs/swagger": "^11.4.7", "@nestjs/throttler": "^6.0.0", "@nestjs/websockets": "^11.1.28", + "@prometheus-io/client": "^0.16.1", "@stellar/stellar-sdk": "^12.3.0", "axios": "^1.6.0", "class-transformer": "^0.5.1", @@ -54,37 +50,32 @@ "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" + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.28", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/supertest": "^7.2.0", + "@types/ws": "^8.5.10", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "babel-jest": "^30.5.2", + "eslint": "^8.57.0", + "fast-check": "^4.10.2", + "jest": "^30.4.2", + "nodemon": "^3.0.2", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" } } diff --git a/src/app.module.ts b/src/app.module.ts index 3ad2975..31c65b7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,180 +1,54 @@ -import { Module, OnApplicationShutdown, OnModuleInit, Logger } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import configuration, { AppConfig } from './config/configuration'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { ClaimsModule } from './claims/claims.module'; -import { OracleModule } from './oracle/oracle.module'; -import { DatabaseModule } from './database/database.module'; -import { RedisModule } from './redis/redis.module'; -import { CacheModule } from './cache/cache.module'; -import { ClaimModule } from './claim/claim.module'; -import { DbModule } from './db/db.module'; -import { IdempotencyModule } from './common/idempotency.module'; -import { HealthModule } from './health/health.module'; -import { SecurityReportsModule } from './security-reports/security-reports.module'; -import { DigestModule } from './digest/diges -import { PolicyModule } from './policy/policy.module'; -import { PoolModule } from './pool/pool.module'; -import { QuoteModule } from './quote/quote.module'; -import { StellarModule } from './stellar/stellar.module'; -import { TxModule } from './tx/tx.module'; -import { AuthModule } from './auth/auth.module'; -import { ApiKeyGuard } from './auth/api-key.guard'; -import { SecurityReportsModule } from './security-reports/security-reports.module'; -import { DigestModule } from './digest/digest.module'; - -/** - * Sandbox mode lets the full API run with zero external dependencies - * (no Postgres, Redis, or Soroban RPC) using in-memory/mocked backing - * services. It is a contributor-onboarding tool only and must never be - * used in a deployed environment. - */ -export const isSandboxMode = (): boolean => - (process.env.SANDBOX_MODE ?? '').toLowerCase() === 'true'; +import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import configuration, { AppConfig } from "./config/configuration"; +import { CacheModule } from "./cache/cache.module"; +import { ClaimModule } from "./claim/claim.module"; +import { DatabaseModule } from "./database/database.module"; +import { DbModule } from "./db/db.module"; +import { EventsModule } from "./events/events.module"; +import { HealthModule } from "./health/health.module"; +import { IdempotencyModule } from "./common/idempotency.module"; +import { MetricsModule } from "./metrics/metrics.module"; +import { OracleModule } from "./oracle/oracle.module"; +import { PolicyModule } from "./policy/policy.module"; +import { PoolModule } from "./pool/pool.module"; +import { QuoteModule } from "./quote/quote.module"; +import { StellarModule } from "./stellar/stellar.module"; +import { TxModule } from "./tx/tx.module"; @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), + ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ throttlers: [ - { - name: 'upstream', - ttl: config.get('throttle.upstream.ttl', { infer: true }), - limit: config.get('throttle.upstream.limit', { infer: true }), - }, - { - name: 'chain', - ttl: config.get('throttle.chain.ttl', { infer: true }), - limit: config.get('throttle.chain.limit', { infer: true }), - }, - { - name: 'default', - ttl: config.get('throttle.default.ttl', { infer: true }), - limit: config.get('throttle.default.limit', { infer: true }), - }, - { - name: 'catalog', - ttl: config.get('throttle.catalog.ttl', { infer: true }), - limit: config.get('throttle.catalog.limit', { infer: true }), - }, + { name: "upstream", ...config.get("throttle.upstream", { infer: true }) }, + { name: "chain", ...config.get("throttle.chain", { infer: true }) }, + { name: "default", ...config.get("throttle.default", { infer: true }) }, + { name: "catalog", ...config.get("throttle.catalog", { infer: true }) }, ], }), }), ScheduleModule.forRoot(), - // In sandbox mode we skip the real Postgres connection entirely so - // `npm run dev` works right after `npm install` with no other setup. - ...(isSandboxMode() - ? [] - : [ - TypeOrmModule.forRootAsync({ - imports: [DatabaseModule], - inject: [DatabaseModule], - useFactory: (databaseModule: DatabaseModule) => - databaseModule.getTypeOrmConfig(), - }), - ]), - DatabaseModule, - Ide - ScheduleModule.forRoot(), - // In sandbox mode we skip the real Postgres connection entirely so - // `npm run dev` works right after `npm install` with no other setup. - ...(isSandboxMode() - ? [] - : [ - TypeOrmModule.forRootAsync({ - imports: [DatabaseModule], - inject: [DatabaseModule], - useFactory: (databaseModule: DatabaseModule) => - databaseModule.getTypeOrmConfig(), - }), - ]), DatabaseModule, - IdempotencyModule, - // CacheModule is @Global — imported once here, available everywhere. + DbModule, CacheModule, + IdempotencyModule, StellarModule, - DbModule, HealthModule, - AuthModule, + MetricsModule, + EventsModule, QuoteModule, PolicyModule, PoolModule, - RedisModule, - ClaimsModule, OracleModule, ClaimModule, TxModule, - SecurityReportsModule, - DigestModule, - ], - controllers: [AppController], - providers: [ - AppService, - { - provide: APP_GUARD, - useClass: ApiKeyGuard, - }, - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, ], + providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], }) -export class AppModule implements OnModuleInit, OnApplicationShutdown { - private readonly logger = new Logger(AppModule.name); - - constructor(private readonly configService: ConfigService) {} - - onModuleInit(): void { - if (!isSandboxMode()) { - return; - } - - const nodeEnv = ( - this.configService.get('NODE_ENV') ?? - process.env.NODE_ENV ?? - '' - ).toLowerCase(); - - // Boot-time safety check: sandbox mode is a local-only onboarding tool - // and must never be reachable from a production-configured deployment. - if (nodeEnv === 'production') { - throw new Error( - 'SANDBOX_MODE=true is not allowed when NODE_ENV=production. ' + - 'Sandbox mode is a local contributor-onboarding tool only.', - ); - } - - this.logger.warn( - '============================================================', - ); - this.logger.warn( - ' SANDBOX MODE ENABLED — NOT A REAL ENVIRONMENT', - ); - this.logger.warn( - ' Postgres, Redis, and Soroban RPC are replaced with in-memory', - ); - this.logger.warn( - ' mocks. All data resets on restart. Do not use outside local dev.', - ); - this.logger.warn( - '============================================================', - ); - } - - async onApplicationShutdown(signal?: string): Promise { - this.logger.log( - `Application shutdown initiated${signal ? ` (signal: ${signal})` : ''} — draining schedulers and connection pools`, - ); - } -} +export class AppModule {} diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts index 75db2af..586efeb 100644 --- a/src/cache/cache.module.ts +++ b/src/cache/cache.module.ts @@ -1,26 +1,9 @@ import { Global, Module } from "@nestjs/common"; import { CacheService } from "./cache.service"; -/** - * CacheModule is marked @Global so any feature module that imports - * AppModule (which imports CacheModule) can inject CacheService without - * explicitly importing CacheModule itself. - * - * When SANDBOX_MODE is enabled, CacheService is registered with an - * in-memory backing store so no real Redis instance is required. The - * sandbox flag is read here (at module registration time) rather than - * branching inside business logic, keeping the swap point in one place. - */ -const SANDBOX_MODE = process.env.SANDBOX_MODE === "true"; - @Global() @Module({ - providers: [ - { - provide: CacheService, - useFactory: () => new CacheService({ sandbox: SANDBOX_MODE }), - }, - ], + providers: [CacheService], exports: [CacheService], }) export class CacheModule {} diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index 0f2db00..9382df6 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -1,62 +1,95 @@ -import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; +import { Injectable, Logger, OnApplicationShutdown, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; +import { AppConfig } from "../config/configuration"; + +export const ORACLE_TTL = 55; @Injectable() -export class CacheService implements OnApplicationShutdown { +export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicationShutdown { private readonly logger = new Logger(CacheService.name); private readonly client: Redis; + private closed = false; - constructor(private readonly configService: ConfigService) { - const url = this.configService.get('REDIS_URL'); - this.client = url - ? new Redis(url, { lazyConnect: false }) - : new Redis({ - host: this.configService.get('REDIS_HOST', 'localhost'), - port: this.configService.get('REDIS_PORT', 6379), - password: this.configService.get('REDIS_PASSWORD'), - }); - - this.client.on('error', (err) => { - this.logger.error(`Redis client error: ${err.message}`); - }); + constructor(configService: ConfigService) { + const configured = configService.get("redis", { infer: true }); + const url = process.env.REDIS_URL ?? configured.url; + this.client = new Redis(url, { lazyConnect: false }); + this.client.on("error", (err) => this.logger.error(`Redis client error: ${err.message}`)); } + onModuleInit(): void {} + async get(key: string): Promise { - const value = await this.client.get(key); - return value ? (JSON.parse(value) as T) : null; + try { + const value = await this.client.get(key); + return value === null ? null : (JSON.parse(value) as T); + } catch (error) { + this.logger.warn(`Redis cache read failed for ${key}: ${this.errorMessage(error)}`); + return null; + } } async set(key: string, value: unknown, ttlSeconds?: number): Promise { - const serialized = JSON.stringify(value); - if (ttlSeconds) { - await this.client.set(key, serialized, 'EX', ttlSeconds); - } else { - await this.client.set(key, serialized); + try { + const serialized = JSON.stringify(value); + if (ttlSeconds) await this.client.set(key, serialized, "EX", ttlSeconds); + else await this.client.set(key, serialized); + } catch (error) { + this.logger.warn(`Redis cache write failed for ${key}: ${this.errorMessage(error)}`); + } + } + + async del(...keys: string[]): Promise { + if (keys.length === 0) return; + try { + await this.client.del(...keys); + } catch (error) { + this.logger.warn(`Redis cache delete failed: ${this.errorMessage(error)}`); + } + } + + async ping(): Promise { + try { + return (await this.client.ping()) === "PONG"; + } catch (error) { + this.logger.warn(`Redis health check failed: ${this.errorMessage(error)}`); + return false; } } - async del(key: string): Promise { - await this.client.del(key); + isConnected(): boolean { + return this.client.status === "ready"; } - /** - * Gracefully close the Redis connection pool during application shutdown. - * Called by Nest when `app.enableShutdownHooks()` is active and the process - * receives SIGTERM/SIGINT, ensuring in-flight commands drain before exit. - */ - async onApplicationShutdown(signal?: string): Promise { - this.logger.log( - `Closing Redis connection pool${signal ? ` (signal: ${signal})` : ''}...`, - ); + async wrap(key: string, ttlSeconds: number, loader: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + const fresh = await loader(); + await this.set(key, fresh, ttlSeconds); + return fresh; + } + + async onModuleDestroy(): Promise { + await this.close(); + } + + async onApplicationShutdown(): Promise { + await this.close(); + } + + private async close(): Promise { + if (this.closed) return; + this.closed = true; try { await this.client.quit(); - this.logger.log('Redis connection pool closed cleanly.'); - } catch (err) { - this.logger.error( - `Failed to close Redis connection pool cleanly: ${(err as Error).message}`, - ); + } catch (error) { + this.logger.error(`Failed to close Redis connection: ${this.errorMessage(error)}`); this.client.disconnect(); } } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/claim/claim.repository.ts b/src/claim/claim.repository.ts index e960e06..855a626 100644 --- a/src/claim/claim.repository.ts +++ b/src/claim/claim.repository.ts @@ -1,6 +1,34 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../db/database.service'; -import { ClaimResult } from './claim-result'; + +export interface ClaimRecord { + id: string; + policyId: string; + coverageType: string; + status: string; + payoutAmount?: string | number | null; + premiumAmount?: string | number | null; + triggerDetectedAt?: Date | string | null; + processedAt?: Date | string | null; +} + +export interface OnChainClaimInput { + policyUuid: string; + holder: string; + coverageType: number; + payout: bigint; + triggerValue: number; + triggerSource: string; + txHash: string; +} + +const COVERAGE_TYPE_DB = [ + 'stablecoin_depeg', + 'market_crash', + 'liquidation_shield', + 'smart_contract_risk', + 'flight_delay', +] as const; /** * Per-coverage-type operational performance metrics derived from the claims @@ -40,7 +68,7 @@ interface SettlementLatencyRow { export class ClaimRepository { constructor(private readonly db: DatabaseService) {} - async save(result: ClaimResult): Promise { + async save(result: ClaimRecord): Promise { await this.db.query( `INSERT INTO claims (id, policy_id, coverage_type, status, payout_amount, premium_amount, trigger_detected_at, processed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -64,15 +92,37 @@ export class ClaimRepository { return result; } - async findById(id: string): Promise { - const rows = await this.db.query( + async findById(id: string): Promise { + const result = await this.db.query( `SELECT id, policy_id AS "policyId", coverage_type AS "coverageType", status, payout_amount AS "payoutAmount", premium_amount AS "premiumAmount", trigger_detected_at AS "triggerDetectedAt", processed_at AS "processedAt" FROM claims WHERE id = $1`, [id], ); - return rows[0] ?? null; + return result.rows[0] ?? null; + } + + async insertFromChain(input: OnChainClaimInput): Promise { + const coverageType = COVERAGE_TYPE_DB[input.coverageType]; + if (!coverageType) { + throw new Error(`Unknown coverage type ordinal: ${input.coverageType}`); + } + await this.db.query( + `INSERT INTO claims + (policy_id, holder, coverage_type, payout, trigger_value, trigger_source, tx_hash) + SELECT $1, $2, $3::coverage_type, $4, $5, $6, $7 + WHERE NOT EXISTS (SELECT 1 FROM claims WHERE tx_hash = $7)`, + [ + input.policyUuid, + input.holder, + coverageType, + input.payout.toString(), + input.triggerValue, + input.triggerSource, + input.txHash, + ], + ); } /** @@ -130,7 +180,7 @@ export class ClaimRepository { ORDER BY t.coverage_type ASC`, ); - return rows.map((row) => { + return rows.rows.map((row) => { const totalPremium = toNumber(row.total_premium_amount); const totalPayout = toNumber(row.total_payout_amount); return { diff --git a/src/claim/claim.scheduler.ts b/src/claim/claim.scheduler.ts index 1cefc74..c81f4b0 100644 --- a/src/claim/claim.scheduler.ts +++ b/src/claim/claim.scheduler.ts @@ -1,7 +1,6 @@ -import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { ClaimService } from './claim.service'; -import { ProtocolParametersService } from '../parameters/protocol-parameters.service'; +import { Injectable, Logger, OnApplicationShutdown } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { ClaimService } from "./claim.service"; /** * Upper bound (ms) we are willing to wait for an in-flight settlement run to @@ -11,19 +10,8 @@ import { ProtocolParametersService } from '../parameters/protocol-parameters.ser */ const SHUTDOWN_DRAIN_TIMEOUT_MS = 30_000; -/** - * Default interval (in seconds) used when the registry has no override for the - * claim scheduler interval. Kept as a fallback only; the effective value is - * read from the protocol-parameters registry at runtime so governance can - * adjust it without a code deploy. - */ -export const DEFAULT_CLAIM_SCHEDULER_INTERVAL_SECONDS = 60; - -/** Registry key for the claim scheduler interval. */ -export const CLAIM_SCHEDULER_INTERVAL_KEY = 'scheduler.claim.interval_seconds'; - @Injectable() -export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { +export class ClaimScheduler implements OnApplicationShutdown { private readonly logger = new Logger(ClaimScheduler.name); /** @@ -40,26 +28,8 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { constructor( private readonly claimService: ClaimService, - private readonly protocolParameters: ProtocolParametersService, ) {} - async onModuleInit(): Promise { - const interval = await this.getIntervalSeconds(); - this.logger.log(`Claim scheduler initialized with interval ${interval}s`); - } - - /** - * Resolve the claim scheduler interval from the protocol-parameters registry, - * falling back to the default when no override is configured. - */ - private async getIntervalSeconds(): Promise { - const value = await this.protocolParameters.getNumber( - CLAIM_SCHEDULER_INTERVAL_KEY, - DEFAULT_CLAIM_SCHEDULER_INTERVAL_SECONDS, - ); - return value > 0 ? value : DEFAULT_CLAIM_SCHEDULER_INTERVAL_SECONDS; - } - @Cron(CronExpression.EVERY_MINUTE) async handleClaimSettlement(): Promise { if (this.shuttingDown) { @@ -76,8 +46,7 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { return; } - const interval = await this.getIntervalSeconds(); - this.logger.debug(`Running claim processing (interval=${interval}s)`); + this.logger.debug("Running claim processing"); const run = this.runSettlement(); this.inFlight = run; @@ -93,7 +62,7 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { private async runSettlement(): Promise { try { - await this.claimService.settleClaims(); + await this.claimService.processTriggered(); } catch (error) { this.logger.error( 'Claim settlement run failed.', @@ -149,6 +118,4 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { }); }); } -} - } } diff --git a/src/claim/claim.service.ts b/src/claim/claim.service.ts index 98b54b3..8a9b5b8 100644 --- a/src/claim/claim.service.ts +++ b/src/claim/claim.service.ts @@ -135,7 +135,7 @@ export class ClaimService { `PAYOUT triggered: policy=${policy.id} holder=${policy.holder} payout=${result.payout} reason="${result.reason}"` ); - const settlement = await this.claimSettlementService.settleClaim(policy.id, policy.holder, BigInt(result.payout)); + const settlement = await this.claimSettlementService.settleClaim(policy.id); if (!settlement.settled) { this.logger.error(`Settlement did not confirm for policy ${policy.id}, will retry next scan: ${settlement.error}`); return undefined; diff --git a/src/claim/dto/claim-stats.response.ts b/src/claim/dto/claim-stats.response.ts new file mode 100644 index 0000000..f0b89ad --- /dev/null +++ b/src/claim/dto/claim-stats.response.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer"; + +export class ClaimStatsResponseDto { + @Expose() + activePolicies!: number; + + @Expose() + processedClaims!: number; + + @Expose() + totalPayout!: string; +} + +export class ClaimStatsOpsResponseDto extends ClaimStatsResponseDto { + @Expose() + settlementConfigured!: boolean; +} diff --git a/src/claim/ops-claim.controller.ts b/src/claim/ops-claim.controller.ts index fddf845..7f093e0 100644 --- a/src/claim/ops-claim.controller.ts +++ b/src/claim/ops-claim.controller.ts @@ -1,138 +1,19 @@ -import { - Body, - Controller, - Get, - HttpException, - HttpStatus, - Param, - Post, - Query, - Req, - UseGuards, -} from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; -import { Request } from 'express'; -import { AuthGuard } from '../auth/auth.guard'; -import { Scopes } from '../auth/scopes.decorator'; -import { ScopesGuard } from '../auth/scopes.guard'; -import { AuditService } from '../audit/audit.service'; -import { ClaimService } from './claim.service'; - -interface OpsRequest extends Request { - apiKey?: { id?: string; name?: string }; -} +import { Controller, Post, UseGuards } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { AdminApiKeyGuard, AdminOnly } from "../common/admin-auth"; +import { SecurityAudit } from "../common/security-audit.interceptor"; +import { ClaimService } from "./claim.service"; @ApiExcludeController() -@Controller('api/v1/ops/claims') -@UseGuards(AuthGuard, ScopesGuard) -@Scopes('ops:read') +@Controller("api/v1/ops/claims") +@UseGuards(AdminApiKeyGuard) +@AdminOnly() export class OpsClaimController { - constructor( - private readonly claimService: ClaimService, - private readonly auditService: AuditService, - ) {} - - @Post('scan') - @Scopes('ops:write') - async scan( - @Query('dryRun') dryRun: string, - @Req() req: OpsRequest, - ) { - const isDryRun = dryRun === 'true' || dryRun === '1'; - const actor = this.actor(req); - - if (this.claimService.isScanRunning()) { - await this.auditService.record({ - actor, - action: 'ops.claims.scan', - params: { dryRun: isDryRun }, - outcome: 'conflict', - }); - throw new HttpException( - 'A claim scan is already running', - HttpStatus.CONFLICT, - ); - } - - try { - const result = await this.claimService.runScan({ dryRun: isDryRun }); - await this.auditService.record({ - actor, - action: 'ops.claims.scan', - params: { dryRun: isDryRun }, - outcome: 'success', - detail: { evaluated: result.evaluated, triggered: result.triggered }, - }); - return result; - } catch (err) { - await this.auditService.record({ - actor, - action: 'ops.claims.scan', - params: { dryRun: isDryRun }, - outcome: 'error', - detail: { message: (err as Error).message }, - }); - throw err; - } - } - - @Post(':policyId/settle') - @Scopes('ops:write') - async settle( - @Param('policyId') policyId: string, - @Query('dryRun') dryRun: string, - @Query('confirm') confirm: string, - @Req() req: OpsRequest, - ) { - const isDryRun = dryRun === 'true' || dryRun === '1'; - const actor = this.actor(req); - - if (!isDryRun && confirm !== 'true') { - await this.auditService.record({ - actor, - action: 'ops.claims.settle', - params: { policyId, dryRun: isDryRun }, - outcome: 'rejected', - }); - throw new HttpException( - 'Force-settle requires confirm=true', - HttpStatus.BAD_REQUEST, - ); - } - - try { - const result = await this.claimService.settlePolicy(policyId, { - dryRun: isDryRun, - }); - await this.auditService.record({ - actor, - action: 'ops.claims.settle', - params: { policyId, dryRun: isDryRun }, - outcome: 'success', - detail: { triggered: result.triggered }, - }); - return result; - } catch (err) { - await this.auditService.record({ - actor, - action: 'ops.claims.settle', - params: { policyId, dryRun: isDryRun }, - outcome: 'error', - detail: { message: (err as Error).message }, - }); - throw err; - } - } - - @Get('scan-history') - async scanHistory(@Query('limit') limit?: string) { - const parsed = limit ? Number.parseInt(limit, 10) : undefined; - return this.claimService.getScanHistory( - Number.isFinite(parsed) ? parsed : undefined, - ); - } + constructor(private readonly claimService: ClaimService) {} - private actor(req: OpsRequest): string { - return req.apiKey?.name ?? req.apiKey?.id ?? 'unknown'; + @Post("scan") + @SecurityAudit("claims.ops.scan") + scan() { + return this.claimService.processTriggered(); } } diff --git a/src/common/coverage-catalog.ts b/src/common/coverage-catalog.ts index 6814591..3ae77c8 100644 --- a/src/common/coverage-catalog.ts +++ b/src/common/coverage-catalog.ts @@ -170,6 +170,7 @@ export const COVERAGE_CATALOG = { maxDuration: 7, icon: "✈️", defaultTriggerThreshold: 120, + maxPoolExposurePct: 35, status: "live", }, } as const satisfies Record; diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index 707c6d3..1abe673 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -7,7 +7,7 @@ import { Logger, } from "@nestjs/common"; import { Request, Response } from "express"; -import { DomainError, ErrorDetails } from "../errors/domain-errors"; +import { DomainError, ErrorDetails } from "../errors"; /** * Global exception filter (issue #60). diff --git a/src/common/guards/ops-auth.guard.ts b/src/common/guards/ops-auth.guard.ts index ef20543..dcc7671 100644 --- a/src/common/guards/ops-auth.guard.ts +++ b/src/common/guards/ops-auth.guard.ts @@ -36,7 +36,7 @@ export class OpsAuthGuard implements CanActivate { constructor(private readonly configService: ConfigService) {} canActivate(context: ExecutionContext): boolean { - const token = this.configService.get("ops", { infer: true }).apiToken; + const token = this.configService.get("adminApiKey", { infer: true }); if (!token) { throw new UnauthorizedException({ error: "Ops API token not configured (OPS_API_TOKEN)" }); } @@ -72,7 +72,9 @@ export class OpsAuthGuard implements CanActivate { * community key record, so elevation is impossible by construction. */ private resolveCommunityScope(presented: string): ApiKeyScope | null { - const communityKeys = this.configService.get("ops", { infer: true }).communityApiKeys ?? []; + const communityKeys = this.configService + .get("communityApiKeys", { infer: true }) + .map(({ key }) => key); return communityKeys.includes(presented) ? "community" : null; } diff --git a/src/common/validators/coverage-limits.validator.ts b/src/common/validators/coverage-limits.validator.ts index 78bd1e2..692ce3e 100644 --- a/src/common/validators/coverage-limits.validator.ts +++ b/src/common/validators/coverage-limits.validator.ts @@ -6,7 +6,7 @@ import { ValidatorConstraintInterface, } from "class-validator"; import { CoverageTypeName } from "../../quote/coverage-type"; -import { getCoverageType } from "../coverage-catalog"; +import { COVERAGE_CATALOG, CoverageType } from "../coverage-catalog"; /** * Per-coverage-type duration and amount limits. @@ -25,6 +25,18 @@ import { getCoverageType } from "../coverage-catalog"; const BASE_UNIT_DECIMALS = 7; const BASE_UNIT_FACTOR = 10 ** BASE_UNIT_DECIMALS; +function getCoverageLimits(coverageType: CoverageTypeName) { + const product = COVERAGE_CATALOG[coverageType as CoverageType]; + return product + ? { + minDurationDays: 1, + maxDurationDays: product.maxDuration, + minCoverageAmount: 100, + maxCoverageAmount: product.maxCoverage, + } + : undefined; +} + function toWholeUsdc(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -56,7 +68,7 @@ export class CoverageLimitsConstraint implements ValidatorConstraintInterface { return true; } - const limits = getCoverageType(coverageType); + const limits = getCoverageLimits(coverageType); if (!limits) { return true; } @@ -81,7 +93,7 @@ export class CoverageLimitsConstraint implements ValidatorConstraintInterface { defaultMessage(args: ValidationArguments): string { const object = args.object as Record; const coverageType = object.coverageType as CoverageTypeName | undefined; - const limits = coverageType ? getCoverageType(coverageType) : undefined; + const limits = coverageType ? getCoverageLimits(coverageType) : undefined; if (!limits) { return "coverageAmount or durationDays is outside the allowed range for this coverage type"; diff --git a/src/common/validators/stellar-address.validator.ts b/src/common/validators/stellar-address.validator.ts index 78696ad..7301d4c 100644 --- a/src/common/validators/stellar-address.validator.ts +++ b/src/common/validators/stellar-address.validator.ts @@ -11,8 +11,8 @@ import { StrKey } from '@stellar/stellar-sdk'; function validateStrKey( value: unknown, isValid: (candidate: string) => boolean, - expected: string, - args: ValidationArguments, + _expected: string, + _args: ValidationArguments, ): boolean { if (typeof value !== 'string') { return false; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 15da439..cd8d30f 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,59 +1,213 @@ -import { registerAs } from '@nestjs/config'; +export type FeeProfileName = "moderate" | "aggressive"; -/** - * Resolves the sandbox-mode flag from the environment. - * - * 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. - * - * 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). - */ -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'; +export interface AppConfig { + port: number; + frontendUrl: string; + database: { + url: string; + poolMax: number; + poolMin: number; + poolIdleTimeoutMs: number; + poolAcquireTimeoutMs: number; + statementTimeoutMs: number; + connectRetries: number; + connectRetryDelayMs: number; + }; + redis: { url: string }; + stellar: { + network: string; + sorobanRpcUrl: string; + sorobanRpcUrls: string[]; + eventStartLedger: number; + networkPassphrase: string; + poolContractId: string; + policyContractId: string; + oracleContractId: string; + relayerSecret: string; + validateOnChain: boolean; + requireNetwork: boolean; + validateTimeoutMs: number; + relayer: { + feeMultiplier: number; + feeCeiling: number; + idleResyncMs: number; + minBalance: number; + maxAttempts: number; + }; + eventIndexer: { + enabled: boolean; + pollIntervalMs: number; + lagAlertLedgers: number; + pageLimit: number; + }; + }; + confirmation: { + initialIntervalMs: number; + backoffMultiplier: number; + maxIntervalMs: number; + deadlineMs: number; + jitterRatio: number; + settlementDeadlineMs: number; + httpDeadlineMs: number; + }; + oraclePublish: { mode: "every_poll" | "on_change"; minIntervalMs: number }; + oracles: { + coingeckoBaseUrl: string; + horizonUrl: string; + defiLlamaBaseUrl: string; + defiLlamaProtocolSlug: string; + httpTimeoutMs: number; + }; + fees: { + statsTtlMs: number; + ceilingStroops: string; + profiles: Record; + }; + eventIndexer: { + enabled: boolean; + pollIntervalMs: number; + lagAlertLedgers: number; + pageLimit: number; + }; + adminApiKey: string; + communityApiKeys: Array<{ + key: string; + scope: "community"; + contact: string; + rateLimitPerMinute: number; + createdAt: string; + }>; + policyPendingTtlMs: number; + throttle: Record<"upstream" | "chain" | "default" | "catalog", { ttl: number; limit: number }>; } -/** - * 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 NETWORKS = { + 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; - 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.', - ); - } +function numberFromEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a number`); + return value; } -/** - * 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 network = (process.env.STELLAR_NETWORK ?? "testnet").trim().toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(NETWORKS, network)) { + throw new Error("STELLAR_NETWORK expected testnet, futurenet, or mainnet"); + } + const selectedNetwork = NETWORKS[network as keyof typeof NETWORKS]; + const suffix = network.toUpperCase(); + const env = process.env; -export default registerAs('app', () => ({ - env: process.env.NODE_ENV ?? 'development', - port: parseInt(process.env.PORT ?? '3000', 10), - sandboxMode: isSandboxMode(), -})); + return { + port: numberFromEnv("PORT", 4001), + frontendUrl: env.FRONTEND_URL ?? "http://localhost:3000", + database: { + url: env.DATABASE_URL ?? "postgres://localhost:5432/refract", + poolMax: numberFromEnv("DB_POOL_MAX", 10), + poolMin: numberFromEnv("DB_POOL_MIN", 2), + poolIdleTimeoutMs: numberFromEnv("DB_POOL_IDLE_TIMEOUT_MS", 30_000), + poolAcquireTimeoutMs: numberFromEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5_000), + statementTimeoutMs: numberFromEnv("DB_STATEMENT_TIMEOUT_MS", 30_000), + connectRetries: numberFromEnv("DB_CONNECT_RETRIES", 5), + connectRetryDelayMs: numberFromEnv("DB_CONNECT_RETRY_DELAY_MS", 1_000), + }, + redis: { url: env.REDIS_URL ?? "redis://localhost:6379" }, + stellar: { + network, + sorobanRpcUrl: selectedNetwork.sorobanRpcUrl, + sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? selectedNetwork.sorobanRpcUrl) + .split(",") + .map((url) => url.trim()) + .filter(Boolean), + eventStartLedger: numberFromEnv("EVENT_START_LEDGER", 1), + networkPassphrase: selectedNetwork.networkPassphrase, + poolContractId: env[`REFRACT_POOL_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POOL_CONTRACT_ID ?? "", + policyContractId: env[`REFRACT_POLICY_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POLICY_CONTRACT_ID ?? "", + oracleContractId: env[`REFRACT_ORACLE_CONTRACT_ID_${suffix}`] ?? env.REFRACT_ORACLE_CONTRACT_ID ?? "", + relayerSecret: env.ORACLE_RELAYER_SECRET ?? "", + validateOnChain: env.STELLAR_VALIDATE_ONCHAIN === "true", + requireNetwork: env.STELLAR_REQUIRE_NETWORK === "true", + validateTimeoutMs: numberFromEnv("STELLAR_VALIDATE_TIMEOUT_MS", 5_000), + relayer: { + feeMultiplier: numberFromEnv("RELAYER_FEE_MULTIPLIER", 1.2), + feeCeiling: numberFromEnv("RELAYER_FEE_CEILING", 1_000_000), + idleResyncMs: numberFromEnv("RELAYER_IDLE_RESYNC_MS", 30_000), + minBalance: numberFromEnv("RELAYER_MIN_BALANCE", 50_000_000), + maxAttempts: numberFromEnv("RELAYER_MAX_ATTEMPTS", 3), + }, + eventIndexer: { + enabled: env.EVENT_INDEXER_ENABLED === "true", + pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000), + lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100), + pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100), + }, + }, + confirmation: { + initialIntervalMs: numberFromEnv("CONFIRMATION_INITIAL_INTERVAL_MS", 400), + backoffMultiplier: numberFromEnv("CONFIRMATION_BACKOFF_MULTIPLIER", 1.8), + maxIntervalMs: numberFromEnv("CONFIRMATION_MAX_INTERVAL_MS", 8_000), + deadlineMs: numberFromEnv("CONFIRMATION_DEADLINE_MS", 30_000), + jitterRatio: numberFromEnv("CONFIRMATION_JITTER_RATIO", 0.2), + settlementDeadlineMs: numberFromEnv("CONFIRMATION_SETTLEMENT_DEADLINE_MS", 90_000), + httpDeadlineMs: numberFromEnv("CONFIRMATION_HTTP_DEADLINE_MS", 20_000), + }, + oraclePublish: { + mode: env.ORACLE_PUBLISH_MODE === "every_poll" ? "every_poll" : "on_change", + minIntervalMs: numberFromEnv("ORACLE_PUBLISH_MIN_INTERVAL_MS", 300_000), + }, + oracles: { + coingeckoBaseUrl: env.COINGECKO_BASE_URL ?? "https://api.coingecko.com/api/v3", + horizonUrl: selectedNetwork.horizonUrl, + defiLlamaBaseUrl: env.DEFILLAMA_BASE_URL ?? "https://api.llama.fi", + defiLlamaProtocolSlug: env.DEFILLAMA_PROTOCOL_SLUG ?? "aave", + httpTimeoutMs: numberFromEnv("ORACLE_HTTP_TIMEOUT_MS", 5_000), + }, + eventIndexer: { + enabled: env.EVENT_INDEXER_ENABLED === "true", + pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000), + lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100), + pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100), + }, + fees: { + statsTtlMs: numberFromEnv("FEE_STATS_TTL_MS", 15_000), + ceilingStroops: String(numberFromEnv("FEE_CEILING_STROOPS", 10_000_000)), + profiles: { + moderate: { + percentile: numberFromEnv("FEE_MODERATE_PERCENTILE", 90), + multiplier: numberFromEnv("FEE_MODERATE_MULTIPLIER", 1.2), + }, + aggressive: { + percentile: numberFromEnv("FEE_AGGRESSIVE_PERCENTILE", 99), + multiplier: numberFromEnv("FEE_AGGRESSIVE_MULTIPLIER", 1.5), + }, + }, + }, + policyPendingTtlMs: numberFromEnv("POLICY_PENDING_TTL_MS", 3_600_000), + adminApiKey: env.ADMIN_API_KEY ?? "", + communityApiKeys: [], + throttle: { + upstream: { ttl: 60_000, limit: 30 }, + chain: { ttl: 60_000, limit: 10 }, + default: { ttl: 60_000, limit: 100 }, + catalog: { ttl: 60_000, limit: 200 }, + }, + }; +}; diff --git a/src/config/test-stellar.ts b/src/config/test-stellar.ts index 9ed6ee0..36771e5 100644 --- a/src/config/test-stellar.ts +++ b/src/config/test-stellar.ts @@ -13,6 +13,8 @@ export function testStellarConfig( return { network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", + sorobanRpcUrls: ["https://soroban-testnet.stellar.org"], + eventStartLedger: 1, networkPassphrase: "Test SDF Network ; September 2015", poolContractId: "", policyContractId: "", diff --git a/src/db/db.module.ts b/src/db/db.module.ts index 8b972de..209036c 100644 --- a/src/db/db.module.ts +++ b/src/db/db.module.ts @@ -1,9 +1,32 @@ -import { Global, Module } from "@nestjs/common"; +import { Global, Inject, Injectable, Module, OnApplicationShutdown } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Pool } from "pg"; +import { AppConfig } from "../config/configuration"; import { DbService } from "./db.service"; +export const PG_POOL = Symbol("PG_POOL"); + +@Injectable() +class PgPoolShutdown implements OnApplicationShutdown { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + + async onApplicationShutdown(): Promise { + await this.pool.end(); + } +} + @Global() @Module({ - providers: [DbService], - exports: [DbService], + providers: [ + DbService, + { + provide: PG_POOL, + inject: [ConfigService], + useFactory: (config: ConfigService) => + new Pool({ connectionString: config.get("database.url", { infer: true }) }), + }, + PgPoolShutdown, + ], + exports: [DbService, PG_POOL], }) export class DbModule {} diff --git a/src/digest/digest.scheduler.ts b/src/digest/digest.scheduler.ts deleted file mode 100644 index 1b8e4a3..0000000 --- a/src/digest/digest.scheduler.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { NotificationsService } from '../notifications/notifications.service'; -import { AnalyticsService } from '../analytics/analytics.service'; -import { TreasuryService } from '../treasury/treasury.service'; -import { ClaimsService } from '../claims/claims.service'; -import { PoliciesService } from '../policies/policies.service'; - -export interface DigestCadence { - /** Interval between digest runs in milliseconds. Defaults to 24h. */ - intervalMs?: number; -} - -export interface DigestPayload { - generatedAt: string; - periodStart: string; - periodEnd: string; - newPoliciesByCoverageType: Record; - claimsTriggered: { count: number; totalAmount: number }; - claimsSettled: { count: number; totalAmount: number }; - netPoolCapitalChange: number; - needsReviewClaims: number; - deadLetteredClaims: number; - hasActivity: boolean; -} - -const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; - -@Injectable() -export class DigestScheduler implements OnModuleInit { - private readonly logger = new Logger(DigestScheduler.name); - private timer?: NodeJS.Timeout; - private lastRunAt: Date; - - constructor( - private readonly notifications: NotificationsService, - private readonly analytics: AnalyticsService, - private readonly treasury: TreasuryService, - private readonly claims: ClaimsService, - private readonly policies: PoliciesService, - ) { - this.lastRunAt = new Date(); - } - - onModuleInit(): void { - const intervalMs = this.resolveInterval(); - this.timer = setInterval(() => { - void this.runDigest(); - }, intervalMs); - if (typeof this.timer.unref === 'function') { - this.timer.unref(); - } - this.logger.log(`Digest scheduler started with cadence ${intervalMs}ms`); - } - - private resolveInterval(): number { - const configured = Number(process.env.DIGEST_INTERVAL_MS); - if (Number.isFinite(configured) && configured > 0) { - return configured; - } - return DEFAULT_INTERVAL_MS; - } - - /** Compiles the digest for the elapsed period and delivers it via the webhook service. */ - async runDigest(now: Date = new Date()): Promise { - const periodStart = this.lastRunAt; - const periodEnd = now; - const payload = await this.compileDigest(periodStart, periodEnd); - this.lastRunAt = periodEnd; - await this.notifications.deliverWebhook({ - event: 'protocol.digest', - payload, - }); - return payload; - } - - /** Builds the structured digest from existing aggregation queries. */ - async compileDigest(periodStart: Date, periodEnd: Date): Promise { - const [policiesByType, lossRatio, treasuryReport, triggered, settled, reviewClaims] = - await Promise.all([ - this.policies.countByCoverageType(periodStart, periodEnd), - this.analytics.getLossRatio(periodStart, periodEnd), - this.treasury.getReport(periodStart, periodEnd), - this.claims.listByStatus('triggered', periodStart, periodEnd), - this.claims.listByStatus('settled', periodStart, periodEnd), - this.claims.listNeedsReview(periodStart, periodEnd), - ]); - - const claimsTriggered = { - count: triggered.length, - totalAmount: triggered.reduce((sum, c) => sum + (c.amount ?? 0), 0), - }; - const claimsSettled = { - count: settled.length, - totalAmount: settled.reduce((sum, c) => sum + (c.amount ?? 0), 0), - }; - - const newPoliciesByCoverageType = policiesByType; - const netPoolCapitalChange = treasuryReport.netCapitalChange ?? 0; - const needsReviewClaims = reviewClaims.filter((c) => !c.deadLettered).length; - const deadLetteredClaims = reviewClaims.filter((c) => c.deadLettered).length; - - const hasActivity = - Object.values(newPoliciesByCoverageType).some((n) => n > 0) || - claimsTriggered.count > 0 || - claimsSettled.count > 0 || - netPoolCapitalChange !== 0 || - needsReviewClaims > 0 || - deadLetteredClaims > 0 || - lossRatio.sampleSize > 0; - - return { - generatedAt: periodEnd.toISOString(), - periodStart: periodStart.toISOString(), - periodEnd: periodEnd.toISOString(), - newPoliciesByCoverageType, - claimsTriggered, - claimsSettled, - netPoolCapitalChange, - needsReviewClaims, - deadLetteredClaims, - hasActivity, - }; - } -} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index c9d2275..27dd4a0 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,58 +1,31 @@ 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", - }; + return { status: "ok", protocol: "Refract" }; } - /** - * 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) { + async getReadiness(@Res({ passthrough: true }) response: Response) { const result = await this.healthService.getReadiness(); - res.status(result.ready ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE); + response.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() { + getDetail() { return this.healthService.getDetail(); } -} - } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 9011dc4..6bd5081 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,22 +1,13 @@ import { Module } from "@nestjs/common"; -import { StellarModule } from "../stellar/stellar.module"; +import { CacheModule } from "../cache/cache.module"; +import { DbModule } from "../db/db.module"; import { HealthController } from "./health.controller"; import { HealthService } from "./health.service"; -import { SorobanRpcService } from "../stellar/soroban-rpc.service"; -import { OracleService } from "../oracle/oracle.service"; -import { ClaimSettlementService } from "../claim/claim-settlement.service"; -// DatabaseModule is @Global(), so DatabaseService is available here -// without a local import — the controller receives it via DI automatically. @Module({ - imports: [StellarModule], + imports: [CacheModule, DbModule], controllers: [HealthController], - providers: [ - HealthService, - SorobanRpcService, - OracleService, - ClaimSettlementService, - ], + providers: [HealthService], exports: [HealthService], }) export class HealthModule {} diff --git a/src/health/health.service.ts b/src/health/health.service.ts new file mode 100644 index 0000000..75087ba --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { CacheService } from "../cache/cache.service"; +import { DbService } from "../db/db.service"; + +@Injectable() +export class HealthService { + constructor( + private readonly database: DbService, + private readonly cache: CacheService, + ) {} + + getLiveness(): { status: "ok" } { + return { status: "ok" }; + } + + async getReadiness() { + const [postgres, redis] = await Promise.all([ + Promise.resolve(this.database.isAvailable()), + this.cache.ping(), + ]); + return { + ready: postgres && redis, + dependencies: { postgres, redis }, + }; + } + + getDetail() { + return { + status: "ok", + dependencies: { + postgres: this.database.isAvailable(), + redis: this.cache.isConnected(), + }, + }; + } +} diff --git a/src/oracle/oracle-reading.ts b/src/oracle/oracle-reading.ts index 6dd9583..70ebaed 100644 --- a/src/oracle/oracle-reading.ts +++ b/src/oracle/oracle-reading.ts @@ -1,12 +1,15 @@ export type Severity = "low" | "medium" | "high" | "triggered"; export interface OracleReading { + readingId?: string; coverageType: string; type: "oracle_update"; value: number; threshold: number; severity: Severity; message: string; + source?: string; + timestamp?: number; /** * True when this reading came from the fail-safe fallback (upstream outage). * OraclePublisherService must never publish degraded readings on-chain. diff --git a/src/oracle/oracle.gateway.ts b/src/oracle/oracle.gateway.ts index 5b65954..1911779 100644 --- a/src/oracle/oracle.gateway.ts +++ b/src/oracle/oracle.gateway.ts @@ -1,12 +1,12 @@ import { OnGatewayConnection, OnGatewayDisconnect, - OnModuleDestroy, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; -import { Logger } from '@nestjs/common'; +import { Logger, OnModuleDestroy } from '@nestjs/common'; import { Server, WebSocket } from 'ws'; +import { OracleReading } from './oracle-reading'; @WebSocketGateway({ path: '/oracle' }) export class OracleGateway @@ -15,7 +15,7 @@ export class OracleGateway private readonly logger = new Logger(OracleGateway.name); @WebSocketServer() - server: Server; + server!: Server; handleConnection(client: WebSocket): void { this.logger.log('Oracle WebSocket client connected'); @@ -25,9 +25,27 @@ export class OracleGateway } handleDisconnect(client: WebSocket): void { + void client; this.logger.log('Oracle WebSocket client disconnected'); } + broadcastAlert(reading: OracleReading): void { + if (!this.server) return; + const payload = JSON.stringify(reading); + for (const client of this.server.clients) { + if (client.readyState !== WebSocket.OPEN) continue; + try { + client.send(payload, (error) => { + if (error) this.logger.warn(`Oracle WebSocket send failed: ${error.message}`); + }); + } catch (error) { + this.logger.warn( + `Oracle WebSocket send failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + /** * Graceful shutdown: send a proper close frame to every connected client * before the shared HTTP server stops accepting connections. Dropping the diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index 5615f5d..d71f728 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -1,15 +1,17 @@ import { Module } from "@nestjs/common"; +import { DbModule } from "../db/db.module"; import { StellarModule } from "../stellar/stellar.module"; import { OracleController } from "./oracle.controller"; +import { OracleEventRepository } from "./oracle-event.repository"; import { OracleGateway } from "./oracle.gateway"; import { OraclePublisherService } from "./oracle-publisher.service"; import { OracleScheduler } from "./oracle.scheduler"; import { OracleService } from "./oracle.service"; @Module({ - imports: [StellarModule], + imports: [DbModule, StellarModule], controllers: [OracleController], - providers: [OracleService, OracleGateway, OracleScheduler, OraclePublisherService], - exports: [OracleService, OracleGateway, OraclePublisherService], + providers: [OracleService, OracleEventRepository, OracleGateway, OracleScheduler, OraclePublisherService], + exports: [OracleService, OracleEventRepository, OracleGateway, OraclePublisherService], }) export class OracleModule {} diff --git a/src/oracle/oracle.scheduler.ts b/src/oracle/oracle.scheduler.ts index 3332c6d..38b1436 100644 --- a/src/oracle/oracle.scheduler.ts +++ b/src/oracle/oracle.scheduler.ts @@ -1,50 +1,32 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; -import { CronJob } from 'cron'; -import { OracleService } from './oracle.service'; -import { ProtocolParametersService } from '../parameters/protocol-parameters.service'; -import { ProtocolParameterKey } from '../parameters/protocol-parameter-key.enum'; - -const ORACLE_REFRESH_JOB_NAME = 'oracle-refresh'; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { OracleGateway } from "./oracle.gateway"; +import { OraclePublisherService } from "./oracle-publisher.service"; +import { OracleService } from "./oracle.service"; @Injectable() -export class OracleScheduler implements OnModuleInit, OnModuleDestroy { +export class OracleScheduler { private readonly logger = new Logger(OracleScheduler.name); constructor( private readonly oracleService: OracleService, - private readonly schedulerRegistry: SchedulerRegistry, - private readonly protocolParameters: ProtocolParametersService, + private readonly oracleGateway: OracleGateway, + private readonly oraclePublisher: OraclePublisherService, ) {} - async onModuleInit(): Promise { - const intervalSeconds = await this.protocolParameters.getNumber( - ProtocolParameterKey.ORACLE_SCHEDULER_INTERVAL_SECONDS, - ); - - const job = new CronJob(`*/${intervalSeconds} * * * * *`, () => { - void this.refresh(); - }); - - this.schedulerRegistry.addCronJob(ORACLE_REFRESH_JOB_NAME, job); - job.start(); - - this.logger.log( - `Oracle refresh scheduler started with ${intervalSeconds}s interval`, - ); - } - - onModuleDestroy(): void { - if (this.schedulerRegistry.doesExist('cron', ORACLE_REFRESH_JOB_NAME)) { - this.schedulerRegistry.deleteCronJob(ORACLE_REFRESH_JOB_NAME); - } - } - - private async refresh(): Promise { + @Cron(CronExpression.EVERY_MINUTE) + async pollOracles(): Promise { try { - await this.oracleService.refreshPrices(); + const readings = await this.oracleService.checkAll(); + for (const reading of readings) { + await this.oraclePublisher.maybePublish(reading); + if (reading.severity !== "low") this.oracleGateway.broadcastAlert(reading); + } } catch (error) { - this.logger.error('Oracle refresh failed', error as Error); + this.logger.error( + "Oracle polling failed", + error instanceof Error ? error.stack : String(error), + ); } } } diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index 97c9d3d..2c65356 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { randomUUID } from "node:crypto"; import axios from "axios"; import { CacheService, ORACLE_TTL } from "../cache/cache.service"; import { AppConfig } from "../config/configuration"; @@ -125,12 +126,15 @@ export class OracleService { : "low"; return { + readingId: randomUUID(), 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]`, + source: "coingecko", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("StablecoinDepeg", threshold, "CoinGecko", err); @@ -171,12 +175,15 @@ export class OracleService { : " [CoinGecko]"; return { + readingId: randomUUID(), coverageType: "MarketCrash", type: "oracle_update", value: change24h, threshold, severity, message: `XLM 24h change: ${change24h.toFixed(2)}% (trigger at ${threshold}%)${chainContext}`, + source: "coingecko", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("MarketCrash", threshold, "CoinGecko", err); @@ -211,12 +218,15 @@ export class OracleService { : "low"; return { + readingId: randomUUID(), coverageType: "SmartContractRisk", type: "oracle_update", value: pctChange, threshold, severity, message: `${data.name ?? this.defiLlamaProtocolSlug} TVL 24h change: ${pctChange.toFixed(2)}% (trigger at ${threshold}%) [DeFiLlama]`, + source: "defillama", + timestamp: Math.floor(Date.now() / 1000), }; } catch (err) { return this.degraded("SmartContractRisk", threshold, "DeFiLlama", err); @@ -234,12 +244,15 @@ export class OracleService { const threshold = 0.85; // shield triggers below 85% collateralization return { + readingId: randomUUID(), 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]`, + source: "mock", + timestamp: Math.floor(Date.now() / 1000), }; } @@ -251,12 +264,15 @@ export class OracleService { const threshold = 120; // 2h delay triggers return { + readingId: randomUUID(), 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]`, + source: "mock", + timestamp: Math.floor(Date.now() / 1000), }; } @@ -273,12 +289,15 @@ export class OracleService { err instanceof Error ? err.message : String(err) ); return { + readingId: randomUUID(), coverageType, type: "oracle_update", value: threshold, threshold, severity: "low", message: `${source} unavailable — degraded to a non-triggering reading`, + source: source.toLowerCase(), + timestamp: Math.floor(Date.now() / 1000), degraded: true, }; } diff --git a/src/policy/dto/buy-policy.dto.ts b/src/policy/dto/buy-policy.dto.ts index e48437b..7b65fd7 100644 --- a/src/policy/dto/buy-policy.dto.ts +++ b/src/policy/dto/buy-policy.dto.ts @@ -1,32 +1,70 @@ -import { IsString, IsNotEmpty, IsOptional, IsNumber, Min } from 'class-validator'; -import { IsStellarPublicKey } from '../../common/validators/stellar-address.validator'; +import { + IsInt, + IsObject, + IsNotEmpty, + IsString, + Matches, + Max, + MaxLength, + Min, + Validate, + ValidateIf, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; +import { IsStellarPublicKey } from "../../common/validators/stellar-address.validator"; + +@ValidatorConstraint({ name: "isFlatTriggerParams", async: false }) +class IsFlatTriggerParamsConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const entries = Object.entries(value); + return entries.length <= 8 && entries.every( + ([key, field]) => + key.length > 0 && + key.length <= 64 && + (typeof field === "string" || typeof field === "number" || typeof field === "boolean") && + (typeof field !== "string" || field.length <= 256) + ); + } + + defaultMessage(): string { + return "triggerParams must be a flat object containing at most 8 scalar values"; + } +} export class BuyPolicyDto { @IsString() @IsNotEmpty() @IsStellarPublicKey() - holder: string; + holder!: string; + @IsInt() + @Min(0) + @Max(4) + coverageType!: number; + + /** Coverage in 1e7 USDC base units, represented as a decimal string. */ @IsString() - @IsNotEmpty() - policyId: string; + @Matches(/^\d+$/) + @MaxLength(38) + coverageAmount!: string; - @IsOptional() - @IsNumber() - @Min(0) - premium?: number; + @IsInt() + @Min(1) + @Max(365) + durationDays!: number; + + @ValidateIf((_object, value) => value !== undefined) + @IsObject() + @Validate(IsFlatTriggerParamsConstraint) + triggerParams?: Record; } -/** - * 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) + @IsNotEmpty() + @IsStellarPublicKey() holder!: string; @IsInt() @@ -34,9 +72,9 @@ export class PreflightPolicyDto { @Max(4) coverageType!: number; - /** USDC amount in 1e7 base units, passed as a decimal string to avoid precision loss. */ @IsString() @Matches(/^\d+$/) + @MaxLength(38) coverageAmount!: string; @IsInt() @@ -44,7 +82,8 @@ export class PreflightPolicyDto { @Max(365) durationDays!: number; - @IsOptional() + @ValidateIf((_object, value) => value !== undefined) @IsObject() + @Validate(IsFlatTriggerParamsConstraint) triggerParams?: Record; } diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts index 4f15e60..46eb1c3 100644 --- a/src/policy/policy.controller.ts +++ b/src/policy/policy.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, NotFoundException, Param, Post, Body } from "@nestjs/common"; +import { Controller, Get, NotFoundException, Param, Post, Body, Query } from "@nestjs/common"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; import { PolicyService } from "./policy.service"; import { SecurityAudit } from "../common/security-audit.interceptor"; @@ -21,8 +21,8 @@ export class PolicyController { * swallowed as a policy id. */ @Get("coverage-bounds") - async getCoverageBounds() { - const bounds = await this.policyService.onChainCoverageBounds(); + async getCoverageBounds(@Query("holder") holder?: string) { + const bounds = await this.policyService.onChainCoverageBounds(holder); return { minCoverage: bounds ? bounds.minCoverage.toString() : null, maxCoverage: bounds ? bounds.maxCoverage.toString() : null, diff --git a/src/policy/policy.service.spec.ts b/src/policy/policy.service.spec.ts index 887f79b..d9cb162 100644 --- a/src/policy/policy.service.spec.ts +++ b/src/policy/policy.service.spec.ts @@ -12,6 +12,7 @@ import { xdr, } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; +import { testStellarConfig } from "../config/test-stellar"; import { MetricsService } from "../metrics/metrics.service"; import { PolicyService } from "./policy.service"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; @@ -20,16 +21,7 @@ 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 stellar = testStellarConfig({ poolContractId: POOL_CONTRACT_ID, ...overrides }); return { get: jest.fn().mockReturnValue(stellar) } as unknown as ConfigService; } @@ -157,7 +149,7 @@ describe("PolicyService", () => { expect(diff).toBeLessThanOrEqual(1n); }); - it("stores an active policy with the expected fields and an unsigned buy_policy invocation", async () => { + it("keeps the policy pending until its buy_policy transaction confirms", async () => { const dto = buildDto(holder, { coverageType: 2, durationDays: 10 }); const beforeSeconds = Math.floor(Date.now() / 1000); @@ -168,8 +160,10 @@ describe("PolicyService", () => { expect(policy.coverageTypeName).toBe("Liquidation Shield"); expect(policy.coverageAmount).toBe(dto.coverageAmount); expect(policy.durationDays).toBe(10); - expect(policy.isActive).toBe(true); + expect(policy.isActive).toBe(false); expect(policy.expiresAt).toBeGreaterThanOrEqual(beforeSeconds + 10 * 86_400); + expect(service.findById(policy.id)).toBeUndefined(); + expect(service.listActive()).toEqual([]); expect(message).toBe("Sign and submit to activate coverage"); // The built tx must actually invoke RefractPool.buy_policy(holder, @@ -187,9 +181,21 @@ describe("PolicyService", () => { duration_days: 10, trigger_threshold: 500n, }); + + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.activateFromBuyConfirmation(txHash, nativeToScVal(42n, { type: "u64" }))).toEqual({ + onChainPolicyId: "42", + }); + expect(service.findById("42")).toMatchObject({ + holder, + coverageType: 2, + coverageAmount: dto.coverageAmount, + isActive: true, + }); + expect(service.listActive().map((active) => active.id)).toContain("42"); }); - it("persists triggerParams so ClaimService can read them back for scanning (e.g. flight number)", async () => { + it("persists triggerParams after successful on-chain confirmation", async () => { // Flight Delay caps at 2,000 USDC — stay under it. const dto = buildDto(holder, { coverageType: 4, @@ -197,16 +203,22 @@ describe("PolicyService", () => { triggerParams: { flightNumber: "BA249" }, }); - const { policy } = await service.buy(dto); - const stored = service.findById(policy.id); + const { txXdr } = await service.buy(dto); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.listActive()).toEqual([]); + service.activateFromBuyConfirmation(txHash, nativeToScVal(7n, { type: "u64" })); + const stored = service.findById("7"); expect(stored?.triggerParams).toEqual({ flightNumber: "BA249" }); }); - it("leaves triggerParams undefined when the buyer didn't supply any", async () => { - const { policy } = await service.buy(buildDto(holder, { coverageType: 0 })); - + it("leaves triggerParams undefined after activation when the buyer didn't supply any", async () => { + const { policy, txXdr } = await service.buy(buildDto(holder, { coverageType: 0 })); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + service.activateFromBuyConfirmation(txHash, nativeToScVal(8n, { type: "u64" })); expect(policy.triggerParams).toBeUndefined(); + expect(service.findById(policy.id)).toBeUndefined(); + expect(service.findById("8")?.triggerParams).toBeUndefined(); }); it("rejects buying Flight Delay coverage without a flightNumber in triggerParams", async () => { @@ -355,17 +367,29 @@ describe("PolicyService", () => { }); describe("lookups and lifecycle", () => { - it("findById/findByHolder return a bought policy, and listActive drops it once deactivated", async () => { - const { policy } = await service.buy(buildDto(holder)); + it("does not activate when a confirmation has no valid u64 policy id", async () => { + const { txXdr } = await service.buy(buildDto(holder)); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + + expect(service.activateFromBuyConfirmation(txHash, nativeToScVal("42"))).toBeNull(); + expect(service.listActive()).toEqual([]); + expect(service.findById("42")).toBeUndefined(); + }); + + it("findById/findByHolder return a confirmed policy, and listActive drops it once deactivated", async () => { + const { txXdr } = await service.buy(buildDto(holder)); + const txHash = (TransactionBuilder.fromXDR(txXdr, NETWORK_PASSPHRASE) as Transaction).hash().toString("hex"); + expect(service.findByHolder(holder)).toEqual([]); - expect(service.findById(policy.id)).toEqual(policy); - expect(service.findByHolder(holder)).toEqual([policy]); - expect(service.listActive().map((p) => p.id)).toContain(policy.id); + service.activateFromBuyConfirmation(txHash, nativeToScVal(9n, { type: "u64" })); + expect(service.findById("9")?.id).toBe("9"); + expect(service.findByHolder(holder).map((p) => p.id)).toEqual(["9"]); + expect(service.listActive().map((p) => p.id)).toContain("9"); - service.deactivate(policy.id); + service.deactivate("9"); - expect(service.findById(policy.id)?.isActive).toBe(false); - expect(service.listActive().map((p) => p.id)).not.toContain(policy.id); + expect(service.findById("9")?.isActive).toBe(false); + expect(service.listActive().map((p) => p.id)).not.toContain("9"); }); it("findById returns undefined for an unknown id", () => { diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index eacb1a0..bfdc654 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -1,18 +1,16 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { randomUUID } from "node:crypto"; import { - Account, Address, BASE_FEE, Contract, - Keypair, TransactionBuilder, nativeToScVal, rpc, scValToNative, xdr, } from "@stellar/stellar-sdk"; -import { v4 as uuidv4 } from "uuid"; import { AppConfig } from "../config/configuration"; import { MetricsService } from "../metrics/metrics.service"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; @@ -156,9 +154,10 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ @Injectable() export class PolicyService { - // In-memory store — replaced by the Postgres-backed repository in a - // follow-up PR that wires the app onto src/db/schema.sql. + // In-memory store — only confirmed on-chain policies are exposed to claim + // scans and lookups. private readonly policies = new Map(); + private readonly pendingBuys = new Map(); private readonly server: rpc.Server; private readonly networkPassphrase: string; @@ -275,9 +274,19 @@ export class PolicyService { * pool is actually configured to allow right now — e.g. after an admin * calls set_pool_config() — which the catalog alone can't see. */ - async onChainCoverageBounds(): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { - if (!this.poolCo + async onChainCoverageBounds(holder?: string): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { + if (!this.poolContractId || !holder) return null; + try { + const sourceAccount = await this.server.getAccount(holder); + const operation = new Contract(this.poolContractId).call("pool_config"); + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); const sim = await this.metricsService.observeSorobanRpc("simulate_transaction", () => this.server.simulateTransaction(tx) ); @@ -314,6 +323,11 @@ export class PolicyService { return [...this.policies.values()].filter((p) => p.isActive && p.expiresAt > now); } + getExpiredActive(): StoredPolicy[] { + const now = Math.floor(Date.now() / 1000); + return [...this.policies.values()].filter((policy) => policy.isActive && policy.expiresAt <= now); + } + /** Marks a policy inactive after a claim has been paid out. */ deactivate(id: string): void { const policy = this.policies.get(id); @@ -323,7 +337,59 @@ export class PolicyService { } } + /** + * Activates a policy only after TxService has observed a successful + * buy_policy transaction. Transaction hashes link the confirmation to the + * metadata used to build that exact unsigned buy invocation. + */ + activateFromBuyConfirmation( + txHash: string, + returnValue?: xdr.ScVal + ): { onChainPolicyId: string } | null { + const pending = this.pendingBuys.get(txHash); + if (!pending) return null; + + this.pendingBuys.delete(txHash); + if (!returnValue || returnValue.switch().name !== "scvU64") return null; + + let policyId: string; + try { + const decoded = scValToNative(returnValue); + if (typeof decoded === "bigint") { + policyId = decoded.toString(); + } else if (typeof decoded === "number" && Number.isSafeInteger(decoded) && decoded >= 0) { + policyId = decoded.toString(); + } else if (typeof decoded === "string" && /^\d+$/.test(decoded)) { + policyId = decoded; + } else { + return null; + } + } catch { + return null; + } + + if (BigInt(policyId) > 18_446_744_073_709_551_615n || this.policies.has(policyId)) { + return null; + } + + const policy = { ...pending, id: policyId, isActive: true }; + this.policies.set(policyId, policy); + return { onChainPolicyId: policyId }; + } + async buy(dto: BuyPolicyDto): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { + return this.buildBuy(dto, true); + } + + async simulateBuy(dto: BuyPolicyDto): Promise<{ valid: true; policy: StoredPolicy; txXdr: string; message: string }> { + const result = await this.buildBuy(dto, false); + return { valid: true, ...result }; + } + + private async buildBuy( + dto: BuyPolicyDto, + storePolicy: boolean + ): Promise<{ policy: StoredPolicy; txXdr: string; message: string }> { const { holder, coverageType, coverageAmount, durationDays, triggerParams } = dto; const catalogEntry = COVERAGE_TYPES[coverageType]; @@ -363,7 +429,7 @@ export class PolicyService { // (or, after a set_pool_config() change, looser) than what the catalog // advertises. Catch a mismatch here with a specific error instead of // letting buildUnsignedBuyInvoke's simulation fail it opaquely. - const bounds = await this.onChainCoverageBounds(); + const bounds = await this.onChainCoverageBounds(holder); if (bounds && (coverage < bounds.minCoverage || coverage > bounds.maxCoverage)) { throw new BadRequestException({ error: `coverageAmount must be between ${bounds.minCoverage} and ${bounds.maxCoverage} (pool contract units) per the pool's current configuration`, @@ -373,12 +439,12 @@ export class PolicyService { } const multiplier = RISK_MULTIPLIERS[coverageType]; - const annualRate = (BASE_RATE_BPS / 10_000) * multiplier; // bps -> fraction, e.g. 300bps * 1.0 = 0.03 (3%) + const annualRate = (this.baseRateBps() / 10_000) * multiplier; // bps -> fraction, e.g. 300bps * 1.0 = 0.03 (3%) const dailyRate = annualRate / 365; const premiumFraction = dailyRate * durationDays; const premium = BigInt(Math.floor(Number(coverage) * premiumFraction)); - const policyId = uuidv4(); + const policyId = randomUUID(); const expiresAt = Math.floor(Date.now() / 1000) + durationDays * 86400; const policy: StoredPolicy = { @@ -390,13 +456,11 @@ export class PolicyService { premium: premium.toString(), durationDays, expiresAt, - isActive: true, + isActive: false, createdAt: new Date().toISOString(), triggerParams, }; - this.policies.set(policyId, policy); - const paramsScVal = this.buildPolicyParamsScVal( coverageType, coverage, @@ -404,6 +468,10 @@ export class PolicyService { TRIGGER_THRESHOLDS[coverageType] ); const txXdr = await this.buildUnsignedBuyInvoke(holder, paramsScVal); + if (storePolicy) { + const tx = TransactionBuilder.fromXDR(txXdr, this.networkPassphrase); + this.pendingBuys.set(tx.hash().toString("hex"), policy); + } return { policy, diff --git a/src/pool/pool.service.ts b/src/pool/pool.service.ts index 8870a8d..f678571 100644 --- a/src/pool/pool.service.ts +++ b/src/pool/pool.service.ts @@ -241,8 +241,8 @@ export class PoolService { try { const [positionRetval, stateRetval, lockupExpiresAt] = await Promise.all([ - this.simulateRead("provider_position", encodeProviderPosition(address)), - this.simulateRead("pool_state", []), + this.simulateRead("shares_of", encodeProviderPosition(address)), + this.simulateRead("pool_stats", []), this.lockupExpiresAt(address), ]); @@ -312,6 +312,10 @@ export class PoolService { }; } + async simulateProvide(dto: DepositDto) { + return this.provide(dto); + } + async withdraw(dto: WithdrawDto) { const { provider, shares } = dto; const sharesBn = BigInt(shares); @@ -355,6 +359,10 @@ export class PoolService { }; } + async simulateWithdraw(dto: WithdrawDto) { + return this.withdraw(dto); + } + getPremiumHistory(): PremiumHistoryEntry[] { return Array.from({ length: 30 }, (_, i) => ({ date: new Date(Date.now() - i * 86400000).toISOString().split("T")[0], diff --git a/src/quote/dto/create-quote.dto.ts b/src/quote/dto/create-quote.dto.ts index 1717657..a4b5e44 100644 --- a/src/quote/dto/create-quote.dto.ts +++ b/src/quote/dto/create-quote.dto.ts @@ -2,9 +2,9 @@ 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) + @IsWithinCoverageLimits() coverageType!: CoverageTypeName; @IsNumber() diff --git a/src/stellar/contracts/refract-pool.ts b/src/stellar/contracts/refract-pool.ts new file mode 100644 index 0000000..469cdcc --- /dev/null +++ b/src/stellar/contracts/refract-pool.ts @@ -0,0 +1,70 @@ +import { Address, Contract, nativeToScVal, scValToNative, xdr } from "@stellar/stellar-sdk"; + +export function poolOperation( + contractId: string, + method: string, + args: xdr.ScVal[], +): ReturnType { + return new Contract(contractId).call(method, ...args); +} + +export function encodeLockupExpiresAt(provider: string): xdr.ScVal[] { + return [new Address(provider).toScVal()]; +} + +export function encodeProviderPosition(provider: string): xdr.ScVal[] { + return [new Address(provider).toScVal()]; +} + +export function encodeProvideCapital(provider: string, amount: bigint): xdr.ScVal[] { + return [new Address(provider).toScVal(), nativeToScVal(amount, { type: "i128" })]; +} + +export function encodeWithdrawCapital(provider: string, shares: bigint): xdr.ScVal[] { + return [new Address(provider).toScVal(), nativeToScVal(shares, { type: "i128" })]; +} + +export function decodeLockupExpiresAt(value: xdr.ScVal): bigint | null { + const decoded = scValToNative(value); + return decoded === null || decoded === undefined ? null : toBigInt(decoded, "lockup expiry"); +} + +export function decodeProviderPosition(value: xdr.ScVal): { + shares: bigint; + premiumEarned: bigint; +} { + return { shares: toBigInt(scValToNative(value), "share balance"), premiumEarned: 0n }; +} + +export function decodePoolState(value: xdr.ScVal): { + totalUsdc: bigint; + totalShares: bigint; +} { + const decoded = scValToNative(value); + const fields = toRecord(decoded, "pool stats"); + return { + totalUsdc: toBigInt(fields.total_capital, "total capital"), + totalShares: toBigInt(fields.total_shares, "total shares"), + }; +} + +function toRecord(value: unknown, name: string): Record { + if (value instanceof Map) { + const record: Record = {}; + for (const [key, entry] of value) { + record[String(key)] = entry; + } + return record; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + throw new Error(`Unexpected ${name} response`); +} + +function toBigInt(value: unknown, name: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value); + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + throw new Error(`Unexpected ${name} response`); +} diff --git a/src/stellar/soroban-confirmation.util.ts b/src/stellar/soroban-confirmation.util.ts index 8fd30cf..411840b 100644 --- a/src/stellar/soroban-confirmation.util.ts +++ b/src/stellar/soroban-confirmation.util.ts @@ -10,7 +10,9 @@ export type ConfirmationOutcome = | "success" | "failed_on_chain" | "not_found_yet_deadline_exceeded" - | "aborted"; + | "aborted" + | "submission_rejected" + | "unexpected_status"; export interface ConfirmationResult { /** Convenience mirror of `outcome === "success"`. */ diff --git a/src/stellar/transaction-submitter.ts b/src/stellar/transaction-submitter.ts index faf993a..c1263fe 100644 --- a/src/stellar/transaction-submitter.ts +++ b/src/stellar/transaction-submitter.ts @@ -175,6 +175,7 @@ export async function submitAndConfirm( const retryAfter = Math.max(1, Math.ceil(Math.min(backoff, Math.max(remaining, 1000)) / 1000)); return { confirmed: false, + outcome: "not_found_yet_deadline_exceeded", txHash: sendResult.hash, error: `RPC congested (TRY_AGAIN_LATER) after ${attempt} attempt(s); retry after ${retryAfter}s`, httpStatus: 503, @@ -197,6 +198,7 @@ export async function submitAndConfirm( logger.error(`Submission ERROR for ${sendResult.hash}: ${detail}`); return { confirmed: false, + outcome: "submission_rejected", txHash: sendResult.hash, error: detail, resultCode: code, @@ -207,6 +209,7 @@ export async function submitAndConfirm( default: return { confirmed: false, + outcome: "unexpected_status", txHash: lastHash, error: `Unexpected sendTransaction status: ${sendResult.status}`, httpStatus: 502, diff --git a/src/tx/tx.service.spec.ts b/src/tx/tx.service.spec.ts index af813b7..6126e15 100644 --- a/src/tx/tx.service.spec.ts +++ b/src/tx/tx.service.spec.ts @@ -11,6 +11,7 @@ import { rpc, } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; +import { testStellarConfig } from "../config/test-stellar"; import { PolicyService } from "../policy/policy.service"; import { TxService } from "./tx.service"; @@ -27,16 +28,10 @@ const CONFIRMATION: AppConfig["confirmation"] = { }; function buildConfig(overrides: Partial = {}): ConfigService { - const stellar: AppConfig["stellar"] = { - network: "testnet", - sorobanRpcUrl: "https://soroban-testnet.stellar.org", - networkPassphrase: NETWORK_PASSPHRASE, + const stellar = testStellarConfig({ poolContractId: StrKey.encodeContract(Buffer.alloc(32, 1)), - policyContractId: "", - oracleContractId: "", - relayerSecret: "", ...overrides, - }; + }); return { get: jest.fn((key: string) => { if (key === "stellar") return stellar;