Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ rome facts programs devnet # Solana program ids for a network
rome cookbook cpi-recipe # the CPI account-rules + SDK encoders (grounded addresses)
rome cookbook patterns lending # which example repo + guide fits a goal
rome call hadrian 0x… "balanceOf(address) returns (uint256)" 0x… # read a contract (no key)
rome doctor hadrian --address 0x… # preflight: chain live? RPC reachable? program set? wallet funded?

# actions — sign on-chain, need ROME_EVM_KEY (never a flag/log/MCP):
rome deploy hadrian ./out/Store.json # deploy a compiled artifact
Expand Down
10 changes: 10 additions & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getChainFacts, getTokenFacts, getContractFacts, getGasFacts, getBalance
import { getCpiRecipe, getPatterns } from "./cookbook.js";
import { callContract, deployContract, sendContract } from "./actions.js";
import { fundHandler, bridgeHandler } from "./bridge.js";
import { doctor } from "./doctor.js";
import { type Deps } from "./deps.js";

export interface ArgSpec {
Expand Down Expand Up @@ -143,6 +144,15 @@ export const CAPABILITIES: Capability[] = [
(a, deps) => sendContract(a.chain, a.address, a.signature, a.args, deps),
),

// ── doctor: read-only preflight (CLI + MCP) ──
verbCap(
"doctor",
"doctor",
"Preflight a chain: is it live, is the RPC reachable, is the program configured, is a wallet funded? e.g. rome doctor hadrian --address 0x…",
[chainArg, { name: "address", required: false, description: "optional 0x address to check is funded" }],
(a, deps) => doctor(a.chain, a.address, deps),
),

// ── fund / bridge: the "from home" on-ramp (CCTP USDC inbound). CLI-only actions. ──
verbAction(
"fund",
Expand Down
52 changes: 52 additions & 0 deletions src/core/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getChainFacts } from "./facts.js";
import { defaultDeps, type Deps } from "./deps.js";

// `doctor` — a read-only preflight. Composes registry facts + a live RPC ping so a
// builder (or agent) can confirm the environment is sane before deploying/sending.
// No key: the optional funded check takes a plain address.

export interface DoctorCheck {
name: string;
ok: boolean;
detail: string;
}

export interface DoctorResult {
chain: { id: number; name: string; network: string; status: string };
checks: DoctorCheck[];
ok: boolean;
}

export async function doctor(
chain: string | number,
address: string | undefined,
deps: Deps = defaultDeps,
): Promise<DoctorResult> {
const c = getChainFacts(chain); // throws "Unknown chain …" on a bad chain
const status = (c as { status?: string }).status ?? "unknown";
const network = (c as { network?: string }).network ?? "";
const checks: DoctorCheck[] = [
{ name: "chain-live", ok: status === "live", detail: `status=${status}` },
{ name: "program-configured", ok: Boolean(c.romeEvmProgramId), detail: c.romeEvmProgramId || "(none)" },
];

// rpc-reachable: a live gas-price ping proves the configured RPC actually answers.
const rpc = deps.makeRpc(c.rpcUrl);
try {
const gp = await rpc.getGasPrice();
checks.push({ name: "rpc-reachable", ok: true, detail: `${c.rpcUrl} (gasPrice ${gp.toString()} wei)` });
} catch (e) {
checks.push({ name: "rpc-reachable", ok: false, detail: `${c.rpcUrl} unreachable: ${(e as Error).message}` });
}

if (address) {
try {
const bal = await rpc.getBalance(address);
checks.push({ name: "wallet-funded", ok: bal > 0n, detail: `${bal.toString()} wei (${c.nativeCurrency.symbol})` });
} catch (e) {
checks.push({ name: "wallet-funded", ok: false, detail: `balance check failed: ${(e as Error).message}` });
}
}

return { chain: { id: c.chainId, name: c.name, network, status }, checks, ok: checks.every((x) => x.ok) };
}
60 changes: 60 additions & 0 deletions test/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { doctor } from "../src/core/doctor.js";
import { CAPABILITIES } from "../src/core/capabilities.js";
import { buildMcpTools } from "../src/mcp.js";
import type { Deps, RpcClient } from "../src/core/deps.js";

// `doctor` is a read-only preflight: chain live? RPC reachable? program configured?
// wallet funded (optional address)? It composes registry facts + a live RPC ping, so
// it's on BOTH the CLI and MCP. No key — the funded check takes a plain address.

function depsWith(rpc: Partial<RpcClient>): Deps {
return {
makeRpc: () => ({
getGasPrice: rpc.getGasPrice ?? (async () => 10n),
getBalance: rpc.getBalance ?? (async () => 0n),
}),
};
}
const check = (r: Awaited<ReturnType<typeof doctor>>, name: string) => r.checks.find((c) => c.name === name);

describe("doctor — preflight", () => {
it("all green on a live chain with a reachable RPC", async () => {
const r = await doctor("hadrian", undefined, depsWith({ getGasPrice: async () => 10_000n }));
expect(r.chain.id).toBe(200010);
expect(check(r, "chain-live")?.ok).toBe(true);
expect(check(r, "rpc-reachable")?.ok).toBe(true);
expect(check(r, "program-configured")?.ok).toBe(true);
// no address → no wallet-funded check
expect(check(r, "wallet-funded")).toBeUndefined();
expect(r.ok).toBe(true);
});

it("flags an unreachable RPC (ping throws) and fails overall", async () => {
const r = await doctor("hadrian", undefined, depsWith({ getGasPrice: async () => { throw new Error("ECONNREFUSED"); } }));
expect(check(r, "rpc-reachable")?.ok).toBe(false);
expect(r.ok).toBe(false);
});

it("adds a wallet-funded check when an address is given", async () => {
const funded = await doctor("hadrian", "0x1Fc309eeF3D24dc2585aFb2175fAd4592f2a7b75", depsWith({ getBalance: async () => 5n }));
expect(check(funded, "wallet-funded")?.ok).toBe(true);
const empty = await doctor("hadrian", "0x1Fc309eeF3D24dc2585aFb2175fAd4592f2a7b75", depsWith({ getBalance: async () => 0n }));
expect(check(empty, "wallet-funded")?.ok).toBe(false);
expect(empty.ok).toBe(false);
});

it("errors clearly on an unknown chain", async () => {
await expect(doctor("nope-chain", undefined, depsWith({}))).rejects.toThrow(/unknown chain/i);
});
});

describe("doctor is a read capability, exposed on MCP", () => {
it("registered as a read verb and present on the MCP surface", () => {
const cap = CAPABILITIES.find((c) => c.id === "doctor.doctor");
expect(cap?.kind).toBe("read");
expect(cap?.requiresKey).toBe(false);
expect(cap?.verb).toBe(true);
expect(new Set(buildMcpTools().map((t) => t.name)).has("doctor")).toBe(true);
});
});
Loading