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 @@ -34,6 +34,7 @@ rome cookbook cpi-recipe # the CPI account-rules + SDK encoders (groun
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?
rome tx hadrian 0x… # diagnose a tx: EVM receipt + the Solana settlement tx(s) + a Via link

# 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 @@ -3,6 +3,7 @@ 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 { diagnoseTx } from "./tx.js";
import { type Deps } from "./deps.js";

export interface ArgSpec {
Expand Down Expand Up @@ -153,6 +154,15 @@ export const CAPABILITIES: Capability[] = [
(a, deps) => doctor(a.chain, a.address, deps),
),

// ── tx: cross-VM diagnosis (read; CLI + MCP) ──
verbCap(
"tx",
"tx",
"Diagnose a tx: EVM receipt + status, the Solana settlement tx(s) via rome_solanaTxForEvmTx (Rome has no debug_trace*), and a Via link. e.g. rome tx hadrian 0x…",
[chainArg, { name: "hash", required: true, description: "the EVM tx hash (0x…)" }],
(a) => diagnoseTx(a.chain, a.hash),
),

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

// `tx <hash>` — read-only cross-VM diagnosis. Rome has no debug_trace*; the substitute
// is the EVM receipt + `rome_solanaTxForEvmTx` (EVM hash → the Solana settlement tx(s),
// raw JSON-RPC, no SDK wrapper) + a Via explorer link. One injectable RpcCall keeps it
// testable without a network.

export type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;

export interface TxDiagnosis {
chainId: number;
hash: string;
status: "success" | "reverted" | "pending";
receipt: { blockNumber: string; gasUsed: string; from: string; to: string | null } | null;
solanaSettlement: string[];
explorer: string;
note: string;
}

function realRpcCall(rpcUrl: string): RpcCall {
return async (method, params) => {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
const j = (await res.json()) as { result?: unknown; error?: { message?: string } };
if (j.error) throw new Error(j.error.message ?? "rpc error");
return j.result;
};
}

export async function diagnoseTx(chain: string | number, hash: string, call?: RpcCall): Promise<TxDiagnosis> {
const c = getChainFacts(chain);
const rpc = call ?? realRpcCall(c.rpcUrl);

const receiptRaw = (await rpc("eth_getTransactionReceipt", [hash])) as Record<string, string> | null;

// Tolerate the method being unavailable on a given proxy (→ []).
let solanaSettlement: string[] = [];
try {
const sol = await rpc("rome_solanaTxForEvmTx", [hash]);
if (Array.isArray(sol)) solanaSettlement = sol as string[];
} catch {
solanaSettlement = [];
}

const status: TxDiagnosis["status"] = !receiptRaw ? "pending" : receiptRaw.status === "0x1" ? "success" : "reverted";
const receipt = receiptRaw
? { blockNumber: receiptRaw.blockNumber, gasUsed: receiptRaw.gasUsed, from: receiptRaw.from, to: receiptRaw.to ?? null }
: null;

const explorerBase = ((c as { explorerUrl?: string }).explorerUrl ?? "").replace(/\/?$/, "/");
const explorer = explorerBase ? `${explorerBase}tx/${hash}` : hash;
const note =
status === "pending"
? "No receipt yet — the tx may still be settling (Rome indexes the EVM block after the Solana tx lands)."
: status === "reverted"
? "Reverted. Rome has no debug_trace*; re-run the same inputs via rome_emulateTx to see the failure."
: "Confirmed. solanaSettlement lists the Solana tx(s) that settled this EVM tx.";

return { chainId: c.chainId, hash, status, receipt, solanaSettlement, explorer, note };
}
67 changes: 67 additions & 0 deletions test/tx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { diagnoseTx, type RpcCall } from "../src/core/tx.js";
import { CAPABILITIES } from "../src/core/capabilities.js";
import { buildMcpTools } from "../src/mcp.js";

// `tx <hash>` — read-only cross-VM diagnosis: EVM receipt + the Solana settlement
// tx(s) via rome_solanaTxForEvmTx (Rome has no debug_trace*) + a Via explorer link.
// One injectable RpcCall (method,params)→result keeps it testable without a network.

const HASH = "0x8d993f2a9802ea0633a1e8d5fa32194f85524d9d6adc9332f41e2b82295c5d74";
const SOL = "5Lwke7W1w3nxrLNai4w7j5rFCMpVSYHffaJKTKC5231jgZVdgQF59ZnWNTxmMDPzAwuVuqTNgN9DRV8Pdxv1DNZU";

/** Build a mock RpcCall from a per-method response map. */
function mockCall(map: Record<string, unknown>): RpcCall {
return async (method) => map[method];
}

describe("diagnoseTx — cross-VM tx diagnosis", () => {
it("maps a confirmed EVM tx to its Solana settlement + a Via link", async () => {
const call = mockCall({
eth_getTransactionReceipt: { status: "0x1", gasUsed: "0x1607b0", blockNumber: "0x1c7a083d", from: "0xaa", to: "0xbb" },
rome_solanaTxForEvmTx: [SOL],
});
const r = await diagnoseTx("hadrian", HASH, call);
expect(r.status).toBe("success");
expect(r.hash).toBe(HASH);
expect(r.receipt?.gasUsed).toBe("0x1607b0");
expect(r.solanaSettlement).toEqual([SOL]);
expect(r.explorer).toContain(HASH);
});

it("reports a reverted tx", async () => {
const call = mockCall({
eth_getTransactionReceipt: { status: "0x0", gasUsed: "0x5208", blockNumber: "0x10", from: "0xaa", to: "0xbb" },
rome_solanaTxForEvmTx: [SOL],
});
const r = await diagnoseTx("hadrian", HASH, call);
expect(r.status).toBe("reverted");
});

it("reports pending when the receipt isn't indexed yet (settlement empty)", async () => {
const call = mockCall({ eth_getTransactionReceipt: null, rome_solanaTxForEvmTx: [] });
const r = await diagnoseTx("hadrian", HASH, call);
expect(r.status).toBe("pending");
expect(r.receipt).toBeNull();
expect(r.solanaSettlement).toEqual([]);
});

it("tolerates rome_solanaTxForEvmTx being unavailable (returns [])", async () => {
const call: RpcCall = async (m) => {
if (m === "eth_getTransactionReceipt") return { status: "0x1", gasUsed: "0x1", blockNumber: "0x1", from: "0xaa", to: "0xbb" };
throw new Error("method not found");
};
const r = await diagnoseTx("hadrian", HASH, call);
expect(r.status).toBe("success");
expect(r.solanaSettlement).toEqual([]);
});
});

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