diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d2e5338b..da80a3fd 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -82,6 +82,15 @@ STELLAR_TIMEOUT=30000 STELLAR_RETRY_ATTEMPTS=3 STELLAR_RETRY_DELAY=1000 +# Dependency latency budget health thresholds +# Latency (ms) above which a dependency is flagged as "degraded" (HTTP 200). +# Latency (ms) above which a dependency is flagged as "hard-down" (HTTP 503). +# Leave unset to use the built-in defaults shown here. +HEALTH_HORIZON_LATENCY_DEGRADED_MS=1000 +HEALTH_HORIZON_LATENCY_HARD_DOWN_MS=4000 +HEALTH_SOROBAN_RPC_LATENCY_DEGRADED_MS=1500 +HEALTH_SOROBAN_RPC_LATENCY_HARD_DOWN_MS=5000 + # Soroban contract addresses (set after deployment; leave blank if not yet deployed) # Canonical LumenToken contract ID used by backend and exposed to web/mobile apps via the Stellar config API. STELLAR_CONTRACT_LUMEN_TOKEN= diff --git a/apps/backend/src/common/event-type-mapper.ts b/apps/backend/src/common/event-type-mapper.ts index a8a0ede1..225f6abd 100644 --- a/apps/backend/src/common/event-type-mapper.ts +++ b/apps/backend/src/common/event-type-mapper.ts @@ -1,6 +1,4 @@ -import { - CanonicalEventType, -} from './event-catalog'; +import { CanonicalEventType } from './event-catalog'; import { NotificationType } from '../notification/notification.entity'; import { WebhookEventType } from '../webhook/webhook.types'; @@ -23,11 +21,15 @@ const CANONICAL_TO_NOTIFICATION: Record = { [CanonicalEventType.MILESTONE_VOTE_STARTED]: NotificationType.MILESTONE, [CanonicalEventType.MILESTONE_VOTE_CAST]: NotificationType.MILESTONE, [CanonicalEventType.GOVERNANCE_PROPOSAL_CREATED]: NotificationType.GOVERNANCE, - [CanonicalEventType.GOVERNANCE_PROPOSAL_EXECUTED]: NotificationType.GOVERNANCE, - [CanonicalEventType.GOVERNANCE_PROPOSAL_CANCELLED]: NotificationType.GOVERNANCE, + [CanonicalEventType.GOVERNANCE_PROPOSAL_EXECUTED]: + NotificationType.GOVERNANCE, + [CanonicalEventType.GOVERNANCE_PROPOSAL_CANCELLED]: + NotificationType.GOVERNANCE, [CanonicalEventType.GOVERNANCE_PROPOSAL_EXPIRED]: NotificationType.GOVERNANCE, - [CanonicalEventType.GOVERNANCE_SIGNATURE_COLLECTED]: NotificationType.GOVERNANCE, - [CanonicalEventType.GOVERNANCE_MULTISIG_CONFIGURED]: NotificationType.GOVERNANCE, + [CanonicalEventType.GOVERNANCE_SIGNATURE_COLLECTED]: + NotificationType.GOVERNANCE, + [CanonicalEventType.GOVERNANCE_MULTISIG_CONFIGURED]: + NotificationType.GOVERNANCE, [CanonicalEventType.TOKEN_BURNED]: NotificationType.TOKEN, [CanonicalEventType.TOKEN_VESTING_CREATED]: NotificationType.TOKEN, [CanonicalEventType.TOKEN_CLAIMED]: NotificationType.TOKEN, diff --git a/apps/backend/src/config/config.service.ts b/apps/backend/src/config/config.service.ts index ca5529b3..82d9cf2a 100644 --- a/apps/backend/src/config/config.service.ts +++ b/apps/backend/src/config/config.service.ts @@ -44,29 +44,25 @@ export class ConfigService { networkPassphrase: NETWORK_PASSPHRASES[network], contracts: { lumenToken: - (overrides.lumenToken as string | undefined) ?? - config.stellar.contracts.lumenToken ?? - null, + overrides.lumenToken ?? config.stellar.contracts.lumenToken ?? null, crowdfundVault: - (overrides.crowdfundVault as string | undefined) ?? + overrides.crowdfundVault ?? config.stellar.contracts.crowdfundVault ?? null, projectRegistry: - (overrides.projectRegistry as string | undefined) ?? + overrides.projectRegistry ?? config.stellar.contracts.projectRegistry ?? null, contributorRegistry: - (overrides.contributorRegistry as string | undefined) ?? + overrides.contributorRegistry ?? config.stellar.contracts.contributorRegistry ?? null, matchingPool: - (overrides.matchingPool as string | undefined) ?? + overrides.matchingPool ?? config.stellar.contracts.matchingPool ?? null, treasury: - (overrides.treasury as string | undefined) ?? - config.stellar.contracts.treasury ?? - null, + overrides.treasury ?? config.stellar.contracts.treasury ?? null, }, }; } diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts index 348d0f6b..ebdec8f4 100644 --- a/apps/backend/src/health/health.controller.ts +++ b/apps/backend/src/health/health.controller.ts @@ -56,4 +56,32 @@ export class HealthController { return healthReport; } + + @Get('health/latency') + @ApiOperation({ + summary: + 'Returns latency budget health signals for Horizon and Soroban RPC', + description: + 'Probes each testnet dependency and classifies response time against ' + + 'configurable thresholds. Returns HTTP 200 for ok/degraded and HTTP 503 ' + + 'when any dependency exceeds its hard-down threshold. ' + + 'Thresholds are set via HEALTH_HORIZON_LATENCY_* and ' + + 'HEALTH_SOROBAN_RPC_LATENCY_* environment variables.', + }) + @ApiOkResponse({ + description: + 'All dependencies are within their latency budgets, or only degraded.', + }) + @ApiServiceUnavailableResponse({ + description: + 'At least one dependency has exceeded its hard-down latency threshold.', + }) + async getLatencyHealth(@Res({ passthrough: true }) response: Response) { + const report = await this.healthService.getHealthReport(); + const latencyReport = report.latencyBudget; + + response.status(latencyReport.overallState === 'hard_down' ? 503 : 200); + + return latencyReport; + } } diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts index 16295c9a..e8f38d4a 100644 --- a/apps/backend/src/health/health.module.ts +++ b/apps/backend/src/health/health.module.ts @@ -6,6 +6,7 @@ import { StellarModule } from '../stellar/stellar.module'; import { ContractHealthService } from './contract-health.service'; import { HealthController } from './health.controller'; import { HealthService } from './health.service'; +import { LatencyBudgetHealthService } from './latency-budget.health.service'; @Module({ imports: [ @@ -18,6 +19,6 @@ import { HealthService } from './health.service'; StellarModule, ], controllers: [HealthController], - providers: [HealthService, ContractHealthService], + providers: [HealthService, ContractHealthService, LatencyBudgetHealthService], }) export class HealthModule {} diff --git a/apps/backend/src/health/health.service.spec.ts b/apps/backend/src/health/health.service.spec.ts index 782cd46a..4c67c4e3 100644 --- a/apps/backend/src/health/health.service.spec.ts +++ b/apps/backend/src/health/health.service.spec.ts @@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs'; import { CacheService } from '../cache/cache.service'; import { StellarService } from '../stellar/stellar.service'; import { HealthService } from './health.service'; +import { LatencyBudgetHealthService } from './latency-budget.health.service'; describe('HealthService', () => { let service: HealthService; @@ -13,6 +14,9 @@ describe('HealthService', () => { let cacheService: { checkHealth: jest.Mock }; let stellarService: { checkHealth: jest.Mock }; let httpService: { get: jest.Mock }; + let latencyBudgetHealthService: { + getLatencyBudgetReport: jest.Mock; + }; const mockHealthIndicatorService = { check: jest.fn((key: string) => ({ @@ -25,6 +29,12 @@ describe('HealthService', () => { })), }; + const okLatencyReport = { + overallState: 'ok' as const, + checkedAt: new Date().toISOString(), + dependencies: [], + }; + beforeEach(async () => { dataSource = { query: jest.fn(), @@ -38,6 +48,9 @@ describe('HealthService', () => { httpService = { get: jest.fn(), }; + latencyBudgetHealthService = { + getLatencyBudgetReport: jest.fn().mockResolvedValue(okLatencyReport), + }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -62,11 +75,20 @@ describe('HealthService', () => { provide: HttpService, useValue: httpService, }, + { + provide: LatencyBudgetHealthService, + useValue: latencyBudgetHealthService, + }, ], }).compile(); service = module.get(HealthService); jest.clearAllMocks(); + + // Re-apply the default latency mock after clearAllMocks + latencyBudgetHealthService.getLatencyBudgetReport.mockResolvedValue( + okLatencyReport, + ); }); it('returns healthy when all critical and non-critical checks pass', async () => { @@ -83,6 +105,8 @@ describe('HealthService', () => { expect(report.details.redis.status).toBe('up'); expect(report.details.horizon.status).toBe('up'); expect(report.details.externalApis.status).toBe('up'); + expect(report.latencyBudget).toBeDefined(); + expect(report.latencyBudget.overallState).toBe('ok'); }); it('returns degraded when a non-critical dependency fails', async () => { @@ -139,4 +163,42 @@ describe('HealthService', () => { }), ); }); + + // ── Latency budget integration ───────────────────────────────────────────── + + it('returns status=error and summary=down when latency is hard_down', async () => { + dataSource.query.mockResolvedValue([{ '?column?': 1 }]); + cacheService.checkHealth.mockResolvedValue(true); + stellarService.checkHealth.mockResolvedValue(true); + httpService.get.mockReturnValue(of({ data: {} })); + latencyBudgetHealthService.getLatencyBudgetReport.mockResolvedValue({ + overallState: 'hard_down', + checkedAt: new Date().toISOString(), + dependencies: [], + }); + + const report = await service.getHealthReport(); + + expect(report.status).toBe('error'); + expect(report.summary).toBe('down'); + expect(report.latencyBudget.overallState).toBe('hard_down'); + }); + + it('returns status=ok and summary=degraded when latency is degraded', async () => { + dataSource.query.mockResolvedValue([{ '?column?': 1 }]); + cacheService.checkHealth.mockResolvedValue(true); + stellarService.checkHealth.mockResolvedValue(true); + httpService.get.mockReturnValue(of({ data: {} })); + latencyBudgetHealthService.getLatencyBudgetReport.mockResolvedValue({ + overallState: 'degraded', + checkedAt: new Date().toISOString(), + dependencies: [], + }); + + const report = await service.getHealthReport(); + + expect(report.status).toBe('ok'); + expect(report.summary).toBe('degraded'); + expect(report.latencyBudget.overallState).toBe('degraded'); + }); }); diff --git a/apps/backend/src/health/health.service.ts b/apps/backend/src/health/health.service.ts index c68f7101..2d30f9f1 100644 --- a/apps/backend/src/health/health.service.ts +++ b/apps/backend/src/health/health.service.ts @@ -10,6 +10,10 @@ import { DataSource } from 'typeorm'; import { firstValueFrom } from 'rxjs'; import { CacheService } from '../cache/cache.service'; import { StellarService } from '../stellar/stellar.service'; +import { + LatencyBudgetHealthService, + LatencyBudgetReport, +} from './latency-budget.health.service'; interface DependencyCheckResult { name: string; @@ -30,6 +34,7 @@ type HealthPayload = { export interface LumenpulseHealthReport extends HealthCheckResult { summary: 'healthy' | 'degraded' | 'down'; + latencyBudget: LatencyBudgetReport; } @Injectable() @@ -40,11 +45,13 @@ export class HealthService { private readonly cacheService: CacheService, private readonly stellarService: StellarService, private readonly httpService: HttpService, + private readonly latencyBudgetHealthService: LatencyBudgetHealthService, ) {} async getHealthReport(): Promise { - const database = await this.checkDatabase(); - const dependencyChecks = await Promise.all([ + const [database, latencyBudget, ...dependencyChecks] = await Promise.all([ + this.checkDatabase(), + this.latencyBudgetHealthService.getLatencyBudgetReport(), this.checkRedis(), this.checkHorizon(), this.checkExternalApis(), @@ -66,17 +73,23 @@ export class HealthService { } } - const status = database.isUp ? 'ok' : 'error'; - const summary = + // A hard_down latency result is treated as a critical failure (503). + const latencyIsHardDown = latencyBudget.overallState === 'hard_down'; + const latencyIsDegraded = latencyBudget.overallState === 'degraded'; + + const status = !database.isUp || latencyIsHardDown ? 'error' : 'ok'; + + const summary: LumenpulseHealthReport['summary'] = status === 'error' ? 'down' - : Object.keys(error).length > 0 + : Object.keys(error).length > 0 || latencyIsDegraded ? 'degraded' : 'healthy'; return { status, summary, + latencyBudget, info, error, details, diff --git a/apps/backend/src/health/latency-budget.config.ts b/apps/backend/src/health/latency-budget.config.ts new file mode 100644 index 00000000..fba28287 --- /dev/null +++ b/apps/backend/src/health/latency-budget.config.ts @@ -0,0 +1,54 @@ +/** + * Latency budget thresholds for testnet dependency health checks. + * + * Each dependency has two thresholds: + * - degradedMs – response time above this triggers a "degraded" signal + * (HTTP 200, summary = degraded). + * - hardDownMs – response time above this (or a connection failure) triggers + * a "hard-down" signal (HTTP 503, summary = down). + * + * Values are sourced from environment variables so they can be tuned per + * deployment without a code change. See `.env.example` for the full list. + */ + +export interface LatencyBudgetThreshold { + /** Latency (ms) above which the dependency is considered degraded */ + degradedMs: number; + /** Latency (ms) above which the dependency is considered hard-down */ + hardDownMs: number; +} + +export interface LatencyBudgetConfig { + horizon: LatencyBudgetThreshold; + sorobanRpc: LatencyBudgetThreshold; +} + +const env = process.env; + +const toMs = (raw: string | undefined, fallback: number): number => { + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +export const latencyBudgetConfig: LatencyBudgetConfig = { + horizon: { + degradedMs: toMs( + env.HEALTH_HORIZON_LATENCY_DEGRADED_MS, + 1_000, // 1 s — warn if Horizon is slow + ), + hardDownMs: toMs( + env.HEALTH_HORIZON_LATENCY_HARD_DOWN_MS, + 4_000, // 4 s — treat as hard-down + ), + }, + sorobanRpc: { + degradedMs: toMs( + env.HEALTH_SOROBAN_RPC_LATENCY_DEGRADED_MS, + 1_500, // 1.5 s — RPC is heavier than Horizon root + ), + hardDownMs: toMs( + env.HEALTH_SOROBAN_RPC_LATENCY_HARD_DOWN_MS, + 5_000, // 5 s — treat as hard-down + ), + }, +}; diff --git a/apps/backend/src/health/latency-budget.health.service.spec.ts b/apps/backend/src/health/latency-budget.health.service.spec.ts new file mode 100644 index 00000000..981d13d9 --- /dev/null +++ b/apps/backend/src/health/latency-budget.health.service.spec.ts @@ -0,0 +1,145 @@ +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import { of, throwError } from 'rxjs'; +import { LatencyBudgetHealthService } from './latency-budget.health.service'; + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- +describe('LatencyBudgetHealthService', () => { + let service: LatencyBudgetHealthService; + let httpService: { get: jest.Mock; post: jest.Mock }; + + beforeEach(async () => { + httpService = { + get: jest.fn(), + post: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LatencyBudgetHealthService, + { provide: HttpService, useValue: httpService }, + ], + }).compile(); + + service = module.get( + LatencyBudgetHealthService, + ); + + // Restore original env so tests are isolated + delete process.env.HEALTH_HORIZON_LATENCY_DEGRADED_MS; + delete process.env.HEALTH_HORIZON_LATENCY_HARD_DOWN_MS; + delete process.env.HEALTH_SOROBAN_RPC_LATENCY_DEGRADED_MS; + delete process.env.HEALTH_SOROBAN_RPC_LATENCY_HARD_DOWN_MS; + }); + + afterEach(() => jest.clearAllMocks()); + + // ── Happy path ───────────────────────────────────────────────────────────── + + it('returns overallState=ok when both probes respond quickly', async () => { + httpService.get.mockReturnValue(of({ data: {}, status: 200 })); + httpService.post.mockReturnValue( + of({ data: { result: { status: 'healthy' } }, status: 200 }), + ); + + const report = await service.getLatencyBudgetReport(); + + expect(report.overallState).toBe('ok'); + expect(report.dependencies).toHaveLength(2); + expect(report.dependencies.every((d) => d.state === 'ok')).toBe(true); + }); + + it('includes checkedAt as an ISO date string', async () => { + httpService.get.mockReturnValue(of({ data: {} })); + httpService.post.mockReturnValue(of({ data: {} })); + + const report = await service.getLatencyBudgetReport(); + + expect(() => new Date(report.checkedAt)).not.toThrow(); + expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt); + }); + + // ── Hard-down: connection failure ────────────────────────────────────────── + + it('classifies horizon as hard_down when the HTTP probe throws', async () => { + httpService.get.mockReturnValue( + throwError(() => new Error('ECONNREFUSED')), + ); + httpService.post.mockReturnValue(of({ data: {} })); + + const report = await service.getLatencyBudgetReport(); + const horizon = report.dependencies.find((d) => d.name === 'horizon')!; + + expect(horizon.state).toBe('hard_down'); + expect(horizon.message).toContain('ECONNREFUSED'); + expect(report.overallState).toBe('hard_down'); + }); + + it('classifies sorobanRpc as hard_down when the RPC probe throws', async () => { + httpService.get.mockReturnValue(of({ data: {} })); + httpService.post.mockReturnValue( + throwError(() => new Error('socket hang up')), + ); + + const report = await service.getLatencyBudgetReport(); + const rpc = report.dependencies.find((d) => d.name === 'sorobanRpc')!; + + expect(rpc.state).toBe('hard_down'); + expect(report.overallState).toBe('hard_down'); + }); + + // ── Overall state rollup ─────────────────────────────────────────────────── + + it('reports overallState=hard_down even when one dep is ok', async () => { + httpService.get.mockReturnValue(of({ data: {} })); + httpService.post.mockReturnValue(throwError(() => new Error('timeout'))); + + const report = await service.getLatencyBudgetReport(); + + expect(report.overallState).toBe('hard_down'); + }); + + // ── Response shape ───────────────────────────────────────────────────────── + + it('includes url and thresholds in each dependency result', async () => { + httpService.get.mockReturnValue(of({ data: {} })); + httpService.post.mockReturnValue(of({ data: {} })); + + const report = await service.getLatencyBudgetReport(); + + for (const dep of report.dependencies) { + expect(dep.url).toBeTruthy(); + expect(dep.thresholds).toMatchObject({ + degradedMs: expect.any(Number), + hardDownMs: expect.any(Number), + }); + expect(dep.latencyMs).toBeDefined(); + } + }); + + it('names the two dependencies "horizon" and "sorobanRpc"', async () => { + httpService.get.mockReturnValue(of({ data: {} })); + httpService.post.mockReturnValue(of({ data: {} })); + + const report = await service.getLatencyBudgetReport(); + const names = report.dependencies.map((d) => d.name); + + expect(names).toContain('horizon'); + expect(names).toContain('sorobanRpc'); + }); + + // ── Error message handling ───────────────────────────────────────────────── + + it('captures non-Error thrown values as a string message', async () => { + httpService.get.mockReturnValue(throwError(() => 'raw string error')); + httpService.post.mockReturnValue(of({ data: {} })); + + const report = await service.getLatencyBudgetReport(); + const horizon = report.dependencies.find((d) => d.name === 'horizon')!; + + expect(horizon.state).toBe('hard_down'); + expect(horizon.message).toBe('raw string error'); + }); +}); diff --git a/apps/backend/src/health/latency-budget.health.service.ts b/apps/backend/src/health/latency-budget.health.service.ts new file mode 100644 index 00000000..9a632515 --- /dev/null +++ b/apps/backend/src/health/latency-budget.health.service.ts @@ -0,0 +1,238 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { + latencyBudgetConfig, + LatencyBudgetThreshold, +} from './latency-budget.config'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Severity classification for a single dependency */ +export type LatencyHealthState = 'ok' | 'degraded' | 'hard_down'; + +export interface DependencyLatencyResult { + /** Human-readable dependency name */ + name: string; + /** URL that was probed */ + url: string; + /** Measured round-trip time in milliseconds, undefined when unreachable */ + latencyMs: number | undefined; + /** Thresholds used for classification */ + thresholds: LatencyBudgetThreshold; + /** Derived health state */ + state: LatencyHealthState; + /** Optional human-readable explanation */ + message?: string; +} + +export interface LatencyBudgetReport { + /** + * Overall status across all checked dependencies: + * - ok → all within budget + * - degraded → at least one dependency is slow but reachable + * - hard_down → at least one dependency is unreachable or over hard-down threshold + */ + overallState: LatencyHealthState; + checkedAt: string; + dependencies: DependencyLatencyResult[]; +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +/** Soroban RPC default endpoints, keyed by network */ +const DEFAULT_SOROBAN_RPC_URLS = { + testnet: 'https://soroban-testnet.stellar.org', + mainnet: 'https://soroban.stellar.org', +} as const; + +const DEFAULT_HORIZON_URLS = { + testnet: 'https://horizon-testnet.stellar.org', + mainnet: 'https://horizon.stellar.org', +} as const; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +@Injectable() +export class LatencyBudgetHealthService { + private readonly logger = new Logger(LatencyBudgetHealthService.name); + + constructor(private readonly httpService: HttpService) {} + + /** + * Probes Horizon and Soroban RPC and returns a structured latency budget + * report. All requests run concurrently; individual failures never propagate + * as unhandled exceptions — they are captured and classified as hard_down. + */ + async getLatencyBudgetReport(): Promise { + const network = (process.env.STELLAR_NETWORK ?? 'testnet') as + 'testnet' | 'mainnet'; + + const horizonUrl = + process.env.STELLAR_HORIZON_URL ?? DEFAULT_HORIZON_URLS[network]; + const sorobanRpcUrl = + process.env.STELLAR_SOROBAN_RPC_URL ?? DEFAULT_SOROBAN_RPC_URLS[network]; + + const [horizonResult, rpcResult] = await Promise.all([ + this.probeEndpoint('horizon', horizonUrl, latencyBudgetConfig.horizon), + this.probeRpc( + 'sorobanRpc', + sorobanRpcUrl, + latencyBudgetConfig.sorobanRpc, + ), + ]); + + const dependencies: DependencyLatencyResult[] = [horizonResult, rpcResult]; + + const overallState = this.deriveOverallState(dependencies); + + return { + overallState, + checkedAt: new Date().toISOString(), + dependencies, + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Probes an HTTP endpoint with a HEAD/GET and measures round-trip latency. + */ + private async probeEndpoint( + name: string, + url: string, + thresholds: LatencyBudgetThreshold, + ): Promise { + const started = Date.now(); + + try { + await firstValueFrom( + this.httpService.get(url, { + // Hard ceiling is the hard-down threshold + a 500 ms margin to + // distinguish "timeout" from "very slow but answered". + timeout: thresholds.hardDownMs + 500, + headers: { Accept: 'application/json' }, + }), + ); + + const latencyMs = Date.now() - started; + + return this.classify(name, url, latencyMs, thresholds); + } catch (error) { + const latencyMs = Date.now() - started; + const message = this.toMessage(error); + + this.logger.warn(`Latency probe for ${name} failed: ${message}`); + + return { + name, + url, + latencyMs, + thresholds, + state: 'hard_down', + message, + }; + } + } + + /** + * Probes the Soroban JSON-RPC endpoint using the `getHealth` method so we + * get an application-level signal rather than just a TCP handshake. + */ + private async probeRpc( + name: string, + url: string, + thresholds: LatencyBudgetThreshold, + ): Promise { + const started = Date.now(); + + try { + await firstValueFrom( + this.httpService.post( + url, + { jsonrpc: '2.0', id: 1, method: 'getHealth', params: [] }, + { + timeout: thresholds.hardDownMs + 500, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + const latencyMs = Date.now() - started; + + return this.classify(name, url, latencyMs, thresholds); + } catch (error) { + const latencyMs = Date.now() - started; + const message = this.toMessage(error); + + this.logger.warn(`Latency probe for ${name} failed: ${message}`); + + return { + name, + url, + latencyMs, + thresholds, + state: 'hard_down', + message, + }; + } + } + + /** + * Classifies a successful probe result according to the latency thresholds. + */ + private classify( + name: string, + url: string, + latencyMs: number, + thresholds: LatencyBudgetThreshold, + ): DependencyLatencyResult { + if (latencyMs >= thresholds.hardDownMs) { + return { + name, + url, + latencyMs, + thresholds, + state: 'hard_down', + message: `Latency ${latencyMs}ms exceeds hard-down threshold (${thresholds.hardDownMs}ms)`, + }; + } + + if (latencyMs >= thresholds.degradedMs) { + return { + name, + url, + latencyMs, + thresholds, + state: 'degraded', + message: `Latency ${latencyMs}ms exceeds degraded threshold (${thresholds.degradedMs}ms)`, + }; + } + + return { name, url, latencyMs, thresholds, state: 'ok' }; + } + + /** + * Rolls up individual states: any hard_down wins; any degraded wins over ok. + */ + private deriveOverallState( + results: DependencyLatencyResult[], + ): LatencyHealthState { + if (results.some((r) => r.state === 'hard_down')) return 'hard_down'; + if (results.some((r) => r.state === 'degraded')) return 'degraded'; + return 'ok'; + } + + private toMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); + } +} diff --git a/apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts b/apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts index 6764d473..91d80db4 100644 --- a/apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts +++ b/apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts @@ -132,11 +132,7 @@ export class PortfolioSnapshotBatchStatusDto { example: 'running', }) status: - | 'queued' - | 'running' - | 'completed' - | 'completed_with_errors' - | 'failed'; + 'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed'; @ApiProperty({ description: 'Total users scheduled for snapshot generation', @@ -208,11 +204,7 @@ export class TriggerSnapshotBatchResponseDto { example: 'queued', }) status: - | 'queued' - | 'running' - | 'completed' - | 'completed_with_errors' - | 'failed'; + 'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed'; @ApiProperty({ description: 'Total users scheduled for snapshot generation', diff --git a/apps/backend/src/portfolio/queue/portfolio-snapshot.types.ts b/apps/backend/src/portfolio/queue/portfolio-snapshot.types.ts index 9682dad4..3419cf3b 100644 --- a/apps/backend/src/portfolio/queue/portfolio-snapshot.types.ts +++ b/apps/backend/src/portfolio/queue/portfolio-snapshot.types.ts @@ -13,11 +13,7 @@ export interface PortfolioSnapshotUserJobData { export interface PortfolioSnapshotBatchStatus { batchId: string; status: - | 'queued' - | 'running' - | 'completed' - | 'completed_with_errors' - | 'failed'; + 'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed'; total: number; completed: number; failed: number; diff --git a/apps/backend/src/search/search.service.spec.ts b/apps/backend/src/search/search.service.spec.ts index 36cf56dc..207a585b 100644 --- a/apps/backend/src/search/search.service.spec.ts +++ b/apps/backend/src/search/search.service.spec.ts @@ -16,7 +16,7 @@ describe('SearchService', () => { beforeEach(async () => { verificationService = { listProjects: jest.fn() }; stellarService = { discoverAssets: jest.fn() }; - newsRepo = { query: jest.fn() } as any; + newsRepo = { query: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -172,7 +172,7 @@ describe('SearchService', () => { const res = await service.searchAssets({ minAccounts: 100, authRequired: false, - } as any); + }); expect(res.assets).toHaveLength(1); expect(res.assets[0].assetCode).toBe('B'); }); diff --git a/apps/backend/src/soroban-events/guards/soroban-event-ingestion.guard.ts b/apps/backend/src/soroban-events/guards/soroban-event-ingestion.guard.ts index 892001e8..fc8284d8 100644 --- a/apps/backend/src/soroban-events/guards/soroban-event-ingestion.guard.ts +++ b/apps/backend/src/soroban-events/guards/soroban-event-ingestion.guard.ts @@ -56,11 +56,9 @@ export class SorobanEventIngestionGuard implements CanActivate { } const signature = request.headers[SOROBAN_SIGNATURE_HEADER] as - | string - | undefined; + string | undefined; const timestampHeader = request.headers[SOROBAN_TIMESTAMP_HEADER] as - | string - | undefined; + string | undefined; const nonce = request.headers[SOROBAN_NONCE_HEADER] as string | undefined; if (!signature) { diff --git a/apps/backend/src/soroban-events/soroban-event-mapper.ts b/apps/backend/src/soroban-events/soroban-event-mapper.ts index 3cf8a072..b00a35cd 100644 --- a/apps/backend/src/soroban-events/soroban-event-mapper.ts +++ b/apps/backend/src/soroban-events/soroban-event-mapper.ts @@ -1,4 +1,8 @@ -import { CanonicalEventType, getCategory, EventCategory } from '../common/event-catalog'; +import { + CanonicalEventType, + getCategory, + EventCategory, +} from '../common/event-catalog'; const RAW_EVENT_MAP: Record = { InitializedEvent: CanonicalEventType.ADMIN_STORAGE_MIGRATED, diff --git a/apps/backend/src/stellar/services/matching-pool-admin.service.spec.ts b/apps/backend/src/stellar/services/matching-pool-admin.service.spec.ts index 157c48b2..dd70ff97 100644 --- a/apps/backend/src/stellar/services/matching-pool-admin.service.spec.ts +++ b/apps/backend/src/stellar/services/matching-pool-admin.service.spec.ts @@ -5,8 +5,7 @@ const mockConfig = { timeout: 3000, serverSecret: { reveal: jest.fn( - () => - 'SB6RIPM3GJQ7RP3Q6R5F3QIBYZHP4N27SGGCQ3R4LWA2ZKXZWQ3NU3G4', + () => 'SB6RIPM3GJQ7RP3Q6R5F3QIBYZHP4N27SGGCQ3R4LWA2ZKXZWQ3NU3G4', ), }, contracts: { @@ -60,8 +59,9 @@ describe('MatchingPoolAdminService', () => { }, ) => { try { - (service as unknown as { handleError: (e: unknown, m: string) => never }) - .handleError(err, 'createRound'); + ( + service as unknown as { handleError: (e: unknown, m: string) => never } + ).handleError(err, 'createRound'); fail('Expected handleError to throw'); } catch (thrown) { expect(thrown).toBeInstanceOf(HttpException); diff --git a/apps/backend/src/transaction/transaction.service.ts b/apps/backend/src/transaction/transaction.service.ts index aed88491..b5458f8e 100644 --- a/apps/backend/src/transaction/transaction.service.ts +++ b/apps/backend/src/transaction/transaction.service.ts @@ -135,8 +135,7 @@ export class TransactionService { const response = await fetch(url); const data = (await response.json()) as - | HorizonResponse - | HorizonErrorResponse; + HorizonResponse | HorizonErrorResponse; if (!response.ok) { const errorDetail = (data as HorizonErrorResponse).detail; diff --git a/apps/backend/src/treasury/treasury-error.util.spec.ts b/apps/backend/src/treasury/treasury-error.util.spec.ts index a78f4c13..8311fb37 100644 --- a/apps/backend/src/treasury/treasury-error.util.spec.ts +++ b/apps/backend/src/treasury/treasury-error.util.spec.ts @@ -46,8 +46,9 @@ describe('mapContractErrorCode', () => { it('maps StreamNotFound (7) with the beneficiary in details', () => { const ex = mapContractErrorCode(7, undefined, 'GABC'); expect(ex).toBeInstanceOf(TreasuryStreamNotFoundException); - expect((ex.getResponse() as { details: { beneficiary: string } }).details) - .toEqual({ beneficiary: 'GABC' }); + expect( + (ex.getResponse() as { details: { beneficiary: string } }).details, + ).toEqual({ beneficiary: 'GABC' }); }); it('falls back to a generic failure for unknown codes', () => { diff --git a/apps/backend/src/treasury/treasury-error.util.ts b/apps/backend/src/treasury/treasury-error.util.ts index e72b9f7c..2a48465b 100644 --- a/apps/backend/src/treasury/treasury-error.util.ts +++ b/apps/backend/src/treasury/treasury-error.util.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ import { TreasuryException, TreasuryInvalidAmountException, @@ -38,7 +39,7 @@ export function mapContractErrorCode( fallbackMessage?: string, beneficiary?: string, ): TreasuryException { - switch (code as TreasuryContractError) { + switch (code) { case TreasuryContractError.NotInitialized: return new TreasuryNotInitializedException(); case TreasuryContractError.Unauthorized: diff --git a/apps/backend/src/treasury/treasury-soroban.client.ts b/apps/backend/src/treasury/treasury-soroban.client.ts index 08094042..c2f35f28 100644 --- a/apps/backend/src/treasury/treasury-soroban.client.ts +++ b/apps/backend/src/treasury/treasury-soroban.client.ts @@ -14,9 +14,7 @@ import { import { config } from '../lib/config'; import { BadRequestException } from '@nestjs/common'; import { ErrorCode } from '../common/enums/error-code.enum'; -import { - SorobanRpcError, -} from '../stellar/services/soroban-rpc-client.service'; +import { SorobanRpcError } from '../stellar/services/soroban-rpc-client.service'; import { TreasuryNotConfiguredException, TreasuryRpcUnavailableException, @@ -327,7 +325,7 @@ export class TreasurySorobanClient { error && typeof error === 'object' && 'getStatus' in error && - typeof (error as { getStatus: unknown }).getStatus === 'function' + typeof error.getStatus === 'function' ) { return error as unknown as Error; } diff --git a/apps/backend/src/treasury/treasury.controller.ts b/apps/backend/src/treasury/treasury.controller.ts index bddd3516..8219df7d 100644 --- a/apps/backend/src/treasury/treasury.controller.ts +++ b/apps/backend/src/treasury/treasury.controller.ts @@ -112,7 +112,10 @@ export class TreasuryController { @ApiResponse({ status: 400, description: 'Invalid request parameters' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Caller is not an admin' }) - @ApiResponse({ status: 404, description: 'Stream not found for old beneficiary' }) + @ApiResponse({ + status: 404, + description: 'Stream not found for old beneficiary', + }) @ApiResponse({ status: 502, description: 'Treasury transaction failed' }) @ApiResponse({ status: 503, diff --git a/apps/backend/src/vesting-wallet/vesting-error.util.spec.ts b/apps/backend/src/vesting-wallet/vesting-error.util.spec.ts index 6faf0b34..52f6cee0 100644 --- a/apps/backend/src/vesting-wallet/vesting-error.util.spec.ts +++ b/apps/backend/src/vesting-wallet/vesting-error.util.spec.ts @@ -14,7 +14,9 @@ import { describe('extractVestingWalletContractErrorCode', () => { it('extracts the code from a HostError diagnostic', () => { expect( - extractVestingWalletContractErrorCode('HostError: Error(Contract, #3) ...'), + extractVestingWalletContractErrorCode( + 'HostError: Error(Contract, #3) ...', + ), ).toBe(3); }); @@ -42,8 +44,9 @@ describe('mapVestingWalletContractErrorCode', () => { it('maps VestingNotFound (4) with the beneficiary in details', () => { const ex = mapVestingWalletContractErrorCode(4, undefined, 'GABC'); expect(ex).toBeInstanceOf(VestingWalletNotFoundException); - expect((ex.getResponse() as { details: { beneficiary: string } }).details) - .toEqual({ beneficiary: 'GABC' }); + expect( + (ex.getResponse() as { details: { beneficiary: string } }).details, + ).toEqual({ beneficiary: 'GABC' }); }); it('maps InsufficientBalance (9) to the insufficient funds exception', () => { diff --git a/apps/backend/src/vesting-wallet/vesting-error.util.ts b/apps/backend/src/vesting-wallet/vesting-error.util.ts index b941e68e..b4b886c9 100644 --- a/apps/backend/src/vesting-wallet/vesting-error.util.ts +++ b/apps/backend/src/vesting-wallet/vesting-error.util.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ import { VestingWalletException, VestingWalletInvalidAmountException, @@ -31,7 +32,7 @@ export function mapVestingWalletContractErrorCode( fallbackMessage?: string, beneficiary?: string, ): VestingWalletException { - switch (code as VestingWalletContractError) { + switch (code) { case VestingWalletContractError.NotInitialized: return new VestingWalletNotInitializedException(); case VestingWalletContractError.Unauthorized: diff --git a/apps/backend/src/vesting-wallet/vesting-wallet-soroban.client.ts b/apps/backend/src/vesting-wallet/vesting-wallet-soroban.client.ts index 8f4e56b9..dd9ac6d4 100644 --- a/apps/backend/src/vesting-wallet/vesting-wallet-soroban.client.ts +++ b/apps/backend/src/vesting-wallet/vesting-wallet-soroban.client.ts @@ -305,7 +305,7 @@ export class VestingWalletSorobanClient { error && typeof error === 'object' && 'getStatus' in error && - typeof (error as { getStatus: unknown }).getStatus === 'function' + typeof error.getStatus === 'function' ) { return error as unknown as Error; } diff --git a/apps/backend/src/webhook/webhook-verification.guard.ts b/apps/backend/src/webhook/webhook-verification.guard.ts index df03d123..5832c61b 100644 --- a/apps/backend/src/webhook/webhook-verification.guard.ts +++ b/apps/backend/src/webhook/webhook-verification.guard.ts @@ -96,11 +96,9 @@ export class WebhookVerificationGuard implements CanActivate { providerInfo?.timestampHeader?.toLowerCase() || 'x-webhook-timestamp'; const signatureHeader = request.headers[signatureHeaderName] as - | string - | undefined; + string | undefined; const timestampHeader = request.headers[timestampHeaderName] as - | string - | undefined; + string | undefined; if (!signatureHeader) { this.logger.warn('Missing signature header');