From df02ce6f1e27a3bb349926c79b581e9c1e007aa7 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:04:29 +0300 Subject: [PATCH] feat: bridge --to (CCTP outbound) + activate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge wUSDC out of Rome: burn on Rome (RomeBridgeWithdraw / CCTP v2) → register → poll to attestation-ready → return a claim handle. Claiming on the destination is the user's responsibility (MessageTransmitterV2. receiveMessage, needs destination gas) — Rome sponsors the inbound settle intent, never the outbound destination claim. First outbound requires a funded external-auth PDA (CCTP creates a per-burn messageSentEventData account). `rome activate` funds it once via the on-chain SimpleActivator (idempotent); `bridge --to` pre-checks it with a pure-EVM account_lamports read and directs to `rome activate` rather than letting the burn revert deep in CCTP with Custom(1). EVM key only. Funded-verified on Hadrian: activate -> burn 0.1 wUSDC -> Base Sepolia, tx 0xc454...9fbe SUCCESS, attestation-ready, claim handle emitted. 92 unit tests; docs: GUIDES "Bridge out" + activation + claim responsibility. --- docs/ARCHITECTURE.md | 11 ++- docs/GUIDES.md | 46 ++++++++++ src/core/activate.ts | 128 ++++++++++++++++++++++++++ src/core/bridge.ts | 188 ++++++++++++++++++++++++++++++++++++++- src/core/capabilities.ts | 20 ++++- test/activate.test.ts | 82 +++++++++++++++++ test/bridge.test.ts | 150 +++++++++++++++++++++++++++++++ 7 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 src/core/activate.ts create mode 100644 test/activate.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad6da36..350d2cf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ ``` ┌───────────────── capability core (src/core/capabilities.ts) ─────────────────┐ │ READS (no keys): facts · cookbook · call · doctor · tx · preset │ - │ ACTIONS (key-gated): deploy · send · fund · bridge · verify │ + │ ACTIONS (key-gated): deploy · send · fund · bridge · activate · verify │ └───────────────────────────┬───────────────────────────┬─────────────────────┘ rome (CLI — all commands) rome mcp (stdio MCP server) humans + agent shell-outs MCP-native agents — READS ONLY @@ -71,7 +71,9 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by | `deploy [args]` | `ROME_EVM_KEY` | deploy a compiled artifact, handling Rome's gas quirks | | `send [args]` | `ROME_EVM_KEY` | write to a contract via `submitRomeTx` (the correct Rome write path) | | `fund --from --amount ` | `ROME_EVM_KEY` | bridge USDC → Rome **gas** (CCTP); the "from home" on-ramp | -| `bridge --from --amount [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC in as gas or wUSDC | +| `bridge --from --amount [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC **in** as gas or wUSDC | +| `bridge --to --amount [--recipient 0x…]` | `ROME_EVM_KEY` | bridge wUSDC **out**: burn on Rome → claim handle for the destination (you claim there) | +| `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 | @@ -100,7 +102,8 @@ src/ facts.ts chain/token/contract/gas/balance/programs + resolveChainId cookbook.ts cpi-recipe + patterns + errors (the taxonomy) + curated index actions.ts call (read) · deploy · send (viem + submitRomeTx) - bridge.ts fund · bridge — the inbound CCTP flow engine (orchestrates the SDK) + bridge.ts fund · bridge in/out — the CCTP flow engines (orchestrate the SDK) + activate.ts one-time PDA funding for bridge-out (SimpleActivator) + the check doctor.ts preflight checklist tx.ts cross-VM diagnosis (rome_solanaTxForEvmTx; no debug_trace) verify.ts the path-aware works-gate (+ probe.ts: bundled Store + CPI-Memo probes) @@ -116,4 +119,4 @@ test/ per-module unit tests + alignment + behavioral CLI (+ mcp- ## Roadmap -Shipped: the full four-paths surface above (reads + actions), all funded-verified on a live Rome chain — including `verify --path solana-program` (an EVM-lane call driving a Solana program via CPI). Next: `verify --path from-home` · bridge ETH (Wormhole) + outbound (`from-rome`) · `new` (wraps `create-rome-app`). Deploy/build stays orchestrated (Foundry/Hardhat/create-rome-app) — `rome` is the connective tissue + the Rome-unique gaps, not a re-implementation of the EVM toolchain. +Shipped: the full four-paths surface above (reads + actions), all funded-verified on a live Rome chain — including `verify --path solana-program` (an EVM-lane call driving a Solana program via CPI). Next: `verify --path from-home` · bridge ETH (Wormhole) · `new` (wraps `create-rome-app`). Shipped since: `bridge --to` (CCTP outbound, funded-verified) + `activate`. Deploy/build stays orchestrated (Foundry/Hardhat/create-rome-app) — `rome` is the connective tissue + the Rome-unique gaps, not a re-implementation of the EVM toolchain. diff --git a/docs/GUIDES.md b/docs/GUIDES.md index 4d76bc5..e3940c9 100644 --- a/docs/GUIDES.md +++ b/docs/GUIDES.md @@ -6,6 +6,7 @@ Concrete, copy-paste recipes: real commands with real output, and how to fold `r - [Integrate into an AI agent (MCP)](#integrate-into-an-ai-agent-mcp) - [The agent grounding loop](#the-agent-grounding-loop) - [Fund a wallet from another chain (`fund` / `bridge`)](#fund-a-wallet-from-another-chain-fund--bridge) +- [Bridge out of Rome (`bridge --to`) + `activate`](#bridge-out-of-rome-bridge---to--activate) - [Prove it works — `rome verify`](#prove-it-works--rome-verify) - [Shell & scripting recipes](#shell--scripting-recipes) - [Use it in CI](#use-it-in-ci) @@ -238,6 +239,51 @@ Supported source chains come from the registry's bridge config for the target Ro --- +## Bridge out of Rome (`bridge --to`) + `activate` + +The reverse of `fund` / `bridge --from`: you hold **wUSDC** on Rome and want USDC back on another chain. `bridge --to ` burns your wUSDC on Rome via `RomeBridgeWithdraw` (CCTP v2) and hands you a **claim handle** for the destination. `--from` = in, `--to` = out (exactly one; `fund` is always in). + +**The responsibility splits by chain — this is the design, not a limitation:** +- **Rome side — orchestrated for you.** You sign one burn tx; the engine registers the transfer and polls until Circle attestation is ready. (Rome runs a sponsor for the *inbound* settle intent; outbound carries no settle.) +- **Destination side — yours.** Rome does **not** sponsor the destination mint. You call `MessageTransmitterV2.receiveMessage(message, attestation)` on the destination — which needs gas **on that chain**. The command returns the transmitter, the CCTP domain, and the transfer id so you (or Circle's portal / the bridge-api) can complete it. Delivery is permissionless (`destinationCaller = 0`), so anyone can submit it. + +### First time out: activate once (`rome activate`) + +Inbound is deliberately frictionless — no per-user account is needed. The **first time you bridge out**, CCTP's `deposit_for_burn` creates a per-burn `messageSentEventData` account funded by your **external-auth PDA**, so that PDA must hold lamports (~15M reserve per burn). `rome activate ` funds it once (~2 USDC, via the on-chain `SimpleActivator`); it's **idempotent** — it skips with no spend if you're already activated. `bridge --to` checks this first (a pure EVM read of the PDA lamports via the `account_lamports` precompile) and points you here rather than letting the burn revert deep inside CCTP. + +```console +$ rome activate hadrian +{ "address": "0x1Fc3…", "pda": "BTbRPi8n…", "alreadyActivated": true, "lamports": "26099680" } +``` + +### Bridge out + +```console +$ rome bridge hadrian --to base-sepolia --amount 0.1 --dry-run # quote + planned burn, no spend +$ rome bridge hadrian --to base-sepolia --amount 0.1 +{ + "route": "usdc-cctp-from-rome", + "burnTxHash": "0xc4549892…", + "destinationChainId": 84532, + "claim": { + "yourResponsibility": true, + "status": "ready", + "transmitter": "0xE737…E275", + "domain": 6, + "note": "Claiming on Base Sepolia … call MessageTransmitterV2.receiveMessage(message, attestation) … (needs gas on Base Sepolia). The bridge-api tracks this transfer at id=txf_…" + } +} +``` + +Destinations are the same registry CCTP chains as sources (resolve by id / name / slug). `--recipient` sets the destination address (default = your address). + +### How the bridge actually works + +- **The real bridge is on-chain.** `RomeBridgeWithdraw.burnUSDC` (egress) + Circle CCTP v2 do the work. The **bridge-api** ([`rome-protocol/rome-bridge-api`](https://github.com/rome-protocol/rome-bridge-api)) is an off-chain *orchestrator + tracker* — it quotes the route and follows the transfer; it **holds no funds and cannot move yours**. +- **Inbound** (`--from`): burn on the source L2 → Circle attestation → Rome's sponsor settles on Rome. You sign only the source burn; no activation needed. +- **Outbound** (`--to`): activate (first time) → burn wUSDC on Rome → Circle attestation → **you** claim on the destination. No settle authorization, no destination sponsor. +- Base override for the orchestrator: `--bridge-api ` or `ROME_BRIDGE_API` (defaults to the devnet orchestrator). + ## Prove it works — `rome verify` The keystone: prove a contract actually works on Rome, driven from **both lanes**. `verify --path solidity` deploys a probe, sets a value from the EVM lane (`submitRomeTx`) *and* the Solana lane (`submitRomeTxSolanaLane`), and asserts the same contract answered on each — the litmus test, runnable. diff --git a/src/core/activate.ts b/src/core/activate.ts new file mode 100644 index 0000000..660dbbc --- /dev/null +++ b/src/core/activate.ts @@ -0,0 +1,128 @@ +import { createPublicClient, http, encodeFunctionData, parseAbi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { deriveAuthorityPda, pubkeyToBytes32, submitRomeTx } from "@rome-protocol/sdk"; +import { getContracts } from "@rome-protocol/registry"; +import { getChainFacts, resolveChainId } from "./facts.js"; +import { requireEvmKey } from "./keys.js"; +import { eip1193FromAccount } from "./eip1193.js"; + +// ── Account activation ────────────────────────────────────────────────────── +// Inbound bridging is frictionless — no per-user account needed. The FIRST time a +// user bridges OUT, CCTP's `deposit_for_burn` creates a per-tx `messageSentEventData` +// account funded by the user's external-auth PDA, which must therefore hold lamports. +// `rome activate` funds that PDA once (via SimpleActivator); `bridge --to` checks it +// and points here if it's missing. The check is a pure EVM read of the PDA's lamports +// through the CpiProgram precompile (`account_lamports`) — no Solana RPC required. + +/** SimpleActivator.CCTP_BURN_RESERVE — the lamports one outbound burn's event account needs. */ +export const CCTP_BURN_RESERVE = 15_000_000n; + +const CPI_PRECOMPILE = "0xff00000000000000000000000000000000000008" as const; +const LAMPORTS_ABI = parseAbi(["function account_lamports(bytes32) view returns (uint64)"]); +const ACTIVATOR_ABI = parseAbi(["function activate() payable", "function activationCost() view returns (uint256)"]); + +export interface ActivationStatus { + address: `0x${string}`; + pda: string; + lamports: bigint; + /** true once the PDA holds enough lamports for at least one outbound CCTP burn. */ + activated: boolean; +} + +export type EthCall = (to: string, data: `0x${string}`) => Promise<`0x${string}`>; + +/** Read a wallet's activation status as a pure EVM call — the PDA's lamports via `account_lamports`. */ +export async function readActivation(address: `0x${string}`, programId: string, ethCall: EthCall): Promise { + const pdaKey = deriveAuthorityPda(address, programId); + const data = encodeFunctionData({ abi: LAMPORTS_ABI, functionName: "account_lamports", args: [pubkeyToBytes32(pdaKey)] }); + const ret = await ethCall(CPI_PRECOMPILE, data); + const lamports = BigInt(ret && ret !== "0x" ? ret : "0x0"); + return { address, pda: pdaKey.toBase58(), lamports, activated: lamports >= CCTP_BURN_RESERVE }; +} + +/** The clear error `bridge --to` throws when the actor's account isn't activated yet. */ +export function notActivatedError(status: ActivationStatus, chain: string | number): Error { + return new Error( + `Account not activated for outbound. Your external-auth PDA (${status.pda}) holds ${status.lamports} lamports — ` + + `outbound needs >= ${CCTP_BURN_RESERVE} to fund CCTP's per-burn event account. ` + + `Run \`rome activate ${chain}\` first (one-time, ~2 USDC). Inbound (\`--from\`) needs no activation.`, + ); +} + +export interface ActivateResult { + address: `0x${string}`; + pda: string; + alreadyActivated: boolean; + lamports: string; + txHash?: `0x${string}`; + cost?: string; +} + +export interface ActivateDeps { + address: `0x${string}`; + pda: string; + readLamports(): Promise; + getActivationCost(): Promise; + activate(cost: bigint): Promise<{ hash: `0x${string}`; success: boolean }>; +} + +/** Fund the actor's external-auth PDA once (idempotent — skips, no spend, if already activated). */ +export async function runActivate(deps: ActivateDeps): Promise { + const before = await deps.readLamports(); + if (before >= CCTP_BURN_RESERVE) { + return { address: deps.address, pda: deps.pda, alreadyActivated: true, lamports: before.toString() }; + } + const cost = await deps.getActivationCost(); + const { hash, success } = await deps.activate(cost); + if (!success) throw new Error(`Activation tx reverted (${hash}).`); + const after = await deps.readLamports(); + return { address: deps.address, pda: deps.pda, alreadyActivated: false, txHash: hash, cost: cost.toString(), lamports: after.toString() }; +} + +function resolveActivator(chainId: number): `0x${string}` { + const raw = getContracts(chainId) as unknown; + const list = (Array.isArray(raw) ? raw : (raw as { contracts?: unknown[] })?.contracts ?? []) as Array<{ + name?: string; + address?: string; + versions?: Array<{ address?: string; status?: string }>; + }>; + const entry = list.find((c) => c.name === "SimpleActivator"); + const addr = entry?.versions?.find((v) => v.status === "live")?.address ?? entry?.address; + if (!addr) throw new Error(`No live SimpleActivator in the registry for chain ${chainId}.`); + return addr as `0x${string}`; +} + +/** Real deps: SimpleActivator from the registry + activate via submitRomeTx. EVM key only. */ +export function defaultActivateDeps(chain: string | number): ActivateDeps { + const c = getChainFacts(chain); + const account = privateKeyToAccount(requireEvmKey()); // fail fast — no network + const activator = resolveActivator(c.chainId); + const pub = createPublicClient({ transport: http(c.rpcUrl) }); + const provider = eip1193FromAccount(account, c.rpcUrl, c.chainId); + const pdaKey = deriveAuthorityPda(account.address, c.romeEvmProgramId); + const pdaB32 = pubkeyToBytes32(pdaKey); + + return { + address: account.address, + pda: pdaKey.toBase58(), + async readLamports() { + const { data } = await pub.call({ to: CPI_PRECOMPILE, data: encodeFunctionData({ abi: LAMPORTS_ABI, functionName: "account_lamports", args: [pdaB32] }) }); + return BigInt(data && data !== "0x" ? data : "0x0"); + }, + async getActivationCost() { + const { data } = await pub.call({ to: activator, data: encodeFunctionData({ abi: ACTIVATOR_ABI, functionName: "activationCost" }) }); + return BigInt(data && data !== "0x" ? data : "0x0"); + }, + async activate(cost) { + const hash = await submitRomeTx(provider, { from: account.address, to: activator, data: encodeFunctionData({ abi: ACTIVATOR_ABI, functionName: "activate" }), value: cost }); + const rcpt = await pub.waitForTransactionReceipt({ hash }); + return { hash, success: rcpt.status === "success" }; + }, + }; +} + +/** `rome activate ` handler — one-time PDA funding for outbound. */ +export function activateHandler(args: Record): Promise { + resolveChainId(args.chain); // validate the chain up front + return runActivate(defaultActivateDeps(args.chain)); +} diff --git a/src/core/bridge.ts b/src/core/bridge.ts index 586b8e2..f4ab757 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -3,8 +3,9 @@ import { privateKeyToAccount } from "viem/accounts"; import { getBridge } from "@rome-protocol/registry"; import * as bridgeSdk from "@rome-protocol/sdk/bridge"; import type { SettleTypedData, TransferRecord, UnsignedTx, FlowStatus } from "@rome-protocol/sdk/bridge"; -import { resolveChainId } from "./facts.js"; +import { resolveChainId, getChainFacts } from "./facts.js"; import { requireEvmKey } from "./keys.js"; +import { readActivation, notActivatedError } from "./activate.js"; // ── Fund / bridge: the "from home" on-ramp ────────────────────────────────── // One rail-agnostic INBOUND flow engine that ORCHESTRATES @rome-protocol/sdk/bridge @@ -16,6 +17,7 @@ import { requireEvmKey } from "./keys.js"; /** The subset of the bridge SDK the engine calls — an interface so tests inject a mock. */ export interface BridgeSdk { inboundCctpQuoteRequest: typeof bridgeSdk.inboundCctpQuoteRequest; + outboundCctpQuoteRequest: typeof bridgeSdk.outboundCctpQuoteRequest; userSignedTxs: typeof bridgeSdk.userSignedTxs; step1BindingTxIndex: typeof bridgeSdk.step1BindingTxIndex; settleTypedDataWithBurn: typeof bridgeSdk.settleTypedDataWithBurn; @@ -262,6 +264,151 @@ async function pollTransfer( return rec; } +// ── The outbound flow engine (from-rome) ──────────────────────────────────── +// The user burns wUSDC ON ROME (user-signed). Rome runs a sponsor for the *inbound* +// settle intent, but outbound carries NO settle and NO destination sponsor — claiming +// on the destination (a standard CCTP receiveMessage) is the USER's responsibility. We +// orchestrate the Rome side (burn → register → poll to attestation-ready) and hand back +// a claim handle. + +export interface OutboundUsdcParams { + romeChainId: number; + base: string; + romeRpcUrl: string; + dest: SourceChain; + amountBaseUnits: bigint; + address: `0x${string}`; + recipient?: `0x${string}`; + dryRun?: boolean; + pollMax?: number; + pollIntervalMs?: number; + deps: BridgeDeps; +} + +export interface OutboundClaimHandle { + /** Claiming on the destination is the user's responsibility — Rome does not sponsor it. */ + yourResponsibility: true; + status: string; + transmitter?: string; + domain?: number; + note: string; +} + +export type OutboundResult = + | { + dryRun: true; + route: string; + amountIn: string; + amountOut: string; + fee?: unknown; + etaSeconds?: number; + plannedTxs: Array<{ stepN: number; to: string; description?: string }>; + } + | { + dryRun: false; + transferId: string; + route: string; + outcome: string; + flow: FlowStatus; + burnTxHash: string; + destinationChainId: number; + claim: OutboundClaimHandle; + }; + +const CLAIM_KIND = "cctp-claim-on-destination"; + +export async function runOutboundUsdc(p: OutboundUsdcParams): Promise { + const { sdk } = p.deps; + const opts = { base: p.base }; + + const req = sdk.outboundCctpQuoteRequest({ + destinationChainId: p.dest.chainId, + romeChainId: p.romeChainId, + amount: p.amountBaseUnits, + evmAddress: p.address, + recipient: p.recipient ?? p.address, + }); + const quote = await sdk.requestQuote(req, opts); + + // guard: never sign a quote that isn't the outbound flow we asked for + if (quote.direction !== "from-rome") { + throw new Error(`Bridge quote direction "${quote.direction}" != "from-rome"; refusing to sign.`); + } + + const signed = sdk.userSignedTxs(quote, quote.route); // step-1: the Rome wUSDC burn + + if (p.dryRun) { + return { + dryRun: true, + route: quote.route, + amountIn: quote.amountIn, + amountOut: quote.amountOut, + fee: quote.fee, + etaSeconds: quote.etaSeconds, + plannedTxs: signed.map((s) => ({ stepN: s.stepN, to: s.tx.to, description: s.tx.description })), + }; + } + + // 1. burn wUSDC on Rome (user-signed; no settle, no destination sponsor) + const burnSigner = p.deps.makeSigner(p.romeRpcUrl); + const burnHashes: string[] = []; + for (const item of signed) burnHashes.push(await burnSigner.sendTx(item.tx)); + const burnTxHash = burnHashes[sdk.step1BindingTxIndex(signed)]; + + // 2. register (no settle authorization on outbound) + const record = await sdk.registerTransfer({ quote, step1TxHash: burnTxHash }, opts); + + // 3. poll until the destination claim is ready (Circle attestation) or terminal + const final = await pollOutbound(sdk, record.id, record, opts, p.deps.sleep, p.pollMax ?? 40, p.pollIntervalMs ?? 3000); + + const q2 = quote.steps.find((s) => s.kind === CLAIM_KIND) as Record | undefined; + const claimStep = (final.steps as Array<{ kind?: string; status?: string }> | undefined)?.find((s) => s.kind === CLAIM_KIND); + const transmitter = q2?.claimTransmitter as string | undefined; + return { + dryRun: false, + transferId: record.id, + route: quote.route, + outcome: final.outcome, + flow: sdk.transferFlowStatus(final), + burnTxHash, + destinationChainId: p.dest.chainId, + claim: { + yourResponsibility: true, + status: claimStep?.status ?? "pending", + transmitter, + domain: q2?.claimDomain as number | undefined, + note: + `Claiming on ${p.dest.name} (chain ${p.dest.chainId}) is your step — Rome does not sponsor it. ` + + `When Circle attestation is ready, call MessageTransmitterV2.receiveMessage(message, attestation)` + + `${transmitter ? ` on ${transmitter}` : ""} (needs gas on ${p.dest.name}). ` + + `The bridge-api tracks this transfer at id=${record.id}. See docs/GUIDES.md "Bridge out".`, + }, + }; +} + +function claimIsReady(rec: TransferRecord): boolean { + const step = (rec.steps as Array<{ kind?: string; status?: string }> | undefined)?.find((s) => s.kind === CLAIM_KIND); + return step?.status === "ready"; +} + +async function pollOutbound( + sdk: BridgeSdk, + id: string, + first: TransferRecord, + opts: { base: string }, + sleep: (ms: number) => Promise, + max: number, + intervalMs: number, +): Promise { + let rec = first; + for (let i = 0; i < max; i++) { + if (rec.outcome === "complete" || rec.outcome === "failed" || claimIsReady(rec)) return rec; + await sleep(intervalMs); + rec = await sdk.getTransfer(id, opts); + } + return rec; +} + // ── Capability handlers (CLI-only; key from env; never MCP) ────────────────── function requireArg(args: Record, name: string): string { @@ -286,8 +433,43 @@ export function fundHandler(args: Record): Promise --from --amount [--intent gas|wrapper]`. */ -export function bridgeHandler(args: Record): Promise { +/** `rome bridge --to --amount ` — burn wUSDC on Rome → USDC on the + * destination. Rome-side only; claiming on the destination is the user's responsibility. */ +async function outboundHandler(args: Record): Promise { + const romeChainId = resolveChainId(args.chain); + const dest = resolveSourceChain(romeChainId, requireArg(args, "to")); + const amountBaseUnits = usdcBaseUnits(requireArg(args, "amount")); + const base = resolveBridgeBase(romeChainId, args["bridge-api"]); + const dryRun = args["dry-run"] === "true"; + // Key FIRST (fail fast, no network): the burn signer + the actor address. + const account = privateKeyToAccount(requireEvmKey()); + const facts = getChainFacts(romeChainId); + const romeRpcUrl = facts.rpcUrl; + const recipient = (args.recipient as `0x${string}` | undefined) ?? account.address; + + // Outbound needs an activated account (a lamport-funded PDA) for the CCTP burn. + // Check first with a pure EVM read and point at `rome activate` — otherwise the burn + // reverts deep in CCTP (Custom(1)). Skip on dry-run (no burn). Inbound needs none. + if (!dryRun) { + const pub = createPublicClient({ transport: http(romeRpcUrl) }); + 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, facts.romeEvmProgramId, ethCall); + if (!status.activated) throw notActivatedError(status, args.chain); + } + + return runOutboundUsdc({ romeChainId, base, romeRpcUrl, dest, amountBaseUnits, address: account.address, recipient, dryRun, deps: defaultBridgeDeps() }); +} + +/** `rome bridge --from …` (in: gas|wrapper) OR `--to …` (out). */ +export function bridgeHandler(args: Record): Promise { + const hasFrom = Boolean(args.from); + const hasTo = Boolean(args.to); + if (hasFrom && hasTo) throw new Error(`Use --from (bridge in) OR --to (bridge out), not both.`); + if (!hasFrom && !hasTo) throw new Error(`Bridge direction required: --from (in) or --to (out).`); + if (hasTo) return outboundHandler(args); const intent = (args.intent ?? "gas").toLowerCase(); if (intent !== "gas" && intent !== "wrapper") { throw new Error(`--intent must be "gas" or "wrapper" (got "${args.intent}").`); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 062fae5..e05c91b 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -5,6 +5,7 @@ import { fundHandler, bridgeHandler } from "./bridge.js"; import { doctor } from "./doctor.js"; import { diagnoseTx } from "./tx.js"; import { verifyHandler } from "./verify.js"; +import { activateHandler } from "./activate.js"; import { getPreset } from "./presets.js"; import { type Deps } from "./deps.js"; @@ -198,18 +199,29 @@ export const CAPABILITIES: Capability[] = [ verbAction( "bridge", "bridge", - "Bridge USDC from a source chain into Rome (CCTP), as gas or a wrapper (wUSDC). Needs ROME_EVM_KEY. e.g. rome bridge hadrian --from base-sepolia --amount 1 --intent wrapper", + "Bridge USDC in (--from : gas or wUSDC) or out (--to : burn wUSDC → USDC on the destination, which you claim there). Needs ROME_EVM_KEY. e.g. rome bridge hadrian --from base-sepolia --amount 1 --intent wrapper · rome bridge hadrian --to base-sepolia --amount 1", [ chainArg, - { name: "from", required: true, description: "source chain (id, name, or slug) holding your USDC" }, + { name: "from", required: false, description: "IN: source chain (id/name/slug) holding your USDC" }, + { name: "to", required: false, description: "OUT: destination chain (id/name/slug) to receive USDC — you claim there" }, { name: "amount", required: true, description: "USDC amount to bridge (human, e.g. 1.5)" }, - { name: "intent", required: false, description: "gas (default) → native gas · wrapper → wUSDC on Rome" }, + { name: "intent", required: false, description: "IN only: gas (default) → native gas · wrapper → wUSDC on Rome" }, + { name: "recipient", required: false, description: "OUT only: destination recipient (default = your address)" }, { name: "bridge-api", required: false, description: "override the bridge-api base URL" }, - { name: "dry-run", required: false, description: "quote + plan the source txs without signing/broadcasting" }, + { name: "dry-run", required: false, description: "quote + plan the txs without signing/broadcasting" }, ], (a) => bridgeHandler(a), ), + // ── activate: one-time PDA funding, required before the first bridge OUT ── + verbAction( + "activate", + "activate", + "One-time account activation for bridging OUT: funds your external-auth PDA so CCTP can create its per-burn event account. Needs ROME_EVM_KEY (~2 USDC; idempotent — skips if already active). Inbound needs no activation. e.g. rome activate hadrian", + [chainArg], + (a) => activateHandler(a), + ), + // ── verify: the path-aware works-gate (CLI-only action; keys vary by path) ── verbAction( "verify", diff --git a/test/activate.test.ts b/test/activate.test.ts new file mode 100644 index 0000000..2b9b588 --- /dev/null +++ b/test/activate.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { runActivate, readActivation, CCTP_BURN_RESERVE, type ActivateDeps } from "../src/core/activate.js"; +import { CAPABILITIES } from "../src/core/capabilities.js"; +import { buildMcpTools } from "../src/mcp.js"; + +// `rome activate` — one-time PDA funding for the first outbound bridge. A key-gated +// action (CLI-only, never MCP). Idempotent: skips (no spend) when already activated. +// The status check is a pure EVM read of the PDA lamports via `account_lamports`. + +function deps(over: Partial): ActivateDeps { + return { + address: "0xabc0000000000000000000000000000000000abc", + pda: "PdA1111111111111111111111111111111111111111", + readLamports: vi.fn(async () => 0n), + getActivationCost: vi.fn(async () => 2n * 10n ** 18n), + activate: vi.fn(async () => ({ hash: "0xact" as `0x${string}`, success: true })), + ...over, + }; +} + +describe("runActivate", () => { + it("activates an unfunded PDA: read → cost → activate{value} → re-read lamports", async () => { + const order: string[] = []; + let reads = 0; + const d = deps({ + readLamports: vi.fn(async () => { + order.push("read"); + return reads++ === 0 ? 0n : 30_000_000n; + }), + getActivationCost: vi.fn(async () => (order.push("cost"), 2n * 10n ** 18n)), + activate: vi.fn(async () => (order.push("activate"), { hash: "0xact" as `0x${string}`, success: true })), + }); + const r = await runActivate(d); + expect(order).toEqual(["read", "cost", "activate", "read"]); + expect(r.alreadyActivated).toBe(false); + expect(r.txHash).toBe("0xact"); + expect(r.cost).toBe((2n * 10n ** 18n).toString()); + expect(r.lamports).toBe("30000000"); + }); + + it("skips with NO spend when already activated (lamports >= reserve)", async () => { + const d = deps({ readLamports: vi.fn(async () => CCTP_BURN_RESERVE + 1n) }); + const r = await runActivate(d); + expect(r.alreadyActivated).toBe(true); + expect(d.getActivationCost).not.toHaveBeenCalled(); + expect(d.activate).not.toHaveBeenCalled(); + }); + + it("throws if the activation tx reverts", async () => { + const d = deps({ activate: vi.fn(async () => ({ hash: "0xbad" as `0x${string}`, success: false })) }); + await expect(runActivate(d)).rejects.toThrow(/reverted/); + }); +}); + +describe("readActivation — pure EVM lamports read", () => { + const PROG = "RPTWwELXAY4KC9ZPHhaxp7Sq1hHtU3HNEgLbSegCcWf"; + const ADDR = "0x1Fc309eeF3D24dc2585aFb2175fAd4592f2a7b75" as const; + + it("activated=true when account_lamports >= reserve; queries the CPI precompile", async () => { + const ethCall = vi.fn(async () => `0x${(20_000_000n).toString(16).padStart(64, "0")}` as `0x${string}`); + const s = await readActivation(ADDR, PROG, ethCall); + expect((ethCall.mock.calls[0][0] as string).toLowerCase()).toBe("0xff00000000000000000000000000000000000008"); + expect(s.activated).toBe(true); + expect(s.lamports).toBe(20_000_000n); + expect(s.pda).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/); // base58 + }); + + it("activated=false when unfunded (0x / 0 lamports)", async () => { + const s = await readActivation(ADDR, PROG, vi.fn(async () => "0x" as `0x${string}`)); + expect(s.lamports).toBe(0n); + expect(s.activated).toBe(false); + }); +}); + +describe("activate 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 === "activate.activate"); + expect(cap?.kind).toBe("action"); + expect(cap?.requiresKey).toBe(true); + expect(new Set(buildMcpTools().map((t) => t.name)).has("activate")).toBe(false); + }); +}); diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 77f52dc..903b734 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -7,6 +7,7 @@ import { resolveSourceChain, resolveBridgeBase, runInboundUsdc, + runOutboundUsdc, type BridgeDeps, type EvmSigner, } from "../src/core/bridge.js"; @@ -65,6 +66,7 @@ function cctpInQuote(withSettle: boolean) { function mockSdk(quote: ReturnType, order: string[]) { return { inboundCctpQuoteRequest: realBridge.inboundCctpQuoteRequest, + outboundCctpQuoteRequest: realBridge.outboundCctpQuoteRequest, userSignedTxs: realBridge.userSignedTxs, step1BindingTxIndex: realBridge.step1BindingTxIndex, settleTypedDataWithBurn: realBridge.settleTypedDataWithBurn, @@ -269,6 +271,154 @@ describe("runInboundUsdc — the flow engine (gas intent)", () => { }); }); +// Outbound (from-rome): the user burns wUSDC on Rome (user-signed — NO settle, NO +// destination sponsor) → the engine registers + polls to attestation-ready → hands back +// a claim handle. Claiming on the destination is the USER's responsibility. +const DEST = { chainId: 84532, name: "Base Sepolia", rpcUrl: "https://sepolia.base.org" }; + +function cctpOutQuote() { + return { + route: "usdc-cctp-from-rome", + direction: "from-rome" as const, + amountIn: "1000000", + amountOut: "1000000", + fee: { bps: 0, absolute: "0", asset: "USDC" }, + etaSeconds: 90, + steps: [ + { + n: 1, + chain: "rome-200010", + kind: "cctp-burn-usdc", + userSigns: true, + unsignedTxs: [{ to: "0xBurn" as `0x${string}`, data: "0xburn" as `0x${string}`, description: "cctp burn wUSDC" }], + }, + { + n: 2, + chain: "evm-84532", + kind: "cctp-claim-on-destination", + userSigns: true, + unsignedTx: null, + blockedBy: ["step-1", "circle-attestation"], + claimTransmitter: "0xTransmitter", + claimDomain: 6, + }, + ], + }; +} + +function mockSdkOut(quote: ReturnType, order: string[], claimStatus: string) { + return { + inboundCctpQuoteRequest: realBridge.inboundCctpQuoteRequest, + outboundCctpQuoteRequest: realBridge.outboundCctpQuoteRequest, + userSignedTxs: realBridge.userSignedTxs, + step1BindingTxIndex: realBridge.step1BindingTxIndex, + settleTypedDataWithBurn: realBridge.settleTypedDataWithBurn, + transferFlowStatus: realBridge.transferFlowStatus, + requestQuote: vi.fn(async () => { + order.push("quote"); + return quote as unknown as realBridge.Quote; + }), + registerTransfer: vi.fn(async (_p: unknown) => { + order.push("register"); + return { id: "tr_out", route: "usdc-cctp-from-rome", outcome: "pending", steps: [] } as unknown as realBridge.TransferRecord; + }), + getTransfer: vi.fn(async () => { + order.push("poll"); + return { + id: "tr_out", + route: "usdc-cctp-from-rome", + outcome: "pending", + steps: [{ n: 2, kind: "cctp-claim-on-destination", status: claimStatus }], + } as unknown as realBridge.TransferRecord; + }), + }; +} + +describe("runOutboundUsdc — from-rome, user claims on destination", () => { + let order: string[]; + beforeEach(() => { + order = []; + }); + + it("quotes (from-rome) → signs the Rome burn → registers WITHOUT settle → polls to claim-ready → hands back the user-owned claim handle", async () => { + const quote = cctpOutQuote(); + const sdk = mockSdkOut(quote, order, "ready"); + const signer = mockSigner(order); + const res = await runOutboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + romeRpcUrl: "https://rome.example", + dest: DEST, + amountBaseUnits: 1_000_000n, + address: ADDR, + deps: makeDeps(sdk, signer), + }); + + // one burn send, NO settle sign; register + poll + expect(order).toEqual(["quote", "send", "register", "poll"]); + expect(signer.signTypedData).not.toHaveBeenCalled(); + + // requested from-rome for the right destination + amount + const req = (sdk.requestQuote as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(req).toMatchObject({ asset: "USDC", direction: "from-rome", amount: "1000000", destinationChainId: 84532 }); + + // register bound to the burn hash, WITHOUT a settle sig (outbound needs none) + const regArg = (sdk.registerTransfer as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(regArg.step1TxHash).toBe("0xhash0"); + expect(regArg.userSettleSig).toBeUndefined(); + + expect(res.dryRun).toBe(false); + if (res.dryRun === false) { + expect(res.transferId).toBe("tr_out"); + expect(res.burnTxHash).toBe("0xhash0"); + expect(res.destinationChainId).toBe(84532); + expect(res.claim.yourResponsibility).toBe(true); + expect(res.claim.status).toBe("ready"); + expect(res.claim.transmitter).toBe("0xTransmitter"); + } + }); + + it("refuses to sign a quote that isn't from-rome (direction guard)", async () => { + const quote = { ...cctpOutQuote(), direction: "to-rome" as const }; + const sdk = mockSdkOut(quote, order, "ready"); + await expect( + runOutboundUsdc({ + romeChainId: 200010, + base: "b", + romeRpcUrl: "r", + dest: DEST, + amountBaseUnits: 1_000_000n, + address: ADDR, + deps: makeDeps(sdk, mockSigner(order)), + }), + ).rejects.toThrow(/from-rome/); + }); + + it("dry-run quotes + plans the burn but signs/registers NOTHING", async () => { + const quote = cctpOutQuote(); + const sdk = mockSdkOut(quote, order, "ready"); + const signer = mockSigner(order); + const res = await runOutboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + romeRpcUrl: "https://rome.example", + dest: DEST, + amountBaseUnits: 1_000_000n, + address: ADDR, + dryRun: true, + deps: makeDeps(sdk, signer), + }); + expect(order).toEqual(["quote"]); + expect(signer.sendTx).not.toHaveBeenCalled(); + expect(sdk.registerTransfer).not.toHaveBeenCalled(); + expect(res.dryRun).toBe(true); + if (res.dryRun === true) { + expect(res.plannedTxs).toHaveLength(1); + expect(res.route).toBe("usdc-cctp-from-rome"); + } + }); +}); + describe("action key-gating (fund/bridge refuse without a key, before any network call)", () => { it("the fund capability handler throws /ROME_EVM_KEY/ when unset", async () => { const prev = process.env.ROME_EVM_KEY;