From da842c2404e0ee3165c95600ced184751791f2d6 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:20:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20verify=20--path=20from-home=20=E2=80=94?= =?UTF-8?q?=20the=20round-trip=20works-gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes verify across all four builder paths. from-home composes the shipped bridge engines into one asserted round trip: bridge USDC in as wUSDC (CCTP wrapper intent, waits through Circle attestation and asserts the wUSDC balance delta) -> act with it on Rome (a real wUSDC transfer via submitRomeTx) -> bridge back out to attestation-ready + claim handle. Legs run in order and fail fast. Activation is pre-checked BEFORE any spend (never bridge in and strand); the destination claim stays the user's own step. EVM key only. wUSDC resolves chain-first from the registry (spl_wrapper matched by the gas token's mint - getTokens drops assetRef). Funded-verified on Hadrian (0.2 USDC round trip): in 0xc60ca63b (delta 200000) -> act 0x9af1ebe4 -> out 0x1f7e74b1 claim-ready, ok:true. 97 unit tests; GUIDES round-trip section + ARCHITECTURE row. --- docs/ARCHITECTURE.md | 1 + docs/GUIDES.md | 21 +++++- src/core/capabilities.ts | 8 ++- src/core/verify.ts | 146 ++++++++++++++++++++++++++++++++++++++- test/verify.test.ts | 75 ++++++++++++++++++++ 5 files changed, 244 insertions(+), 7 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 350d2cf..3336cb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,6 +76,7 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by | `activate ` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) | | `verify [--path solidity]` | `ROME_EVM_KEY` + `ROME_SOLANA_KEY` | the **both-lane works-gate**: deploy a probe, drive it from the EVM lane *and* the Solana lane, assert parity | | `verify --path solana-program` | `ROME_EVM_KEY` | the **cross-VM works-gate**: deploy a thin CPI wrapper; an EVM-lane call drives a Solana program (SPL Memo) via CPI. `--solana-rpc` adds the Solana-log deep check | +| `verify --path from-home --from --amount ` | `ROME_EVM_KEY` | the **round-trip works-gate**: bridge in (wrapper) → act on Rome → bridge out to claim-ready. Waits on Circle attestation (~20 min); needs an activated account | `fund`/`bridge` orchestrate `@rome-protocol/sdk/bridge` (quote → sign the source burn → settle → register → poll); `verify --path solidity` orchestrates `submitRomeTx` (EVM lane) + `submitRomeTxSolanaLane` (Solana lane); `verify --path solana-program` orchestrates `submitRomeTx` into a wrapper that CPIs a Solana program via the CPI precompile (`0xff…08`). Every action prints what it did; `fund`/`bridge` preview with `--dry-run`. diff --git a/docs/GUIDES.md b/docs/GUIDES.md index e3940c9..0fbb7da 100644 --- a/docs/GUIDES.md +++ b/docs/GUIDES.md @@ -321,7 +321,26 @@ $ rome verify hadrian --path solana-program } ``` -Add `--solana-rpc ` for the deep check: `verify` resolves the EVM tx to its Solana settlement (`rome_solanaTxForEvmTx`) and confirms the memo landed in the program's logs (`"memoConfirmed": true`). (`--path from-home` follows.) +Add `--solana-rpc ` for the deep check: `verify` resolves the EVM tx to its Solana settlement (`rome_solanaTxForEvmTx`) and confirms the memo landed in the program's logs (`"memoConfirmed": true`). + +### `--path from-home` — the round trip, proven + +Coming **from another chain**? This gate proves the whole journey: USDC bridges **in** (as wUSDC), **works** on Rome, and bridges back **out**. + +```console +$ rome verify hadrian --path from-home --from sepolia --amount 0.2 +{ + "path": "from-home", + "legs": { + "in": { "ok": true, "txHash": "0x…", "landed": true, "wusdcDelta": "200000" }, + "act": { "ok": true, "hash": "0x…" }, + "out": { "ok": true, "burnTxHash": "0x…", "claimReady": true } + }, + "ok": true +} +``` + +Three legs, in order, failing fast: **in** (CCTP wrapper intent — waits through Circle attestation, ~15–20 min, and asserts the wUSDC actually landed) → **act** (a real wUSDC transfer via `submitRomeTx` — the asset is usable on Rome) → **out** (`bridge --to` back to the source chain, asserted to attestation-ready + a claim handle). The destination claim is your step, as always. Prereqs (checked up front with clear errors): `ROME_EVM_KEY`; USDC + gas on the source chain; gas on Rome; **an activated account** (`rome activate` — the out-leg burns). --- diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index e05c91b..12ee375 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -226,11 +226,13 @@ export const CAPABILITIES: Capability[] = [ verbAction( "verify", "verify", - "The path-aware works-gate. solidity: prove the SAME contract answers on the EVM lane AND the Solana lane (ROME_EVM_KEY + ROME_SOLANA_KEY). solana-program: an EVM-lane call drives a Solana program via CPI (ROME_EVM_KEY). e.g. rome verify hadrian --path solana-program", + "The path-aware works-gate. solidity: the SAME contract answers on BOTH lanes (ROME_EVM_KEY + ROME_SOLANA_KEY). solana-program: an EVM-lane call drives a Solana program via CPI (ROME_EVM_KEY). from-home: bridge in → act → bridge out to claim-ready (ROME_EVM_KEY; needs --from + --amount; ~20 min). e.g. rome verify hadrian --path from-home --from sepolia --amount 0.2", [ chainArg, - { name: "path", required: false, description: "builder path: solidity (default) | solana-program (from-home follows)" }, - { name: "solana-rpc", required: false, description: "Solana RPC for the opt-in deep check (confirm the CPI effect in Solana logs)" }, + { name: "path", required: false, description: "builder path: solidity (default) | solana-program | from-home" }, + { name: "solana-rpc", required: false, description: "solana-program: Solana RPC for the opt-in deep check" }, + { name: "from", required: false, description: "from-home: source chain holding your USDC" }, + { name: "amount", required: false, description: "from-home: USDC amount for the round trip (small, e.g. 0.2)" }, ], (a) => verifyHandler(a), ), diff --git a/src/core/verify.ts b/src/core/verify.ts index c3d1d5e..7b53887 100644 --- a/src/core/verify.ts +++ b/src/core/verify.ts @@ -2,10 +2,13 @@ import { createPublicClient, createWalletClient, http, encodeFunctionData } from import { privateKeyToAccount } from "viem/accounts"; import { Connection, type Transaction } from "@solana/web3.js"; import { submitRomeTx, submitRomeTxSolanaLane } from "@rome-protocol/sdk"; +import { getTokens } from "@rome-protocol/registry"; import { getChainFacts } from "./facts.js"; import { requireEvmKey, requireSolanaKey } from "./keys.js"; import { eip1193FromAccount } from "./eip1193.js"; import { STORE_PROBE, CPI_MEMO_PROBE } from "./probe.js"; +import { runInboundUsdc, runOutboundUsdc, resolveSourceChain, resolveBridgeBase, usdcBaseUnits, defaultBridgeDeps } from "./bridge.js"; +import { readActivation, notActivatedError } from "./activate.js"; // `rome verify --path solidity` — the both-lane works-gate. Deploy a probe, then prove // the SAME contract answers when set from the EVM lane (submitRomeTx) AND the Solana lane @@ -201,11 +204,148 @@ export function defaultVerifySolanaProgramDeps(chain: string | number, opts?: { }; } -/** `rome verify --path ` handler — validates the path, runs the gate. */ -export async function verifyHandler(args: Record): Promise { +// ───────────────────────────────────────────────────────────────────────────── +// `rome verify --path from-home` — the round-trip works-gate. Bridge USDC IN as +// wUSDC (CCTP wrapper intent) → ACT with it on Rome (a real wUSDC self-transfer via +// submitRomeTx) → bridge it back OUT to attestation-ready + a claim handle. Legs run +// in order and fail fast. The gate asserts to attestation-ready — the destination +// claim is the user's own step (Rome sponsors inbound settle, never the outbound +// claim). Prereqs checked up front: activation (the out-leg burns), source USDC + +// gas, Rome gas. EVM key only. The in-leg waits on Circle attestation (~15–20 min). + +export interface VerifyFromHomeResult { + path: "from-home"; + legs: { + in: { ok: boolean; txHash?: string; landed?: boolean; wusdcDelta?: string }; + act?: { ok: boolean; hash?: string }; + out?: { ok: boolean; burnTxHash?: string; claimReady?: boolean }; + }; + ok: boolean; +} + +export interface VerifyFromHomeDeps { + /** Bridge USDC in (wrapper intent); resolve once the transfer completes and report the wUSDC delta. */ + bridgeIn(): Promise<{ txHash: string; landed: boolean; wusdcDelta: bigint }>; + /** One real act with the bridged asset on Rome (wUSDC self-transfer). */ + act(amount: bigint): Promise<{ hash: `0x${string}`; success: boolean }>; + /** Bridge back out; ready = burn landed + Circle attestation ready (claim handle emitted). */ + bridgeOut(amount: bigint): Promise<{ burnTxHash: string; claimReady: boolean }>; +} + +/** In → act → out, failing fast — a red leg stops the gate. */ +export async function runVerifyFromHome(deps: VerifyFromHomeDeps): Promise { + const inRes = await deps.bridgeIn(); + const inOk = inRes.landed && inRes.wusdcDelta > 0n; + const legs: VerifyFromHomeResult["legs"] = { + in: { ok: inOk, txHash: inRes.txHash, landed: inRes.landed, wusdcDelta: inRes.wusdcDelta.toString() }, + }; + if (!inOk) return { path: "from-home", legs, ok: false }; + + const actRes = await deps.act(inRes.wusdcDelta); + legs.act = { ok: actRes.success, hash: actRes.hash }; + if (!actRes.success) return { path: "from-home", legs, ok: false }; + + const outRes = await deps.bridgeOut(inRes.wusdcDelta); + legs.out = { ok: outRes.claimReady, burnTxHash: outRes.burnTxHash, claimReady: outRes.claimReady }; + return { path: "from-home", legs, ok: legs.out.ok }; +} + +const ERC20_ABI = [ + { inputs: [{ name: "to", type: "address" }, { name: "amount", type: "uint256" }], name: "transfer", outputs: [{ name: "", type: "bool" }], stateMutability: "nonpayable", type: "function" }, + { inputs: [{ name: "owner", type: "address" }], name: "balanceOf", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, +] as const; + +/** The chain's wUSDC wrapper — the spl_wrapper over the gas token's mint (registry getTokens drops assetRef). */ +export function wrapperUsdcAddress(chainId: number): `0x${string}` { + const raw = getTokens(chainId) as unknown; + const rows = (Array.isArray(raw) ? raw : (raw as { tokens?: unknown[] })?.tokens ?? []) as Array<{ address?: string; kind?: string; mintId?: string }>; + const gas = rows.find((t) => t.kind === "gas"); + const w = gas && rows.find((t) => t.kind === "spl_wrapper" && t.mintId === gas.mintId); + if (!w?.address) throw new Error(`No wUSDC wrapper (spl_wrapper over the gas mint) in the registry for chain ${chainId}.`); + return w.address as `0x${string}`; +} + +/** Real legs: the bridge engines + a wUSDC self-transfer. EVM key only. */ +export function defaultVerifyFromHomeDeps( + chain: string | number, + opts: { from: string; amount: string; bridgeApi?: string }, +): VerifyFromHomeDeps { + const c = getChainFacts(chain); + const account = privateKeyToAccount(requireEvmKey()); // fail fast — no network + const source = resolveSourceChain(c.chainId, opts.from); + const base = resolveBridgeBase(c.chainId, opts.bridgeApi); + const amountBaseUnits = usdcBaseUnits(opts.amount); + const wusdc = wrapperUsdcAddress(c.chainId); + const pub = createPublicClient({ transport: http(c.rpcUrl) }); + const provider = eip1193FromAccount(account, c.rpcUrl, c.chainId); + const balance = async () => + BigInt((await pub.readContract({ address: wusdc, abi: ERC20_ABI, functionName: "balanceOf", args: [account.address] })) as bigint); + + return { + async bridgeIn() { + const before = await balance(); + // Wrapper intent; wait through Circle attestation (~15–20 min) to completion. + const r = await runInboundUsdc({ + romeChainId: c.chainId, + base, + source, + amountBaseUnits, + address: account.address, + intent: "wrapper", + pollMax: 400, + pollIntervalMs: 5000, + deps: defaultBridgeDeps(), + }); + if (r.dryRun) throw new Error("unexpected dry-run result in the from-home in-leg"); + const landed = r.outcome === "complete"; + const after = landed ? await balance() : before; + return { txHash: r.step1TxHash, landed, wusdcDelta: after - before }; + }, + async act(amount) { + const data = encodeFunctionData({ abi: ERC20_ABI, functionName: "transfer", args: [account.address, amount] }); + const hash = await submitRomeTx(provider, { from: account.address, to: wusdc, data }); + const rcpt = await pub.waitForTransactionReceipt({ hash }); + return { hash, success: rcpt.status === "success" }; + }, + async bridgeOut(amount) { + const r = await runOutboundUsdc({ + romeChainId: c.chainId, + base, + romeRpcUrl: c.rpcUrl, + dest: source, // round trip: back to the chain the funds came from + amountBaseUnits: amount, + address: account.address, + recipient: account.address, + deps: defaultBridgeDeps(), + }); + if (r.dryRun) throw new Error("unexpected dry-run result in the from-home out-leg"); + return { burnTxHash: r.burnTxHash, claimReady: r.claim.status === "ready" }; + }, + }; +} + +/** `rome verify --path ` handler. */ +export async function verifyHandler( + args: Record, +): Promise { const path = (args.path ?? "solidity").toLowerCase(); // async so a synchronous key/config throw surfaces as a rejected promise, not a throw. if (path === "solidity") return runVerifySolidity(defaultVerifyDeps(args.chain)); if (path === "solana-program") return runVerifySolanaProgram(defaultVerifySolanaProgramDeps(args.chain, { solanaRpc: args["solana-rpc"] })); - throw new Error(`--path ${path} is not supported yet (available: solidity, solana-program; from-home follows).`); + if (path === "from-home") { + if (!args.from) throw new Error("from-home needs --from (where your USDC starts)."); + if (!args.amount) throw new Error("from-home needs --amount (small, e.g. 0.2)."); + const c = getChainFacts(args.chain); + const account = privateKeyToAccount(requireEvmKey()); + // The out-leg burns — check activation FIRST so we never bridge in and then strand. + const pub = createPublicClient({ transport: http(c.rpcUrl) }); + const ethCall = async (to: string, data: `0x${string}`) => { + const { data: ret } = await pub.call({ to: to as `0x${string}`, data }); + return (ret ?? "0x") as `0x${string}`; + }; + const status = await readActivation(account.address, c.romeEvmProgramId, ethCall); + if (!status.activated) throw notActivatedError(status, args.chain); + return runVerifyFromHome(defaultVerifyFromHomeDeps(args.chain, { from: args.from, amount: args.amount, bridgeApi: args["bridge-api"] })); + } + throw new Error(`--path ${path} is not supported (available: solidity, solana-program, from-home).`); } diff --git a/test/verify.test.ts b/test/verify.test.ts index 1cde311..a383fc6 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, vi } from "vitest"; import { runVerifySolidity, runVerifySolanaProgram, + runVerifyFromHome, verifyHandler, defaultVerifyDeps, defaultVerifySolanaProgramDeps, type VerifyDeps, type VerifySolanaProgramDeps, + type VerifyFromHomeDeps, } from "../src/core/verify.js"; import { CAPABILITIES } from "../src/core/capabilities.js"; import { buildMcpTools } from "../src/mcp.js"; @@ -110,6 +112,79 @@ describe("runVerifySolanaProgram — EVM drives a Solana program via CPI", () => }); }); +// `verify --path from-home` — the round-trip works-gate: bridge USDC IN (wrapper) → +// act with it on Rome (wUSDC self-transfer) → bridge OUT to attestation-ready. Legs run +// in order and FAIL FAST (a red leg stops the gate). The out-leg asserts to +// attestation-ready + claim handle — the destination claim is the user's own step. +function fhDeps(over: Partial, order: string[] = []): VerifyFromHomeDeps { + return { + bridgeIn: vi.fn(async () => { + order.push("in"); + return { txHash: "0xin", landed: true, wusdcDelta: 200000n }; + }), + act: vi.fn(async () => { + order.push("act"); + return { hash: "0xact" as `0x${string}`, success: true }; + }), + bridgeOut: vi.fn(async () => { + order.push("out"); + return { burnTxHash: "0xburn", claimReady: true }; + }), + ...over, + }; +} + +describe("runVerifyFromHome — the round-trip works-gate", () => { + it("GREEN: in (wUSDC landed) → act → out (claim-ready), in order", async () => { + const order: string[] = []; + const r = await runVerifyFromHome(fhDeps({}, order)); + expect(order).toEqual(["in", "act", "out"]); + expect(r.path).toBe("from-home"); + expect(r.legs.in.ok).toBe(true); + expect(r.legs.act?.ok).toBe(true); + expect(r.legs.out?.ok).toBe(true); + expect(r.ok).toBe(true); + }); + + it("RED + fail-fast: inbound lands no wUSDC → act/out never run", async () => { + const order: string[] = []; + const d = fhDeps({ bridgeIn: vi.fn(async () => ({ txHash: "0xin", landed: true, wusdcDelta: 0n })) }, order); + const r = await runVerifyFromHome(d); + expect(r.legs.in.ok).toBe(false); + expect(r.ok).toBe(false); + expect(d.act).not.toHaveBeenCalled(); + expect(d.bridgeOut).not.toHaveBeenCalled(); + }); + + it("RED + fail-fast: act reverts → out never runs", async () => { + const d = fhDeps({ act: vi.fn(async () => ({ hash: "0xbad" as `0x${string}`, success: false })) }); + const r = await runVerifyFromHome(d); + expect(r.legs.act?.ok).toBe(false); + expect(r.ok).toBe(false); + expect(d.bridgeOut).not.toHaveBeenCalled(); + }); + + it("RED: out-leg not claim-ready → gate fails", async () => { + const r = await runVerifyFromHome(fhDeps({ bridgeOut: vi.fn(async () => ({ burnTxHash: "0xburn", claimReady: false })) })); + expect(r.legs.out?.ok).toBe(false); + expect(r.ok).toBe(false); + }); +}); + +describe("verifyHandler --path from-home arg contract", () => { + it("requires --from and --amount with a clear error (before any network call)", async () => { + const pe = process.env.ROME_EVM_KEY; + process.env.ROME_EVM_KEY = `0x${"11".repeat(32)}`; + try { + await expect(verifyHandler({ chain: "hadrian", path: "from-home" })).rejects.toThrow(/--from/); + await expect(verifyHandler({ chain: "hadrian", path: "from-home", from: "sepolia" })).rejects.toThrow(/--amount/); + } finally { + if (pe !== undefined) process.env.ROME_EVM_KEY = pe; + else delete process.env.ROME_EVM_KEY; + } + }); +}); + describe("verify is a key-gated action, never on MCP", () => { it("registered as an action requiring a key, and absent from the MCP surface", () => { const cap = CAPABILITIES.find((c) => c.id === "verify.verify");