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 docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by
| `activate <chain>` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) |
| `verify <chain> [--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 <chain> --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 <chain> --path from-home --from <src> --amount <usdc>` | `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`.

Expand Down
21 changes: 20 additions & 1 deletion docs/GUIDES.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,26 @@ $ rome verify hadrian --path solana-program
}
```

Add `--solana-rpc <url>` 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 <url>` 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).

---

Expand Down
8 changes: 5 additions & 3 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
Expand Down
146 changes: 143 additions & 3 deletions src/core/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -201,11 +204,148 @@ export function defaultVerifySolanaProgramDeps(chain: string | number, opts?: {
};
}

/** `rome verify --path <solidity|solana-program>` handler — validates the path, runs the gate. */
export async function verifyHandler(args: Record<string, string>): Promise<VerifyResult | VerifySolanaProgramResult> {
// ─────────────────────────────────────────────────────────────────────────────
// `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<VerifyFromHomeResult> {
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 <solidity|solana-program|from-home>` handler. */
export async function verifyHandler(
args: Record<string, string>,
): Promise<VerifyResult | VerifySolanaProgramResult | VerifyFromHomeResult> {
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 <source chain> (where your USDC starts).");
if (!args.amount) throw new Error("from-home needs --amount <usdc> (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).`);
}
75 changes: 75 additions & 0 deletions test/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<VerifyFromHomeDeps>, 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");
Expand Down
Loading