Skip to content
Open
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added .vs/Soter/v17/.wsuo
Binary file not shown.
12 changes: 12 additions & 0 deletions .vs/Soter/v17/DocumentLayout.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Version": 1,
"WorkspaceRootPath": "C:\\Users\\Admin\\Documents\\Drips\\Soter\\",
"Documents": [],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": []
}
]
}
Binary file added .vs/Soter/v17/workspaceFileList.bin
Binary file not shown.
6 changes: 6 additions & 0 deletions .vs/VSWorkspaceState.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ExpandedNodes": [
""
],
"PreviewInSolutionExplorer": false
}
Binary file added .vs/slnx.sqlite
Binary file not shown.
169 changes: 169 additions & 0 deletions app/backend/src/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,3 +144,168 @@ describe('HealthController', () => {
);
});
});

describe('HealthController - Onchain Probe', () => {
let app: INestApplication;

const configValues: Record<string, string | undefined> = {
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<OnchainAdapter> = {
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);
});
});
34 changes: 33 additions & 1 deletion app/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<OnchainProbeResponse> {
return this.healthService.probeOnchain();
}

@Get('error')
@Version(API_VERSIONS.V1)
@ApiOperation({ summary: 'Trigger an error for testing' })
Expand Down
3 changes: 2 additions & 1 deletion app/backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
101 changes: 100 additions & 1 deletion app/backend/src/health/health.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -216,4 +231,88 @@ export class HealthService {

return value.trim().toLowerCase() === 'true';
}

async probeOnchain(): Promise<OnchainProbeResponse> {
const startTime = Date.now();
const contractId = this.configService.get<string>('SOROBAN_CONTRACT_ID');
const rpcUrl = this.configService.get<string>(
'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<string>('HEALTHCHECK_ONCHAIN_TIMEOUT_MS') ??
'5000',
);

// Create a promise that rejects after timeout
const timeoutPromise = new Promise<never>((_, 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,
};
}
}
}
Loading