diff --git a/.env.example b/.env.example index 3716648..2b5a9be 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,30 @@ TIPS_INGRESS_WRITER_KAFKA_BROKERS=localhost:9092 TIPS_INGRESS_WRITER_KAFKA_TOPIC=tips-ingress-rpc TIPS_INGRESS_WRITER_KAFKA_GROUP_ID=local-writer TIPS_INGRESS_WRITER_LOG_LEVEL=info + +# OP Node (Consensus Layer) - Common configuration for simulator-cl and shadow-builder-cl +OP_NODE_NETWORK= +OP_NODE_ROLLUP_CONFIG=/data/rollup.json +OP_NODE_ROLLUP_LOAD_PROTOCOL_VERSIONS=true +OP_NODE_SYNCMODE=consensus-layer +OP_NODE_L1_ETH_RPC=http://host.docker.internal:8545 +OP_NODE_L1_BEACON=http://host.docker.internal:3500 +OP_NODE_L1_RPC_KIND=debug_geth +OP_NODE_L1_TRUST_RPC=false +OP_NODE_L2_ENGINE_KIND=reth +OP_NODE_L2_ENGINE_AUTH=/data/jwtsecret +OP_NODE_P2P_LISTEN_IP=0.0.0.0 +OP_NODE_P2P_LISTEN_TCP_PORT=9222 +OP_NODE_P2P_LISTEN_UDP_PORT=9222 +OP_NODE_P2P_INTERNAL_IP=true +OP_NODE_P2P_ADVERTISE_IP=host.docker.internal +OP_NODE_P2P_NO_DISCOVERY=true +OP_NODE_RPC_ADDR=0.0.0.0 +OP_NODE_RPC_PORT=8545 +OP_NODE_LOG_LEVEL=debug +OP_NODE_LOG_FORMAT=json +OP_NODE_SNAPSHOT_LOG=/tmp/op-node-snapshot-log +OP_NODE_METRICS_ENABLED=true +OP_NODE_METRICS_ADDR=0.0.0.0 +OP_NODE_METRICS_PORT=7300 +STATSD_ADDRESS=172.17.0.1 diff --git a/Cargo.lock b/Cargo.lock index bd8442d..a14aae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6275,6 +6275,27 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "op-alloy-rpc-types-engine" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e50c94013a1d036a529df259151991dbbd6cf8dc215e3b68b784f95eec60e6" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives 1.3.1", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-serde", + "derive_more 2.0.1", + "ethereum_ssz", + "ethereum_ssz_derive", + "op-alloy-consensus 0.20.0", + "serde", + "snap", + "thiserror 2.0.16", +] + [[package]] name = "op-revm" version = "10.0.0" @@ -7810,7 +7831,7 @@ dependencies = [ "alloy-rpc-types-engine", "eyre", "futures-util", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "reth-chainspec", "reth-engine-primitives", "reth-ethereum-engine-primitives", @@ -8951,7 +8972,7 @@ dependencies = [ "alloy-op-evm", "alloy-primitives 1.3.1", "op-alloy-consensus 0.19.1", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "op-revm", "reth-chainspec", "reth-evm", @@ -9022,7 +9043,7 @@ dependencies = [ "clap", "eyre", "op-alloy-consensus 0.19.1", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "op-revm", "reth-chainspec", "reth-consensus", @@ -9069,7 +9090,7 @@ dependencies = [ "alloy-rpc-types-engine", "derive_more 2.0.1", "op-alloy-consensus 0.19.1", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "reth-basic-payload-builder", "reth-chain-state", "reth-chainspec", @@ -9140,7 +9161,7 @@ dependencies = [ "op-alloy-network 0.19.1", "op-alloy-rpc-jsonrpsee", "op-alloy-rpc-types 0.19.1", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "op-revm", "reqwest", "reth-chainspec", @@ -9265,7 +9286,7 @@ dependencies = [ "alloy-primitives 1.3.1", "alloy-rpc-types-engine", "auto_impl", - "op-alloy-rpc-types-engine", + "op-alloy-rpc-types-engine 0.19.1", "reth-chain-state", "reth-chainspec", "reth-errors", @@ -11214,6 +11235,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "shadow-boost" +version = "0.1.0" +dependencies = [ + "alloy-primitives 1.3.1", + "alloy-rpc-types-engine", + "base64 0.22.1", + "clap", + "eyre", + "hex", + "hmac", + "http 1.3.1", + "jsonrpsee", + "op-alloy-rpc-types-engine 0.20.0", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber 0.3.20", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index f70c690..b0e553f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/datastore", "crates/audit", "crates/ingress-rpc", "crates/maintenance", "crates/ingress-writer", "crates/simulator"] +members = ["crates/datastore", "crates/audit", "crates/ingress-rpc", "crates/maintenance", "crates/ingress-writer", "crates/simulator", "crates/shadow-boost"] resolver = "2" [workspace.dependencies] diff --git a/README.md b/README.md index 0bc1969..dc79795 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,41 @@ A service that consumes bundles from Kafka and persists them to the datastore. ### ๐Ÿ–ฅ๏ธ UI (`ui`) A debug UI for viewing the state of the bundle store and S3. + +### ๐Ÿงช Simulator (`crates/simulator`) +A Reth-based execution client that: +- Simulates bundles to estimate resource usage (e.g. execution time) +- Provides transaction tracing and simulation capabilities +- Syncs from production sequencer via an op-node instance (simulator-cl) +- Used by the block builder stack to throttle transactions based on resource consumption + +## ๐Ÿ—๏ธ Shadow Builder Stack + +The shadow builder stack enables production-ready block building with TIPS bundle integration. It consists of: + +**shadow-builder-cl**: An op-node instance running in sequencer mode that: +- Syncs from production sequencer via P2P +- Drives block building through Engine API calls +- Uses a placeholder sequencer key so built blocks will be rejected by the network +- Does not submit blocks to L1 (shadow sequencer mode) + +**shadow-builder**: A modified op-rbuilder instance that: +- Receives Engine API calls from shadow-builder-cl +- Queries TIPS datastore for bundles with resource usage estimates from the simulator +- Builds blocks including eligible bundles while respecting resource constraints +- Runs in parallel with the production builder for testing and validation + +**Prerequisites**: +- [builder-playground](https://github.com/flashbots/builder-playground) running locally with the `niran:authorize-signers` branch +- op-rbuilder Docker image built using `just build-rbuilder` + +**Quick Start**: +```bash +# Build op-rbuilder (optionally from a specific branch) +just build-rbuilder + +# Start the shadow builder stack (requires builder-playground running) +just start-builder +``` + +The shadow-builder-cl syncs from the production sequencer via P2P while shadow-builder builds blocks with TIPS bundles in parallel with the production builder. The shadow builder's blocks are never broadcast to the network due to the invalid sequencer key, and there is no batcher service to submit them to L1, making this safe for testing and validation without affecting production. diff --git a/crates/datastore/src/postgres.rs b/crates/datastore/src/postgres.rs index 6efe8dd..f416c36 100644 --- a/crates/datastore/src/postgres.rs +++ b/crates/datastore/src/postgres.rs @@ -26,6 +26,7 @@ pub enum BundleState { #[derive(sqlx::FromRow, Debug)] struct BundleRow { + id: Uuid, senders: Option>, minimum_base_fee: Option, txn_hashes: Option>, @@ -81,6 +82,7 @@ impl BundleFilter { /// Extended bundle data that includes the original bundle plus extracted metadata #[derive(Debug, Clone)] pub struct BundleWithMetadata { + pub id: Uuid, pub bundle: EthSendBundle, pub txn_hashes: Vec, pub senders: Vec
, @@ -88,13 +90,6 @@ pub struct BundleWithMetadata { pub state: BundleState, } -/// Bundle with its latest simulation -#[derive(Debug, Clone)] -pub struct BundleWithLatestSimulation { - pub bundle_with_metadata: BundleWithMetadata, - pub latest_simulation: Simulation, -} - /// State diff type: maps account addresses to storage slot mappings pub type StateDiff = HashMap>; @@ -204,6 +199,7 @@ impl PostgresDatastore { .collect(); Ok(BundleWithMetadata { + id: row.id, bundle, txn_hashes: parsed_txn_hashes?, senders: parsed_senders?, @@ -312,9 +308,9 @@ impl BundleDatastore for PostgresDatastore { async fn get_bundle(&self, id: Uuid) -> Result> { let result = sqlx::query_as::<_, BundleRow>( r#" - SELECT senders, minimum_base_fee, txn_hashes, txs, reverting_tx_hashes, + SELECT id, senders, minimum_base_fee, txn_hashes, txs, reverting_tx_hashes, dropping_tx_hashes, block_number, min_timestamp, max_timestamp, "state" - FROM bundles + FROM bundles WHERE id = $1 "#, ) @@ -352,9 +348,9 @@ impl BundleDatastore for PostgresDatastore { let rows = sqlx::query_as::<_, BundleRow>( r#" - SELECT senders, minimum_base_fee, txn_hashes, txs, reverting_tx_hashes, + SELECT id, senders, minimum_base_fee, txn_hashes, txs, reverting_tx_hashes, dropping_tx_hashes, block_number, min_timestamp, max_timestamp, "state" - FROM bundles + FROM bundles WHERE minimum_base_fee >= $1 AND (block_number = $2 OR block_number IS NULL OR block_number = 0 OR $2 = 0) AND (min_timestamp <= $3 OR min_timestamp IS NULL) @@ -463,7 +459,7 @@ impl BundleDatastore for PostgresDatastore { async fn select_bundles_with_latest_simulation( &self, filter: BundleFilter, - ) -> Result> { + ) -> Result> { let base_fee = filter.base_fee.unwrap_or(0); let block_number = filter.block_number.unwrap_or(0) as i64; @@ -487,9 +483,9 @@ impl BundleDatastore for PostgresDatastore { ROW_NUMBER() OVER (PARTITION BY s.bundle_id ORDER BY s.block_number DESC) as rn FROM simulations s ) - SELECT - b.senders, b.minimum_base_fee, b.txn_hashes, b.txs, - b.reverting_tx_hashes, b.dropping_tx_hashes, + SELECT + b.id, b.senders, b.minimum_base_fee, b.txn_hashes, b.txs, + b.reverting_tx_hashes, b.dropping_tx_hashes, b.block_number, b.min_timestamp, b.max_timestamp, b."state", ls.sim_id, ls.bundle_id as sim_bundle_id, ls.sim_block_number, ls.block_hash, ls.execution_time_us, ls.gas_used, ls.state_diff @@ -505,6 +501,7 @@ impl BundleDatastore for PostgresDatastore { #[derive(sqlx::FromRow)] struct BundleWithSimulationRow { // Bundle fields + id: Uuid, senders: Option>, minimum_base_fee: Option, txn_hashes: Option>, @@ -537,6 +534,7 @@ impl BundleDatastore for PostgresDatastore { for row in rows { // Convert bundle part let bundle_row = BundleRow { + id: row.id, senders: row.senders, minimum_base_fee: row.minimum_base_fee, txn_hashes: row.txn_hashes, @@ -562,10 +560,7 @@ impl BundleDatastore for PostgresDatastore { }; let simulation = self.row_to_simulation(simulation_row)?; - results.push(BundleWithLatestSimulation { - bundle_with_metadata, - latest_simulation: simulation, - }); + results.push((bundle_with_metadata, simulation)); } Ok(results) diff --git a/crates/datastore/src/traits.rs b/crates/datastore/src/traits.rs index 927425b..630b735 100644 --- a/crates/datastore/src/traits.rs +++ b/crates/datastore/src/traits.rs @@ -1,6 +1,4 @@ -use crate::postgres::{ - BundleFilter, BundleWithLatestSimulation, BundleWithMetadata, Simulation, StateDiff, -}; +use crate::postgres::{BundleFilter, BundleWithMetadata, Simulation, StateDiff}; use alloy_primitives::TxHash; use alloy_rpc_types_mev::EthSendBundle; use anyhow::Result; @@ -46,5 +44,5 @@ pub trait BundleDatastore: Send + Sync { async fn select_bundles_with_latest_simulation( &self, filter: BundleFilter, - ) -> Result>; + ) -> Result>; } diff --git a/crates/datastore/tests/datastore.rs b/crates/datastore/tests/datastore.rs index 0251620..657f2c7 100644 --- a/crates/datastore/tests/datastore.rs +++ b/crates/datastore/tests/datastore.rs @@ -503,8 +503,7 @@ async fn multiple_simulations_latest_selection() -> eyre::Result<()> { // Should return exactly one bundle assert_eq!(results.len(), 1, "Should return exactly one bundle"); - let bundle_with_sim = &results[0]; - let latest_sim = &bundle_with_sim.latest_simulation; + let (_bundle_meta, latest_sim) = &results[0]; // Verify it's the latest simulation (highest block number) let expected_latest_block = base_block + 4; // Last iteration was i=4 @@ -637,26 +636,26 @@ async fn select_bundles_with_latest_simulation() -> eyre::Result<()> { // Verify the results contain the correct bundles and latest simulations let bundle1_result = results .iter() - .find(|r| r.bundle_with_metadata.bundle.block_number == 100); + .find(|(bundle_meta, _)| bundle_meta.bundle.block_number == 100); let bundle2_result = results .iter() - .find(|r| r.bundle_with_metadata.bundle.block_number == 200); + .find(|(bundle_meta, _)| bundle_meta.bundle.block_number == 200); assert!(bundle1_result.is_some(), "Bundle1 should be in results"); assert!(bundle2_result.is_some(), "Bundle2 should be in results"); - let bundle1_result = bundle1_result.unwrap(); - let bundle2_result = bundle2_result.unwrap(); + let (_bundle1_meta, sim1) = bundle1_result.unwrap(); + let (_bundle2_meta, sim2) = bundle2_result.unwrap(); // Check that bundle1 has the latest simulation (block 18500001) - assert_eq!(bundle1_result.latest_simulation.id, latest_sim1_id); - assert_eq!(bundle1_result.latest_simulation.block_number, 18500001); - assert_eq!(bundle1_result.latest_simulation.gas_used, 22000); + assert_eq!(sim1.id, latest_sim1_id); + assert_eq!(sim1.block_number, 18500001); + assert_eq!(sim1.gas_used, 22000); // Check that bundle2 has its simulation - assert_eq!(bundle2_result.latest_simulation.id, sim2_id); - assert_eq!(bundle2_result.latest_simulation.block_number, 18500002); - assert_eq!(bundle2_result.latest_simulation.gas_used, 19000); + assert_eq!(sim2.id, sim2_id); + assert_eq!(sim2.block_number, 18500002); + assert_eq!(sim2.gas_used, 19000); Ok(()) } @@ -720,10 +719,8 @@ async fn select_bundles_with_latest_simulation_filtered() -> eyre::Result<()> { 1, "Should return 1 bundle valid for block 200" ); - assert_eq!( - filtered_results[0].bundle_with_metadata.bundle.block_number, - 200 - ); + let (bundle_meta, _sim) = &filtered_results[0]; + assert_eq!(bundle_meta.bundle.block_number, 200); // Test filtering by timestamp let timestamp_filter = BundleFilter::new().valid_for_timestamp(1200); @@ -738,13 +735,8 @@ async fn select_bundles_with_latest_simulation_filtered() -> eyre::Result<()> { 1, "Should return 1 bundle valid for timestamp 1200" ); - assert_eq!( - timestamp_results[0] - .bundle_with_metadata - .bundle - .block_number, - 100 - ); + let (bundle_meta, _sim) = ×tamp_results[0]; + assert_eq!(bundle_meta.bundle.block_number, 100); Ok(()) } diff --git a/crates/shadow-boost/Cargo.toml b/crates/shadow-boost/Cargo.toml new file mode 100644 index 0000000..672750e --- /dev/null +++ b/crates/shadow-boost/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "shadow-boost" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "shadow-boost" +path = "src/main.rs" + +[dependencies] +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +eyre.workspace = true +serde.workspace = true +serde_json.workspace = true +base64.workspace = true + +alloy-primitives.workspace = true +alloy-rpc-types-engine = "1.0.33" +op-alloy-rpc-types-engine = "0.20.0" + +jsonrpsee = { workspace = true, features = ["http-client", "server"] } +http = "1.0" +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" diff --git a/crates/shadow-boost/Dockerfile b/crates/shadow-boost/Dockerfile new file mode 100644 index 0000000..c45174e --- /dev/null +++ b/crates/shadow-boost/Dockerfile @@ -0,0 +1,40 @@ +FROM rust:1-bookworm AS base + +RUN apt-get update && apt-get install -y \ + clang \ + libclang-dev \ + llvm-dev \ + pkg-config && \ + rm -rf /var/lib/apt/lists/* + +RUN cargo install cargo-chef --locked +WORKDIR /app + +FROM base AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM base AS builder +COPY --from=planner /app/recipe.json recipe.json + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo chef cook --recipe-path recipe.json + +COPY . . +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --bin shadow-boost && \ + cp target/debug/shadow-boost /tmp/shadow-boost + +FROM debian:bookworm + +RUN apt-get update && apt-get install -y libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /tmp/shadow-boost /app/shadow-boost + +ENTRYPOINT ["/app/shadow-boost"] diff --git a/crates/shadow-boost/README.md b/crates/shadow-boost/README.md new file mode 100644 index 0000000..ee81067 --- /dev/null +++ b/crates/shadow-boost/README.md @@ -0,0 +1,72 @@ +# shadow-boost + +A minimal proxy for driving a shadow builder (op-rbuilder) from a non-sequencer op-node. + +## Purpose + +Shadow-boost enables running a shadow builder in parallel with the canonical sequencer without causing reorgs or P2P block rejections. It sits between a non-sequencer op-node and a builder (op-rbuilder), enabling the builder to produce blocks in parallel with the canonical sequencer without interfering with L2 consensus. + +## How It Works + +1. **Intercepts `forkchoiceUpdated` calls**: Rewrites `no_tx_pool=true` to `no_tx_pool=false` to trigger block building +2. **Synthetically calls `getPayload`**: Fetches built blocks from the builder after a delay for analysis/logging +3. **Forwards `newPayload` calls**: Keeps the builder synced with the canonical chain via P2P blocks + +The builder produces blocks in parallel with the canonical sequencer, but these blocks are never sent back to the op-node (getPayload is not supported). The op-node follows the canonical chain via P2P and L1 derivation normally, while the builder independently produces shadow blocks for comparison/analysis. + +## Why Not Sequencer Mode? + +Running a shadow builder with op-node in sequencer mode causes several issues: + +### Sequencer Mode Problems + +1. **P2P Block Rejection**: The shadow op-node builds its own blocks locally and considers them the "unsafe head". When the real sequencer's blocks arrive via P2P gossip, they are rejected with "skipping unsafe payload, since it is older than unsafe head" because they have the same block number but different hashes. + +2. **Multiple L1-Triggered Reorgs**: When L1 blocks arrive containing batched L2 data, the derivation pipeline advances the "safe head" and re-derives L2 blocks from L1 data. If the locally-built blocks don't match the L1-derived attributes (random field, etc.), the shadow builder reorgs away from its own chain, discarding the queued P2P blocks in the process. + +3. **Delayed Convergence**: The shadow builder may reorg the same block number multiple times as it receives: + - Its own locally-built block + - L1-derived blocks (from ancestor batch data) + - The final canonical block (from the specific block's L1 batch data) + + This can take 10-20+ seconds per block to converge. + +4. **Fork Persistence**: The shadow builder maintains a persistent fork from the canonical chain until L1 derivation eventually produces matching blocks. + +### Shadow-Boost Solution + +Shadow-boost solves these issues by: + +1. **Non-Sequencer Mode**: The op-node runs as a follower, accepting canonical blocks via P2P immediately without rejection. + +2. **Forced Block Building**: Intercepts `forkchoiceUpdated` calls and rewrites `no_tx_pool=true` to `no_tx_pool=false`, triggering the builder to construct blocks even though the op-node isn't sequencing. + +3. **Parallel Building**: The builder produces blocks in parallel with the canonical sequencer, but these blocks are never sent back to the op-node. + +4. **No Reorgs**: The op-node follows the canonical chain via P2P and L1 derivation normally, while the builder independently produces shadow blocks for comparison/analysis. + +This allows the shadow builder to build blocks at the same pace as the real sequencer while staying synchronized with the canonical chain without constant reorgs. + +## Usage + +```bash +shadow-boost \ + --builder-url http://localhost:9551 \ + --builder-jwt-secret /path/to/jwt/secret \ + --listen-addr 127.0.0.1:8554 +``` + +Then configure your op-node to use this proxy as its execution engine: + +```bash +op-node \ + --l2.engine-rpc http://127.0.0.1:8554 \ + --l2.engine-jwt-secret /path/to/jwt/secret +``` + +## Environment Variables + +- `BUILDER_URL`: Builder's Engine API URL +- `BUILDER_JWT_SECRET`: Path to builder's JWT secret file +- `LISTEN_ADDR`: Address to listen on (default: 127.0.0.1:8554) +- `TIMEOUT_MS`: Request timeout in milliseconds (default: 2000) diff --git a/crates/shadow-boost/src/auth.rs b/crates/shadow-boost/src/auth.rs new file mode 100644 index 0000000..4835087 --- /dev/null +++ b/crates/shadow-boost/src/auth.rs @@ -0,0 +1,31 @@ +use alloy_rpc_types_engine::JwtSecret; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn generate_jwt_token(secret: &JwtSecret) -> String { + use base64::Engine; + use hmac::Mac; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let header = r#"{"alg":"HS256","typ":"JWT"}"#; + let payload = format!(r#"{{"iat":{}}}"#, now); + + let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + + let message = format!("{}.{}", header_b64, payload_b64); + + let signature = { + use sha2::Sha256; + let mut mac = + hmac::Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC creation failed"); + mac.update(message.as_bytes()); + let result = mac.finalize(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(result.into_bytes()) + }; + + format!("{}.{}", message, signature) +} diff --git a/crates/shadow-boost/src/config.rs b/crates/shadow-boost/src/config.rs new file mode 100644 index 0000000..d146760 --- /dev/null +++ b/crates/shadow-boost/src/config.rs @@ -0,0 +1,27 @@ +use alloy_rpc_types_engine::JwtSecret; +use clap::Parser; +use eyre::Result; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "shadow-boost")] +#[command(about = "Shadow builder proxy for driving builder from non-sequencer op-node")] +pub struct Config { + #[arg(long, env = "BUILDER_URL")] + pub builder_url: String, + + #[arg(long, env = "BUILDER_JWT_SECRET")] + pub builder_jwt_secret: PathBuf, + + #[arg(long, env = "LISTEN_ADDR", default_value = "127.0.0.1:8554")] + pub listen_addr: String, + + #[arg(long, env = "TIMEOUT_MS", default_value = "2000")] + pub timeout_ms: u64, +} + +impl Config { + pub fn load_jwt_secret(&self) -> Result { + Ok(JwtSecret::from_file(&self.builder_jwt_secret)?) + } +} diff --git a/crates/shadow-boost/src/main.rs b/crates/shadow-boost/src/main.rs new file mode 100644 index 0000000..0a95a2b --- /dev/null +++ b/crates/shadow-boost/src/main.rs @@ -0,0 +1,40 @@ +//! Minimal proxy for driving a shadow builder from a non-sequencer op-node. +//! +//! Intercepts Engine API calls and modifies them to force block building while keeping +//! the op-node synchronized with the canonical chain. + +mod auth; +mod config; +mod proxy; +mod server; + +use clap::Parser; +use config::Config; +use eyre::Result; +use proxy::ShadowBuilderProxy; +use server::{build_rpc_module, start_server}; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); + + let config = Config::parse(); + + info!("Starting Shadow Builder Proxy"); + info!("Builder URL: {}", config.builder_url); + info!("Listen address: {}", config.listen_addr); + + let builder_jwt = config.load_jwt_secret()?; + let proxy = ShadowBuilderProxy::new(&config.builder_url, builder_jwt, config.timeout_ms)?; + let rpc_module = build_rpc_module(proxy); + + info!("Shadow Builder Proxy listening on {}", config.listen_addr); + info!("Point your op-node to this proxy as the execution engine"); + + start_server(&config.listen_addr, rpc_module).await?; + + Ok(()) +} diff --git a/crates/shadow-boost/src/proxy.rs b/crates/shadow-boost/src/proxy.rs new file mode 100644 index 0000000..4120b5d --- /dev/null +++ b/crates/shadow-boost/src/proxy.rs @@ -0,0 +1,300 @@ +use crate::auth::generate_jwt_token; +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ExecutionPayloadV3, ForkchoiceUpdated, JwtSecret, PayloadAttributes}; +use eyre::Result; +use jsonrpsee::{ + core::client::ClientT, + http_client::{HttpClient, HttpClientBuilder}, + types::ErrorObjectOwned, +}; +use op_alloy_rpc_types_engine::OpPayloadAttributes; +use serde_json::Value; +use std::{sync::Arc, time::Duration}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +/// Information extracted from the last newPayload call, used to construct +/// synthetic payload attributes for shadow building when op-node sends FCU +/// without attributes (Boost Sync). +#[derive(Clone, Default)] +pub struct LastPayloadInfo { + pub timestamp: u64, + pub prev_randao: B256, + pub fee_recipient: alloy_primitives::Address, + pub gas_limit: u64, + pub eip_1559_params: Option, + pub parent_beacon_block_root: B256, +} + +/// A pass-through proxy between op-node and the shadow builder that: +/// 1. Logs all Engine API requests and responses +/// 2. Injects synthetic payload attributes to trigger shadow building when +/// FCU arrives without attributes (non-sequencer Boost Sync scenario) +/// 3. Suppresses payload_id from responses when using injected attributes +/// so op-node doesn't know shadow building occurred +#[derive(Clone)] +pub struct ShadowBuilderProxy { + pub builder_client: Arc>, + builder_url: String, + jwt_secret: JwtSecret, + timeout_ms: u64, + pub last_payload_info: Arc>>, +} + +impl ShadowBuilderProxy { + fn create_client( + builder_url: &str, + jwt_secret: &JwtSecret, + timeout_ms: u64, + ) -> Result { + let token = generate_jwt_token(jwt_secret); + let auth_value = format!("Bearer {}", token); + + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_str(&auth_value) + .map_err(|e| eyre::eyre!("Invalid auth header: {}", e))?, + ); + + let client = HttpClientBuilder::new() + .set_headers(headers) + .request_timeout(Duration::from_millis(timeout_ms)) + .build(builder_url)?; + + Ok(client) + } + + pub fn new(builder_url: &str, jwt_secret: JwtSecret, timeout_ms: u64) -> Result { + let client = Self::create_client(builder_url, &jwt_secret, timeout_ms)?; + + let proxy = Self { + builder_client: Arc::new(RwLock::new(client)), + builder_url: builder_url.to_string(), + jwt_secret, + timeout_ms, + last_payload_info: Arc::new(RwLock::new(None)), + }; + + proxy.start_token_refresh_task(); + + Ok(proxy) + } + + fn start_token_refresh_task(&self) { + let builder_client = self.builder_client.clone(); + let builder_url = self.builder_url.clone(); + let jwt_secret = self.jwt_secret; + let timeout_ms = self.timeout_ms; + + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + + loop { + interval.tick().await; + + match Self::create_client(&builder_url, &jwt_secret, timeout_ms) { + Ok(new_client) => { + *builder_client.write().await = new_client; + info!("Refreshed JWT token for builder client"); + } + Err(e) => { + error!(error = %e, "Failed to refresh JWT token"); + } + } + } + }); + } + + /// Handle engine_forkchoiceUpdatedV3 with synthetic attribute injection. + /// + /// When op-node sends FCU without payload attributes (params_count=1), this + /// indicates a Boost Sync call to update chain state. In non-sequencer mode, + /// we inject synthetic payload attributes based on the last received newPayload + /// to trigger shadow building. The payload_id returned by the builder is + /// suppressed before returning to op-node. + /// + /// When FCU has payload attributes (params_count=2), pass through unchanged. + pub async fn handle_fcu(&self, params_vec: Vec) -> Result { + let has_payload_attrs = params_vec.len() == 2; + + info!( + method = "engine_forkchoiceUpdatedV3", + params_count = params_vec.len(), + params = ?params_vec, + "JSON-RPC request (original from op-node)" + ); + + let mut injected_attrs = false; + let modified_params = if !has_payload_attrs { + let last_info = self.last_payload_info.read().await; + if let Some(info) = last_info.as_ref() { + let timestamp = info.timestamp + 2; + let synthetic_attrs = OpPayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp, + prev_randao: info.prev_randao, + suggested_fee_recipient: info.fee_recipient, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(info.parent_beacon_block_root), + }, + transactions: None, + no_tx_pool: Some(false), + gas_limit: Some(info.gas_limit), + eip_1559_params: info.eip_1559_params, + min_base_fee: None, + }; + + info!( + timestamp, + gas_limit = info.gas_limit, + "Injected synthetic payload attributes to trigger shadow building" + ); + + let mut new_params = params_vec.clone(); + new_params.push(serde_json::to_value(synthetic_attrs).unwrap()); + injected_attrs = true; + new_params + } else { + info!("No last payload info available, passing FCU through unchanged"); + params_vec + } + } else { + info!("FCU already has payload attributes, passing through unchanged"); + params_vec + }; + + if injected_attrs { + info!( + method = "engine_forkchoiceUpdatedV3", + params_count = modified_params.len(), + "JSON-RPC request (modified, sent to builder)" + ); + } + + let client = self.builder_client.read().await; + let mut response: ForkchoiceUpdated = client + .request("engine_forkchoiceUpdatedV3", modified_params) + .await + .map_err(|e| { + warn!( + method = "engine_forkchoiceUpdatedV3", + error = %e, + "JSON-RPC request failed" + ); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; + drop(client); + + let builder_payload_id = response.payload_id; + + info!( + method = "engine_forkchoiceUpdatedV3", + payload_status = ?response.payload_status.status, + payload_id = ?builder_payload_id, + injected_attrs, + "JSON-RPC response (from builder)" + ); + + if injected_attrs && builder_payload_id.is_some() { + info!( + payload_id = ?builder_payload_id, + "Suppressing payload_id from injected attributes before returning to op-node" + ); + response.payload_id = None; + } + + let response_value = serde_json::to_value(response).unwrap(); + + info!( + method = "engine_forkchoiceUpdatedV3", + response = ?response_value, + "JSON-RPC response (returned to op-node)" + ); + + Ok(response_value) + } + + /// Handle engine_newPayloadV4 and capture payload info for synthetic attributes. + /// + /// Extracts key information from the payload (timestamp, gas_limit, prevRandao, + /// feeRecipient, EIP-1559 params, parent beacon block root) and stores it for + /// use in constructing synthetic payload attributes when future FCU calls arrive + /// without attributes. + /// + /// The request and response are passed through unchanged to/from the builder. + pub async fn handle_new_payload( + &self, + params_vec: Vec, + ) -> Result { + info!( + method = "engine_newPayloadV4", + params_count = params_vec.len(), + params = ?params_vec, + "JSON-RPC request" + ); + + if params_vec.len() >= 3 { + if let Ok(payload) = serde_json::from_value::(params_vec[0].clone()) + { + let parent_beacon_block_root = if let Some(root_val) = params_vec.get(2) { + serde_json::from_value(root_val.clone()).ok() + } else { + None + }; + + if let Some(parent_beacon_block_root) = parent_beacon_block_root { + let timestamp = payload.payload_inner.payload_inner.timestamp; + let prev_randao = payload.payload_inner.payload_inner.prev_randao; + let fee_recipient = payload.payload_inner.payload_inner.fee_recipient; + let gas_limit = payload.payload_inner.payload_inner.gas_limit; + let extra_data = &payload.payload_inner.payload_inner.extra_data; + + let eip_1559_params = if extra_data.len() >= 9 { + Some(alloy_primitives::B64::from_slice(&extra_data[1..9])) + } else { + Some(alloy_primitives::B64::from_slice(&[ + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + ])) + }; + + *self.last_payload_info.write().await = Some(LastPayloadInfo { + timestamp, + prev_randao, + fee_recipient, + gas_limit, + eip_1559_params, + parent_beacon_block_root, + }); + + info!( + timestamp, + gas_limit, "Captured payload info for future synthetic attributes" + ); + } + } + } + + let client = self.builder_client.read().await; + let result: Value = client + .request("engine_newPayloadV4", params_vec) + .await + .map_err(|e| { + warn!( + method = "engine_newPayloadV4", + error = %e, + "JSON-RPC request failed" + ); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; + + info!( + method = "engine_newPayloadV4", + response = ?result, + "JSON-RPC response" + ); + + Ok(result) + } +} diff --git a/crates/shadow-boost/src/server.rs b/crates/shadow-boost/src/server.rs new file mode 100644 index 0000000..6e9031d --- /dev/null +++ b/crates/shadow-boost/src/server.rs @@ -0,0 +1,123 @@ +use crate::proxy::ShadowBuilderProxy; +use jsonrpsee::{ + core::client::ClientT, + server::Server, + types::{ErrorObjectOwned, Params}, + RpcModule, +}; +use serde_json::Value; +use tracing::{info, warn}; + +pub fn build_rpc_module(proxy: ShadowBuilderProxy) -> RpcModule { + let mut module = RpcModule::new(proxy); + + module + .register_async_method( + "engine_forkchoiceUpdatedV3", + |params: Params<'static>, context, _| async move { + let mut params_vec = Vec::new(); + let mut seq = params.sequence(); + while let Ok(Some(value)) = seq.optional_next::() { + params_vec.push(value); + } + context.handle_fcu(params_vec).await + }, + ) + .unwrap(); + + module + .register_async_method( + "engine_newPayloadV4", + |params: Params<'static>, context, _| async move { + let mut params_vec = Vec::new(); + let mut seq = params.sequence(); + while let Ok(Some(value)) = seq.optional_next::() { + params_vec.push(value); + } + context.handle_new_payload(params_vec).await + }, + ) + .unwrap(); + + let methods = [ + "eth_chainId", + "eth_syncing", + "eth_getBlockByNumber", + "eth_getBlockByHash", + "eth_sendRawTransaction", + "eth_sendRawTransactionConditional", + "miner_setExtra", + "miner_setGasPrice", + "miner_setGasLimit", + "miner_setMaxDASize", + "engine_exchangeCapabilities", + "engine_forkchoiceUpdatedV1", + "engine_forkchoiceUpdatedV2", + "engine_forkchoiceUpdatedV4", + "engine_newPayloadV1", + "engine_newPayloadV2", + "engine_newPayloadV3", + "engine_getPayloadV1", + "engine_getPayloadV2", + "engine_getPayloadV3", + "engine_getPayloadV4", + "engine_newPayloadWithWitnessV4", + "engine_getPayloadBodiesByHashV1", + "engine_getPayloadBodiesByRangeV1", + ]; + + for method in methods { + register_passthrough_method(&mut module, method); + } + + module +} + +fn register_passthrough_method( + module: &mut RpcModule, + method_name: &'static str, +) { + let method_owned = method_name.to_string(); + module + .register_async_method(method_name, move |params: Params<'static>, context, _| { + let method = method_owned.clone(); + async move { + let mut params_vec = Vec::new(); + let mut seq = params.sequence(); + while let Ok(Some(value)) = seq.optional_next::() { + params_vec.push(value); + } + + info!( + method, + params_count = params_vec.len(), + params = ?params_vec, + "JSON-RPC request" + ); + + let client = context.builder_client.read().await; + let result: Value = client.request(&method, params_vec).await.map_err(|e| { + warn!(method, error = %e, "JSON-RPC request failed"); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; + + info!(method, response = ?result, "JSON-RPC response"); + Ok::(result) + } + }) + .unwrap(); +} + +pub async fn start_server( + listen_addr: &str, + rpc_module: RpcModule, +) -> eyre::Result<()> { + let server = Server::builder().build(listen_addr).await?; + let handle = server.start(rpc_module); + + tokio::signal::ctrl_c().await?; + handle.stop()?; + handle.stopped().await; + + Ok(()) +} diff --git a/crates/simulator/src/listeners/exex.rs b/crates/simulator/src/listeners/exex.rs index db5bcf9..2a70fec 100644 --- a/crates/simulator/src/listeners/exex.rs +++ b/crates/simulator/src/listeners/exex.rs @@ -48,11 +48,9 @@ where .map_err(|e| eyre::eyre!("Failed to select bundles: {}", e))?; // Convert to (Uuid, EthSendBundle) pairs - // TODO: The bundle ID should be returned from the datastore query - // For now, we generate new IDs for each bundle let result = bundles_with_metadata .into_iter() - .map(|bwm| (Uuid::new_v4(), bwm.bundle)) + .map(|bwm| (bwm.id, bwm.bundle)) .collect(); Ok(result) @@ -230,13 +228,9 @@ where // Queue simulations for each bundle for (index, bundle_metadata) in bundles_with_metadata.into_iter().enumerate() { - // TODO: The bundle ID should be returned from the datastore query - // For now, we generate new IDs for each bundle - let bundle_id = Uuid::new_v4(); - // Create simulation request let request = SimulationRequest { - bundle_id, + bundle_id: bundle_metadata.id, bundle: bundle_metadata.bundle, block_number, block_hash: *block_hash, diff --git a/docker-compose.tips.yml b/docker-compose.tips.yml index 9cfd8d7..dfbdea3 100644 --- a/docker-compose.tips.yml +++ b/docker-compose.tips.yml @@ -68,4 +68,24 @@ services: timeout: 5s retries: 10 profiles: - - simulator + - builder + + shadow-boost: + build: + context: . + dockerfile: crates/shadow-boost/Dockerfile + container_name: tips-shadow-boost + depends_on: + shadow-builder: + condition: service_started + profiles: + - builder + ports: + - "8554:8554" # Engine API proxy + volumes: + - ~/.playground/devnet/jwtsecret:/data/jwtsecret:ro + environment: + BUILDER_URL: http://shadow-builder:4444 + BUILDER_JWT_SECRET: /data/jwtsecret + LISTEN_ADDR: 0.0.0.0:8554 + TIMEOUT_MS: "2000" diff --git a/docker-compose.yml b/docker-compose.yml index 46b87a9..6fe19b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,63 +98,65 @@ services: simulator: condition: service_healthy profiles: - - simulator + - builder ports: - "18545:8545" # RPC - - "19222:9222" # P2P TCP - - "19222:9222/udp" # P2P UDP - "17300:7300" # metrics - "16060:6060" # pprof volumes: - ~/.playground/devnet/jwtsecret:/data/jwtsecret:ro - ~/.playground/devnet/rollup.json:/data/rollup.json:ro - environment: - # NETWORK CONFIGURATION - OP_NODE_NETWORK: "" - OP_NODE_ROLLUP_CONFIG: /data/rollup.json - - # BASE SEQUENCER ENDPOINTS - RETH_SEQUENCER_HTTP: http://host.docker.internal:8547 - OP_SEQUENCER_HTTP: http://host.docker.internal:8547 - OP_RETH_SEQUENCER_HTTP: http://host.docker.internal:8547 - - # SYNC CONFIGURATION - OP_NODE_SYNCMODE: consensus-layer - OP_NODE_ROLLUP_LOAD_PROTOCOL_VERSIONS: "true" - - # L1 CONFIGURATION - OP_NODE_L1_ETH_RPC: http://host.docker.internal:8545 - OP_NODE_L1_BEACON: http://host.docker.internal:3500 - OP_NODE_L1_RPC_KIND: debug_geth - OP_NODE_L1_TRUST_RPC: "false" - - # ENGINE CONFIGURATION - OP_NODE_L2_ENGINE_KIND: reth + env_file: + - .env.docker + environment: + # ENGINE CONFIGURATION (simulator-specific) OP_NODE_L2_ENGINE_RPC: http://simulator:4444 - OP_NODE_L2_ENGINE_AUTH: /data/jwtsecret - - # P2P CONFIGURATION - OP_NODE_P2P_LISTEN_IP: 0.0.0.0 - OP_NODE_P2P_LISTEN_TCP_PORT: "9222" - OP_NODE_P2P_LISTEN_UDP_PORT: "9222" - OP_NODE_INTERNAL_IP: "true" - OP_NODE_P2P_ADVERTISE_IP: host.docker.internal - OP_NODE_P2P_ADVERTISE_TCP: "19222" - OP_NODE_P2P_ADVERTISE_UDP: "19222" - # Only connect to the sequencer in playground mode - OP_NODE_P2P_NO_DISCOVERY: "true" - - # RPC CONFIGURATION - OP_NODE_RPC_ADDR: 0.0.0.0 - OP_NODE_RPC_PORT: "8545" - - # LOGGING & MONITORING - OP_NODE_LOG_LEVEL: debug - OP_NODE_LOG_FORMAT: json - OP_NODE_SNAPSHOT_LOG: /tmp/op-node-snapshot-log - OP_NODE_METRICS_ENABLED: "true" - OP_NODE_METRICS_ADDR: 0.0.0.0 - OP_NODE_METRICS_PORT: "7300" - STATSD_ADDRESS: "172.17.0.1" + + shadow-builder-cl: + image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.13.7 + container_name: tips-shadow-builder-cl + depends_on: + shadow-boost: + condition: service_started + profiles: + - builder + ports: + - "28545:8545" # RPC + - "27300:7300" # metrics + - "26060:6060" # pprof + volumes: + - ~/.playground/devnet/jwtsecret:/data/jwtsecret:ro + - ~/.playground/devnet/rollup.json:/data/rollup.json:ro env_file: - - .env.playground + - .env.docker + environment: + OP_NODE_L2_ENGINE_RPC: http://shadow-boost:8554 + + + shadow-builder: + image: op-rbuilder:latest + container_name: tips-shadow-builder + depends_on: + postgres: + condition: service_healthy + profiles: + - builder + ports: + - "8555:4444" # Engine API (playground mode uses 4444) + - "27301:7300" # metrics + volumes: + - ~/.playground/devnet/jwtsecret:/data/jwtsecret:ro + - ~/.playground/devnet/rollup.json:/data/rollup.json:ro + - ~/.playground/devnet:/playground + command: ["node", "--datadir", "/playground/tips-shadow-builder"] + env_file: + - .env.docker + extra_hosts: + # op-rbuilder/tips-prototype hardcodes the postgres host to localhost + - "localhost:host-gateway" + environment: + PLAYGROUND_DIR: /playground + ENABLE_FLASHBLOCKS: "true" + healthcheck: + # op-rbuilder's Dockerfile uses distroless, which prevents an easy healthcheck + disable: true diff --git a/justfile b/justfile index cefe96c..bd4b528 100644 --- a/justfile +++ b/justfile @@ -40,12 +40,12 @@ sync-env: # Change other dependencies sed -i '' 's/localhost/host.docker.internal/g' ./.env.docker -stop-all profile="default": - export COMPOSE_FILE=docker-compose.yml:docker-compose.tips.yml && docker compose --profile {{ profile }} down && docker compose --profile {{ profile }} rm && rm -rf data/ +stop-all profiles="default": + export COMPOSE_FILE=docker-compose.yml:docker-compose.tips.yml && export COMPOSE_PROFILES={{ profiles }} && docker compose down && docker compose rm && rm -rf data/ # Start every service running in docker, useful for demos -start-all profile="default": (stop-all profile) - export COMPOSE_FILE=docker-compose.yml:docker-compose.tips.yml && mkdir -p data/postgres data/kafka data/minio && docker compose --profile {{ profile }} build && docker compose --profile {{ profile }} up -d +start-all profiles="default": (stop-all profiles) + export COMPOSE_FILE=docker-compose.yml:docker-compose.tips.yml && export COMPOSE_PROFILES={{ profiles }} && mkdir -p data/postgres data/kafka data/minio && docker compose build && docker compose up -d # Stop only the specified service without stopping the other services or removing the data directories stop-only program: @@ -106,9 +106,197 @@ simulator-playground: ui: cd ui && yarn dev -playground-env: - echo "BUILDER_PLAYGROUND_HOST_IP=$(docker run --rm alpine nslookup host.docker.internal | awk '/Address: / && $2 !~ /:/ {print $2; exit}')" > .env.playground - echo "BUILDER_PLAYGROUND_PEER_ID=$(grep 'started p2p host' ~/.playground/devnet/logs/op-node.log | sed -n 's/.*peerID=\([^ ]*\).*/\1/p' | head -1)" >> .env.playground - echo "OP_NODE_P2P_STATIC=/ip4/\$BUILDER_PLAYGROUND_HOST_IP/tcp/9003/p2p/\$BUILDER_PLAYGROUND_PEER_ID" >> .env.playground +playground-env: sync-env + #!/bin/bash + set -euo pipefail + + # Check if the op-node log file exists + OP_NODE_LOG="$HOME/.playground/devnet/logs/op-node.log" + if [ ! -f "$OP_NODE_LOG" ]; then + echo "Error: Builder Playground op-node log not found at $OP_NODE_LOG" + echo "" + echo "This recipe requires the Builder Playground to be running." + echo "Please ensure:" + echo " 1. The Builder Playground is installed and running" + echo " 2. The op-node service has started and created its log file" + echo "" + echo "For more information, see: https://github.com/base-org/builder-playground" + exit 1 + fi + + # Try to get the host IP for host.docker.internal + echo "Resolving host.docker.internal IP address..." + if ! BUILDER_PLAYGROUND_HOST_IP=$(docker run --rm alpine nslookup host.docker.internal 2>/dev/null | awk '/Address: / && $2 !~ /:/ {print $2; exit}'); then + echo "Error: Failed to resolve host.docker.internal" + echo "Docker must be running to use this recipe." + exit 1 + fi + + if [ -z "$BUILDER_PLAYGROUND_HOST_IP" ]; then + echo "Error: Could not determine IP address for host.docker.internal" + echo "This may indicate an issue with your Docker installation." + exit 1 + fi + + # Extract the peer ID from the op-node log + echo "Extracting Builder Playground peer ID from op-node logs..." + BUILDER_PLAYGROUND_PEER_ID=$(grep 'started p2p host' "$OP_NODE_LOG" | sed -n 's/.*peerID=\([^ ]*\).*/\1/p' | head -1 || true) + + if [ -z "$BUILDER_PLAYGROUND_PEER_ID" ]; then + echo "Error: Could not extract peer ID from $OP_NODE_LOG" + echo "The op-node may not have fully started yet." + echo "" + echo "Please wait for the op-node to complete startup and try again." + exit 1 + fi + + # Append the configuration to .env.docker + echo "" >> .env.docker + echo "# Builder Playground P2P Configuration" >> .env.docker + echo "BUILDER_PLAYGROUND_HOST_IP=${BUILDER_PLAYGROUND_HOST_IP}" >> .env.docker + echo "BUILDER_PLAYGROUND_PEER_ID=${BUILDER_PLAYGROUND_PEER_ID}" >> .env.docker + echo "OP_NODE_P2P_STATIC=/ip4/${BUILDER_PLAYGROUND_HOST_IP}/tcp/9003/p2p/${BUILDER_PLAYGROUND_PEER_ID}" >> .env.docker + + echo "โœ“ Builder Playground environment configured successfully" + +# Start shadow builder stack (shadow-builder-cl + shadow-builder, simulator-cl + simulator) +start-builder: playground-env (start-all "builder") + +### BUILDER COMMANDS ### + +# Build op-rbuilder docker image from a given remote/branch/tag +# +# This command integrates the tips-datastore crate into op-rbuilder for building. +# The complexity arises because: +# 1. op-rbuilder references tips-datastore as a sibling directory (../tips/crates/datastore) +# 2. tips-datastore uses workspace dependencies from the TIPS workspace +# 3. Docker build context only includes the op-rbuilder directory +# +# Solution: Copy tips-datastore into the build context and merge workspace dependencies +build-rbuilder remote="https://github.com/base/op-rbuilder" ref="tips-prototype": + #!/bin/bash + set -euo pipefail + + REMOTE="{{ remote }}" + REF="{{ ref }}" + JUSTFILE="{{ justfile() }}" + JUSTFILE_DIR="{{ justfile_directory() }}" + + TEMP_DIR=$(mktemp -d) + trap "rm -rf $TEMP_DIR" EXIT + + echo "Cloning $REMOTE ($REF)..." + git clone --depth 1 --branch "$REF" "$REMOTE" $TEMP_DIR/op-rbuilder + + # Get the git revision from the cloned repo + GIT_REV=$(cd $TEMP_DIR/op-rbuilder && git rev-parse --short HEAD) + + just --justfile "$JUSTFILE" --working-directory "$JUSTFILE_DIR" _build-rbuilder-common $TEMP_DIR "$REF" "$GIT_REV" + +# Build op-rbuilder docker image from a local checkout +# +# The local checkout is copied to a temp directory so the original is not modified. +build-rbuilder-local local_path tag="local": + #!/bin/bash + set -euo pipefail + + TAG="{{ tag }}" + JUSTFILE="{{ justfile() }}" + JUSTFILE_DIR="{{ justfile_directory() }}" + + # Expand path to absolute + LOCAL_PATH=$(cd {{ local_path }} && pwd) + + if [ ! -d "$LOCAL_PATH" ]; then + echo "Error: Directory $LOCAL_PATH does not exist" + exit 1 + fi + + # Get git revision and check if working tree is dirty + cd "$LOCAL_PATH" + GIT_REV=$(git rev-parse --short HEAD) + if [ -n "$(git status --porcelain)" ]; then + echo "Warning: Working tree has uncommitted changes" + GIT_REV="${GIT_REV}-dirty" + fi + + TEMP_DIR=$(mktemp -d) + trap "rm -rf $TEMP_DIR" EXIT + + echo "Copying local checkout from $LOCAL_PATH (excluding generated files)..." + mkdir -p "$TEMP_DIR/op-rbuilder" + rsync -a \ + --exclude='target/' \ + --exclude='.git/' \ + --exclude='node_modules/' \ + --exclude='*.log' \ + --exclude='.DS_Store' \ + "$LOCAL_PATH/" "$TEMP_DIR/op-rbuilder/" + + just --justfile "$JUSTFILE" --working-directory "$JUSTFILE_DIR" _build-rbuilder-common $TEMP_DIR "$TAG" "$GIT_REV" + +# Internal helper for building op-rbuilder docker images +_build-rbuilder-common temp_dir tag revision: + #!/bin/bash + set -euo pipefail + + TEMP_DIR="{{ temp_dir }}" + TAG="{{ tag }}" + REVISION="{{ revision }}" + JUSTFILE_DIR="{{ justfile_directory() }}" + + echo "Setting up tips-datastore..." + cd "$JUSTFILE_DIR" + + # Copy tips-datastore and its workspace Cargo.toml into the op-rbuilder directory + # so they're included in the Docker build context + mkdir -p "$TEMP_DIR/op-rbuilder/tips/crates" + cp Cargo.toml "$TEMP_DIR/op-rbuilder/tips/" + cp -r crates/datastore "$TEMP_DIR/op-rbuilder/tips/crates/" + + # Copy sqlx offline data into the datastore crate for compile-time query verification + cp -r .sqlx "$TEMP_DIR/op-rbuilder/tips/crates/datastore/" + + echo "Updating workspace configuration..." + cd "$TEMP_DIR/op-rbuilder" + + # Modify Dockerfile to set SQLX_OFFLINE=true in the cargo build RUN command + # This tells sqlx to use the offline .sqlx data instead of trying to connect to a database + sed -i '' 's/cargo build --release/SQLX_OFFLINE=true cargo build --release/g' Dockerfile + + # Fix the dependency path: op-rbuilder expects ../tips/crates/datastore, + # but we copied it to tips/crates/datastore (inside the build context) + sed -i '' 's|path = "\.\./tips/crates/datastore"|path = "tips/crates/datastore"|g' Cargo.toml + + # Merge workspace dependencies: tips-datastore uses .workspace = true for its dependencies, + # which need to be defined in the workspace root. We automatically extract only the + # dependencies that tips-datastore actually uses from the TIPS workspace and add them + # to op-rbuilder's workspace. This keeps them in sync automatically. + echo "" >> Cargo.toml + echo "# TIPS workspace dependencies (auto-extracted)" >> Cargo.toml + + # Extract the entire [workspace.dependencies] section from TIPS for processing + awk '/^\[workspace\.dependencies\]/,0' tips/Cargo.toml > /tmp/tips-workspace-deps.txt + + # Find each dependency tips-datastore uses (marked with .workspace = true) + # and extract its full definition from TIPS, handling multiline entries + grep "\.workspace = true" tips/crates/datastore/Cargo.toml | sed 's/\.workspace.*//' | awk '{print $1}' | while read dep; do + if ! grep -q "^$dep = " Cargo.toml; then + # Extract the dependency with context, stopping at the next dependency line + # (handles multiline deps like features = [...]) + grep -A 10 "^$dep = " /tmp/tips-workspace-deps.txt | awk '/^[a-zA-Z-]/ && NR>1 {exit} {print}' >> Cargo.toml + fi + done + rm -f /tmp/tips-workspace-deps.txt + + echo "Building docker image (revision: $REVISION)..." + docker build -t "op-rbuilder:$TAG" . + + # Tag with git revision + docker tag "op-rbuilder:$TAG" "op-rbuilder:$REVISION" + + # Tag as latest for convenience + docker tag "op-rbuilder:$TAG" op-rbuilder:latest -start-playground: playground-env (start-all "simulator") + echo "โœ“ Built op-rbuilder:$TAG (revision: $REVISION)" + docker images | grep op-rbuilder