From 398c31461b4ec36ae40a9ec9fd27422bdee578b9 Mon Sep 17 00:00:00 2001 From: davidugorji Date: Wed, 26 Aug 2026 15:40:20 +0100 Subject: [PATCH] feat(cli): add check-status command to ping deployed contracts Adds a `bc-forge check-status` command that pings every contract declared under `contracts` in .bc-forge.json and reports per-contract reachability and latency. Each contract is probed by reading its instance ledger entry via getLedgerEntries. An entry proves the contract is deployed and served by the RPC node, so the check needs no contract-specific method and works uniformly across token, wrapper and any future contract. Four distinct statuses are reported rather than a single pass/fail, so an operator can tell the failure modes apart: - responsive instance entry returned, with measured latency - not_deployed RPC answered but holds no instance for that id - unreachable the RPC call itself failed - invalid contract id is malformed or absent from the config Contract ids are validated locally before any network call, so a typo in the config is reported immediately instead of as a spurious RPC failure. The command exits non-zero when any contract is not responsive, making it usable as a deployment gate in CI. The clock is injected so latency assertions in tests are deterministic. Closes #699 --- cli/src/__tests__/check-status.test.ts | 176 +++++++++++++++++++++++++ cli/src/commands/check-status.ts | 171 ++++++++++++++++++++++++ cli/src/parseArgs.ts | 4 +- 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 cli/src/__tests__/check-status.test.ts create mode 100644 cli/src/commands/check-status.ts 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; }