From 42f428d9ad9a4142913502b41f547fdef9a100e6 Mon Sep 17 00:00:00 2001 From: ngoziobidigwe212 Date: Wed, 21 Oct 2026 13:55:01 +0000 Subject: [PATCH] redesigned --- Dockerfile | 4 +- docker-compose.yml | 4 +- package.json | 89 ++++ src/app.module.ts | 21 - src/auth/public.decorator.ts | 5 + src/cache/cache.service.ts | 130 ++++-- 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/claim/claim.scheduler.ts | 2 - src/config/configuration.spec.ts | 64 +++ src/config/configuration.ts | 227 +++++++-- src/config/oracle-feed.config.ts | 124 +++++ src/db/database.module.ts | 13 + src/db/db.module.ts | 9 + src/health/health.controller.ts | 56 +++ src/health/health.module.ts | 16 +- src/health/health.service.spec.ts | 73 +++ src/health/health.service.ts | 130 ++++++ src/metrics/metrics.service.spec.ts | 5 + src/metrics/metrics.service.ts | 30 ++ src/oracle/feed-payload.validator.spec.ts | 56 +++ src/oracle/feed-payload.validator.ts | 114 +++++ src/oracle/oracle-publisher.service.spec.ts | 44 ++ src/oracle/oracle-publisher.service.ts | 344 ++++++++++++++ src/oracle/oracle-reading.ts | 4 + src/oracle/oracle-trust.module.ts | 9 + src/oracle/oracle-trust.service.ts | 181 ++++++++ src/oracle/oracle.controller.ts | 30 ++ src/oracle/oracle.gateway.ts | 36 +- src/oracle/oracle.module.ts | 18 + src/oracle/oracle.scheduler.spec.ts | 109 +++++ src/oracle/oracle.scheduler.ts | 111 +++-- src/oracle/oracle.service.spec.ts | 264 +++++++++++ src/oracle/oracle.service.ts | 361 +++++++++++++++ src/policy/dto/buy-policy.dto.ts | 38 ++ src/policy/policy.module.ts | 14 + src/policy/policy.repository.ts | 89 ++++ src/policy/policy.service.spec.ts | 20 + src/policy/policy.service.ts | 60 ++- 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/quote/quote.module.ts | 3 +- src/quote/quote.service.spec.ts | 16 + src/quote/quote.service.ts | 43 +- 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 ++++ 60 files changed, 4947 insertions(+), 184 deletions(-) create mode 100644 src/auth/public.decorator.ts create mode 100644 src/config/oracle-feed.config.ts create mode 100644 src/health/health.service.spec.ts create mode 100644 src/health/health.service.ts create mode 100644 src/oracle/feed-payload.validator.spec.ts create mode 100644 src/oracle/feed-payload.validator.ts create mode 100644 src/oracle/oracle-trust.module.ts create mode 100644 src/oracle/oracle-trust.service.ts diff --git a/Dockerfile b/Dockerfile index dd45e25..d141aec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY --chown=node:node package.json ./ USER node EXPOSE 4001 -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:4001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:4001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "dist/main.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 3794465..3a7546a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,9 +59,9 @@ services: redis: condition: service_healthy healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s - timeout: 3s + timeout: 10s retries: 3 start_period: 10s diff --git a/package.json b/package.json index e69de29..a4fb3eb 100644 --- a/package.json +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "refract-protocol-backend", + "version": "0.1.0", + "description": "Refract Protocol backend — oracle monitoring, claim processing, premium quoting", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "start:dev": "ts-node src/main.ts", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest --runInBand" + }, + "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.1142.0", + "@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/websockets": "^11.1.28", + "@prometheus-io/client": "^0.16.1", + "@stellar/stellar-sdk": "^16.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", + "winston": "^3.11.0", + "ws": "^8.16.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@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" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest", + "^.+\\.js$": [ + "babel-jest", + { + "plugins": ["@babel/plugin-transform-modules-commonjs"] + } + ] + }, + "transformIgnorePatterns": [ + "node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras)/)" + ], + "testEnvironment": "node" + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 3ad2975..33e7159 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,8 +16,6 @@ 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'; @@ -25,8 +23,6 @@ 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 @@ -84,21 +80,6 @@ export const isSandboxMode = (): boolean => }), ]), 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. CacheModule, @@ -114,8 +95,6 @@ export const isSandboxMode = (): boolean => OracleModule, ClaimModule, TxModule, - SecurityReportsModule, - DigestModule, ], controllers: [AppController], providers: [ diff --git a/src/auth/public.decorator.ts b/src/auth/public.decorator.ts new file mode 100644 index 0000000..cdd98b1 --- /dev/null +++ b/src/auth/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = (): MethodDecorator & ClassDecorator => + SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index 0f2db00..244374d 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -1,62 +1,104 @@ -import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; +import { AppConfig } from "../config/configuration"; + +/** + * CacheService — thin, typed wrapper around ioredis. + * + * Design goals + * ------------ + * - Single Redis connection per process, shared across all consumers. + * - Fail-open: every public method swallows Redis errors and falls back + * to the non-cached path so a Redis outage never brings down the API. + * - `wrap()` is the primary interface: "return cached value or run + * the loader, cache the result, return it." + * + * TTL conventions (seconds) + * ------------------------- + * ORACLE_TTL 55 s — just under the 60 s scheduler interval so the + * HTTP endpoint always returns data that is at most one + * cycle stale without issuing a redundant upstream call. + */ +export const ORACLE_TTL = 55; @Injectable() -export class CacheService implements OnApplicationShutdown { +export class CacheService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(CacheService.name); - private readonly client: Redis; - - 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}`); + private client!: Redis; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit(): void { + const { url } = this.configService.get("redis", { infer: true }); + this.client = new Redis(url, { + // Don't retry indefinitely — give up after 3 reconnection attempts + // so a missing Redis doesn't stall startup. + maxRetriesPerRequest: 1, + enableReadyCheck: false, + lazyConnect: false, }); + + this.client.on("connect", () => this.logger.log("Redis connected")); + this.client.on("error", (err: Error) => + this.logger.warn(`Redis error (cache degraded): ${err.message}`) + ); } - async get(key: string): Promise { - const value = await this.client.get(key); - return value ? (JSON.parse(value) as T) : null; + async onModuleDestroy(): Promise { + await this.client?.quit().catch(() => undefined); } - 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); + /** Returns the parsed JSON value for `key`, or `null` on miss or error. */ + async get(key: string): Promise { + try { + const raw = await this.client.get(key); + return raw ? (JSON.parse(raw) as T) : null; + } catch (err) { + this.logger.warn(`cache.get("${key}") failed: ${(err as Error).message}`); + return null; } } - async del(key: string): Promise { - await this.client.del(key); + /** Serialises `value` to JSON and stores it under `key` for `ttlSeconds`. */ + async set(key: string, value: T, ttlSeconds: number): Promise { + try { + await this.client.set(key, JSON.stringify(value), "EX", ttlSeconds); + } catch (err) { + this.logger.warn(`cache.set("${key}") failed: ${(err as Error).message}`); + } } - /** - * 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})` : ''}...`, - ); + /** Deletes one or more keys (best-effort). */ + async del(...keys: string[]): Promise { try { - await this.client.quit(); - this.logger.log('Redis connection pool closed cleanly.'); + if (keys.length > 0) await this.client.del(...keys); } catch (err) { - this.logger.error( - `Failed to close Redis connection pool cleanly: ${(err as Error).message}`, - ); - this.client.disconnect(); + this.logger.warn(`cache.del(${keys.join(",")}) failed: ${(err as Error).message}`); } } + + /** + * Cache-aside helper. + * + * Returns the cached value for `key` when present. Otherwise calls + * `loader`, stores the result for `ttlSeconds`, and returns it. + * If either the cache read or write throws, the loader result is + * returned without caching (fail-open). + * + * @example + * const readings = await this.cacheService.wrap( + * "oracle:checkAll", + * ORACLE_TTL, + * () => this.runAllChecks() + * ); + */ + 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; + } } 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/claim/claim.scheduler.ts b/src/claim/claim.scheduler.ts index 1cefc74..858012c 100644 --- a/src/claim/claim.scheduler.ts +++ b/src/claim/claim.scheduler.ts @@ -149,6 +149,4 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { }); }); } -} - } } diff --git a/src/config/configuration.spec.ts b/src/config/configuration.spec.ts index e69de29..8119311 100644 --- a/src/config/configuration.spec.ts +++ b/src/config/configuration.spec.ts @@ -0,0 +1,64 @@ +import configuration from "./configuration"; + +const NETWORK_CONFIG = { + testnet: { + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + futurenet: { + sorobanRpcUrl: "https://soroban-futurenet.stellar.org", + horizonUrl: "https://horizon-futurenet.stellar.org", + networkPassphrase: "Test SDF Future Network ; October 2022", + }, + mainnet: { + sorobanRpcUrl: "https://soroban-rpc.mainnet.stellar.org", + horizonUrl: "https://horizon.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, +} as const; + +describe("Stellar network configuration", () => { + const originalEnv = new Map(); + + function setEnv(name: string, value: string | undefined): void { + if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + + afterEach(() => { + for (const [name, value] of originalEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + originalEnv.clear(); + }); + + it.each(Object.entries(NETWORK_CONFIG))("selects all Stellar endpoints and IDs together for %s", (network, expected) => { + const suffix = network.toUpperCase(); + setEnv("STELLAR_NETWORK", network); + setEnv(`REFRACT_POOL_CONTRACT_ID_${suffix}`, `${suffix}-pool`); + setEnv(`REFRACT_POLICY_CONTRACT_ID_${suffix}`, `${suffix}-policy`); + setEnv(`REFRACT_ORACLE_CONTRACT_ID_${suffix}`, `${suffix}-oracle`); + setEnv("SOROBAN_RPC_URL", "https://wrong-network.invalid"); + setEnv("STELLAR_NETWORK_PASSPHRASE", "wrong passphrase"); + + const config = configuration(); + + expect(config.stellar).toMatchObject({ + network, + ...expected, + poolContractId: `${suffix}-pool`, + policyContractId: `${suffix}-policy`, + oracleContractId: `${suffix}-oracle`, + }); + expect(config.oracles.horizonUrl).toBe(expected.horizonUrl); + }); + + it("rejects unsupported network selectors", () => { + setEnv("STELLAR_NETWORK", "local"); + + expect(configuration).toThrow("expected testnet, futurenet, or mainnet"); + }); +}); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 15da439..ca03b8e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,59 +1,194 @@ -import { registerAs } from '@nestjs/config'; - /** - * 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. + * Typed application configuration, loaded once by Nest's ConfigModule. * - * 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). + * Kept as a single factory function (rather than scattered `process.env` + * reads) so every consumer gets the same parsed/defaulted values and the + * shape is documented in one place. See `.env.example` for the full list + * of variables this reads. */ +export interface AppConfig { + port: number; + frontendUrl: string; + database: { + url: string; + }; + redis: { + url: string; + }; + stellar: { + network: string; + sorobanRpcUrl: string; + networkPassphrase: string; + poolContractId: string; + policyContractId: string; + oracleContractId: string; + relayerSecret: string; + }; + /** + * Shared Soroban confirmation poller defaults (see + * `pollForConfirmation`). Per-call overrides let the HTTP submit path + * use a short deadline while settlement/oracle publish use a longer one. + */ + confirmation: { + initialIntervalMs: number; + backoffMultiplier: number; + maxIntervalMs: number; + deadlineMs: number; + jitterRatio: number; + /** Longer deadline for relayer-signed settlement / oracle publishes. */ + settlementDeadlineMs: number; + /** Shorter deadline so HTTP handlers don't block for a full minute. */ + httpDeadlineMs: number; + }; + oraclePublish: { + /** + * `every_poll` — publish on every scheduler tick (subject to min interval). + * `on_change` — publish only when the scaled fixed-point value changes + * (default; bounds fee spend). + */ + mode: "every_poll" | "on_change"; + /** Floor between publishes for the same coverage type (ms). */ + minIntervalMs: number; + }; + oracles: { + coingeckoBaseUrl: string; + horizonUrl: string; + defiLlamaBaseUrl: string; + defiLlamaProtocolSlug: string; + httpTimeoutMs: number; + feedLatencyBudgetsMs?: Record; + }; + fees: { + statsTtlMs: number; + ceilingStroops: string; + profiles: Record; + }; + eventIndexer: { + enabled: boolean; + pollIntervalMs: number; + contractId: string; + }; + adminApiKey: string; + communityApiKeys: string[]; + ops: { + apiToken: string; + communityApiKeys: string[]; + }; + oracleTrustMode: "strict" | "quorum" | "development"; + /** Pending off-chain policies that never get submitted expire after this many ms. */ + policyPendingTtlMs: number; +} + +export type FeeProfileName = "moderate" | "aggressive"; + +export default (): AppConfig => ({ + port: parseInt(process.env.PORT || "4001", 10), + frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + database: { + url: process.env.DATABASE_URL || "postgres://refract:refract@localhost:5432/refract", + }, + redis: { + url: process.env.REDIS_URL || "redis://localhost:6379", + }, + stellar: { + network: process.env.STELLAR_NETWORK || "testnet", + sorobanRpcUrl: process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org", + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", + poolContractId: process.env.REFRACT_POOL_CONTRACT_ID || "", + policyContractId: process.env.REFRACT_POLICY_CONTRACT_ID || "", + oracleContractId: process.env.REFRACT_ORACLE_CONTRACT_ID || "", + relayerSecret: process.env.ORACLE_RELAYER_SECRET || "", + }, + confirmation: { + initialIntervalMs: parseInt(process.env.CONFIRMATION_INITIAL_INTERVAL_MS || "400", 10), + backoffMultiplier: parseFloat(process.env.CONFIRMATION_BACKOFF_MULTIPLIER || "1.8"), + maxIntervalMs: parseInt(process.env.CONFIRMATION_MAX_INTERVAL_MS || "8000", 10), + deadlineMs: parseInt(process.env.CONFIRMATION_DEADLINE_MS || "30000", 10), + jitterRatio: parseFloat(process.env.CONFIRMATION_JITTER_RATIO || "0.2"), + settlementDeadlineMs: parseInt(process.env.CONFIRMATION_SETTLEMENT_DEADLINE_MS || "90000", 10), + httpDeadlineMs: parseInt(process.env.CONFIRMATION_HTTP_DEADLINE_MS || "20000", 10), + }, + oraclePublish: { + mode: (process.env.ORACLE_PUBLISH_MODE as "every_poll" | "on_change") || "on_change", + minIntervalMs: parseInt(process.env.ORACLE_PUBLISH_MIN_INTERVAL_MS || "300000", 10), + }, + oracles: { + coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || "https://api.coingecko.com/api/v3", + horizonUrl: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org", + defiLlamaBaseUrl: process.env.DEFILLAMA_BASE_URL || "https://api.llama.fi", + // Placeholder "covered protocol" for the SmartContractRisk TVL-drop + // check until Refract defines a real list of covered Soroban + // protocols. Defaults to a large, consistently-tracked protocol so + // the drop-detection logic has real data to run against. + defiLlamaProtocolSlug: process.env.DEFILLAMA_PROTOCOL_SLUG || "aave", + httpTimeoutMs: parseInt(process.env.ORACLE_HTTP_TIMEOUT_MS || "5000", 10), + feedLatencyBudgetsMs: parseFeedBudgets(process.env.ORACLE_FEED_LATENCY_BUDGETS_MS), + }, + fees: { + statsTtlMs: parseInt(process.env.FEE_STATS_TTL_MS || "30000", 10), + ceilingStroops: process.env.FEE_CEILING_STROOPS || "10000000", + profiles: { + moderate: { + percentile: parseInt(process.env.FEE_MODERATE_PERCENTILE || "50", 10), + multiplier: parseFloat(process.env.FEE_MODERATE_MULTIPLIER || "1.2"), + }, + aggressive: { + percentile: parseInt(process.env.FEE_AGGRESSIVE_PERCENTILE || "90", 10), + multiplier: parseFloat(process.env.FEE_AGGRESSIVE_MULTIPLIER || "1.5"), + }, + }, + }, + eventIndexer: { + enabled: (process.env.EVENT_INDEXER_ENABLED || "false") === "true", + pollIntervalMs: parseInt(process.env.EVENT_INDEXER_POLL_INTERVAL_MS || "5000", 10), + contractId: process.env.REFRACT_POOL_CONTRACT_ID || "", + }, + adminApiKey: process.env.ADMIN_API_KEY || "", + communityApiKeys: parseCsv(process.env.COMMUNITY_API_KEYS), + ops: { + apiToken: process.env.OPS_API_TOKEN || "", + communityApiKeys: parseCsv(process.env.COMMUNITY_API_KEYS), + }, + oracleTrustMode: parseTrustMode(process.env.ORACLE_TRUST_MODE), + policyPendingTtlMs: parseInt(process.env.POLICY_PENDING_TTL_MS || "3600000", 10), +}); + 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'; + const raw = (env.SANDBOX_MODE ?? "").trim().toLowerCase(); + return raw === "true" || raw === "1" || raw === "yes" || raw === "on"; } -/** - * 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; + if (isSandboxMode(env) && (env.NODE_ENV ?? "").trim().toLowerCase() === "production") { + throw new Error("SANDBOX_MODE is not allowed when NODE_ENV=production"); } +} - 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 parseCsv(value?: string): string[] { + return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); } -/** - * 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.', - '============================================================', - ]; +function parseFeedBudgets(value?: string): Record | undefined { + if (!value) return undefined; + let result: unknown; + try { + result = JSON.parse(value); + } catch { + throw new Error("ORACLE_FEED_LATENCY_BUDGETS_MS must be valid JSON"); + } + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new Error("ORACLE_FEED_LATENCY_BUDGETS_MS must be a JSON object"); + } + for (const [feed, budget] of Object.entries(result)) { + if (!Number.isInteger(budget) || Number(budget) <= 0) { + throw new Error(`Latency budget for ${feed} must be a positive integer`); + } + } + return result as Record; } -export default registerAs('app', () => ({ - env: process.env.NODE_ENV ?? 'development', - port: parseInt(process.env.PORT ?? '3000', 10), - sandboxMode: isSandboxMode(), -})); +function parseTrustMode(value?: string): AppConfig["oracleTrustMode"] { + if (value === undefined || value === "") return "strict"; + if (value === "strict" || value === "quorum" || value === "development") return value; + throw new Error("ORACLE_TRUST_MODE must be strict, quorum, or development"); +} diff --git a/src/config/oracle-feed.config.ts b/src/config/oracle-feed.config.ts new file mode 100644 index 0000000..6d4f97f --- /dev/null +++ b/src/config/oracle-feed.config.ts @@ -0,0 +1,124 @@ +export interface OracleFeedConfiguration { + latencyBudgetMs: number; + requiredQuorum: number; + unit: string; + sourceType: "http" | "mock"; +} + +export const ORACLE_FEEDS = { + "coingecko-usdc": { + latencyBudgetMs: 30_000, + requiredQuorum: 1, + unit: "USD", + sourceType: "http", + }, + "coingecko-stellar": { + latencyBudgetMs: 30_000, + requiredQuorum: 1, + unit: "percent_change_24h", + sourceType: "http", + }, + "defillama-aave": { + latencyBudgetMs: 45_000, + requiredQuorum: 1, + unit: "percent_change_24h", + sourceType: "http", + }, + "nexus-liquidations": { + latencyBudgetMs: 10_000, + requiredQuorum: 1, + unit: "collateral_ratio", + sourceType: "mock", + }, + aviationstack: { + latencyBudgetMs: 10_000, + requiredQuorum: 1, + unit: "minutes", + sourceType: "mock", + }, +} as const satisfies Record; + +export type OracleFeedId = keyof typeof ORACLE_FEEDS; + +export interface FeedPayloadRule { + requiredFields: readonly string[]; + numericRanges: Readonly>; + maxStepChanges?: Readonly>; + allowedCategoricalValues?: Readonly>; + expectedUnits: Readonly>; +} + +export const ORACLE_FEED_RULES: Readonly> = { + "coingecko-usdc": { + requiredFields: ["price", "unit"], + numericRanges: { price: [0, 2] }, + maxStepChanges: { price: 0.2 }, + expectedUnits: { unit: "USD" }, + }, + "coingecko-stellar": { + requiredFields: ["price", "change24h", "unit", "changeUnit"], + numericRanges: { price: [0, 1_000_000], change24h: [-100, 1_000] }, + maxStepChanges: { price: 25, change24h: 50 }, + expectedUnits: { unit: "USD", changeUnit: "percent" }, + }, + "defillama-aave": { + requiredFields: ["unit", "tvl[].date", "tvl[].totalLiquidityUSD"], + numericRanges: { + "tvl[].date": [0, 4_102_444_800], + "tvl[].totalLiquidityUSD": [0, 1_000_000_000_000_000], + }, + maxStepChanges: { "tvl[].totalLiquidityUSD": 20_000_000_000 }, + expectedUnits: { unit: "USD" }, + }, + "nexus-liquidations": { + requiredFields: ["collateralRatio", "status", "unit"], + numericRanges: { collateralRatio: [0, 2] }, + allowedCategoricalValues: { status: ["healthy", "liquidated"] }, + expectedUnits: { unit: "ratio" }, + }, + aviationstack: { + requiredFields: ["flightNumber", "delayMinutes", "status", "unit"], + numericRanges: { delayMinutes: [0, 10_080] }, + allowedCategoricalValues: { status: ["scheduled", "delayed", "cancelled"] }, + expectedUnits: { unit: "minutes" }, + }, +}; + +export const COVERAGE_REQUIRED_FEEDS = { + StablecoinDepeg: ["coingecko-usdc"], + MarketCrash: ["coingecko-stellar"], + LiquidationShield: ["nexus-liquidations"], + SmartContractRisk: ["defillama-aave"], + FlightDelay: ["aviationstack"], +} as const; + +export function feedLatencyBudgetMs( + feedId: string, + configuredBudgets?: Readonly>, +): number { + const configured = ORACLE_FEEDS[feedId as OracleFeedId]; + const configuredOverride = configuredBudgets?.[feedId]; + if (configuredOverride !== undefined) { + if (!Number.isInteger(configuredOverride) || configuredOverride <= 0) { + throw new Error(`Latency budget for ${feedId} must be a positive integer`); + } + return configuredOverride; + } + const override = process.env.ORACLE_FEED_LATENCY_BUDGETS_MS; + if (override) { + let budgets: Record; + try { + budgets = JSON.parse(override) as Record; + } catch { + throw new Error("ORACLE_FEED_LATENCY_BUDGETS_MS must be a JSON object of feed IDs to positive integer milliseconds"); + } + const value = budgets[feedId]; + if (value !== undefined) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Latency budget for ${feedId} must be a positive integer`); + } + return value; + } + } + return configured?.latencyBudgetMs ?? 30_000; +} 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..56abbbf 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -0,0 +1,56 @@ +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/health/health.module.ts b/src/health/health.module.ts index 9011dc4..7457601 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,22 +1,12 @@ import { Module } from "@nestjs/common"; -import { StellarModule } from "../stellar/stellar.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"; +import { OracleTrustModule } from "../oracle/oracle-trust.module"; -// DatabaseModule is @Global(), so DatabaseService is available here -// without a local import — the controller receives it via DI automatically. @Module({ - imports: [StellarModule], + imports: [OracleTrustModule], controllers: [HealthController], - providers: [ - HealthService, - SorobanRpcService, - OracleService, - ClaimSettlementService, - ], + providers: [HealthService], exports: [HealthService], }) export class HealthModule {} diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts new file mode 100644 index 0000000..24b65de --- /dev/null +++ b/src/health/health.service.spec.ts @@ -0,0 +1,73 @@ +import { DbService } from "../db/db.service"; +import { ORACLE_FEEDS } from "../config/oracle-feed.config"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; +import { SorobanRpcService } from "../stellar/soroban-rpc.service"; +import { HealthService } from "./health.service"; + +describe("HealthService readiness", () => { + const originalTrustMode = process.env.ORACLE_TRUST_MODE; + + afterEach(() => { + if (originalTrustMode === undefined) delete process.env.ORACLE_TRUST_MODE; + else process.env.ORACLE_TRUST_MODE = originalTrustMode; + }); + + it("requires reachable trusted feeds, a satisfiable quorum, healthy RPC, and writable evidence storage", async () => { + process.env.ORACLE_TRUST_MODE = "development"; + const trust = new OracleTrustService(); + for (const [feedId, config] of Object.entries(ORACLE_FEEDS)) { + if (config.sourceType === "http") trust.recordObservation(feedId, true); + } + trust.setPublisherConfigured(true); + + const rpc = { + server: { getHealth: jest.fn().mockResolvedValue({ status: "healthy" }) }, + } as unknown as SorobanRpcService; + const db = { + isAvailable: jest.fn().mockReturnValue(true), + withClient: jest.fn(async (fn: (client: { query: jest.Mock }) => Promise) => + fn({ query: jest.fn().mockResolvedValue({}) }), + ), + } as unknown as DbService; + const service = new HealthService(trust, rpc, db); + + await expect(service.getReadiness()).resolves.toMatchObject({ + ready: true, + checks: { + sourcesReachable: true, + quorumSatisfiable: true, + rpcHealthy: true, + evidenceStoreWritable: true, + publisherConfigured: true, + }, + }); + + (db.withClient as jest.Mock).mockRejectedValueOnce(new Error("read-only database")); + await expect(service.getReadiness()).resolves.toMatchObject({ + ready: false, + checks: { evidenceStoreWritable: false }, + }); + }); + + it("is not ready when no fresh feed observations can satisfy trust", async () => { + const rpc = { + server: { getHealth: jest.fn().mockRejectedValue(new Error("RPC down")) }, + } as unknown as SorobanRpcService; + const db = { + isAvailable: jest.fn().mockReturnValue(false), + } as unknown as DbService; + const service = new HealthService(new OracleTrustService(), rpc, db); + + await expect(service.getReadiness()).resolves.toMatchObject({ + ready: false, + status: "not_ready", + checks: { + sourcesReachable: false, + quorumSatisfiable: false, + rpcHealthy: false, + evidenceStoreWritable: false, + publisherConfigured: false, + }, + }); + }); +}); diff --git a/src/health/health.service.ts b/src/health/health.service.ts new file mode 100644 index 0000000..d9a8e12 --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger, Optional } from "@nestjs/common"; +import { DbService } from "../db/db.service"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; +import { SorobanRpcService } from "../stellar/soroban-rpc.service"; + +@Injectable() +export class HealthService { + private readonly logger = new Logger(HealthService.name); + + constructor( + @Optional() private readonly oracleTrust?: OracleTrustService, + @Optional() private readonly rpcService?: SorobanRpcService, + @Optional() private readonly dbService?: DbService, + ) {} + + getLiveness(): { status: "ok" } { + return { status: "ok" }; + } + + async getReadiness(): Promise<{ + ready: boolean; + status: "ready" | "not_ready"; + checks: { + sourcesReachable: boolean; + quorumSatisfiable: boolean; + rpcHealthy: boolean; + evidenceStoreWritable: boolean; + publisherConfigured: boolean; + }; + trustMode: string; + feeds: ReturnType["feeds"]; + }> { + const feedReadiness = this.oracleTrust?.readiness(); + const infrastructure = await this.getInfrastructureReadiness(); + const checks = { + sourcesReachable: feedReadiness?.sourcesReachable ?? false, + quorumSatisfiable: feedReadiness?.quorumSatisfiable ?? false, + ...infrastructure.checks, + }; + const ready = Object.values(checks).every(Boolean); + return { + ready, + status: ready ? "ready" : "not_ready", + checks, + trustMode: feedReadiness?.trustMode ?? "strict", + feeds: feedReadiness?.feeds ?? [], + }; + } + + async getInfrastructureReadiness(): Promise<{ + ready: boolean; + checks: { + rpcHealthy: boolean; + evidenceStoreWritable: boolean; + publisherConfigured: boolean; + }; + }> { + const feedReadiness = this.oracleTrust?.readiness(); + const [rpcHealthy, evidenceStoreWritable] = await Promise.all([ + this.checkRpc(), + this.checkEvidenceStore(), + ]); + const checks = { + rpcHealthy, + evidenceStoreWritable, + publisherConfigured: feedReadiness?.publisherConfigured ?? false, + }; + return { ready: Object.values(checks).every(Boolean), checks }; + } + + async getDetail(): Promise> { + const readiness = await this.getReadiness(); + return { + status: readiness.status, + trustMode: readiness.trustMode, + dependencies: readiness.checks, + feeds: readiness.feeds, + }; + } + + private async checkRpc(): Promise { + if (!this.rpcService) return false; + try { + await withTimeout(this.rpcService.server.getHealth(), 3_000, "Soroban RPC health probe timed out"); + return true; + } catch (error) { + this.logger.warn(`Soroban RPC readiness probe failed: ${errorMessage(error)}`); + return false; + } + } + + private async checkEvidenceStore(): Promise { + if (!this.dbService?.isAvailable()) return false; + try { + await this.dbService.withClient(async (client) => { + await client.query("BEGIN"); + try { + await client.query( + `INSERT INTO oracle_events (coverage_type, value, source, severity) + VALUES ('stablecoin_depeg', 0, 'readiness_probe', 'low')`, + ); + await client.query("ROLLBACK"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + return true; + } catch (error) { + this.logger.warn(`Oracle evidence store readiness probe failed: ${errorMessage(error)}`); + return false; + } + } +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/metrics/metrics.service.spec.ts b/src/metrics/metrics.service.spec.ts index ba9c925..257869a 100644 --- a/src/metrics/metrics.service.spec.ts +++ b/src/metrics/metrics.service.spec.ts @@ -9,6 +9,8 @@ describe("MetricsService", () => { const metrics = new MetricsService(); metrics.recordSchedulerRun("claim_settlement", "success", 1.2); metrics.recordOracleCheck("StablecoinDepeg", "failure", 0.3); + metrics.recordOracleStage("coingecko-usdc", "consensus", 0.4); + metrics.recordOracleDeadlineBreach("coingecko-usdc", "confirmation", 0.2); await metrics.observeSorobanRpc("send_transaction", async () => "hash"); const output = await metrics.metrics(); @@ -17,6 +19,9 @@ describe("MetricsService", () => { expect(output).toContain('refract_scheduler_run_duration_seconds_count{scheduler="claim_settlement"} 1'); expect(output).toContain('refract_oracle_checks_total{coverage_type="StablecoinDepeg",outcome="failure"} 1'); expect(output).toContain('refract_oracle_check_duration_seconds_count{coverage_type="StablecoinDepeg"} 1'); + expect(output).toContain('refract_oracle_stage_duration_seconds_count{feed="coingecko-usdc",stage="consensus"} 1'); + expect(output).toContain('refract_oracle_deadline_breaches_total{feed="coingecko-usdc",stage="confirmation"} 1'); + expect(output).toContain('refract_oracle_deadline_overrun_seconds_count{feed="coingecko-usdc",stage="confirmation"} 1'); expect(output).toContain('refract_soroban_rpc_duration_seconds_count{operation="send_transaction",outcome="success"} 1'); }); diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index e9eab0b..0b7e3d4 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -54,6 +54,26 @@ export class MetricsService { buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30], registers: [this.registry], }); + private readonly oracleStageDuration = new Histogram<"feed" | "stage">({ + name: "refract_oracle_stage_duration_seconds", + help: "Oracle feed processing duration by feed and pipeline stage.", + labelNames: ["feed", "stage"], + buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + registers: [this.registry], + }); + private readonly oracleDeadlineBreaches = new Counter<"feed" | "stage">({ + name: "refract_oracle_deadline_breaches_total", + help: "Oracle feed publication deadlines breached, labeled by feed and stage.", + labelNames: ["feed", "stage"], + registers: [this.registry], + }); + private readonly oracleDeadlineOverrun = new Histogram<"feed" | "stage">({ + name: "refract_oracle_deadline_overrun_seconds", + help: "Time by which oracle feed processing exceeded its publication deadline.", + labelNames: ["feed", "stage"], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30], + registers: [this.registry], + }); readonly httpMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { if (req.path === "/metrics") { @@ -86,6 +106,16 @@ export class MetricsService { this.oracleCheckDuration.labels({ coverage_type: coverageType }).observe(durationSeconds); } + recordOracleStage(feed: string, stage: string, durationSeconds: number): void { + this.oracleStageDuration.labels({ feed, stage }).observe(durationSeconds); + } + + recordOracleDeadlineBreach(feed: string, stage: string, overrunSeconds: number): void { + const labels = { feed, stage }; + this.oracleDeadlineBreaches.labels(labels).inc(); + this.oracleDeadlineOverrun.labels(labels).observe(Math.max(0, overrunSeconds)); + } + async observeSorobanRpc(operation: string, call: () => Promise): Promise { const startedAt = process.hrtime.bigint(); let outcome: Outcome = "success"; diff --git a/src/oracle/feed-payload.validator.spec.ts b/src/oracle/feed-payload.validator.spec.ts new file mode 100644 index 0000000..547cda1 --- /dev/null +++ b/src/oracle/feed-payload.validator.spec.ts @@ -0,0 +1,56 @@ +import { FeedPayloadValidator, InvalidFeedPayloadError } from "./feed-payload.validator"; + +describe("FeedPayloadValidator", () => { + it("rejects missing fields, out-of-range values, and unexpected units", () => { + const validator = new FeedPayloadValidator(); + + expect(() => + validator.validateAndRemember("coingecko-usdc", { + price: 4, + unit: "EUR", + }), + ).toThrow(InvalidFeedPayloadError); + expect(() => + validator.validateAndRemember("coingecko-usdc", { + price: 1, + }), + ).toThrow(/required field unit is missing/); + }); + + it("rejects an excessive step change without updating the accepted baseline", () => { + const validator = new FeedPayloadValidator(); + validator.validateAndRemember("coingecko-usdc", { price: 1, unit: "USD" }); + + expect(() => + validator.validateAndRemember("coingecko-usdc", { price: 0.7, unit: "USD" }), + ).toThrow(/changed by more than 0.2/); + + expect(() => + validator.validateAndRemember("coingecko-usdc", { price: 0.9, unit: "USD" }), + ).not.toThrow(); + }); + + it("rejects categorical values outside the configured set", () => { + const validator = new FeedPayloadValidator(); + + expect(() => + validator.validateAndRemember("aviationstack", { + flightNumber: "RF123", + delayMinutes: 0, + status: "unknown", + unit: "minutes", + }), + ).toThrow(/status must be one of scheduled, delayed, cancelled/); + }); + + it("validates every numeric entry in array-valued provider fields", () => { + const validator = new FeedPayloadValidator(); + + expect(() => + validator.validateAndRemember("defillama-aave", { + unit: "USD", + tvl: [{ date: 1, totalLiquidityUSD: -10 }], + }), + ).toThrow(/tvl\[\]\.totalLiquidityUSD/); + }); +}); diff --git a/src/oracle/feed-payload.validator.ts b/src/oracle/feed-payload.validator.ts new file mode 100644 index 0000000..96c7080 --- /dev/null +++ b/src/oracle/feed-payload.validator.ts @@ -0,0 +1,114 @@ +import { ORACLE_FEED_RULES } from "../config/oracle-feed.config"; + +export class InvalidFeedPayloadError extends Error { + constructor(feedId: string, failures: string[]) { + super(`Feed ${feedId} payload rejected: ${failures.join("; ")}`); + this.name = "InvalidFeedPayloadError"; + } +} + +export class FeedPayloadValidator { + private readonly previousPayloads = new Map(); + + validateAndRemember(feedId: string, payload: unknown): void { + const rule = ORACLE_FEED_RULES[feedId]; + if (!rule) { + throw new Error(`No validation rules configured for feed ${feedId}`); + } + + const previous = this.previousPayloads.get(feedId); + const failures: string[] = []; + const record = asRecord(payload); + if (!record) { + throw new InvalidFeedPayloadError(feedId, ["payload must be an object"]); + } + + for (const path of rule.requiredFields) { + if (readPathValues(record, path).length === 0) { + failures.push(`required field ${path} is missing`); + } + } + + for (const [path, [minimum, maximum]] of Object.entries(rule.numericRanges)) { + const values = readPathValues(record, path); + if (values.length === 0) continue; + if ( + values.some( + (value) => + typeof value !== "number" || + !Number.isFinite(value) || + value < minimum || + value > maximum, + ) + ) { + failures.push(`${path} must contain finite numbers in [${minimum}, ${maximum}]`); + } + } + + if (previous && asRecord(previous)) { + for (const [path, maximumStep] of Object.entries(rule.maxStepChanges ?? {})) { + const currentValues = readPathValues(record, path); + const previousValues = readPathValues(asRecord(previous)!, path); + const count = Math.min(currentValues.length, previousValues.length); + for (let index = 0; index < count; index += 1) { + const current = currentValues[index]; + const prior = previousValues[index]; + if ( + typeof current === "number" && + typeof prior === "number" && + Math.abs(current - prior) > maximumStep + ) { + failures.push(`${path} changed by more than ${maximumStep}`); + break; + } + } + } + } + + for (const [path, allowed] of Object.entries(rule.allowedCategoricalValues ?? {})) { + const values = readPathValues(record, path); + if (values.some((value) => typeof value !== "string" || !allowed.includes(value))) { + failures.push(`${path} must be one of ${allowed.join(", ")}`); + } + } + + for (const [path, expected] of Object.entries(rule.expectedUnits)) { + const values = readPathValues(record, path); + if (values.length === 0 || values.some((value) => value !== expected)) { + failures.push(`${path} must equal the configured unit ${expected}`); + } + } + + if (failures.length > 0) { + throw new InvalidFeedPayloadError(feedId, failures); + } + this.previousPayloads.set(feedId, payload); + } +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readPathValues(value: Record, path: string): unknown[] { + let current: unknown[] = [value]; + for (const segment of path.split(".")) { + const expandsArray = segment.endsWith("[]"); + const key = expandsArray ? segment.slice(0, -2) : segment; + const next: unknown[] = []; + for (const item of current) { + const record = asRecord(item); + if (!record || !(key in record)) continue; + const property = record[key]; + if (expandsArray) { + if (Array.isArray(property)) next.push(...property); + } else { + next.push(property); + } + } + current = next; + } + return current.filter((entry) => entry !== undefined && entry !== null); +} diff --git a/src/oracle/oracle-publisher.service.spec.ts b/src/oracle/oracle-publisher.service.spec.ts index 530dea9..f11482e 100644 --- a/src/oracle/oracle-publisher.service.spec.ts +++ b/src/oracle/oracle-publisher.service.spec.ts @@ -2,8 +2,10 @@ import { ConfigService } from "@nestjs/config"; import { Account, Keypair, StrKey, TransactionBuilder, nativeToScVal, rpc, scValToNative, xdr } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { OraclePublisherService } from "./oracle-publisher.service"; +import { MetricsService } from "../metrics/metrics.service"; import { scaleOracleValue, coverageTypeToU32 } from "./oracle-encoding"; import { OracleReading } from "./oracle-reading"; +import { OracleTrustService } from "./oracle-trust.service"; const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; @@ -100,6 +102,40 @@ describe("OraclePublisherService", () => { expect(getAccountSpy).not.toHaveBeenCalled(); }); + it("never publishes without a fresh feed observation satisfying quorum", async () => { + const trust = new OracleTrustService(); + const service = new OraclePublisherService(buildConfig(), undefined, trust); + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + + const result = await service.maybePublish( + reading({ feedId: "coingecko-usdc", deadlineAt: Date.now() + 10_000 }), + ); + + expect(result).toBeNull(); + expect(getAccountSpy).not.toHaveBeenCalled(); + }); + + it("aborts an expired feed budget before making an RPC call and records the breached stage", async () => { + const metrics = new MetricsService(); + const service = new OraclePublisherService(buildConfig(), metrics); + const getAccountSpy = jest.spyOn(rpc.Server.prototype, "getAccount"); + + const result = await service.maybePublish( + reading({ + feedId: "coingecko-usdc", + observedAt: Date.now() - 31_000, + deadlineAt: Date.now() - 1, + }), + ); + + expect(result).toBeNull(); + expect(getAccountSpy).not.toHaveBeenCalled(); + const output = await metrics.metrics(); + expect(output).toContain( + 'refract_oracle_deadline_breaches_total{feed="coingecko-usdc",stage="publication"} 1', + ); + }); + it("encodes update_reading(u32, i128) with the scaled value", async () => { const service = new OraclePublisherService(buildConfig()); @@ -121,6 +157,8 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "oracle-tx", }); const result = await service.maybePublish(reading({ coverageType: "MarketCrash", value: -30.12 })); @@ -160,6 +198,8 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "oracle-tx", }); const first = service.maybePublish(reading({ value: 0.99 })); @@ -190,6 +230,8 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "oracle-tx", }); const result = await service.maybePublish(reading()); @@ -219,6 +261,8 @@ describe("OraclePublisherService", () => { envelopeXdr: {} as never, resultXdr: {} as never, resultMetaXdr: {} as never, + events: { transactionEventsXdr: [], contractEventsXdr: [] }, + txHash: "oracle-tx", }); await service.maybePublish(reading({ value: 1.0 })); diff --git a/src/oracle/oracle-publisher.service.ts b/src/oracle/oracle-publisher.service.ts index e69de29..7016034 100644 --- a/src/oracle/oracle-publisher.service.ts +++ b/src/oracle/oracle-publisher.service.ts @@ -0,0 +1,344 @@ +import { Injectable, Logger, Optional } 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 { MetricsService } from "../metrics/metrics.service"; +import { OracleTrustService } from "./oracle-trust.service"; +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, + @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly oracleTrustService?: OracleTrustService, + ) { + 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 }); + this.oracleTrustService?.setPublisherConfigured(this.isConfigured()); + } + + /** 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, + deadlineAt: number | undefined = reading.deadlineAt, + ): Promise { + const feedId = reading.feedId ?? reading.coverageType; + if (reading.degraded) { + this.logger.warn(`Skipping on-chain publish for degraded ${reading.coverageType} reading`); + this.oracleTrustService?.recordPublication(feedId, false, "provider reading is degraded"); + return null; + } + + if (this.oracleTrustService && !this.oracleTrustService.canPublish(feedId)) { + this.logger.warn(`Skipping on-chain publish for untrusted feed ${feedId}`); + return null; + } + + if (this.deadlineExpired(feedId, "publication", deadlineAt)) { + this.oracleTrustService?.recordPublication(feedId, false, "publication deadline expired"); + 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; + const previousValue = this.lastScaled.get(reading.coverageType); + if (previousValue !== undefined && previousValue === scaled) { + this.oracleTrustService?.recordPublication(feedId, true); + if (this.publishMode === "on_change") return null; + } + + if (now - lastAt < this.minIntervalMs) { + return null; + } + + if (this.inFlight.has(reading.coverageType)) { + this.logger.warn(`Overlapping publish suppressed for ${reading.coverageType}`); + return null; + } + + this.inFlight.add(reading.coverageType); + const publicationStartedAt = Date.now(); + try { + const record = await this.publish(reading.coverageType, scaled, feedId, deadlineAt); + 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()); + } + this.oracleTrustService?.recordPublication( + feedId, + record.confirmed, + record.error ?? record.outcome, + ); + return record; + } finally { + this.metricsService?.recordOracleStage( + feedId, + "publication", + (Date.now() - publicationStartedAt) / 1000, + ); + this.inFlight.delete(reading.coverageType); + } + } + + private async publish( + coverageType: string, + scaledValue: bigint, + feedId: string, + deadlineAt?: number, + ): Promise { + const attemptedAt = Date.now(); + const base: OraclePublishRecord = { + coverageType, + scaledValue: scaledValue.toString(10), + confirmed: false, + attemptedAt, + }; + + try { + if (this.deadlineExpired(feedId, "publication", deadlineAt)) { + return { ...base, error: "Feed publication deadline expired before RPC account lookup" }; + } + const sourceAccount = await this.withinDeadline( + feedId, + "publication", + deadlineAt, + () => this.server.getAccount(this.relayerKeypair!.publicKey()), + ); + if (this.deadlineExpired(feedId, "publication", deadlineAt)) { + return { ...base, error: "Feed publication deadline expired before transaction preparation" }; + } + 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 remainingMs = deadlineAt === undefined ? 30_000 : deadlineAt - Date.now(); + const transactionTimeoutSeconds = Math.min(30, Math.floor(remainingMs / 1000)); + if (transactionTimeoutSeconds < 1) { + this.metricsService?.recordOracleDeadlineBreach( + feedId, + "publication", + Math.max(0, (1_000 - remainingMs) / 1_000), + ); + return { ...base, error: "Insufficient latency budget remains for an on-chain transaction" }; + } + + const builtTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(transactionTimeoutSeconds) + .build(); + + const preparedTx = await this.withinDeadline( + feedId, + "publication", + deadlineAt, + () => this.server.prepareTransaction(builtTx), + ); + if (this.deadlineExpired(feedId, "publication", deadlineAt)) { + return { ...base, error: "Feed publication deadline expired before transaction submission" }; + } + preparedTx.sign(this.relayerKeypair!); + + const sendResult = await this.withinDeadline( + feedId, + "publication", + deadlineAt, + () => 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 confirmationStartedAt = Date.now(); + const confirmationDeadlineMs = deadlineAt === undefined + ? this.confirmationDefaults.settlementDeadlineMs + : Math.min(this.confirmationDefaults.settlementDeadlineMs, deadlineAt - Date.now()); + if (confirmationDeadlineMs <= 0) { + this.deadlineExpired(feedId, "confirmation", deadlineAt); + return { + ...base, + txHash: sendResult.hash, + error: "Feed publication was submitted but its confirmation deadline expired", + }; + } + + const confirmation = await this.withinDeadline( + feedId, + "confirmation", + deadlineAt, + () => + pollForConfirmation(this.server, sendResult.hash, { + initialIntervalMs: this.confirmationDefaults.initialIntervalMs, + backoffMultiplier: this.confirmationDefaults.backoffMultiplier, + maxIntervalMs: this.confirmationDefaults.maxIntervalMs, + jitterRatio: this.confirmationDefaults.jitterRatio, + deadlineMs: confirmationDeadlineMs, + }), + ); + this.metricsService?.recordOracleStage( + feedId, + "confirmation", + (Date.now() - confirmationStartedAt) / 1000, + ); + if (this.deadlineExpired(feedId, "confirmation", deadlineAt)) { + return { + ...base, + txHash: confirmation.txHash, + outcome: confirmation.outcome, + error: "Feed publication confirmation arrived after its deadline", + }; + } + + 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 }; + } + } + + private deadlineExpired(feedId: string, stage: string, deadlineAt?: number): boolean { + if (deadlineAt === undefined) { + return false; + } + const overrunMs = Date.now() - deadlineAt; + if (overrunMs < 0) { + return false; + } + this.metricsService?.recordOracleDeadlineBreach(feedId, stage, Math.max(0, overrunMs / 1000)); + return true; + } + + private async withinDeadline( + feedId: string, + stage: string, + deadlineAt: number | undefined, + operation: () => Promise, + ): Promise { + if (deadlineAt === undefined) { + return operation(); + } + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + this.deadlineExpired(feedId, stage, deadlineAt); + throw new Error(`Feed latency budget expired during ${stage}`); + } + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => { + timer = setTimeout(() => { + this.deadlineExpired(feedId, stage, deadlineAt); + reject(new Error(`Feed latency budget expired during ${stage}`)); + }, remainingMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } +} diff --git a/src/oracle/oracle-reading.ts b/src/oracle/oracle-reading.ts index 6dd9583..5da621f 100644 --- a/src/oracle/oracle-reading.ts +++ b/src/oracle/oracle-reading.ts @@ -7,6 +7,10 @@ export interface OracleReading { threshold: number; severity: Severity; message: string; + feedId?: string; + observedAt?: number; + deadlineAt?: number; + fetchDurationMs?: 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-trust.module.ts b/src/oracle/oracle-trust.module.ts new file mode 100644 index 0000000..f9988ea --- /dev/null +++ b/src/oracle/oracle-trust.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { OracleTrustService } from "./oracle-trust.service"; + +@Global() +@Module({ + providers: [OracleTrustService], + exports: [OracleTrustService], +}) +export class OracleTrustModule {} diff --git a/src/oracle/oracle-trust.service.ts b/src/oracle/oracle-trust.service.ts new file mode 100644 index 0000000..107b733 --- /dev/null +++ b/src/oracle/oracle-trust.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Optional } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "../config/configuration"; +import { + COVERAGE_REQUIRED_FEEDS, + ORACLE_FEEDS, + OracleFeedId, +} from "../config/oracle-feed.config"; + +export interface FeedTrustStatus { + feedId: string; + healthy: boolean; + confirmed: boolean; + quorumRequired: number; + quorumAvailable: number; + observedAt: number | null; + confirmedAt: number | null; + reason?: string; +} + +export interface CoverageAvailability { + available: boolean; + requiredFeeds: string[]; + unavailableFeeds: string[]; + reason?: string; +} + +@Injectable() +export class OracleTrustService { + private readonly feedStatuses = new Map(); + private publisherConfigured = false; + + constructor( + @Optional() private readonly configService?: ConfigService, + ) {} + + setPublisherConfigured(configured: boolean): void { + this.publisherConfigured = configured; + } + + recordObservation( + feedId: string, + healthy: boolean, + reason?: string, + observedAt: number = Date.now(), + ): void { + const configuration = ORACLE_FEEDS[feedId as OracleFeedId]; + if (!configuration) { + throw new Error(`No oracle feed configuration exists for ${feedId}`); + } + this.feedStatuses.set(feedId, { + feedId, + healthy, + confirmed: false, + quorumRequired: configuration.requiredQuorum, + quorumAvailable: healthy ? 1 : 0, + observedAt, + confirmedAt: null, + reason: healthy ? undefined : reason ?? "source is unreachable or payload was rejected", + }); + } + + recordPublication(feedId: string, confirmed: boolean, reason?: string): void { + const current = this.feedStatuses.get(feedId); + if (!current) { + return; + } + this.feedStatuses.set(feedId, { + ...current, + confirmed, + confirmedAt: confirmed ? Date.now() : null, + reason: confirmed ? undefined : reason ?? "on-chain publication is not confirmed", + }); + } + + feedStatus(feedId: string): FeedTrustStatus { + const existing = this.feedStatuses.get(feedId); + if (existing) return { ...existing }; + return { + feedId, + healthy: false, + confirmed: false, + quorumRequired: ORACLE_FEEDS[feedId as OracleFeedId]?.requiredQuorum ?? 1, + quorumAvailable: 0, + observedAt: null, + confirmedAt: null, + reason: "no accepted observation or on-chain confirmation", + }; + } + + canPublish(feedId: string): boolean { + const status = this.feedStatus(feedId); + const budget = ORACLE_FEEDS[feedId as OracleFeedId]?.latencyBudgetMs; + return ( + budget !== undefined && + status.healthy && + status.quorumAvailable >= status.quorumRequired && + status.observedAt !== null && + Date.now() - status.observedAt <= budget + ); + } + + getCoverageAvailability(coverageType: string): CoverageAvailability { + const requiredFeeds = + COVERAGE_REQUIRED_FEEDS[coverageType as keyof typeof COVERAGE_REQUIRED_FEEDS]; + if (!requiredFeeds) { + return { + available: false, + requiredFeeds: [], + unavailableFeeds: [], + reason: "coverage type has no configured feed dependencies", + }; + } + const unavailableFeeds = requiredFeeds.filter((feedId) => { + const status = this.feedStatus(feedId); + const budget = ORACLE_FEEDS[feedId]?.latencyBudgetMs ?? 0; + const isFresh = status.observedAt !== null && Date.now() - status.observedAt <= budget; + return ( + !status.healthy || + !status.confirmed || + status.quorumAvailable < status.quorumRequired || + !isFresh + ); + }); + return { + available: unavailableFeeds.length === 0, + requiredFeeds: [...requiredFeeds], + unavailableFeeds, + reason: + unavailableFeeds.length === 0 + ? undefined + : `required feed(s) unavailable or unconfirmed: ${unavailableFeeds.join(", ")}`, + }; + } + + readiness(): { + sourcesReachable: boolean; + quorumSatisfiable: boolean; + publisherConfigured: boolean; + feeds: FeedTrustStatus[]; + trustMode: "strict" | "quorum" | "development"; + } { + const trustMode = parseTrustMode( + this.configService?.get("oracleTrustMode", { infer: true }) ?? + process.env.ORACLE_TRUST_MODE, + ); + const entries = Object.entries(ORACLE_FEEDS).filter( + ([, config]) => trustMode !== "development" || config.sourceType !== "mock", + ); + const feeds = entries.map(([feedId, configuration]) => { + const status = this.feedStatus(feedId); + const stale = + status.observedAt === null || + Date.now() - status.observedAt > configuration.latencyBudgetMs; + return stale + ? { + ...status, + healthy: false, + quorumAvailable: 0, + reason: "source observation is missing or stale", + } + : status; + }); + const sourcesReachable = feeds.every((feed) => feed.healthy); + const quorumSatisfiable = feeds.every( + (feed) => feed.quorumAvailable >= feed.quorumRequired, + ); + return { + sourcesReachable, + quorumSatisfiable, + publisherConfigured: this.publisherConfigured, + feeds, + trustMode, + }; + } +} + +function parseTrustMode(value: string | undefined): "strict" | "quorum" | "development" { + if (value === "quorum" || value === "development") return value; + return "strict"; +} 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.gateway.ts b/src/oracle/oracle.gateway.ts index 5b65954..a1039b2 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,19 +15,47 @@ 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'); + client.send(JSON.stringify({ + type: 'connected', + message: 'Refract oracle feed connected', + timestamp: new Date().toISOString(), + })); client.on('error', (err) => { this.logger.warn(`Oracle WebSocket client error: ${err.message}`); }); } - handleDisconnect(client: WebSocket): void { + handleDisconnect(_client: WebSocket): void { this.logger.log('Oracle WebSocket client disconnected'); } + broadcastAlert(reading: OracleReading): void { + if (!this.server?.clients) { + return; + } + const message = JSON.stringify({ + type: 'oracle_alert', + data: reading, + timestamp: new Date().toISOString(), + }); + for (const client of this.server.clients) { + if (client.readyState !== WebSocket.OPEN) { + continue; + } + try { + client.send(message); + } catch (error) { + this.logger.warn( + `Failed to send oracle alert: ${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 e69de29..f859e8b 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { StellarModule } from "../stellar/stellar.module"; +import { MetricsModule } from "../metrics/metrics.module"; +import { OracleTrustModule } from "./oracle-trust.module"; +import { HealthModule } from "../health/health.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, MetricsModule, OracleTrustModule, HealthModule], + 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..582c873 100644 --- a/src/oracle/oracle.scheduler.spec.ts +++ b/src/oracle/oracle.scheduler.spec.ts @@ -0,0 +1,109 @@ +import { OracleScheduler } from "./oracle.scheduler"; +import { OracleService } from "./oracle.service"; +import { OracleGateway } from "./oracle.gateway"; +import { OracleReading } from "./oracle-reading"; +import { HealthService } from "../health/health.service"; + +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 = { checkAllFresh: 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.checkAllFresh.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.checkAllFresh.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.checkAllFresh.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(); + }); + + it("aborts an expired consensus result without publishing or broadcasting it", async () => { + const { oracleService, oracleGateway, oraclePublisher } = buildServices(); + oracleService.checkAllFresh.mockResolvedValue([ + buildReading({ + severity: "triggered", + feedId: "coingecko-usdc", + observedAt: Date.now() - 2_000, + deadlineAt: Date.now() - 1, + }), + ]); + const scheduler = new OracleScheduler(oracleService, oracleGateway, oraclePublisher); + + await scheduler.pollOracles(); + + expect(oraclePublisher.maybePublish).not.toHaveBeenCalled(); + expect(oracleGateway.broadcastAlert).not.toHaveBeenCalled(); + }); + + it("does not publish while RPC or evidence storage is not ready", async () => { + const { oracleService, oracleGateway, oraclePublisher } = buildServices(); + oracleService.checkAllFresh.mockResolvedValue([buildReading()]); + const health = { + getInfrastructureReadiness: jest.fn().mockResolvedValue({ + ready: false, + checks: { rpcHealthy: false, evidenceStoreWritable: true, publisherConfigured: true }, + }), + } as unknown as HealthService; + const scheduler = new OracleScheduler( + oracleService, + oracleGateway, + oraclePublisher, + undefined, + health, + ); + + await scheduler.pollOracles(); + + expect(oraclePublisher.maybePublish).not.toHaveBeenCalled(); + expect(oracleGateway.broadcastAlert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/oracle/oracle.scheduler.ts b/src/oracle/oracle.scheduler.ts index 3332c6d..a11351e 100644 --- a/src/oracle/oracle.scheduler.ts +++ b/src/oracle/oracle.scheduler.ts @@ -1,50 +1,97 @@ -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'; +import { Injectable, Logger, Optional } from "@nestjs/common"; +import { Interval } from "@nestjs/schedule"; +import { MetricsService } from "../metrics/metrics.service"; +import { HealthService } from "../health/health.service"; +import { OracleGateway } from "./oracle.gateway"; +import { OraclePublisherService } from "./oracle-publisher.service"; +import { OracleReading } from "./oracle-reading"; +import { OracleService } from "./oracle.service"; -const ORACLE_REFRESH_JOB_NAME = 'oracle-refresh'; +const DEFAULT_POLL_INTERVAL_MS = 60_000; + +function pollIntervalMs(): number { + const value = Number(process.env.ORACLE_SCHEDULER_INTERVAL_MS ?? DEFAULT_POLL_INTERVAL_MS); + if (!Number.isInteger(value) || value < 1_000 || value > 3_600_000) { + throw new Error("ORACLE_SCHEDULER_INTERVAL_MS must be an integer between 1000 and 3600000"); + } + return value; +} @Injectable() -export class OracleScheduler implements OnModuleInit, OnModuleDestroy { +export class OracleScheduler { private readonly logger = new Logger(OracleScheduler.name); + private polling = false; constructor( private readonly oracleService: OracleService, - private readonly schedulerRegistry: SchedulerRegistry, - private readonly protocolParameters: ProtocolParametersService, + private readonly oracleGateway: OracleGateway, + private readonly oraclePublisher: OraclePublisherService, + @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly healthService?: HealthService, ) {} - async onModuleInit(): Promise { - const intervalSeconds = await this.protocolParameters.getNumber( - ProtocolParameterKey.ORACLE_SCHEDULER_INTERVAL_SECONDS, - ); + @Interval(pollIntervalMs()) + async pollOracles(): Promise { + if (this.polling) { + this.logger.warn("Skipping oracle poll because the previous round is still running"); + return; + } + this.polling = true; + const startedAt = Date.now(); + let outcome: "success" | "failure" = "success"; - const job = new CronJob(`*/${intervalSeconds} * * * * *`, () => { - void this.refresh(); - }); + try { + const readings = await this.oracleService.checkAllFresh(); + const infrastructure = await this.healthService?.getInfrastructureReadiness(); + if (infrastructure && !infrastructure.ready) { + this.logger.warn("Skipping oracle publications while required infrastructure is not ready"); + return; + } + await Promise.all(readings.map((reading) => this.processReading(reading))); + } catch (error) { + outcome = "failure"; + this.logger.error("Oracle refresh failed", error instanceof Error ? error.stack : String(error)); + } finally { + this.polling = false; + this.metricsService?.recordSchedulerRun( + "oracle", + outcome, + (Date.now() - startedAt) / 1000, + ); + } + } - this.schedulerRegistry.addCronJob(ORACLE_REFRESH_JOB_NAME, job); - job.start(); + private async processReading(reading: OracleReading): Promise { + const feed = reading.feedId ?? reading.coverageType; + const observedAt = reading.observedAt ?? Date.now(); + const deadlineAt = reading.deadlineAt ?? observedAt + 30_000; + if (reading.degraded) { + this.logger.warn(`Skipping degraded ${feed} reading`); + return; + } + if (reading.fetchDurationMs !== undefined) { + this.metricsService?.recordOracleStage(feed, "source", reading.fetchDurationMs / 1000); + } - this.logger.log( - `Oracle refresh scheduler started with ${intervalSeconds}s interval`, - ); - } + const consensusDuration = (Date.now() - observedAt) / 1000; + this.metricsService?.recordOracleStage(feed, "consensus", consensusDuration); + if (Date.now() >= deadlineAt) { + this.metricsService?.recordOracleDeadlineBreach( + feed, + "consensus", + (Date.now() - deadlineAt) / 1000, + ); + this.logger.warn(`Aborting stale ${feed} reading before publication`); + return; + } - onModuleDestroy(): void { - if (this.schedulerRegistry.doesExist('cron', ORACLE_REFRESH_JOB_NAME)) { - this.schedulerRegistry.deleteCronJob(ORACLE_REFRESH_JOB_NAME); + await this.oraclePublisher.maybePublish(reading, deadlineAt); + if (Date.now() >= deadlineAt) { + return; } - } - private async refresh(): Promise { - try { - await this.oracleService.refreshPrices(); - } catch (error) { - this.logger.error('Oracle refresh failed', error as Error); + if (reading.severity !== "low") { + this.oracleGateway.broadcastAlert(reading); } } } 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..4c63714 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -0,0 +1,361 @@ +import { Injectable, Logger, Optional } 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 { + COVERAGE_REQUIRED_FEEDS, + feedLatencyBudgetMs, +} from "../config/oracle-feed.config"; +import { FeedPayloadValidator } from "./feed-payload.validator"; +import { OracleTrustService } from "./oracle-trust.service"; +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; + private readonly feedValidator = new FeedPayloadValidator(); + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + @Optional() private readonly oracleTrustService?: OracleTrustService, + ) { + 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(); + for (const reading of readings) { + const feedId = reading.feedId ?? feedIdForCoverage(reading.coverageType); + if (feedId) { + this.oracleTrustService?.recordObservation( + feedId, + !reading.degraded, + reading.degraded ? reading.message : undefined, + reading.observedAt, + ); + } + } + // 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 startedAt = Date.now(); + const threshold = 0.95; // 5% depeg + try { + const response = await axios.get>( + `${this.coingeckoBaseUrl}/simple/price`, + { params: { ids: "usd-coin", vs_currencies: "usd" }, timeout: this.timeoutMs } + ); + const observedAt = Date.now(); + const { data } = response; + const usdcPrice = data["usd-coin"].usd; + this.feedValidator.validateAndRemember("coingecko-usdc", { + price: usdcPrice, + unit: "USD", + }); + const deviation = (1 - usdcPrice) * 100; + + const severity: Severity = + usdcPrice < threshold ? "triggered" + : usdcPrice < 0.98 ? "high" + : usdcPrice < 0.99 ? "medium" + : "low"; + + return this.withFeedTiming({ + 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]`, + }, "coingecko-usdc", startedAt, observedAt); + } catch (err) { + return this.degraded("StablecoinDepeg", threshold, "CoinGecko", err); + } + } + + async checkMarketCrash(): Promise { + const startedAt = Date.now(); + 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, + } + ).then((response) => ({ ...response, observedAt: Date.now() })); + // 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; + this.feedValidator.validateAndRemember("coingecko-stellar", { + price: priceRes.data.stellar.usd, + change24h, + unit: "USD", + changeUnit: "percent", + }); + 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 this.withFeedTiming({ + coverageType: "MarketCrash", + type: "oracle_update", + value: change24h, + threshold, + severity, + message: `XLM 24h change: ${change24h.toFixed(2)}% (trigger at ${threshold}%)${chainContext}`, + }, "coingecko-stellar", startedAt, priceRes.observedAt); + } catch (err) { + return this.degraded("MarketCrash", threshold, "CoinGecko", err); + } + } + + async checkSmartContractRisk(): Promise { + const startedAt = Date.now(); + const threshold = -50; // 50% TVL drop in 24h triggers + try { + const response = await axios.get<{ name?: string; tvl?: DefiLlamaTvlPoint[] }>( + `${this.defiLlamaBaseUrl}/protocol/${this.defiLlamaProtocolSlug}`, + { timeout: this.timeoutMs } + ); + const observedAt = Date.now(); + const { data } = response; + this.feedValidator.validateAndRemember("defillama-aave", { + tvl: data.tvl, + unit: "USD", + }); + 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 this.withFeedTiming({ + coverageType: "SmartContractRisk", + type: "oracle_update", + value: pctChange, + threshold, + severity, + message: `${data.name ?? this.defiLlamaProtocolSlug} TVL 24h change: ${pctChange.toFixed(2)}% (trigger at ${threshold}%) [DeFiLlama]`, + }, "defillama-aave", startedAt, observedAt); + } 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 + this.feedValidator.validateAndRemember("nexus-liquidations", { + collateralRatio, + status: collateralRatio < threshold ? "liquidated" : "healthy", + unit: "ratio", + }); + + return this.withFeedTiming({ + 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]`, + degraded: true, + }, "nexus-liquidations", Date.now()); + } + + 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 + this.feedValidator.validateAndRemember("aviationstack", { + flightNumber, + delayMinutes, + status: delayMinutes >= threshold ? "delayed" : "scheduled", + unit: "minutes", + }); + + return this.withFeedTiming({ + 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]`, + degraded: true, + }, "aviationstack", Date.now()); + } + + private withFeedTiming( + reading: OracleReading, + feedId: string, + startedAt: number, + observedAt: number = Date.now(), + ): OracleReading { + const budgets = this.configService.get("oracles", { infer: true }).feedLatencyBudgetsMs; + return { + ...reading, + feedId, + observedAt, + fetchDurationMs: observedAt - startedAt, + deadlineAt: observedAt + feedLatencyBudgetMs(feedId, budgets), + }; + } + + /** + * 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`, + feedId: feedIdForCoverage(coverageType), + degraded: true, + }; + } +} + +function feedIdForCoverage(coverageType: string): string | undefined { + return COVERAGE_REQUIRED_FEEDS[ + coverageType as keyof typeof COVERAGE_REQUIRED_FEEDS + ]?.[0]; +} diff --git a/src/policy/dto/buy-policy.dto.ts b/src/policy/dto/buy-policy.dto.ts index e69de29..70b1388 100644 --- a/src/policy/dto/buy-policy.dto.ts +++ b/src/policy/dto/buy-policy.dto.ts @@ -0,0 +1,38 @@ +import { + IsInt, + IsObject, + IsOptional, + IsString, + Length, + Matches, + Max, + Min, +} from "class-validator"; +import { IsStellarPublicKey } from "../../common/validators/stellar-address.validator"; + +export class BuyPolicyDto { + @IsString() + @Length(56, 56) + @IsStellarPublicKey() + holder!: string; + + @IsInt() + @Min(0) + @Max(4) + coverageType!: number; + + @IsString() + @Matches(/^\d+$/) + coverageAmount!: string; + + @IsInt() + @Min(1) + @Max(365) + durationDays!: number; + + @IsOptional() + @IsObject() + triggerParams?: Record; +} + +export class PreflightPolicyDto extends BuyPolicyDto {} diff --git a/src/policy/policy.module.ts b/src/policy/policy.module.ts index e69de29..3d97672 100644 --- a/src/policy/policy.module.ts +++ b/src/policy/policy.module.ts @@ -0,0 +1,14 @@ +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"; +import { OracleTrustModule } from "../oracle/oracle-trust.module"; + +@Module({ + imports: [StellarModule, OracleTrustModule], + 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/policy/policy.service.spec.ts b/src/policy/policy.service.spec.ts index 887f79b..650a4e1 100644 --- a/src/policy/policy.service.spec.ts +++ b/src/policy/policy.service.spec.ts @@ -14,6 +14,7 @@ import { import { AppConfig } from "../config/configuration"; import { MetricsService } from "../metrics/metrics.service"; import { PolicyService } from "./policy.service"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; import { BuyPolicyDto } from "./dto/buy-policy.dto"; const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; @@ -381,5 +382,24 @@ describe("PolicyService", () => { it("returns all five coverage types", () => { expect(service.listTypes()).toHaveLength(5); }); + + it("marks products unavailable and rejects buying until their required feed is confirmed", async () => { + const trust = new OracleTrustService(); + const guardedService = new PolicyService( + buildConfig(), + new MetricsService(), + undefined, + trust, + ); + + expect(guardedService.listTypes()[0].available).toBe(false); + await expect(guardedService.buy(buildDto(holder))).rejects.toBeInstanceOf( + BadRequestException, + ); + + trust.recordObservation("coingecko-usdc", true); + trust.recordPublication("coingecko-usdc", true); + expect(guardedService.listTypes()[0].available).toBe(true); + }); }); }); diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index eacb1a0..3f650c8 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, Optional } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; import { ConfigService } from "@nestjs/config"; import { Account, @@ -12,10 +13,10 @@ import { 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"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; const FLIGHT_DELAY_COVERAGE_TYPE = 4; @@ -51,6 +52,9 @@ export interface CoverageTypeCatalogEntry { maxCoverage: number; trigger: string; icon: string; + available: boolean; + requiredFeeds: string[]; + unavailableReason?: string; } export interface StoredPolicy { @@ -107,6 +111,8 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ maxCoverage: 100_000, trigger: "USDC price < $0.95 for 15+ minutes", icon: "🪙", + available: true, + requiredFeeds: [], }, { id: 1, @@ -118,6 +124,8 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ maxCoverage: 50_000, trigger: "Market index 24h return < -30%", icon: "📉", + available: true, + requiredFeeds: [], }, { id: 2, @@ -129,6 +137,8 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ maxCoverage: 200_000, trigger: "Collateral ratio drops below maintenance threshold", icon: "🛡️", + available: true, + requiredFeeds: [], }, { id: 3, @@ -140,6 +150,8 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ maxCoverage: 500_000, trigger: "Covered protocol TVL drops >50% in <1 hour", icon: "🔐", + available: true, + requiredFeeds: [], }, { id: 4, @@ -151,6 +163,8 @@ const COVERAGE_TYPES: CoverageTypeCatalogEntry[] = [ maxCoverage: 2_000, trigger: "Flight delayed > 120 minutes per AviationStack data", icon: "✈️", + available: true, + requiredFeeds: [], }, ]; @@ -167,7 +181,8 @@ export class PolicyService { constructor( private readonly configService: ConfigService, private readonly metricsService: MetricsService, - private readonly parameters?: ProtocolParametersReader + private readonly parameters?: ProtocolParametersReader, + @Optional() private readonly oracleTrust?: OracleTrustService, ) { const stellar = this.configService.get("stellar", { infer: true }); this.server = new rpc.Server(stellar.sorobanRpcUrl); @@ -276,7 +291,19 @@ export class PolicyService { * calls set_pool_config() — which the catalog alone can't see. */ async onChainCoverageBounds(): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { - if (!this.poolCo + if (!this.poolContractId) { + return null; + } + try { + const dummySource = new Account(Keypair.random().publicKey(), "0"); + const contract = new Contract(this.poolContractId); + const tx = new TransactionBuilder(dummySource, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call("pool_config")) + .setTimeout(30) + .build(); const sim = await this.metricsService.observeSorobanRpc("simulate_transaction", () => this.server.simulateTransaction(tx) @@ -297,7 +324,15 @@ export class PolicyService { } listTypes(): CoverageTypeCatalogEntry[] { - return COVERAGE_TYPES; + return COVERAGE_TYPES.map((entry) => { + const availability = this.oracleTrust?.getCoverageAvailability(COVERAGE_TYPE_VARIANTS[entry.id]); + return { + ...entry, + available: availability?.available ?? true, + requiredFeeds: availability?.requiredFeeds ?? [], + unavailableReason: availability?.reason, + }; + }); } findByHolder(address: string): StoredPolicy[] { @@ -332,6 +367,17 @@ export class PolicyService { error: `coverageType must be between 0 and ${COVERAGE_TYPES.length - 1}`, }); } + const coverageAvailability = this.oracleTrust?.getCoverageAvailability( + COVERAGE_TYPE_VARIANTS[coverageType], + ); + if (coverageAvailability && !coverageAvailability.available) { + throw new BadRequestException({ + error: `${catalogEntry.name} coverage is unavailable`, + requiredFeeds: coverageAvailability.requiredFeeds, + unavailableFeeds: coverageAvailability.unavailableFeeds, + reason: coverageAvailability.reason, + }); + } if (coverageType === FLIGHT_DELAY_COVERAGE_TYPE && typeof triggerParams?.flightNumber !== "string") { throw new BadRequestException({ @@ -373,12 +419,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 = { 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/quote/quote.module.ts b/src/quote/quote.module.ts index 6bb392c..5427f28 100644 --- a/src/quote/quote.module.ts +++ b/src/quote/quote.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { PoolModule } from "../pool/pool.module"; +import { OracleTrustModule } from "../oracle/oracle-trust.module"; import { QuoteController } from "./quote.controller"; import { QuoteService } from "./quote.service"; @Module({ - imports: [PoolModule], + imports: [PoolModule, OracleTrustModule], controllers: [QuoteController], providers: [QuoteService], exports: [QuoteService], diff --git a/src/quote/quote.service.spec.ts b/src/quote/quote.service.spec.ts index b32910e..ede0aaf 100644 --- a/src/quote/quote.service.spec.ts +++ b/src/quote/quote.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from "@nestjs/common"; import { QuoteService } from "./quote.service"; import { CoverageTypeName } from "./coverage-type"; import { CreateQuoteDto } from "./dto/create-quote.dto"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; function buildDto(overrides: Partial = {}): CreateQuoteDto { return { @@ -23,6 +24,21 @@ describe("QuoteService", () => { it("returns all five coverage types", () => { expect(service.listCoverageTypes()).toHaveLength(5); }); + + it("marks a coverage unavailable until its required feed is healthy and confirmed", () => { + const trust = new OracleTrustService(); + const guardedService = new QuoteService(trust); + + expect(guardedService.listCoverageTypes()[0].available).toBe(false); + expect(() => + guardedService.createQuote(buildDto()), + ).toThrow(BadRequestException); + + trust.recordObservation("coingecko-usdc", true); + trust.recordPublication("coingecko-usdc", true); + expect(guardedService.listCoverageTypes()[0].available).toBe(true); + expect(() => guardedService.createQuote(buildDto())).not.toThrow(); + }); }); describe("createQuote", () => { diff --git a/src/quote/quote.service.ts b/src/quote/quote.service.ts index 0291303..00be0b9 100644 --- a/src/quote/quote.service.ts +++ b/src/quote/quote.service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, Optional } from "@nestjs/common"; import { COVERAGE_TYPES, coverageTypeByName } from "../common/coverage-types"; import { CoverageTypeName } from "./coverage-type"; import { CompareQuotesDto } from "./dto/compare-quotes.dto"; import { CreateQuoteDto } from "./dto/create-quote.dto"; +import { OracleTrustService } from "../oracle/oracle-trust.service"; export interface QuoteResult { coverageType: CoverageTypeName; @@ -39,6 +40,9 @@ export interface CoverageTypeInfo { riskLevel: string; maxDuration: number; riskMultiplier: number; + available: boolean; + requiredFeeds: string[]; + unavailableReason?: string; } const DEFAULT_THRESHOLDS: Record = { @@ -53,6 +57,8 @@ const BASE_RATE = 0.03; // 3% annual base premium @Injectable() export class QuoteService { + constructor(@Optional() private readonly oracleTrust?: OracleTrustService) {} + private calcPremium(coverageAmount: number, riskMultiplier: number, durationDays: number): number { const annualPremium = coverageAmount * BASE_RATE * riskMultiplier; const dailyPremium = annualPremium / 365; @@ -70,6 +76,15 @@ export class QuoteService { if (!catalogEntry) { throw new BadRequestException({ error: `Unknown coverage type: ${coverageType}` }); } + const availability = this.oracleTrust?.getCoverageAvailability(coverageType); + if (availability && !availability.available) { + throw new BadRequestException({ + error: `${catalogEntry.name} coverage is unavailable`, + requiredFeeds: availability.requiredFeeds, + unavailableFeeds: availability.unavailableFeeds, + reason: availability.reason, + }); + } if (durationDays > catalogEntry.maxDuration) { throw new BadRequestException({ error: `${catalogEntry.name} coverage is limited to ${catalogEntry.maxDuration} day(s)`, @@ -119,14 +134,22 @@ export class QuoteService { } listCoverageTypes(): CoverageTypeInfo[] { - return COVERAGE_TYPES.map(({ key, name, description, trigger, riskLevel, maxDuration, riskMultiplier }) => ({ - id: key, - name, - description, - trigger, - riskLevel, - maxDuration, - riskMultiplier, - })); + return COVERAGE_TYPES.map( + ({ key, name, description, trigger, riskLevel, maxDuration, riskMultiplier }) => { + const availability = this.oracleTrust?.getCoverageAvailability(key); + return { + id: key, + name, + description, + trigger, + riskLevel, + maxDuration, + riskMultiplier, + available: availability?.available ?? true, + requiredFeeds: availability?.requiredFeeds ?? [], + unavailableReason: availability?.reason, + }; + }, + ); } } 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, + }; + } + } +}