Skip to content
Closed
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
176 changes: 176 additions & 0 deletions cli/src/__tests__/check-status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
171 changes: 171 additions & 0 deletions cli/src/commands/check-status.ts
Original file line number Diff line number Diff line change
@@ -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<ContractStatusReport> {
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<CheckStatusResult> {
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 <file>', '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;
}
});
}
4 changes: 3 additions & 1 deletion cli/src/parseArgs.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,7 +21,8 @@ export function buildProgram(): Command {

program
.addCommand(createUpgradeCommand())
.addCommand(createSmokeTestCommand());
.addCommand(createSmokeTestCommand())
.addCommand(createCheckStatusCommand());

return program;
}
Expand Down