diff --git a/.vs/Soter/CopilotIndices/17.14.995.13737/CodeChunks.db b/.vs/Soter/CopilotIndices/17.14.995.13737/CodeChunks.db new file mode 100644 index 00000000..d649d6bd Binary files /dev/null and b/.vs/Soter/CopilotIndices/17.14.995.13737/CodeChunks.db differ diff --git a/.vs/Soter/CopilotIndices/17.14.995.13737/SemanticSymbols.db b/.vs/Soter/CopilotIndices/17.14.995.13737/SemanticSymbols.db new file mode 100644 index 00000000..e833a366 Binary files /dev/null and b/.vs/Soter/CopilotIndices/17.14.995.13737/SemanticSymbols.db differ diff --git a/.vs/Soter/FileContentIndex/c7d55940-46c2-496f-beb0-98ffa6f70846.vsidx b/.vs/Soter/FileContentIndex/c7d55940-46c2-496f-beb0-98ffa6f70846.vsidx new file mode 100644 index 00000000..d55bb8d6 Binary files /dev/null and b/.vs/Soter/FileContentIndex/c7d55940-46c2-496f-beb0-98ffa6f70846.vsidx differ diff --git a/.vs/Soter/copilot-chat/09a14d47/sessions/78a00232-4b0e-4625-b932-76cc29871cd9 b/.vs/Soter/copilot-chat/09a14d47/sessions/78a00232-4b0e-4625-b932-76cc29871cd9 new file mode 100644 index 00000000..bf754e8c Binary files /dev/null and b/.vs/Soter/copilot-chat/09a14d47/sessions/78a00232-4b0e-4625-b932-76cc29871cd9 differ diff --git a/.vs/Soter/v17/.wsuo b/.vs/Soter/v17/.wsuo new file mode 100644 index 00000000..9272b7f0 Binary files /dev/null and b/.vs/Soter/v17/.wsuo differ diff --git a/.vs/Soter/v17/DocumentLayout.json b/.vs/Soter/v17/DocumentLayout.json new file mode 100644 index 00000000..574f5bb4 --- /dev/null +++ b/.vs/Soter/v17/DocumentLayout.json @@ -0,0 +1,12 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\Admin\\Documents\\Drips\\Soter\\", + "Documents": [], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [] + } + ] +} \ No newline at end of file diff --git a/.vs/Soter/v17/workspaceFileList.bin b/.vs/Soter/v17/workspaceFileList.bin new file mode 100644 index 00000000..dd241fed Binary files /dev/null and b/.vs/Soter/v17/workspaceFileList.bin differ diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json new file mode 100644 index 00000000..6b611411 --- /dev/null +++ b/.vs/VSWorkspaceState.json @@ -0,0 +1,6 @@ +{ + "ExpandedNodes": [ + "" + ], + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite new file mode 100644 index 00000000..7a685cec Binary files /dev/null and b/.vs/slnx.sqlite differ diff --git a/app/backend/src/health/health.controller.spec.ts b/app/backend/src/health/health.controller.spec.ts index af646137..89d8cb66 100644 --- a/app/backend/src/health/health.controller.spec.ts +++ b/app/backend/src/health/health.controller.spec.ts @@ -6,6 +6,10 @@ import { HealthController } from './health.controller'; import { HealthService } from './health.service'; import { PrismaService } from '../prisma/prisma.service'; import { LoggerService } from '../logger/logger.service'; +import { + ONCHAIN_ADAPTER_TOKEN, + OnchainAdapter, +} from '../onchain/onchain.adapter'; describe('HealthController', () => { let app: INestApplication; @@ -140,3 +144,168 @@ describe('HealthController', () => { ); }); }); + +describe('HealthController - Onchain Probe', () => { + let app: INestApplication; + + const configValues: Record = { + NODE_ENV: 'test', + }; + + const configMock = { + get: jest.fn((key: string) => configValues[key]), + }; + + const loggerMock = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const onchainAdapterMock: jest.Mocked = { + getAidPackageCount: jest.fn(), + getAidPackage: jest.fn(), + getTokenBalance: jest.fn(), + createAidPackage: jest.fn(), + batchCreateAidPackages: jest.fn(), + claimAidPackage: jest.fn(), + disburseAidPackage: jest.fn(), + initEscrow: jest.fn(), + createClaim: jest.fn(), + disburse: jest.fn(), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + HealthService, + { provide: ConfigService, useValue: configMock }, + { provide: ONCHAIN_ADAPTER_TOKEN, useValue: onchainAdapterMock }, + { provide: LoggerService, useValue: loggerMock }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + configValues.SOROBAN_CONTRACT_ID = undefined; + configValues.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org'; + configValues.HEALTHCHECK_ONCHAIN_TIMEOUT_MS = undefined; + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /health/onchain-probe returns ok when contract call succeeds', async () => { + configValues.SOROBAN_CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + onchainAdapterMock.getAidPackageCount.mockResolvedValueOnce({ + aggregates: { + totalCommitted: '1000000', + totalClaimed: '500000', + totalExpiredCancelled: '100000', + }, + timestamp: new Date(), + }); + + const res = await request(app.getHttpServer()) + .get('/health/onchain-probe') + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + status: 'ok', + timestamp: expect.any(String), + latencyMs: expect.any(Number), + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + rpcUrl: 'https://soroban-testnet.stellar.org', + }), + ); + expect(onchainAdapterMock.getAidPackageCount).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ', + }), + ); + }); + + it('GET /health/onchain-probe returns ok when contract ID not configured', async () => { + configValues.SOROBAN_CONTRACT_ID = undefined; + + const res = await request(app.getHttpServer()) + .get('/health/onchain-probe') + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + status: 'ok', + timestamp: expect.any(String), + latencyMs: expect.any(Number), + contractId: undefined, + rpcUrl: 'https://soroban-testnet.stellar.org', + }), + ); + }); + + it('GET /health/onchain-probe returns error when contract call fails', async () => { + configValues.SOROBAN_CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + onchainAdapterMock.getAidPackageCount.mockRejectedValueOnce( + new Error('RPC connection refused'), + ); + + const res = await request(app.getHttpServer()) + .get('/health/onchain-probe') + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + status: 'error', + timestamp: expect.any(String), + latencyMs: expect.any(Number), + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + rpcUrl: 'https://soroban-testnet.stellar.org', + }), + ); + }); + + it('GET /health/onchain-probe returns error on timeout', async () => { + configValues.SOROBAN_CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + configValues.HEALTHCHECK_ONCHAIN_TIMEOUT_MS = '100'; // Very short timeout + + onchainAdapterMock.getAidPackageCount.mockImplementation( + () => + new Promise(resolve => + // Never resolves, so it will timeout + setTimeout(() => { + resolve({ + aggregates: { + totalCommitted: '1000000', + totalClaimed: '500000', + totalExpiredCancelled: '100000', + }, + timestamp: new Date(), + }); + }, 5000), + ), + ); + + const res = await request(app.getHttpServer()) + .get('/health/onchain-probe') + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + status: 'error', + timestamp: expect.any(String), + latencyMs: expect.any(Number), + }), + ); + expect(res.body.latencyMs).toBeGreaterThanOrEqual(100); + }); +}); diff --git a/app/backend/src/health/health.controller.ts b/app/backend/src/health/health.controller.ts index ce638039..cb2604c8 100644 --- a/app/backend/src/health/health.controller.ts +++ b/app/backend/src/health/health.controller.ts @@ -5,10 +5,11 @@ import { ApiOkResponse, ApiServiceUnavailableResponse, ApiInternalServerErrorResponse, + ApiForbiddenResponse, } from '@nestjs/swagger'; import { Response } from 'express'; import { RequestWithRequestId } from '../middleware/request-correlation.middleware'; -import { HealthService } from './health.service'; +import { HealthService, OnchainProbeResponse } from './health.service'; import { LivenessResponse, ReadinessResponse } from './health.service'; import { API_VERSIONS } from '../common/constants/api-version.constants'; import { Public } from '../common/decorators/public.decorator'; @@ -110,6 +111,37 @@ export class HealthController { return readiness; } + @Get('onchain-probe') + @Version(API_VERSIONS.V1) + @Throttle({ default: { ttl: 60, limit: 30 } }) // More conservative limit for read-only contract calls + @ApiOperation({ + summary: 'On-chain health probe (read-only contract ping)', + description: + 'Performs a read-only contract call to verify backend connectivity to Soroban RPC. ' + + 'Returns latency and status without leaking secrets. This endpoint is protected and not public.', + }) + @ApiOkResponse({ + description: 'Successfully pinged the on-chain contract.', + schema: { + example: { + status: 'ok', + timestamp: '2025-02-23T12:00:00.000Z', + latencyMs: 125, + contractId: 'CXXXXX...', + rpcUrl: 'https://soroban-testnet.stellar.org', + }, + }, + }) + @ApiForbiddenResponse({ + description: 'Endpoint requires authentication or authorization.', + }) + @ApiInternalServerErrorResponse({ + description: 'Failed to reach the on-chain contract or RPC endpoint error.', + }) + async onchainProbe(): Promise { + return this.healthService.probeOnchain(); + } + @Get('error') @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Trigger an error for testing' }) diff --git a/app/backend/src/health/health.module.ts b/app/backend/src/health/health.module.ts index 4ada4e4b..503f8521 100644 --- a/app/backend/src/health/health.module.ts +++ b/app/backend/src/health/health.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; import { HealthService } from './health.service'; import { LoggerModule } from '../logger/logger.module'; +import { OnchainModule } from '../onchain/onchain.module'; @Module({ - imports: [LoggerModule], + imports: [LoggerModule, OnchainModule], controllers: [HealthController], providers: [HealthService], }) diff --git a/app/backend/src/health/health.service.ts b/app/backend/src/health/health.service.ts index 1b71d823..b07357d2 100644 --- a/app/backend/src/health/health.service.ts +++ b/app/backend/src/health/health.service.ts @@ -1,7 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { LoggerService } from '../logger/logger.service'; +import { + OnchainAdapter, + ONCHAIN_ADAPTER_TOKEN, +} from '../onchain/onchain.adapter'; type CheckStatus = 'up' | 'down' | 'skipped'; @@ -32,12 +36,23 @@ export interface ReadinessResponse { }; } +export interface OnchainProbeResponse { + status: 'ok' | 'error'; + timestamp: string; + latencyMs: number; + contractId?: string; + rpcUrl?: string; +} + @Injectable() export class HealthService { constructor( private readonly configService: ConfigService, private readonly logger: LoggerService, private readonly prisma: PrismaService, + @Optional() + @Inject(ONCHAIN_ADAPTER_TOKEN) + private readonly onchainAdapter?: OnchainAdapter, ) {} check() { @@ -216,4 +231,88 @@ export class HealthService { return value.trim().toLowerCase() === 'true'; } + + async probeOnchain(): Promise { + const startTime = Date.now(); + const contractId = this.configService.get('SOROBAN_CONTRACT_ID'); + const rpcUrl = this.configService.get( + 'STELLAR_RPC_URL', + 'https://soroban-testnet.stellar.org', + ); + + // If no adapter or contract ID, return skipped status + if (!this.onchainAdapter || !contractId) { + const latencyMs = Date.now() - startTime; + return { + status: 'ok', + timestamp: new Date().toISOString(), + latencyMs, + contractId, + rpcUrl, + }; + } + + try { + const timeoutMs = Number( + this.configService.get('HEALTHCHECK_ONCHAIN_TIMEOUT_MS') ?? + '5000', + ); + + // Create a promise that rejects after timeout + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('Onchain probe timeout')), + Number.isFinite(timeoutMs) ? timeoutMs : 5000, + ), + ); + + // Use a dummy token address for the read-only call + // This allows us to verify RPC connectivity without having a real token + const dummyToken = + 'GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ'; + + // Perform a read-only call to get aid package count + // This doesn't modify state and only reads contract data + const probePromise = this.onchainAdapter.getAidPackageCount({ + token: dummyToken, + }); + + // Race between the probe and timeout + await Promise.race([probePromise, timeoutPromise]); + + const latencyMs = Date.now() - startTime; + + this.logger.log('Onchain probe successful', 'HealthService', { + latencyMs, + contractId, + }); + + return { + status: 'ok', + timestamp: new Date().toISOString(), + latencyMs, + contractId, + rpcUrl, + }; + } catch (error) { + const latencyMs = Date.now() - startTime; + const message = + error instanceof Error ? error.message : 'Unknown onchain error'; + + this.logger.warn('Onchain probe failed', 'HealthService', { + error: message, + latencyMs, + contractId, + rpcUrl, + }); + + return { + status: 'error', + timestamp: new Date().toISOString(), + latencyMs, + contractId, + rpcUrl, + }; + } + } }