diff --git a/README.md b/README.md index 3fb2074..894b01a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ rome facts balance hadrian 0x… # native (gas-token) balance for an address rome facts programs devnet # Solana program ids for a network rome cookbook cpi-recipe # the CPI account-rules + SDK encoders (grounded addresses) rome cookbook patterns lending # which example repo + guide fits a goal +rome call hadrian 0x… "balanceOf(address) returns (uint256)" 0x… # read a contract (no key) + +# actions — sign on-chain, need ROME_EVM_KEY (never a flag/log/MCP): +rome deploy hadrian ./out/Store.json # deploy a compiled artifact +rome send hadrian 0x… "set(uint256)" 42 # write via submitRomeTx +rome fund hadrian --from base-sepolia --amount 1 # bridge USDC → Rome gas (CCTP, "from home") +rome bridge hadrian --from base-sepolia --amount 1 --intent wrapper # USDC → wUSDC on Rome ``` Chains resolve by id, name, or slug (`200010`, `hadrian`, `200010-hadrian`). Output is JSON — pipe it to `jq` or read it in an agent: @@ -67,11 +74,12 @@ More recipes — agent (MCP), shell, and CI integration — in [`docs/GUIDES.md` The client (Claude Code / Claude Desktop / Cursor / …) spawns `rome mcp` as a child process on demand, talks to it over stdin/stdout, and shuts it down when the session ends — no port, no hosting, no process manager. It exposes each capability as a tool (`facts_chain`, `facts_gas`, `cookbook_cpi_recipe`, …), is **read-only and holds no keys** — safe to wire into any agent; it can never sign a transaction or leak a secret. Your app always does the signing, via [`@rome-protocol/sdk`](https://github.com/rome-protocol/rome-sdk-ts). See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#running-it--cli-vs-mcp-server) for the lifecycle. -## What it is — and isn't (v1) +## What it is — two layers -- **v1 is grounding**: facts + cookbook, read-only. It kills hallucination and points at the right pattern. -- It does **not** deploy or sign. Deploy with Foundry / Hardhat or [`create-rome-app`](https://github.com/rome-protocol/create-rome-app); write from your app via the SDK. -- Facts come from [`@rome-protocol/registry`](https://github.com/rome-protocol/rome-registry) and the chain's RPC; the CPI recipe's precompile addresses come from the SDK — nothing is hardcoded here. +- **Grounding — read-only, on both CLI + MCP**: `facts` + `cookbook` + `call`. Kills hallucination, routes you to the right pattern, reads contracts. Holds no keys — safe to wire into any agent. +- **Actions — CLI-only, key-gated, never on MCP**: `deploy` / `send` (contracts) and `fund` / `bridge` (the "from home" on-ramp: bridge USDC in as gas or wUSDC via CCTP). These sign, so they read the key from the environment (`ROME_EVM_KEY`) — never a flag, never logged, never through the MCP server. Every action prints what it did; funding previews with `--dry-run`. +- Everything is sourced from [`@rome-protocol/registry`](https://github.com/rome-protocol/rome-registry) + the chain's RPC + the SDK's `@rome-protocol/sdk/bridge` — nothing chain-specific is hardcoded. +- Still orchestrates, doesn't replace: heavy contract builds stay in Foundry / Hardhat; scaffolding is [`create-rome-app`](https://github.com/rome-protocol/create-rome-app); library writes use [`@rome-protocol/sdk`](https://github.com/rome-protocol/rome-sdk-ts). ## Development diff --git a/docs/GUIDES.md b/docs/GUIDES.md index 00914b4..f1e69b6 100644 --- a/docs/GUIDES.md +++ b/docs/GUIDES.md @@ -5,6 +5,7 @@ Concrete, copy-paste recipes: real commands with real output, and how to fold `r - [Every command, with real output](#every-command-with-real-output) - [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) - [Shell & scripting recipes](#shell--scripting-recipes) - [Use it in CI](#use-it-in-ci) - [End-to-end: build a price-reading contract, grounded by rome](#end-to-end-build-a-price-reading-contract-grounded-by-rome) @@ -151,6 +152,43 @@ The agent now writes against `aerarium`'s pattern with Hadrian's real RPC and th --- +## Fund a wallet from another chain (`fund` / `bridge`) + +The **from-home** path: you hold USDC on another chain and want onto Rome. `fund` bridges it in as **native gas**; `bridge --intent wrapper` brings it in as **wUSDC**. Both use Circle CCTP, orchestrated through [`@rome-protocol/sdk`](https://github.com/rome-protocol/rome-sdk-ts)'s `bridge` module — you sign only the source-chain burn; Rome's sponsor pays the settle. + +These are **actions**: CLI-only, and they read `ROME_EVM_KEY` from the environment (never a flag, never logged, never on MCP). + +**Preview first with `--dry-run`** — quotes the route and shows exactly what you'd sign, spending nothing: + +```console +$ rome fund hadrian --from base-sepolia --amount 0.5 --dry-run +{ + "dryRun": true, + "route": "usdc-cctp-to-rome", + "amountIn": "500000", + "amountOut": "500000", + "fee": { "bps": 0, "absolute": "0", "asset": "USDC" }, + "etaSeconds": 1100, + "plannedTxs": [ + { "stepN": 1, "to": "0x036CbD…dCF7e", "description": "Approve TokenMessenger to spend USDC" }, + { "stepN": 1, "to": "0x8FE6B9…2DAA", "description": "Burn USDC via CCTP, mintRecipient = user's Rome account" } + ] +} +``` + +Drop `--dry-run` to execute: `rome` signs + broadcasts the two source txs, signs the trustless-settle authorization (gas intent only), registers the transfer, then polls to completion — printing the transfer id, outcome, and source tx hashes. + +```bash +rome fund hadrian --from base-sepolia --amount 0.5 # → native gas on Rome +rome bridge hadrian --from base-sepolia --amount 0.5 --intent wrapper # → wUSDC on Rome +``` + +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. + +> The bridge-api base defaults to the devnet orchestrator; override with `--bridge-api ` or `ROME_BRIDGE_API`. + +--- + ## Shell & scripting recipes `rome` prints JSON — pipe it to `jq`. diff --git a/src/cli.ts b/src/cli.ts index 1ee7170..e2a3bdf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,32 @@ export function cliCommandTable(): string[] { return CAPABILITIES.map((c) => c.cliPath); } +/** + * Split a command's trailing argv into ordered positionals and `--flag` values. + * `--name value` → { name: value }; a bare `--name` (end, or followed by another + * `--flag`) → { name: "true" } (boolean). Positionals keep their order. + */ +export function parseRest(rest: string[]): { positionals: string[]; flags: Record } { + const positionals: string[] = []; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const t = rest[i]; + if (t.startsWith("--")) { + const name = t.slice(2); + const next = rest[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[name] = next; + i++; + } else { + flags[name] = "true"; + } + } else { + positionals.push(t); + } + } + return { positionals, flags }; +} + function helpText(): string { const lines = [ "rome — Rome Protocol dev CLI + MCP server", @@ -53,10 +79,18 @@ export async function main(argv: string[]): Promise { } const { cap, rest } = resolved; + // Each declared arg can be given positionally OR as `--name value`; bare `--name` + // is a boolean flag ("true"). Positionals fill declared args left-to-right; any + // extra `--flag` is carried through so handlers can read it. + const { positionals, flags } = parseRest(rest); const argObj: Record = {}; - cap.args.forEach((spec, i) => { - if (rest[i] != null) argObj[spec.name] = rest[i]; - }); + let pi = 0; + for (const spec of cap.args) { + if (flags[spec.name] !== undefined) argObj[spec.name] = flags[spec.name]; + else if (positionals[pi] !== undefined) argObj[spec.name] = positionals[pi++]; + } + for (const [k, v] of Object.entries(flags)) if (!(k in argObj)) argObj[k] = v; + const missing = cap.args.filter((s) => s.required && argObj[s.name] == null); if (missing.length) { console.error(`Missing required: ${missing.map((m) => m.name).join(", ")}`); diff --git a/src/core/bridge.ts b/src/core/bridge.ts new file mode 100644 index 0000000..586b8e2 --- /dev/null +++ b/src/core/bridge.ts @@ -0,0 +1,296 @@ +import { createPublicClient, createWalletClient, http, parseUnits } from "viem"; +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 { requireEvmKey } from "./keys.js"; + +// ── Fund / bridge: the "from home" on-ramp ────────────────────────────────── +// One rail-agnostic INBOUND flow engine that ORCHESTRATES @rome-protocol/sdk/bridge +// (the SDK owns the protocol; the CLI owns the source-chain signing the SDK leaves +// to the client). `fund` = the opinionated USDC→gas front door; `bridge` = the same +// engine with the intent knob (gas | wrapper). Everything is sourced from the +// registry (source chains + RPCs) — no hardcoded chain data. + +/** The subset of the bridge SDK the engine calls — an interface so tests inject a mock. */ +export interface BridgeSdk { + inboundCctpQuoteRequest: typeof bridgeSdk.inboundCctpQuoteRequest; + userSignedTxs: typeof bridgeSdk.userSignedTxs; + step1BindingTxIndex: typeof bridgeSdk.step1BindingTxIndex; + settleTypedDataWithBurn: typeof bridgeSdk.settleTypedDataWithBurn; + transferFlowStatus: typeof bridgeSdk.transferFlowStatus; + requestQuote: typeof bridgeSdk.requestQuote; + registerTransfer: typeof bridgeSdk.registerTransfer; + getTransfer: typeof bridgeSdk.getTransfer; +} + +/** Signs + broadcasts source-chain txs and the settle authorization. Injectable. */ +export interface EvmSigner { + address: `0x${string}`; + /** Broadcast a source-chain tx, wait for its receipt, return the hash. */ + sendTx(tx: UnsignedTx): Promise<`0x${string}`>; + /** Sign the trustless-settle EIP-712 (gas intent only). */ + signTypedData(td: SettleTypedData): Promise<`0x${string}`>; +} + +export interface BridgeDeps { + sdk: BridgeSdk; + makeSigner: (rpcUrl: string) => EvmSigner; + sleep: (ms: number) => Promise; +} + +/** Real deps: the SDK + a viem signer built from ROME_EVM_KEY. */ +export function defaultBridgeDeps(): BridgeDeps { + return { + sdk: bridgeSdk, + makeSigner: realSigner, + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + }; +} + +function realSigner(rpcUrl: string): EvmSigner { + const account = privateKeyToAccount(requireEvmKey()); + const pub = createPublicClient({ transport: http(rpcUrl) }); + const wallet = createWalletClient({ account, transport: http(rpcUrl) }); + return { + address: account.address, + async sendTx(tx) { + // Source-chain txs are plain EVM (not Rome) — sign + broadcast directly, then + // wait for the receipt so the next tx (e.g. burn after approve) sees the effect. + const id = await pub.getChainId(); + const hash = await wallet.sendTransaction({ + account, + to: tx.to, + data: tx.data, + value: tx.value !== undefined ? BigInt(tx.value) : 0n, + chain: { + id, + name: `chain-${id}`, + nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }, + }); + await pub.waitForTransactionReceipt({ hash }); + return hash; + }, + async signTypedData(td) { + // viem rebuilds the EIP712Domain type from `domain`; drop it from `types`. + const { EIP712Domain: _omit, ...types } = td.types as Record; + const typedData = { domain: td.domain, types, primaryType: td.primaryType, message: td.message }; + return account.signTypedData(typedData as Parameters[0]); + }, + }; +} + +// ── Resolvers (chain-first, from registry bridge config) ──────────────────── + +export interface SourceChain { + chainId: number; + name: string; + rpcUrl: string; +} + +interface BridgeCfg { + sourceEvm?: SourceChain; + sourceEvms?: SourceChain[]; +} + +function bridgeCfg(romeChain: string | number): BridgeCfg { + const id = resolveChainId(romeChain); + const cfg = getBridge(id) as BridgeCfg | undefined; + if (!cfg) throw new Error(`No bridge configuration for Rome chain ${id}.`); + return cfg; +} + +function sourceList(cfg: BridgeCfg): SourceChain[] { + const all = [...(cfg.sourceEvm ? [cfg.sourceEvm] : []), ...(cfg.sourceEvms ?? [])]; + const byId = new Map(); + for (const s of all) { + if (!byId.has(s.chainId)) byId.set(s.chainId, { chainId: s.chainId, name: s.name, rpcUrl: s.rpcUrl }); + } + return [...byId.values()]; +} + +/** Resolve a CCTP source chain (id, name, or hyphen-slug) to its registry entry. */ +export function resolveSourceChain(romeChain: string | number, input: string | number): SourceChain { + const list = sourceList(bridgeCfg(romeChain)); + const q = String(input).trim().toLowerCase(); + const match = list.find( + (s) => s.chainId === Number(q) || s.name.toLowerCase() === q || s.name.toLowerCase().replace(/\s+/g, "-") === q, + ); + if (!match) { + const known = list.map((s) => `${s.chainId} (${s.name})`).join(", "); + throw new Error(`Unsupported bridge source "${input}". Supported sources: ${known}.`); + } + return match; +} + +// The Rome bridge-api orchestrator base is NOT in the public registry projection yet; +// resolve --bridge-api > ROME_BRIDGE_API > a devnet default (both public chains are +// devnet). FOLLOW-UP (structural): add `apiBase` to registry bridge.json so this is +// chain-first like everything else. +const DEVNET_BRIDGE_API = "https://bridge-api.devnet.romeprotocol.xyz"; + +/** The bridge-api base (root; the SDK appends `/v1/...`). Never ends in `/` or `/v1`. */ +export function resolveBridgeBase(_romeChain: string | number, override?: string): string { + const pick = override ?? process.env.ROME_BRIDGE_API ?? DEVNET_BRIDGE_API; + return pick.replace(/\/+$/, "").replace(/\/v1$/, ""); +} + +/** Human USDC (6 decimals) → base units. Rejects non-positive / malformed. */ +export function usdcBaseUnits(human: string): bigint { + const v = parseUnits(String(human).trim() as `${number}`, 6); + if (v <= 0n) throw new Error(`Amount must be a positive USDC value (got "${human}").`); + return v; +} + +// ── The inbound flow engine ───────────────────────────────────────────────── + +export type InboundIntent = "gas" | "wrapper"; + +export interface InboundUsdcParams { + romeChainId: number; + base: string; + source: SourceChain; + amountBaseUnits: bigint; + address: `0x${string}`; + intent: InboundIntent; + dryRun?: boolean; + pollMax?: number; + pollIntervalMs?: number; + deps: BridgeDeps; +} + +export type InboundResult = + | { + 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; + step1TxHash: string; + sourceTxHashes: string[]; + settleAuthorized: boolean; + }; + +export async function runInboundUsdc(p: InboundUsdcParams): Promise { + const { sdk } = p.deps; + const opts = { base: p.base }; + + // 1. build the request (gas builder; override intent for wrapper) → quote + const req = sdk.inboundCctpQuoteRequest({ + sourceChainId: p.source.chainId, + romeChainId: p.romeChainId, + amount: p.amountBaseUnits, + evmAddress: p.address, + }); + if (p.intent === "wrapper") req.intent = "wrapper"; + const quote = await sdk.requestQuote(req, opts); + + // guard: never sign a quote that isn't the inbound flow we asked for + if (quote.direction !== "to-rome") { + throw new Error(`Bridge quote direction "${quote.direction}" != "to-rome"; refusing to sign.`); + } + + const signed = sdk.userSignedTxs(quote, quote.route); + + 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 })), + }; + } + + // 2. sign + broadcast the source txs, in order + const signer = p.deps.makeSigner(p.source.rpcUrl); + const sourceTxHashes: string[] = []; + for (const item of signed) sourceTxHashes.push(await signer.sendTx(item.tx)); + + // 3. bind registration to the burn (last tx of the first step) + const step1TxHash = sourceTxHashes[sdk.step1BindingTxIndex(signed)]; + + // 4. gas-intent CCTP → sign the trustless-settle authorization with the burn hash + const td = sdk.settleTypedDataWithBurn(quote, step1TxHash); + const userSettleSig = td ? await signer.signTypedData(td) : undefined; + + // 5. register, then poll to a terminal outcome + const record = await sdk.registerTransfer({ quote, step1TxHash, userSettleSig }, opts); + const final = await pollTransfer(sdk, record.id, record, opts, p.deps.sleep, p.pollMax ?? 40, p.pollIntervalMs ?? 3000); + + return { + dryRun: false, + transferId: record.id, + route: quote.route, + outcome: final.outcome, + flow: sdk.transferFlowStatus(final), + step1TxHash, + sourceTxHashes, + settleAuthorized: userSettleSig !== undefined, + }; +} + +async function pollTransfer( + 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") 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 { + const v = args[name]; + if (v === undefined || v === "") throw new Error(`Missing required --${name}.`); + return v; +} + +async function inboundHandler(args: Record, intent: InboundIntent): Promise { + const romeChainId = resolveChainId(args.chain); + const source = resolveSourceChain(romeChainId, requireArg(args, "from")); + 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): derive the actor address for the quote. + const address = privateKeyToAccount(requireEvmKey()).address; + return runInboundUsdc({ romeChainId, base, source, amountBaseUnits, address, intent, dryRun, deps: defaultBridgeDeps() }); +} + +/** `rome fund --from --amount ` — USDC → Rome gas (CCTP). */ +export function fundHandler(args: Record): Promise { + return inboundHandler(args, "gas"); +} + +/** `rome bridge --from --amount [--intent gas|wrapper]`. */ +export function bridgeHandler(args: Record): Promise { + 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); +} diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 14c7496..59e2838 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,6 +1,7 @@ import { getChainFacts, getTokenFacts, getContractFacts, getGasFacts, getBalanceFacts, getProgramFacts } from "./facts.js"; import { getCpiRecipe, getPatterns } from "./cookbook.js"; import { callContract, deployContract, sendContract } from "./actions.js"; +import { fundHandler, bridgeHandler } from "./bridge.js"; import { type Deps } from "./deps.js"; export interface ArgSpec { @@ -141,6 +142,35 @@ export const CAPABILITIES: Capability[] = [ ], (a, deps) => sendContract(a.chain, a.address, a.signature, a.args, deps), ), + + // ── fund / bridge: the "from home" on-ramp (CCTP USDC inbound). CLI-only actions. ── + verbAction( + "fund", + "fund", + "Fund a wallet: bridge USDC from a source chain into Rome gas (CCTP). Needs ROME_EVM_KEY. e.g. rome fund hadrian --from base-sepolia --amount 1", + [ + chainArg, + { name: "from", required: true, description: "source chain (id, name, or slug) holding your USDC" }, + { name: "amount", required: true, description: "USDC amount to bridge (human, e.g. 1.5)" }, + { 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" }, + ], + (a) => fundHandler(a), + ), + 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", + [ + chainArg, + { name: "from", required: true, description: "source chain (id, name, or slug) holding your USDC" }, + { 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: "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" }, + ], + (a) => bridgeHandler(a), + ), ]; export function findCapability(group: string, command: string): Capability | undefined { diff --git a/test/bridge.test.ts b/test/bridge.test.ts new file mode 100644 index 0000000..77f52dc --- /dev/null +++ b/test/bridge.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as realBridge from "@rome-protocol/sdk/bridge"; +import { CAPABILITIES } from "../src/core/capabilities.js"; +import { buildMcpTools } from "../src/mcp.js"; +import { + usdcBaseUnits, + resolveSourceChain, + resolveBridgeBase, + runInboundUsdc, + type BridgeDeps, + type EvmSigner, +} from "../src/core/bridge.js"; + +// Phase 2 (inbound funding): `fund` + `bridge` are ACTIONS — CLI-only, key-gated, +// never on MCP. The flow engine orchestrates @rome-protocol/sdk/bridge; these tests +// pin the orchestration ORDER + arguments against the REAL SDK helper semantics, +// mocking only the network calls (requestQuote/registerTransfer/getTransfer) + signer. + +const ADDR = "0xB14B4A0c3b64d22bad40f7848F5fBe7C515D4753" as const; + +/** A realistic CCTP-in quote: step 1 = [approve, depositForBurn] on the source chain, + * plus a settle-authorization (gas intent). */ +function cctpInQuote(withSettle: boolean) { + return { + route: "cctp-in", + direction: "to-rome" as const, + amountIn: "1000000", + amountOut: "990000", + fee: { bps: 10, absolute: "10000", asset: "USDC" }, + etaSeconds: 30, + steps: [ + { + n: 1, + chain: "source", + kind: "cctp-burn", + userSigns: true, + unsignedTxs: [ + { to: "0xUSDC" as `0x${string}`, data: "0xapprove" as `0x${string}`, description: "approve USDC" }, + { to: "0xTokenMessenger" as `0x${string}`, data: "0xburn" as `0x${string}`, description: "depositForBurn" }, + ], + }, + { n: 2, chain: "rome", kind: "settle-inbound-bridge-sponsored", sponsor: "rome", blockedBy: ["circle-attestation"] }, + ], + ...(withSettle + ? { + signatureRequests: [ + { + kind: "settle-authorization-eip712", + fillFromBurn: "sourceTxHash", + typedData: { + domain: { name: "RomeBridge", version: "1", chainId: 200010 }, + types: { SettleAuthorization: [{ name: "sourceTxHash", type: "string" }] }, + primaryType: "SettleAuthorization", + message: { recipient: ADDR }, + }, + }, + ], + } + : {}), + }; +} + +/** SDK mock: REAL helper logic (userSignedTxs, step1BindingTxIndex, settleTypedDataWithBurn, + * transferFlowStatus, inboundCctpQuoteRequest) + spied network fns. */ +function mockSdk(quote: ReturnType, order: string[]) { + return { + inboundCctpQuoteRequest: realBridge.inboundCctpQuoteRequest, + 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_1", route: "cctp-in", outcome: "pending", steps: [] } as unknown as realBridge.TransferRecord; + }), + getTransfer: vi.fn(async () => { + order.push("poll"); + return { + id: "tr_1", + route: "cctp-in", + outcome: "complete", + steps: [{ n: 2, kind: "settle-inbound-bridge-sponsored", status: "confirmed" }], + } as unknown as realBridge.TransferRecord; + }), + }; +} + +function mockSigner(order: string[]): EvmSigner & { sent: unknown[] } { + const sent: unknown[] = []; + return { + sent, + address: ADDR, + sendTx: vi.fn(async (tx: unknown) => { + order.push("send"); + sent.push(tx); + return `0xhash${sent.length - 1}` as `0x${string}`; + }), + signTypedData: vi.fn(async () => { + order.push("settle"); + return "0xsig" as `0x${string}`; + }), + }; +} + +function makeDeps(sdk: ReturnType, signer: EvmSigner): BridgeDeps { + return { sdk, makeSigner: () => signer, sleep: async () => {} }; +} + +const SOURCE = { chainId: 84532, name: "Base Sepolia", rpcUrl: "https://sepolia.base.org" }; + +describe("capability wiring: fund + bridge are CLI-only key-gated actions", () => { + it("fund + bridge exist as verb actions requiring a key", () => { + const byId = new Map(CAPABILITIES.map((c) => [c.id, c])); + for (const id of ["fund.fund", "bridge.bridge"]) { + const c = byId.get(id); + expect(c, id).toBeTruthy(); + expect(c!.kind).toBe("action"); + expect(c!.requiresKey).toBe(true); + expect(c!.verb).toBe(true); + } + }); + it("neither fund nor bridge is exposed on the MCP surface", () => { + const tools = new Set(buildMcpTools().map((t) => t.name)); + expect(tools.has("fund")).toBe(false); + expect(tools.has("bridge")).toBe(false); + }); +}); + +describe("usdcBaseUnits (6 decimals)", () => { + it("converts human USDC to base units", () => { + expect(usdcBaseUnits("1")).toBe(1_000_000n); + expect(usdcBaseUnits("1.5")).toBe(1_500_000n); + expect(usdcBaseUnits("0.000001")).toBe(1n); + }); + it("rejects non-positive / malformed amounts", () => { + expect(() => usdcBaseUnits("0")).toThrow(); + expect(() => usdcBaseUnits("-1")).toThrow(); + expect(() => usdcBaseUnits("abc")).toThrow(); + }); +}); + +describe("resolveSourceChain (chain-first, from registry bridge config)", () => { + it("resolves a supported source by id, name, or slug", () => { + expect(resolveSourceChain(200010, "84532").chainId).toBe(84532); + expect(resolveSourceChain(200010, "base sepolia").chainId).toBe(84532); + expect(resolveSourceChain(200010, "Sepolia").chainId).toBe(11155111); + expect(resolveSourceChain(200010, "base sepolia").rpcUrl).toMatch(/^https?:\/\//); + }); + it("throws with the known source list for an unsupported source", () => { + expect(() => resolveSourceChain(200010, "mainnet-eth")).toThrow(/source/i); + }); +}); + +describe("resolveBridgeBase (flag > env > default; root, no /v1)", () => { + const prev = process.env.ROME_BRIDGE_API; + afterEach(() => { + if (prev === undefined) delete process.env.ROME_BRIDGE_API; + else process.env.ROME_BRIDGE_API = prev; + }); + it("prefers an explicit override", () => { + expect(resolveBridgeBase(200010, "https://custom.example")).toBe("https://custom.example"); + }); + it("falls back to env, then a devnet default; never ends in /v1", () => { + process.env.ROME_BRIDGE_API = "https://env.example"; + expect(resolveBridgeBase(200010)).toBe("https://env.example"); + delete process.env.ROME_BRIDGE_API; + const def = resolveBridgeBase(200010); + expect(def).toMatch(/^https:\/\//); + expect(def.endsWith("/v1")).toBe(false); + expect(def.endsWith("/")).toBe(false); + }); +}); + +describe("runInboundUsdc — the flow engine (gas intent)", () => { + let order: string[]; + beforeEach(() => { + order = []; + }); + + it("quotes → signs both source txs → signs settle → registers with the burn hash → polls to complete", async () => { + const quote = cctpInQuote(true); + const sdk = mockSdk(quote, order); + const signer = mockSigner(order); + const res = await runInboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + source: SOURCE, + amountBaseUnits: 1_000_000n, + address: ADDR, + intent: "gas", + deps: makeDeps(sdk, signer), + }); + + // orchestration order: quote, then TWO source sends, then settle-sign, then register, then poll + expect(order).toEqual(["quote", "send", "send", "settle", "register", "poll"]); + + // the quote request is a gas-intent USDC to-rome for the right sender/amount + const req = (sdk.requestQuote as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(req).toMatchObject({ asset: "USDC", direction: "to-rome", intent: "gas", amount: "1000000" }); + expect((req.sender as Record).ethereum).toBe(ADDR); + + // both source txs broadcast, in order + expect(signer.sendTx).toHaveBeenCalledTimes(2); + + // register bound to the LAST tx of the first step (the burn) = 0xhash1, with the settle sig + const regArg = (sdk.registerTransfer as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(regArg.step1TxHash).toBe("0xhash1"); + expect(regArg.userSettleSig).toBe("0xsig"); + + expect(res.dryRun).toBe(false); + if (res.dryRun === false) { + expect(res.transferId).toBe("tr_1"); + expect(res.outcome).toBe("complete"); + expect(res.sourceTxHashes).toEqual(["0xhash0", "0xhash1"]); + expect(res.settleAuthorized).toBe(true); + } + }); + + it("wrapper intent (no settle authorization) registers WITHOUT a settle sig and never signs typed data", async () => { + const quote = cctpInQuote(false); // no signatureRequests → settleTypedDataWithBurn === null + const sdk = mockSdk(quote, order); + const signer = mockSigner(order); + const res = await runInboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + source: SOURCE, + amountBaseUnits: 1_000_000n, + address: ADDR, + intent: "wrapper", + deps: makeDeps(sdk, signer), + }); + + expect(signer.signTypedData).not.toHaveBeenCalled(); + 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.intent).toBe("wrapper"); + const regArg = (sdk.registerTransfer as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as Record; + expect(regArg.userSettleSig).toBeUndefined(); + if (res.dryRun === false) expect(res.settleAuthorized).toBe(false); + }); + + it("dry-run quotes + plans the source txs but signs/broadcasts/registers NOTHING", async () => { + const quote = cctpInQuote(true); + const sdk = mockSdk(quote, order); + const signer = mockSigner(order); + const res = await runInboundUsdc({ + romeChainId: 200010, + base: "https://bridge-api.example", + source: SOURCE, + amountBaseUnits: 1_000_000n, + address: ADDR, + intent: "gas", + 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(2); + expect(res.route).toBe("cctp-in"); + } + }); +}); + +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; + delete process.env.ROME_EVM_KEY; + try { + const fund = CAPABILITIES.find((c) => c.id === "fund.fund")!; + await expect( + Promise.resolve(fund.handler({ chain: "hadrian", from: "base sepolia", amount: "0.01" })), + ).rejects.toThrow(/ROME_EVM_KEY/); + } finally { + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + } + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index a6ca0f1..637004a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { main } from "../src/cli.js"; +import { main, parseRest } from "../src/cli.js"; // Drive the REAL CLI dispatch (findCapability + positional-arg mapping + exit codes), // not just the capability table — this is the behavioral half of the alignment gate. @@ -66,4 +66,39 @@ describe("CLI dispatch (main)", () => { expect(code).toBe(0); expect(c.out.join("\n")).toMatch(/rome mcp/); }); + + it("maps --flag value + boolean --flag through to the handler (fund, no key → exit 1 on the KEY, proving from/amount parsed)", async () => { + const prev = process.env.ROME_EVM_KEY; + delete process.env.ROME_EVM_KEY; + const c = capture(); + const code = await main(["node", "rome", "fund", "hadrian", "--from", "base sepolia", "--amount", "0.01"]); + c.restore(); + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + expect(code).toBe(1); + // if the flags hadn't parsed we'd fail earlier on "missing required from/amount" + expect(c.err.join("\n")).toMatch(/ROME_EVM_KEY/); + expect(c.err.join("\n")).not.toMatch(/missing required/i); + }); +}); + +describe("parseRest (positionals + --flags)", () => { + it("keeps positionals ordered and captures --name value", () => { + expect(parseRest(["hadrian", "--from", "base-sepolia", "--amount", "0.01"])).toEqual({ + positionals: ["hadrian"], + flags: { from: "base-sepolia", amount: "0.01" }, + }); + }); + it("treats a bare --flag (at end or before another flag) as boolean true", () => { + expect(parseRest(["--dry-run"])).toEqual({ positionals: [], flags: { "dry-run": "true" } }); + expect(parseRest(["--dry-run", "--amount", "1"])).toEqual({ + positionals: [], + flags: { "dry-run": "true", amount: "1" }, + }); + }); + it("preserves pure-positional invocations unchanged", () => { + expect(parseRest(["hadrian", "0xabc", "deposit(uint256)", "1"])).toEqual({ + positionals: ["hadrian", "0xabc", "deposit(uint256)", "1"], + flags: {}, + }); + }); });