Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd> (CLI — all commands) rome mcp (stdio MCP server)
humans + agent shell-outs MCP-native agents — READS ONLY
Expand Down Expand Up @@ -71,7 +71,9 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by
| `deploy <chain> <artifact> [args]` | `ROME_EVM_KEY` | deploy a compiled artifact, handling Rome's gas quirks |
| `send <chain> <addr> <sig> [args]` | `ROME_EVM_KEY` | write to a contract via `submitRomeTx` (the correct Rome write path) |
| `fund <chain> --from <src> --amount <usdc>` | `ROME_EVM_KEY` | bridge USDC → Rome **gas** (CCTP); the "from home" on-ramp |
| `bridge <chain> --from <src> --amount <usdc> [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC in as gas or wUSDC |
| `bridge <chain> --from <src> --amount <usdc> [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC **in** as gas or wUSDC |
| `bridge <chain> --to <dest> --amount <usdc> [--recipient 0x…]` | `ROME_EVM_KEY` | bridge wUSDC **out**: burn on Rome → claim handle for the destination (you claim there) |
| `activate <chain>` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) |
| `verify <chain> [--path solidity]` | `ROME_EVM_KEY` + `ROME_SOLANA_KEY` | the **both-lane works-gate**: deploy a probe, drive it from the EVM lane *and* the Solana lane, assert parity |
| `verify <chain> --path solana-program` | `ROME_EVM_KEY` | the **cross-VM works-gate**: deploy a thin CPI wrapper; an EVM-lane call drives a Solana program (SPL Memo) via CPI. `--solana-rpc` adds the Solana-log deep check |

Expand Down Expand Up @@ -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)
Expand All @@ -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.
46 changes: 46 additions & 0 deletions docs/GUIDES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <dest>` 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 <chain>` 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 <url>` 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.
Expand Down
128 changes: 128 additions & 0 deletions src/core/activate.ts
Original file line number Diff line number Diff line change
@@ -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<ActivationStatus> {
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<bigint>;
getActivationCost(): Promise<bigint>;
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<ActivateResult> {
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 <chain>` handler — one-time PDA funding for outbound. */
export function activateHandler(args: Record<string, string>): Promise<ActivateResult> {
resolveChainId(args.chain); // validate the chain up front
return runActivate(defaultActivateDeps(args.chain));
}
Loading
Loading