Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 335 additions & 117 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
],
"scripts": {
"build": "nest build",
"dev": "nodemon --exec ts-node src/main.ts",
"start": "nest start",
"start:dev": "nest start --watch",
"dev": "nest start --watch",
"start:prod": "node dist/main",
"start:worker": "nest start --entryFile worker",
"start:worker:dev": "nest start --watch --entryFile worker",
"start:worker:prod": "node dist/worker",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "jest",
"test": "npm run test:unit && npm run test:contracts",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:property": "jest --testRegex '.*\\.property\\.spec\\.ts$'",
Expand Down
30 changes: 15 additions & 15 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { isOracleSandboxEnabled } from './oracle/sandbox/oracle-sandbox.config';
import { FeedsModule } from './feeds/feeds.module';
import { OperatorsModule } from './routes/operators';
import { StakingModule } from './routes/staking';
import { EventsModule } from './events/events.module';
import { MetricsModule } from './metrics/metrics.module';

/**
* Sandbox mode lets the full API run with zero external dependencies
Expand All @@ -53,23 +55,19 @@ export const isSandboxMode = (): boolean =>

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
throttlers: [
{ name: "upstream", ...config.get("throttle.upstream", { infer: true }) },
{ name: "chain", ...config.get("throttle.chain", { infer: true }) },
{ name: "default", ...config.get("throttle.default", { infer: true }) },
{ name: "catalog", ...config.get("throttle.catalog", { infer: true }) },
],
}),
}),
ScheduleModule.forRoot(),
// In sandbox mode we skip the real Postgres connection entirely so
// `npm run dev` works right after `npm install` with no other setup.
...(isSandboxMode()
? []
: [
TypeOrmModule.forRootAsync({
imports: [DatabaseModule],
inject: [DatabaseModule],
useFactory: (databaseModule: DatabaseModule) =>
databaseModule.getTypeOrmConfig(),
}),
]),
DatabaseModule,
IdempotencyModule,
// CacheModule is @Global — imported once here, available everywhere.
Expand All @@ -79,6 +77,8 @@ export const isSandboxMode = (): boolean =>
StellarModule,
DbModule,
HealthModule,
MetricsModule,
EventsModule,
QuoteModule,
PolicyModule,
PoolModule,
Expand Down
19 changes: 1 addition & 18 deletions src/cache/cache.module.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service";

/**
* CacheModule is marked @Global so any feature module that imports
* AppModule (which imports CacheModule) can inject CacheService without
* explicitly importing CacheModule itself.
*
* When SANDBOX_MODE is enabled, CacheService is registered with an
* in-memory backing store so no real Redis instance is required. The
* sandbox flag is read here (at module registration time) rather than
* branching inside business logic, keeping the swap point in one place.
*/
const SANDBOX_MODE = process.env.SANDBOX_MODE === "true";

@Global()
@Module({
providers: [
{
provide: CacheService,
useFactory: () => new CacheService({ sandbox: SANDBOX_MODE }),
},
],
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
103 changes: 66 additions & 37 deletions src/cache/cache.service.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,77 @@
import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { Injectable, Logger, OnApplicationShutdown, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";
import { AppConfig } from "../config/configuration";

export const ORACLE_TTL = 55;

@Injectable()
export class CacheService implements OnApplicationShutdown {
export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicationShutdown {
private readonly logger = new Logger(CacheService.name);
private readonly client: Redis;
private closed = false;

constructor(private readonly configService: ConfigService) {
const url = this.configService.get<string>('REDIS_URL');
this.client = url
? new Redis(url, { lazyConnect: false })
: new Redis({
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
port: this.configService.get<number>('REDIS_PORT', 6379),
password: this.configService.get<string>('REDIS_PASSWORD'),
});

this.client.on('error', (err) => {
this.logger.error(`Redis client error: ${err.message}`);
});
constructor(configService: ConfigService<AppConfig, true>) {
const configured = configService.get("redis", { infer: true });
const url = process.env.REDIS_URL ?? configured.url;
this.client = new Redis(url, { lazyConnect: false });
this.client.on("error", (err) => this.logger.error(`Redis client error: ${err.message}`));
}

onModuleInit(): void {}

async get<T>(key: string): Promise<T | null> {
const value = await this.client.get(key);
return value ? (JSON.parse(value) as T) : null;
try {
const value = await this.client.get(key);
return value === null ? null : (JSON.parse(value) as T);
} catch (error) {
this.logger.warn(`Redis cache read failed for ${key}: ${this.errorMessage(error)}`);
return null;
}
}

async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
const serialized = JSON.stringify(value);
if (ttlSeconds) {
await this.client.set(key, serialized, 'EX', ttlSeconds);
} else {
await this.client.set(key, serialized);
try {
const serialized = JSON.stringify(value);
if (ttlSeconds) await this.client.set(key, serialized, "EX", ttlSeconds);
else await this.client.set(key, serialized);
} catch (error) {
this.logger.warn(`Redis cache write failed for ${key}: ${this.errorMessage(error)}`);
}
}

async del(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.client.del(...keys);
} catch (error) {
this.logger.warn(`Redis cache delete failed: ${this.errorMessage(error)}`);
}
}

async ping(): Promise<boolean> {
try {
return (await this.client.ping()) === "PONG";
} catch (error) {
this.logger.warn(`Redis health check failed: ${this.errorMessage(error)}`);
return false;
}
}

async del(key: string): Promise<void> {
await this.client.del(key);
isConnected(): boolean {
return this.client.status === "ready";
}

async wrap<T>(key: string, ttlSeconds: number, loader: () => Promise<T>): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await loader();
await this.set(key, value, ttlSeconds);
return value;
if (cached !== null) return cached;
const fresh = await loader();
await this.set(key, fresh, ttlSeconds);
return fresh;
}

async onModuleDestroy(): Promise<void> {
await this.close();
}

/**
Expand All @@ -61,14 +83,21 @@ export class CacheService implements OnApplicationShutdown {
this.logger.log(
`Closing Redis connection pool${signal ? ` (signal: ${signal})` : ''}...`,
);
await this.close();
}

private async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try {
await this.client.quit();
this.logger.log('Redis connection pool closed cleanly.');
} catch (err) {
this.logger.error(
`Failed to close Redis connection pool cleanly: ${(err as Error).message}`,
);
} catch (error) {
this.logger.error(`Failed to close Redis connection: ${this.errorMessage(error)}`);
this.client.disconnect();
}
}

private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
62 changes: 56 additions & 6 deletions src/claim/claim.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
import { ClaimResult } from './claim-result';

export interface ClaimRecord {
id: string;
policyId: string;
coverageType: string;
status: string;
payoutAmount?: string | number | null;
premiumAmount?: string | number | null;
triggerDetectedAt?: Date | string | null;
processedAt?: Date | string | null;
}

export interface OnChainClaimInput {
policyUuid: string;
holder: string;
coverageType: number;
payout: bigint;
triggerValue: number;
triggerSource: string;
txHash: string;
}

const COVERAGE_TYPE_DB = [
'stablecoin_depeg',
'market_crash',
'liquidation_shield',
'smart_contract_risk',
'flight_delay',
] as const;

/**
* Per-coverage-type operational performance metrics derived from the claims
Expand Down Expand Up @@ -129,7 +157,7 @@ function assertBoundedWindow(windowStart: Date, windowEnd: Date): void {
export class ClaimRepository {
constructor(private readonly db: DatabaseService) {}

async save(result: ClaimResult): Promise<ClaimResult> {
async save(result: ClaimRecord): Promise<ClaimRecord> {
await this.db.query(
`INSERT INTO claims (id, policy_id, coverage_type, status, payout_amount, premium_amount, trigger_detected_at, processed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
Expand All @@ -153,15 +181,37 @@ export class ClaimRepository {
return result;
}

async findById(id: string): Promise<ClaimResult | null> {
const rows = await this.db.query<ClaimResult>(
async findById(id: string): Promise<ClaimRecord | null> {
const result = await this.db.query<ClaimRecord>(
`SELECT id, policy_id AS "policyId", coverage_type AS "coverageType", status,
payout_amount AS "payoutAmount", premium_amount AS "premiumAmount",
trigger_detected_at AS "triggerDetectedAt", processed_at AS "processedAt"
FROM claims WHERE id = $1`,
[id],
);
return rows[0] ?? null;
return result.rows[0] ?? null;
}

async insertFromChain(input: OnChainClaimInput): Promise<void> {
const coverageType = COVERAGE_TYPE_DB[input.coverageType];
if (!coverageType) {
throw new Error(`Unknown coverage type ordinal: ${input.coverageType}`);
}
await this.db.query(
`INSERT INTO claims
(policy_id, holder, coverage_type, payout, trigger_value, trigger_source, tx_hash)
SELECT $1, $2, $3::coverage_type, $4, $5, $6, $7
WHERE NOT EXISTS (SELECT 1 FROM claims WHERE tx_hash = $7)`,
[
input.policyUuid,
input.holder,
coverageType,
input.payout.toString(),
input.triggerValue,
input.triggerSource,
input.txHash,
],
);
}

/**
Expand Down Expand Up @@ -219,7 +269,7 @@ export class ClaimRepository {
ORDER BY t.coverage_type ASC`,
);

return rows.map((row) => {
return rows.rows.map((row) => {
const totalPremium = toNumber(row.total_premium_amount);
const totalPayout = toNumber(row.total_payout_amount);
return {
Expand Down
Loading