diff --git a/README.md b/README.md index 4dd9fd6..419d25b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 59e2838..fb3cf9d 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -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 { @@ -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", diff --git a/src/core/doctor.ts b/src/core/doctor.ts new file mode 100644 index 0000000..9d783a0 --- /dev/null +++ b/src/core/doctor.ts @@ -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 { + 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) }; +} diff --git a/test/doctor.test.ts b/test/doctor.test.ts new file mode 100644 index 0000000..d9b2422 --- /dev/null +++ b/test/doctor.test.ts @@ -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): Deps { + return { + makeRpc: () => ({ + getGasPrice: rpc.getGasPrice ?? (async () => 10n), + getBalance: rpc.getBalance ?? (async () => 0n), + }), + }; +} +const check = (r: Awaited>, 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); + }); +});