From da0e72d335a7ae96435f5236d092b8f998bfa5b8 Mon Sep 17 00:00:00 2001 From: julio4 <30329843+julio4@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:55 +0800 Subject: [PATCH] feat(opstack): run op-rbuilder as a host process via builderBinary --- containers/bootnode.ts | 6 ++++ containers/op-geth.ts | 6 ++++ containers/op-rbuilder.ts | 74 +++++++++++++++++++++++++++++++++++++-- containers/op-reth.ts | 4 +++ decker.example.ts | 2 ++ recipes/opstack.ts | 52 +++++++++++++++++++++++---- 6 files changed, 135 insertions(+), 9 deletions(-) diff --git a/containers/bootnode.ts b/containers/bootnode.ts index 4c81445..e9760e4 100644 --- a/containers/bootnode.ts +++ b/containers/bootnode.ts @@ -10,6 +10,12 @@ export const BOOTNODE_SECRET_KEY = "00000000000000000000000000000000000000000000 export const BOOTNODE_ID = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"; +// Deterministic p2p identity for the sequencer L2 EL (op-geth/op-reth), used only +// when op-rbuilder runs as a HOST PROCESS instead of a container. +export const EL_P2P_SECRET_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; +export const EL_P2P_ID = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a"; + // p2p only — reached over pod DNS (peers hardcode host "bootnode" + port 30303), // so nothing is host-published. export const ports: Ports = {}; diff --git a/containers/op-geth.ts b/containers/op-geth.ts index 12b5f14..2b00d83 100644 --- a/containers/op-geth.ts +++ b/containers/op-geth.ts @@ -1,5 +1,6 @@ import type { ContainerDef, ContainerResult, Ctx, Ports } from "../utils/types.ts"; import { portNum } from "../utils/types.ts"; +import { EL_P2P_SECRET_KEY } from "./bootnode.ts"; // op-geth is the L2 execution engine (the sequencer's EL). op-node drives it // over the engine API (authrpc). Default ports sit clear of the L1 reth @@ -28,6 +29,10 @@ export function buildContainer(def: ContainerDef, _ctx: Ctx): ContainerResult { const discovery = bootnodeId ? `--maxpeers 25 --bootnodes enode://${bootnodeId}@$(getent hosts bootnode | awk '{print $1}'):30303 --discovery.v4` : "--maxpeers 0 --nodiscover"; + // Set only by recipes/opstack.ts when op-rbuilder runs as a host process: the + // recipe publishes this EL's p2p port to the host so a deterministic node key + // is needed. + const hostBuilderP2p = ps.p2p !== undefined; // geth init seeds the datadir from the L2 genesis, then exec's the node. const script = [ "geth init --datadir /data_opgeth --state.scheme hash /artifacts/l2-genesis.json", @@ -49,6 +54,7 @@ export function buildContainer(def: ContainerDef, _ctx: Ctx): ContainerResult { "--port 30303", "--metrics --metrics.addr 0.0.0.0", `--metrics.port ${portNum(ps.metrics)}`, + ...(hostBuilderP2p ? [`--nodekeyhex ${EL_P2P_SECRET_KEY}`] : []), ].join(" "); return { diff --git a/containers/op-rbuilder.ts b/containers/op-rbuilder.ts index 52c3ea2..b811a70 100644 --- a/containers/op-rbuilder.ts +++ b/containers/op-rbuilder.ts @@ -1,11 +1,23 @@ -import type { ContainerDef, ContainerResult, Ctx, Ports } from "../utils/types.ts"; +import type { + BinaryBuildSpec, + ContainerDef, + ContainerResult, + Ctx, + HostCtx, + Ports, + ProcessDef, + ProcessResult, +} from "../utils/types.ts"; import { portNum } from "../utils/types.ts"; -import { BOOTNODE_ID } from "./bootnode.ts"; +import { binaryBuildPath } from "../utils/binary-build.ts"; +import { BOOTNODE_ID, EL_P2P_ID } from "./bootnode.ts"; // op-rbuilder is the external L2 block builder (Flashbots' reth-based OP builder). // rollup-boost asks it for a block each slot. It syncs the canonical chain from // the sequencer EL over L2 P2P via the bootnode, then builds on the head. Reads // the same l2-genesis + jwtsecret as the sequencer EL. +// +// recipes/opstack.ts's `builderBinary` runs this as a HOST PROCESS instead export const ports: Ports = { rpc: 9645, authrpc: 9651, // rollup-boost queries the engine API here @@ -13,7 +25,7 @@ export const ports: Ports = { // p2p 30303 is container-internal. }; -function resolvedPorts(def: ContainerDef): Ports { +function resolvedPorts(def: ContainerDef | ProcessDef): Ports { return { ...ports, ...((def.config?.ports as Ports | undefined) ?? {}) }; } @@ -56,3 +68,59 @@ export function buildContainer(def: ContainerDef, _ctx: Ctx): ContainerResult { ], }; } + +// Build from source when no local binary is configured +const BUILD: BinaryBuildSpec = { + repo: "https://github.com/flashbots/op-rbuilder.git", + ref: "main", + cmd: "cargo build --release --bin op-rbuilder", + artifact: "target/release/op-rbuilder", +}; + +// op-rbuilder's own p2p listener when it runs as a host process. Distinct from +// the container-mode literal (30303) above: in this mode host port 30303 is +// already taken by the sequencer EL's published p2p port. +const PROCESS_P2P_PORT = 30313; + +function refs(def: ProcessDef) { + const l2 = def.refs?.l2; + if (!l2) throw new Error(`op-rbuilder ${def.name}: missing refs.l2`); + return { l2 }; +} + +export function buildProcess(def: ProcessDef, ctx: HostCtx): ProcessResult { + const { l2 } = refs(def); + const ps = resolvedPorts(def); + const dataDir = ctx.dataPath(def.name, "data"); + // The sequencer EL only publishes its p2p port to the host in this mode. + const elP2pPort = new URL(ctx.url(l2, "p2p")).port; + + return { + process: { + command: [ + def.binary ?? binaryBuildPath(BUILD), + "node", + "--authrpc.port", String(portNum(ps.authrpc)), + "--authrpc.addr", "0.0.0.0", + "--authrpc.jwtsecret", `${ctx.artifactsPath}/jwtsecret`, + "--http", + "--http.addr", "0.0.0.0", + "--http.port", String(portNum(ps.rpc)), + "--chain", `${ctx.artifactsPath}/l2-genesis.json`, + "--datadir", dataDir, + "--color", "never", + "--metrics", `0.0.0.0:${portNum(ps.metrics)}`, + "--port", String(PROCESS_P2P_PORT), + "--builder.enable-revert-protection", + // Builder signing key, same as container mode. + "--rollup.builder-secret-key", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + // Rootless podman pod IPs aren't reachable from the host and + // discovery-advertised addresses are useless across that boundary, so + // don't rely on discovery at all: dial EL directly by its fixed enode + "--trusted-peers", `enode://${EL_P2P_ID}@127.0.0.1:${elP2pPort}`, + "--disable-discovery", + ], + }, + binaryBuild: def.binary ? undefined : BUILD, + }; +} diff --git a/containers/op-reth.ts b/containers/op-reth.ts index 722df0a..7ad75ba 100644 --- a/containers/op-reth.ts +++ b/containers/op-reth.ts @@ -1,5 +1,6 @@ import type { ContainerDef, ContainerResult, Ctx, Ports } from "../utils/types.ts"; import { portNum } from "../utils/types.ts"; +import { EL_P2P_SECRET_KEY } from "./bootnode.ts"; // op-reth is the L2 execution engine for Karst and beyond: the Karst upgrade // (OP Upgrade 19) ends op-geth support, so from Karst on the sequencer EL is @@ -27,6 +28,8 @@ export function buildContainer(def: ContainerDef, _ctx: Ctx): ContainerResult { const discovery = bootnodeId ? ["--bootnodes", `enode://${bootnodeId}@bootnode:30303`, "--rollup.discovery.v4"] : ["--disable-discovery"]; + // Set only by recipes/opstack.ts when op-rbuilder runs as a host process. + const hostBuilderP2p = ps.p2p !== undefined; return { container: { image: "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-reth:v2.3.3", @@ -58,6 +61,7 @@ export function buildContainer(def: ContainerDef, _ctx: Ctx): ContainerResult { "--engine.persistence-threshold", "0", "--engine.memory-block-buffer-target", "0", ...discovery, + ...(hostBuilderP2p ? ["--p2p-secret-key-hex", EL_P2P_SECRET_KEY] : []), ], ports: ps, volumeMounts: [ diff --git a/decker.example.ts b/decker.example.ts index c524017..96b53ef 100644 --- a/decker.example.ts +++ b/decker.example.ts @@ -51,6 +51,8 @@ export const project: DeckerProject = { // l2Fork: "karst", // l2BlockTime: 1, // externalBuilder: "op-rbuilder", + // // Run op-rbuilder as a host process instead of the pinned container + // builderBinary: "../op-rbuilder/target/profiling/op-rbuilder", // }, // // Add or override the prototypes the recipe resolves by name — tweak a diff --git a/recipes/opstack.ts b/recipes/opstack.ts index 634bb76..1b790f5 100644 --- a/recipes/opstack.ts +++ b/recipes/opstack.ts @@ -1,4 +1,4 @@ -import type { Pod, Recipe } from "../utils/types.ts"; +import type { Pod, ProcessDef, Recipe } from "../utils/types.ts"; import { BOOTNODE_ID } from "../containers/bootnode.ts"; // An OP stack, ported from builder-playground's `opstack` recipe. The artifacts @@ -17,6 +17,14 @@ export type OpstackOptions = { // back to the local EL). Works with all forks — the local EL (op-geth or // op-reth) joins the bootnode so the builder peers + gets mempool txs. externalBuilder?: string | false; // (default off) + // Run op-rbuilder as a HOST PROCESS instead of the pinned container. + // A string as a path to a prebuilt op-rbuilder binary; `true` (or the CLI's + // `--opt builderBinary=true`) builds it from source on `up` instead. + // Moves op-rbuilder from `pods` to `recipe.processes`. The sequencer L2 EL + // gets a deterministic p2p identity + its p2p port published to the host so + // op-rbuilder can trusted-peer-dial it directly instead of going through the + // container-mode bootnode, which the host can't reach. (default off) + builderBinary?: string | boolean; }; export function recipe(o: OpstackOptions = {}): Recipe { @@ -25,6 +33,17 @@ export function recipe(o: OpstackOptions = {}): Recipe { const l2BlockTime = Number(o.l2BlockTime ?? 2); const externalBuilder = o.externalBuilder && o.externalBuilder !== "false" ? String(o.externalBuilder) : false; + // "true" and CLI's "true" (`--opt builderBinary=true`) mean "auto-build"; + // any other string is a path to a prebuilt binary. + const builderBinaryOpt = o.builderBinary; + const builderHostProcess = builderBinaryOpt !== undefined && builderBinaryOpt !== false && builderBinaryOpt !== "false"; + const builderBinaryPath = builderHostProcess && builderBinaryOpt !== true && builderBinaryOpt !== "true" + ? String(builderBinaryOpt) + : undefined; + if (builderHostProcess && externalBuilder !== "op-rbuilder") { + throw new Error(`opstack: builderBinary requires externalBuilder="op-rbuilder" (got ${externalBuilder || "false"})`); + } + // L2 execution client selection. Karst (OP Upgrade 19) ends op-geth support, so // from Karst on the L2 EL is op-reth and op-node needs a newer release. Forks // up to jovian keep the op-geth client set they were verified with. @@ -35,14 +54,24 @@ export function recipe(o: OpstackOptions = {}): Recipe { // trace API, op-geth speaks debug_trace*. const l2Variant = l2El === "op-reth" ? "erigon" : "geth"; - // With an external builder, op-node drives rollup-boost instead of the EL, and - // the local EL joins the bootnode so op-rbuilder can peer + sync the chain. + // With an external builder, op-node drives rollup-boost instead of the EL. + // Container mode: the local EL joins the bootnode so op-rbuilder can peer + + // sync the chain over pod-network discovery. Process mode: discovery can't + // cross the host/pod-network boundary, so instead the EL gets a fixed p2p + // identity + host-published p2p port for op-rbuilder to trusted-peer-dial + // directly. const l2Engine = externalBuilder ? "rollup-boost" : l2El; - const l2ElConfig = externalBuilder ? { bootnodeId: BOOTNODE_ID } : undefined; + const l2ElConfig = builderHostProcess + ? { ports: { p2p: { port: 30303, service: false } } } + : (externalBuilder ? { bootnodeId: BOOTNODE_ID } : undefined); const builderPods: Pod[] = externalBuilder ? [ - { name: "bootnode", containers: [{ name: "bootnode", prototype: "bootnode" }] }, - { name: "op-rbuilder", containers: [{ name: "op-rbuilder", prototype: "op-rbuilder" }] }, + // The bootnode is only needed for container mode's discovery-based + // peering; a host-process op-rbuilder dials the EL directly (see above). + ...(builderHostProcess ? [] : [ + { name: "bootnode", containers: [{ name: "bootnode", prototype: "bootnode" }] }, + { name: "op-rbuilder", containers: [{ name: "op-rbuilder", prototype: "op-rbuilder" }] }, + ]), { name: "rollup-boost", containers: [ @@ -51,6 +80,16 @@ export function recipe(o: OpstackOptions = {}): Recipe { }, ] : []; + const builderProcesses: ProcessDef[] = builderHostProcess + ? [ + { + name: "op-rbuilder", + prototype: "op-rbuilder", + refs: { l2: l2El }, + ...(builderBinaryPath ? { binary: builderBinaryPath } : {}), + }, + ] + : []; // L1: reth (el-1) + lighthouse beacon/validator produce the settlement chain. // L2: the sequencer EL (op-geth/op-reth by fork) is driven by op-node (directly, @@ -166,5 +205,6 @@ export function recipe(o: OpstackOptions = {}): Recipe { ], }, ], + ...(builderProcesses.length > 0 ? { processes: builderProcesses } : {}), }; }