From bc0a2cd165b35f4c1e2d2319146fdd7190ce7362 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:20:21 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20bridge=20--asset=20eth=20=E2=80=94=20ET?= =?UTF-8?q?H=20rails=20via=20Wormhole,=20both=20directions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One knob on the existing engines (asset-parameterized, not duplicated): --asset usdc (default, CCTP, unchanged) | eth (Wormhole). In: wrap on the source -> the sponsor completes on Solana -> lands as wETH (never gas; no settle EIP-712). Out: approve+burn wETH on Rome via RomeBridgeWithdraw -> the USER claims on Ethereum only (rail-aware claim handle carries the isTransferCompleted gotcha). Guards fire pre-key/network: unknown asset, eth-with-intent, eth --to must be the Ethereum rail. Also fixes a real activate gap the funded gate caught: a previously- activated PDA drained below the burn reserve made activate() revert AlreadyActivated — runActivate now has three states (skip / activate / topUpUserPda at the on-chain rate), funded-proven (refill to exactly USER_PDA_FUNDING for ~1.03 USDC). Funded round trip on Hadrian: ETH-in 0.002 (wrap 0xb01fe618 -> VAA -> wETH 200000@8dp landed) -> activate top-up 0xe4aaf88d -> ETH-out burn 0x730db82a SUCCESS, claim ready, wETH back to 0. 122 unit tests; docs (GUIDES ETH in/out + ARCHITECTURE rows). --- docs/ARCHITECTURE.md | 4 +- docs/GUIDES.md | 10 +++ src/core/activate.ts | 44 ++++++++++- src/core/bridge.ts | 126 ++++++++++++++++++++++--------- src/core/capabilities.ts | 11 +-- test/activate.test.ts | 22 ++++++ test/bridge.test.ts | 158 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 332 insertions(+), 43 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d7cf62..7e11772 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -71,8 +71,8 @@ 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 --to --amount [--recipient 0x…]` | `ROME_EVM_KEY` | bridge wUSDC **out**: burn on Rome → claim handle for the destination (you claim there) | +| `bridge --from --amount [--asset usdc\|eth] [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge **in**: USDC (CCTP) as gas or wUSDC · ETH (Wormhole) as wETH | +| `bridge --to --amount [--asset usdc\|eth] [--recipient 0x…]` | `ROME_EVM_KEY` | bridge **out**: burn the wrapper on Rome → claim handle (you claim on the destination; ETH exits to Ethereum only) | | `activate ` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) | | `new [--chain ]` | *none* (keyless) | scaffold a dual-lane app — wraps `create-rome-app`, pre-wires the chain from the registry into `.env`, prints the lifecycle next-steps (fund → deploy → demo → verify). CLI-only: MCP never writes to disk | | `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 | diff --git a/docs/GUIDES.md b/docs/GUIDES.md index 8c96d9c..4100564 100644 --- a/docs/GUIDES.md +++ b/docs/GUIDES.md @@ -259,6 +259,14 @@ rome bridge hadrian --from base-sepolia --amount 0.5 --intent wrapper # → wU Supported source chains come from the registry's bridge config for the target Rome chain — Base Sepolia, Arbitrum Sepolia, Polygon Amoy, Avalanche Fuji, Monad Testnet, Sepolia. Resolve a source by id, name, or slug (`84532`, `"base sepolia"`, `base-sepolia`). CCTP standard attestation takes ~15–20 min, so a real transfer isn't instant; the command polls until it lands. +**Holding ETH instead?** Add `--asset eth` — it rides **Wormhole** and lands as **wETH** on Rome (never gas; gas is USDC, so `--intent` doesn't apply): + +```bash +rome bridge hadrian --from sepolia --amount 0.002 --asset eth # ETH → wETH on Rome (~15 min VAA) +``` + +You sign one wrap-and-transfer tx on the source; Rome's sponsor completes the transfer on the Solana side once the VAA is ready. + > The bridge-api base defaults to the devnet orchestrator; override with `--bridge-api ` or `ROME_BRIDGE_API`. --- @@ -301,6 +309,8 @@ $ rome bridge hadrian --to base-sepolia --amount 0.1 Destinations are the same registry CCTP chains as sources (resolve by id / name / slug). `--recipient` sets the destination address (default = your address). +**ETH out:** `--asset eth` burns your **wETH** on Rome (two txs: approve + burn) and exits via **Wormhole to Ethereum only** (`--to sepolia`; the CLI refuses any other destination). Same claims-are-yours rule — redeem the VAA on Ethereum when it's ready, and guard with `isTransferCompleted`: re-redeeming an already-completed VAA reverts with a misleading gas error. First outbound still needs `rome activate`. + ### 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**. diff --git a/src/core/activate.ts b/src/core/activate.ts index 660dbbc..23a35bf 100644 --- a/src/core/activate.ts +++ b/src/core/activate.ts @@ -19,7 +19,12 @@ 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)"]); +const ACTIVATOR_ABI = parseAbi([ + "function activate() payable", + "function activationCost() view returns (uint256)", + "function USER_PDA_FUNDING() view returns (uint64)", + "function topUpUserPda(uint64 lamports) payable", +]); export interface ActivationStatus { address: `0x${string}`; @@ -53,6 +58,8 @@ export interface ActivateResult { address: `0x${string}`; pda: string; alreadyActivated: boolean; + /** true when a drained (activated-but-below-reserve) PDA was refilled via topUpUserPda. */ + toppedUp?: boolean; lamports: string; txHash?: `0x${string}`; cost?: string; @@ -63,16 +70,39 @@ export interface ActivateDeps { pda: string; readLamports(): Promise; getActivationCost(): Promise; + /** SimpleActivator.USER_PDA_FUNDING — the full lamport level activation establishes. */ + getPdaFunding(): Promise; activate(cost: bigint): Promise<{ hash: `0x${string}`; success: boolean }>; + /** SimpleActivator.topUpUserPda(lamports){value} — refill a drained PDA. */ + topUp(lamports: bigint, value: bigint): Promise<{ hash: `0x${string}`; success: boolean }>; } -/** Fund the actor's external-auth PDA once (idempotent — skips, no spend, if already activated). */ +/** + * Three states, one command: + * - lamports >= reserve → already activated; skip (no spend) + * - lamports == 0 → never activated; SimpleActivator.activate{value: cost} + * - 0 < lamports < reserve → activated but DRAINED (each burn consumes rent); + * activate() would revert AlreadyActivated — refill via + * topUpUserPda back to the full funding level, priced at + * the on-chain rate (activationCost / USER_PDA_FUNDING). + */ 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(); + + if (before > 0n) { + const funding = await deps.getPdaFunding(); + const lamports = funding - before; + const value = (lamports * cost + funding - 1n) / funding; // ceil at the on-chain rate + const { hash, success } = await deps.topUp(lamports, value); + if (!success) throw new Error(`Top-up tx reverted (${hash}).`); + const after = await deps.readLamports(); + return { address: deps.address, pda: deps.pda, alreadyActivated: false, toppedUp: true, txHash: hash, cost: value.toString(), lamports: after.toString() }; + } + const { hash, success } = await deps.activate(cost); if (!success) throw new Error(`Activation tx reverted (${hash}).`); const after = await deps.readLamports(); @@ -113,11 +143,21 @@ export function defaultActivateDeps(chain: string | number): ActivateDeps { const { data } = await pub.call({ to: activator, data: encodeFunctionData({ abi: ACTIVATOR_ABI, functionName: "activationCost" }) }); return BigInt(data && data !== "0x" ? data : "0x0"); }, + async getPdaFunding() { + const { data } = await pub.call({ to: activator, data: encodeFunctionData({ abi: ACTIVATOR_ABI, functionName: "USER_PDA_FUNDING" }) }); + 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" }; }, + async topUp(lamports, value) { + const data = encodeFunctionData({ abi: ACTIVATOR_ABI, functionName: "topUpUserPda", args: [lamports] }); + const hash = await submitRomeTx(provider, { from: account.address, to: activator, data, value }); + const rcpt = await pub.waitForTransactionReceipt({ hash }); + return { hash, success: rcpt.status === "success" }; + }, }; } diff --git a/src/core/bridge.ts b/src/core/bridge.ts index f4ab757..23cde36 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -18,6 +18,8 @@ import { readActivation, notActivatedError } from "./activate.js"; export interface BridgeSdk { inboundCctpQuoteRequest: typeof bridgeSdk.inboundCctpQuoteRequest; outboundCctpQuoteRequest: typeof bridgeSdk.outboundCctpQuoteRequest; + inboundWhQuoteRequest: typeof bridgeSdk.inboundWhQuoteRequest; + outboundWhQuoteRequest: typeof bridgeSdk.outboundWhQuoteRequest; userSignedTxs: typeof bridgeSdk.userSignedTxs; step1BindingTxIndex: typeof bridgeSdk.step1BindingTxIndex; settleTypedDataWithBurn: typeof bridgeSdk.settleTypedDataWithBurn; @@ -147,6 +149,19 @@ export function usdcBaseUnits(human: string): bigint { return v; } +// ── Assets: USDC rides CCTP; ETH rides Wormhole (in → wETH on Rome; out → Ethereum) ── +export type BridgeAsset = "usdc" | "eth"; +const ASSET_DECIMALS: Record = { usdc: 6, eth: 18 }; + +/** Human amount → base units for the asset. Rejects unknown assets / non-positive. */ +export function assetBaseUnits(asset: BridgeAsset, human: string): bigint { + const dp = ASSET_DECIMALS[asset]; + if (dp === undefined) throw new Error(`Unknown asset "${asset}" (supported: usdc, eth).`); + const v = parseUnits(String(human).trim() as `${number}`, dp); + if (v <= 0n) throw new Error(`Amount must be a positive ${asset.toUpperCase()} value (got "${human}").`); + return v; +} + // ── The inbound flow engine ───────────────────────────────────────────────── export type InboundIntent = "gas" | "wrapper"; @@ -158,6 +173,8 @@ export interface InboundUsdcParams { amountBaseUnits: bigint; address: `0x${string}`; intent: InboundIntent; + /** usdc (default, CCTP) | eth (Wormhole → wETH on Rome; no intent, no settle). */ + asset?: BridgeAsset; dryRun?: boolean; pollMax?: number; pollIntervalMs?: number; @@ -189,14 +206,23 @@ export async function runInboundUsdc(p: InboundUsdcParams): Promise Boolean(k && /claim/.test(k)); 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, - }); + // USDC rides CCTP (per-call destination); ETH rides Wormhole (Ethereum only — the + // builder takes no destination). + const req = + (p.asset ?? "usdc") === "eth" + ? sdk.outboundWhQuoteRequest({ + romeChainId: p.romeChainId, + amount: p.amountBaseUnits, + evmAddress: p.address, + recipient: p.recipient ?? p.address, + }) + : 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 @@ -361,9 +401,13 @@ export async function runOutboundUsdc(p: OutboundUsdcParams): Promise 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 q2 = quote.steps.find((s) => isClaimKind(s.kind)) as Record | undefined; + const claimStep = (final.steps as Array<{ kind?: string; status?: string }> | undefined)?.find((s) => isClaimKind(s.kind)); const transmitter = q2?.claimTransmitter as string | undefined; + const eth = (p.asset ?? "usdc") === "eth"; + const claimHow = eth + ? `When the Wormhole VAA is ready, redeem it on Ethereum (guard with isTransferCompleted — an already-redeemed VAA reverts with a misleading gas error)` + : `When Circle attestation is ready, call MessageTransmitterV2.receiveMessage(message, attestation)${transmitter ? ` on ${transmitter}` : ""}`; return { dryRun: false, transferId: record.id, @@ -379,15 +423,14 @@ export async function runOutboundUsdc(p: OutboundUsdcParams): Promise | undefined)?.find((s) => s.kind === CLAIM_KIND); + const step = (rec.steps as Array<{ kind?: string; status?: string }> | undefined)?.find((s) => isClaimKind(s.kind)); return step?.status === "ready"; } @@ -417,28 +460,34 @@ function requireArg(args: Record, name: string): string { return v; } -async function inboundHandler(args: Record, intent: InboundIntent): Promise { +async function inboundHandler(args: Record, intent: InboundIntent, asset: BridgeAsset): Promise { const romeChainId = resolveChainId(args.chain); const source = resolveSourceChain(romeChainId, requireArg(args, "from")); - const amountBaseUnits = usdcBaseUnits(requireArg(args, "amount")); + const amountBaseUnits = assetBaseUnits(asset, requireArg(args, "amount")); const base = resolveBridgeBase(romeChainId, args["bridge-api"]); const dryRun = args["dry-run"] === "true"; // Key FIRST (fail fast, no network): derive the actor address for the quote. const address = privateKeyToAccount(requireEvmKey()).address; - return runInboundUsdc({ romeChainId, base, source, amountBaseUnits, address, intent, dryRun, deps: defaultBridgeDeps() }); + return runInboundUsdc({ romeChainId, base, source, amountBaseUnits, address, intent, asset, dryRun, deps: defaultBridgeDeps() }); } -/** `rome fund --from --amount ` — USDC → Rome gas (CCTP). */ +/** `rome fund --from --amount ` — USDC → Rome gas (CCTP). + * Always USDC: `fund` is the gas on-ramp, and gas is USDC. */ export function fundHandler(args: Record): Promise { - return inboundHandler(args, "gas"); + return inboundHandler(args, "gas", "usdc"); } -/** `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 { +/** `rome bridge --to --amount ` — burn the wrapper on Rome → the asset + * on the destination. Rome-side only; claiming on the destination is the user's step. */ +async function outboundHandler(args: Record, asset: BridgeAsset): Promise { const romeChainId = resolveChainId(args.chain); const dest = resolveSourceChain(romeChainId, requireArg(args, "to")); - const amountBaseUnits = usdcBaseUnits(requireArg(args, "amount")); + // ETH exits via Wormhole to Ethereum ONLY (the route has no destination knob) — + // refuse a mismatched --to before any key/network work so funds can't aim wrong. + if (asset === "eth" && !/^(ethereum|sepolia)$/i.test(dest.name.trim())) { + throw new Error(`ETH bridges out via Wormhole to Ethereum only — use --to sepolia (got "${dest.name}").`); + } + const amountBaseUnits = assetBaseUnits(asset, 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. @@ -460,19 +509,28 @@ async function outboundHandler(args: Record): Promise --from …` (in: gas|wrapper) OR `--to …` (out). */ -export function bridgeHandler(args: Record): Promise { +// async so the synchronous asset/direction guards surface as rejections, not throws. +export async 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); + // Asset guards fire BEFORE any key/network work. + const asset = (args.asset ?? "usdc").toLowerCase() as BridgeAsset; + if (asset !== "usdc" && asset !== "eth") { + throw new Error(`--asset must be "usdc" (default, CCTP) or "eth" (Wormhole) — got "${args.asset}".`); + } + if (asset === "eth" && args.intent) { + throw new Error(`--intent is USDC-only: gas is USDC, so ETH can't bridge in as gas — it lands as wETH. Drop --intent for --asset eth.`); + } + if (hasTo) return outboundHandler(args, asset); const intent = (args.intent ?? "gas").toLowerCase(); if (intent !== "gas" && intent !== "wrapper") { throw new Error(`--intent must be "gas" or "wrapper" (got "${args.intent}").`); } - return inboundHandler(args, intent as InboundIntent); + return inboundHandler(args, intent as InboundIntent, asset); } diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 435ec65..affd1c5 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -206,13 +206,14 @@ export const CAPABILITIES: Capability[] = [ verbAction( "bridge", "bridge", - "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", + "Bridge in (--from ) or out (--to ); --asset usdc (default, CCTP, any catalog chain) or eth (Wormhole: in → wETH on Rome; out → Ethereum only). Outbound you claim on the destination. Needs ROME_EVM_KEY. e.g. rome bridge hadrian --from sepolia --amount 0.002 --asset eth", [ chainArg, - { 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: "IN only: gas (default) → native gas · wrapper → wUSDC on Rome" }, + { name: "from", required: false, description: "IN: source chain (id/name/slug) holding the asset" }, + { name: "to", required: false, description: "OUT: destination chain — you claim there (eth: Ethereum only)" }, + { name: "amount", required: true, description: "amount to bridge (human, e.g. 1.5 USDC or 0.002 ETH)" }, + { name: "asset", required: false, description: "usdc (default, CCTP) | eth (Wormhole)" }, + { name: "intent", required: false, description: "USDC 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 txs without signing/broadcasting" }, diff --git a/test/activate.test.ts b/test/activate.test.ts index 2b9b588..d58058a 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -13,7 +13,9 @@ function deps(over: Partial): ActivateDeps { pda: "PdA1111111111111111111111111111111111111111", readLamports: vi.fn(async () => 0n), getActivationCost: vi.fn(async () => 2n * 10n ** 18n), + getPdaFunding: vi.fn(async () => 29_969_440n), activate: vi.fn(async () => ({ hash: "0xact" as `0x${string}`, success: true })), + topUp: vi.fn(async () => ({ hash: "0xtop" as `0x${string}`, success: true })), ...over, }; } @@ -44,6 +46,26 @@ describe("runActivate", () => { expect(r.alreadyActivated).toBe(true); expect(d.getActivationCost).not.toHaveBeenCalled(); expect(d.activate).not.toHaveBeenCalled(); + expect(d.topUp).not.toHaveBeenCalled(); + }); + + it("DRAINED (0 < lamports < reserve): tops up via topUpUserPda, NEVER re-activate (it reverts AlreadyActivated)", async () => { + let reads = 0; + const d = deps({ + readLamports: vi.fn(async () => (reads++ === 0 ? 14_500_000n : 44_000_000n)), + }); + const r = await runActivate(d); + expect(d.activate).not.toHaveBeenCalled(); + expect(d.topUp).toHaveBeenCalledTimes(1); + // tops back up to full funding: need = 29,969,440 - 14,500,000 lamports, + // priced at the on-chain rate (activationCost / pdaFunding), rounded up. + const [lamports, value] = (d.topUp as ReturnType).mock.calls[0] as [bigint, bigint]; + expect(lamports).toBe(29_969_440n - 14_500_000n); + const expectedValue = (lamports * 2n * 10n ** 18n + 29_969_440n - 1n) / 29_969_440n; + expect(value).toBe(expectedValue); + expect(r.toppedUp).toBe(true); + expect(r.txHash).toBe("0xtop"); + expect(r.lamports).toBe("44000000"); }); it("throws if the activation tx reverts", async () => { diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 903b734..3ee00ef 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -4,10 +4,12 @@ import { CAPABILITIES } from "../src/core/capabilities.js"; import { buildMcpTools } from "../src/mcp.js"; import { usdcBaseUnits, + assetBaseUnits, resolveSourceChain, resolveBridgeBase, runInboundUsdc, runOutboundUsdc, + bridgeHandler, type BridgeDeps, type EvmSigner, } from "../src/core/bridge.js"; @@ -67,6 +69,8 @@ function mockSdk(quote: ReturnType, order: string[]) { return { inboundCctpQuoteRequest: realBridge.inboundCctpQuoteRequest, outboundCctpQuoteRequest: realBridge.outboundCctpQuoteRequest, + inboundWhQuoteRequest: realBridge.inboundWhQuoteRequest, + outboundWhQuoteRequest: realBridge.outboundWhQuoteRequest, userSignedTxs: realBridge.userSignedTxs, step1BindingTxIndex: realBridge.step1BindingTxIndex, settleTypedDataWithBurn: realBridge.settleTypedDataWithBurn, @@ -310,6 +314,8 @@ function mockSdkOut(quote: ReturnType, order: string[], cla return { inboundCctpQuoteRequest: realBridge.inboundCctpQuoteRequest, outboundCctpQuoteRequest: realBridge.outboundCctpQuoteRequest, + inboundWhQuoteRequest: realBridge.inboundWhQuoteRequest, + outboundWhQuoteRequest: realBridge.outboundWhQuoteRequest, userSignedTxs: realBridge.userSignedTxs, step1BindingTxIndex: realBridge.step1BindingTxIndex, settleTypedDataWithBurn: realBridge.settleTypedDataWithBurn, @@ -419,6 +425,158 @@ describe("runOutboundUsdc — from-rome, user claims on destination", () => { }); }); +// ── ETH (Wormhole) rails — the same engines, asset-parameterized ──────────── +// In: eth-wormhole-to-rome (wrap+transfer on the source; the sponsor completes on +// Solana; lands as wETH; NO settle EIP-712). Out: eth-wormhole-from-rome (burn wETH +// on Rome — approve+burn, 2 user txs — then the USER claims on Ethereum). + +function whInQuote() { + return { + route: "eth-wormhole-to-rome", + direction: "to-rome" as const, + amountIn: "2000000000000000", + amountOut: "2000000000000000", + fee: { bps: 0, absolute: "0", asset: "ETH" }, + etaSeconds: 900, + steps: [ + { + n: 1, + chain: "ethereum", + kind: "wormhole-wrap-and-transfer-eth", + // NOTE: the live route-builder omits userSigns on this step — the SDK's + // userSignedTxs collects by unsignedTxs presence, which this pins. + unsignedTxs: [{ to: "0xWormhole" as `0x${string}`, data: "0xwrap" as `0x${string}`, value: "2000000000000000", description: "wrap + transfer ETH" }], + }, + { n: 2, chain: "solana", kind: "wormhole-complete-transfer-wrapped", blockedBy: ["step-1", "wormhole-vaa"] }, + ], + }; +} + +function whOutQuote() { + return { + route: "eth-wormhole-from-rome", + direction: "from-rome" as const, + amountIn: "2000000000000000", + amountOut: "2000000000000000", + fee: { bps: 0, absolute: "0", asset: "ETH" }, + etaSeconds: 900, + steps: [ + { + n: 1, + chain: "rome-200010", + kind: "wormhole-burn-eth", + userSigns: true, + unsignedTxs: [ + { to: "0xWETH" as `0x${string}`, data: "0xapprove" as `0x${string}`, description: "approve wETH" }, + { to: "0xWithdraw" as `0x${string}`, data: "0xburn" as `0x${string}`, description: "burn wETH via RomeBridgeWithdraw" }, + ], + }, + { n: 2, chain: "ethereum", kind: "wormhole-claim-on-ethereum", userSigns: true, unsignedTx: null, blockedBy: ["step-1", "wormhole-vaa"] }, + ], + }; +} + +describe("assetBaseUnits — per-asset decimals", () => { + it("usdc = 6dp, eth = 18dp", () => { + expect(assetBaseUnits("usdc", "1.5")).toBe(1_500_000n); + expect(assetBaseUnits("eth", "0.002")).toBe(2_000_000_000_000_000n); + }); + it("rejects an unknown asset and non-positive amounts", () => { + expect(() => assetBaseUnits("doge" as never, "1")).toThrow(/asset/i); + expect(() => assetBaseUnits("eth", "0")).toThrow(); + }); +}); + +describe("runInboundUsdc — asset=eth (Wormhole in)", () => { + it("uses the WH builder, collects the userSigns-less step-1 tx, registers WITHOUT settle", async () => { + const order: string[] = []; + const quote = whInQuote(); + const sdk = { + ...mockSdk(quote as never, order), + inboundWhQuoteRequest: realBridge.inboundWhQuoteRequest, + outboundWhQuoteRequest: realBridge.outboundWhQuoteRequest, + }; + const signer = mockSigner(order); + const res = await runInboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + source: { chainId: 11155111, name: "Sepolia", rpcUrl: "https://sepolia.example" }, + amountBaseUnits: 2_000_000_000_000_000n, + address: ADDR, + intent: "wrapper", + asset: "eth", + deps: makeDeps(sdk as never, signer), + }); + // ONE source tx (the wrap+transfer), no settle signature, register → poll + expect(order).toEqual(["quote", "send", "register", "poll"]); + expect(signer.signTypedData).not.toHaveBeenCalled(); + const req = (sdk.requestQuote as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(req).toMatchObject({ asset: "ETH", direction: "to-rome" }); + 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(); + if (res.dryRun === false) expect(res.settleAuthorized).toBe(false); + }); +}); + +describe("runOutboundUsdc — asset=eth (Wormhole out)", () => { + it("uses the WH builder (no destinationChainId), burns 2 txs, detects the wormhole claim step", async () => { + const order: string[] = []; + const quote = whOutQuote(); + const sdk = { + ...mockSdkOut(quote as never, order, "ready"), + inboundWhQuoteRequest: realBridge.inboundWhQuoteRequest, + outboundWhQuoteRequest: realBridge.outboundWhQuoteRequest, + getTransfer: vi.fn(async () => { + order.push("poll"); + return { + id: "tr_out", + route: "eth-wormhole-from-rome", + outcome: "pending", + steps: [{ n: 2, kind: "wormhole-claim-on-ethereum", status: "ready" }], + } as unknown as realBridge.TransferRecord; + }), + }; + const signer = mockSigner(order); + const res = await runOutboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + romeRpcUrl: "https://rome.example", + dest: { chainId: 11155111, name: "Sepolia", rpcUrl: "https://sepolia.example" }, + amountBaseUnits: 2_000_000_000_000_000n, + address: ADDR, + asset: "eth", + deps: makeDeps(sdk as never, signer), + }); + expect(order).toEqual(["quote", "send", "send", "register", "poll"]); + const req = (sdk.requestQuote as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(req).toMatchObject({ asset: "ETH", direction: "from-rome" }); + expect(req.destinationChainId).toBeUndefined(); // WH: Ethereum only, no dest param + if (res.dryRun === false) { + expect(res.burnTxHash).toBe("0xhash1"); // last tx of the burn step (approve, then burn) + expect(res.claim.status).toBe("ready"); + expect(res.claim.yourResponsibility).toBe(true); + } + }); +}); + +describe("bridgeHandler — asset guards (fire before key/network)", () => { + it("rejects an unknown --asset and eth-with-intent", async () => { + await expect(bridgeHandler({ chain: "hadrian", from: "sepolia", amount: "1", asset: "doge" })).rejects.toThrow(/asset/i); + await expect(bridgeHandler({ chain: "hadrian", from: "sepolia", amount: "0.002", asset: "eth", intent: "gas" })).rejects.toThrow(/intent/i); + }); + it("rejects an ETH outbound destination that isn't the Ethereum rail", async () => { + const prev = process.env.ROME_EVM_KEY; + process.env.ROME_EVM_KEY = `0x${"11".repeat(32)}`; + try { + await expect(bridgeHandler({ chain: "hadrian", to: "base sepolia", amount: "0.002", asset: "eth" })).rejects.toThrow(/Ethereum/); + } finally { + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + else delete process.env.ROME_EVM_KEY; + } + }); +}); + 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;