diff --git a/cli/src/__tests__/check-status.test.ts b/cli/src/__tests__/check-status.test.ts new file mode 100644 index 00000000..413bdfe5 --- /dev/null +++ b/cli/src/__tests__/check-status.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { StrKey } from '@stellar/stellar-sdk'; +import { + checkStatus, + collectContracts, + pingContract, + type StatusChecker +} from '../commands/check-status.js'; +import type { BcForgeConfig } from '../utils/config-parser.js'; + +const TOKEN_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); +const WRAPPER_ID = StrKey.encodeContract(Buffer.alloc(32, 2)); + +/** Builds a stub RPC server whose getLedgerEntries behaviour is scripted per call. */ +function stubServer( + impl: (...keys: any[]) => Promise<{ entries: unknown[] }> +): StatusChecker { + return { getLedgerEntries: vi.fn(impl) as any } as StatusChecker; +} + +/** Deterministic clock producing a fixed 25ms delta per measured span. */ +function fakeClock(step = 25) { + let current = 0; + return () => { + const value = current; + current += step; + return value; + }; +} + +function baseConfig(contracts: BcForgeConfig['contracts']): BcForgeConfig { + return { + name: 'Test Token', + symbol: 'TTK', + network: 'testnet', + contracts + }; +} + +describe('CLI check-status command (#699)', () => { + describe('collectContracts', () => { + it('returns every contract declared in the configuration', () => { + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const collected = collectContracts(config); + + expect(collected).toHaveLength(2); + expect(collected.map(c => c.name).sort()).toEqual(['token', 'wrapper']); + }); + + it('returns an empty list when no contracts are declared', () => { + expect(collectContracts(baseConfig(undefined))).toEqual([]); + }); + }); + + describe('pingContract', () => { + it('reports a responsive contract with measured latency', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('responsive'); + expect(report.contractId).toBe(TOKEN_ID); + expect(report.latencyMs).toBe(25); + expect(report.error).toBeUndefined(); + }); + + it('reports not_deployed when the RPC returns no instance entry', async () => { + const server = stubServer(async () => ({ entries: [] })); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('not_deployed'); + expect(report.error).toMatch(/No contract instance/); + expect(report.latencyMs).toBe(25); + }); + + it('reports unreachable when the RPC call rejects', async () => { + const server = stubServer(async () => { + throw new Error('connect ECONNREFUSED'); + }); + + const report = await pingContract( + server, + 'token', + { contractId: TOKEN_ID }, + fakeClock() + ); + + expect(report.status).toBe('unreachable'); + expect(report.error).toMatch(/ECONNREFUSED/); + expect(report.latencyMs).toBe(25); + }); + + it('reports invalid when the configured contract id is malformed', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract( + server, + 'token', + { contractId: 'NOT-A-CONTRACT-ID' }, + fakeClock() + ); + + expect(report.status).toBe('invalid'); + expect(server.getLedgerEntries).not.toHaveBeenCalled(); + }); + + it('reports invalid when no contract id is configured', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const report = await pingContract(server, 'token', {}, fakeClock()); + + expect(report.status).toBe('invalid'); + expect(report.error).toMatch(/No contractId configured/); + expect(server.getLedgerEntries).not.toHaveBeenCalled(); + }); + }); + + describe('checkStatus', () => { + it('marks all responsive when every contract answers', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const result = await checkStatus(server, config, 'https://rpc.example', fakeClock()); + + expect(result.allResponsive).toBe(true); + expect(result.reports).toHaveLength(2); + expect(result.network).toBe('testnet'); + expect(result.rpcUrl).toBe('https://rpc.example'); + }); + + it('marks not all responsive when a single contract fails', async () => { + const server = stubServer(async () => ({ entries: [] })); + const config = baseConfig({ + token: { contractId: TOKEN_ID }, + wrapper: { contractId: WRAPPER_ID } + }); + + const result = await checkStatus(server, config, 'https://rpc.example', fakeClock()); + + expect(result.allResponsive).toBe(false); + expect(result.reports.every(r => r.status === 'not_deployed')).toBe(true); + }); + + it('is not responsive when the configuration declares no contracts', async () => { + const server = stubServer(async () => ({ entries: [{ key: 'instance' }] })); + + const result = await checkStatus( + server, + baseConfig(undefined), + 'https://rpc.example', + fakeClock() + ); + + expect(result.reports).toEqual([]); + expect(result.allResponsive).toBe(false); + }); + }); +}); diff --git a/cli/src/commands/check-status.ts b/cli/src/commands/check-status.ts new file mode 100644 index 00000000..bf6c0629 --- /dev/null +++ b/cli/src/commands/check-status.ts @@ -0,0 +1,171 @@ +import { Command } from 'commander'; +import { Contract, rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { getClientConfig, loadConfigFile } from '../utils/config.js'; +import logger from '../utils/logger.js'; +import type { BcForgeConfig, ContractDeploymentConfig } from '../utils/config-parser.js'; + +export type ContractStatus = 'responsive' | 'unreachable' | 'not_deployed' | 'invalid'; + +export interface ContractStatusReport { + name: string; + contractId?: string; + status: ContractStatus; + latencyMs?: number; + error?: string; +} + +export interface CheckStatusResult { + network?: string; + rpcUrl: string; + reports: ContractStatusReport[]; + allResponsive: boolean; +} + +export interface StatusChecker { + getLedgerEntries: SorobanRpc.Server['getLedgerEntries']; +} + +/** + * Collects the deployed contracts declared under `contracts` in .bc-forge.json. + */ +export function collectContracts( + config: BcForgeConfig +): Array<{ name: string; deployment: ContractDeploymentConfig }> { + const contracts = config.contracts; + if (!contracts) return []; + + return Object.entries(contracts).map(([name, deployment]) => ({ + name, + deployment: deployment ?? {} + })); +} + +/** + * Pings a single contract by reading its instance ledger entry. A contract that + * returns an instance entry is deployed and served by the RPC node; a missing + * entry means the ID is not deployed on this network. + */ +export async function pingContract( + server: StatusChecker, + name: string, + deployment: ContractDeploymentConfig, + now: () => number = () => Date.now() +): Promise { + const contractId = deployment.contractId; + + if (!contractId) { + return { + name, + status: 'invalid', + error: 'No contractId configured' + }; + } + + let footprint; + try { + footprint = new Contract(contractId).getFootprint(); + } catch (err: any) { + return { + name, + contractId, + status: 'invalid', + error: err.message + }; + } + + const startedAt = now(); + try { + const response = await server.getLedgerEntries(footprint); + const latencyMs = now() - startedAt; + + if (!response.entries || response.entries.length === 0) { + return { + name, + contractId, + status: 'not_deployed', + latencyMs, + error: 'No contract instance found on this network' + }; + } + + return { name, contractId, status: 'responsive', latencyMs }; + } catch (err: any) { + return { + name, + contractId, + status: 'unreachable', + latencyMs: now() - startedAt, + error: err.message + }; + } +} + +/** + * Pings every configured contract and reports latency and reachability. + */ +export async function checkStatus( + server: StatusChecker, + config: BcForgeConfig, + rpcUrl: string, + now: () => number = () => Date.now() +): Promise { + const contracts = collectContracts(config); + + const reports = await Promise.all( + contracts.map(({ name, deployment }) => pingContract(server, name, deployment, now)) + ); + + return { + network: config.network, + rpcUrl, + reports, + allResponsive: reports.length > 0 && reports.every(r => r.status === 'responsive') + }; +} + +/** + * Builds the `check-status` command. + */ +export function createCheckStatusCommand(): Command { + return new Command('check-status') + .description('Ping all deployed contracts and report latency and status') + .option('-c, --config ', 'Path to a .bc-forge.json deployment configuration file') + .action(async (options) => { + try { + const parsed = loadConfigFile(options.config); + if (!parsed.success || !parsed.config) { + parsed.errors?.forEach(err => logger.error(` - ${err}`)); + throw new Error('Failed to load deployment configuration'); + } + + const clientConfig = getClientConfig(); + logger.debug(`Pinging contracts via RPC: ${clientConfig.rpcUrl}`); + + const server = new SorobanRpc.Server(clientConfig.rpcUrl); + const status = await checkStatus(server, parsed.config, clientConfig.rpcUrl); + + if (status.reports.length === 0) { + logger.warn('No contracts declared under "contracts" in the configuration file.'); + return; + } + + logger.info(`Network: ${status.network ?? 'unknown'} (${status.rpcUrl})`); + for (const report of status.reports) { + const latency = report.latencyMs !== undefined ? ` ${report.latencyMs}ms` : ''; + const target = report.contractId ?? 'no contract id'; + if (report.status === 'responsive') { + logger.success(`${report.name}: responsive${latency} [${target}]`); + } else { + logger.error(`${report.name}: ${report.status}${latency} [${target}] - ${report.error}`); + } + } + + if (!status.allResponsive) { + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/parseArgs.ts b/cli/src/parseArgs.ts index 74f6903f..a5a1df26 100644 --- a/cli/src/parseArgs.ts +++ b/cli/src/parseArgs.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { createUpgradeCommand } from "./commands/upgrade.js"; import { createSmokeTestCommand } from "./commands/smoke-test.js"; +import { createCheckStatusCommand } from "./commands/check-status.js"; const VERSION = "0.1.0"; @@ -20,7 +21,8 @@ export function buildProgram(): Command { program .addCommand(createUpgradeCommand()) - .addCommand(createSmokeTestCommand()); + .addCommand(createSmokeTestCommand()) + .addCommand(createCheckStatusCommand()); return program; }