From dee6b3fdea4d42eb6ed489e1f810a56665462bde Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 1 Oct 2025 11:27:55 -0500 Subject: [PATCH 01/12] Add shadow block builder stack for testing in production Introduces a shadow block building stack that runs in parallel with the production sequencer for testing purposes. The builder syncs from production but does not submit blocks to L1, making it safe for testing TIPS bundle integration. Changes: - Add builder-cl and builder services to docker-compose.yml - builder-cl: op-node in sequencer mode syncing from production via P2P - builder: op-rbuilder instance that queries TIPS datastore and builds blocks with eligible bundles (blocks are not submitted to L1) - Add build-rbuilder command to justfile for building op-rbuilder Docker image with TIPS datastore integration - Consolidate op-node environment configuration into .env.example and use env_file in docker-compose.yml to share common config between simulator-cl and builder-cl - Update justfile commands to support multiple compose profiles - Rename start-playground to start-builder - Update stop-all/start-all to accept comma-separated profiles - Document simulator and shadow block builder stack in README.md with prerequisites and quick start instructions --- .env.example | 27 +++++++++++ README.md | 36 ++++++++++++++ docker-compose.tips.yml | 2 +- docker-compose.yml | 104 ++++++++++++++++++++-------------------- justfile | 101 ++++++++++++++++++++++++++++++++++---- 5 files changed, 208 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index 3716648..7baa16a 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 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/README.md b/README.md index 0bc1969..882896a 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,39 @@ 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 + +## ๐Ÿ—๏ธ Block Builder Stack + +The block builder stack enables production-ready block building with TIPS bundle integration. It consists of: + +**builder-cl**: An op-node instance running in sequencer mode that: +- Syncs from production sequencer via P2P +- Drives block building through Engine API calls +- Does not submit blocks to L1 (shadow sequencer mode) + +**builder**: A modified op-rbuilder instance that: +- Receives Engine API calls from builder-cl +- Queries TIPS datastore for bundles with resource usage estimates from the simulator +- Builds blocks including eligible bundles while respecting resource constraints + +**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 builder stack (requires builder-playground running) +just start-builder +``` + +The builder-cl syncs from the production sequencer via P2P while op-rbuilder builds blocks with TIPS bundles. Built blocks are not submitted to L1, making this safe for testing and development. diff --git a/docker-compose.tips.yml b/docker-compose.tips.yml index 9cfd8d7..062272d 100644 --- a/docker-compose.tips.yml +++ b/docker-compose.tips.yml @@ -68,4 +68,4 @@ services: timeout: 5s retries: 10 profiles: - - simulator + - builder diff --git a/docker-compose.yml b/docker-compose.yml index 46b87a9..69ba93f 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" + + builder-cl: + image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.13.7 + container_name: tips-builder-cl + depends_on: + builder: + 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.docker + environment: + # ENGINE CONFIGURATION (builder-specific, playground mode uses 4444) + OP_NODE_L2_ENGINE_RPC: http://builder:4444 + + builder: + image: tips-builder:latest + container_name: tips-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-builder"] env_file: - - .env.playground + - .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..3091816 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,90 @@ 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 - -start-playground: playground-env (start-all "simulator") +playground-env: sync-env + #!/bin/bash + set -euo pipefail + BUILDER_PLAYGROUND_HOST_IP=$(docker run --rm alpine nslookup host.docker.internal | awk '/Address: / && $2 !~ /:/ {print $2; exit}') + BUILDER_PLAYGROUND_PEER_ID=$(grep 'started p2p host' ~/.playground/devnet/logs/op-node.log | sed -n 's/.*peerID=\([^ ]*\).*/\1/p' | head -1) + 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 + +# Start builder stack (builder-cl + 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 + + 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 + + echo "Setting up tips-datastore..." + cd {{ justfile_directory() }} + + # 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..." + docker build -t tips-builder:{{ ref }} . + + # Tag as latest for convenience + docker tag tips-builder:{{ ref }} tips-builder:latest + + echo "โœ“ Built tips-builder:{{ ref }}" + docker images | grep tips-builder From dd78eb2d4fc08a9aa93441f72aa4a50a6049a2a6 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 2 Oct 2025 17:37:26 -0500 Subject: [PATCH 02/12] Add a justfile command for building from a local op-rbuilder working copy --- justfile | 89 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/justfile b/justfile index 3091816..13b3fca 100644 --- a/justfile +++ b/justfile @@ -135,26 +135,88 @@ 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 + 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_directory() }} + 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/ + 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/ + cp -r .sqlx "$TEMP_DIR/op-rbuilder/tips/crates/datastore/" echo "Updating workspace configuration..." - cd $TEMP_DIR/op-rbuilder + 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 @@ -185,11 +247,14 @@ build-rbuilder remote="https://github.com/base/op-rbuilder" ref="tips-prototype" done rm -f /tmp/tips-workspace-deps.txt - echo "Building docker image..." - docker build -t tips-builder:{{ ref }} . + echo "Building docker image (revision: $REVISION)..." + docker build -t "tips-builder:$TAG" . + + # Tag with git revision + docker tag "tips-builder:$TAG" "tips-builder:$REVISION" # Tag as latest for convenience - docker tag tips-builder:{{ ref }} tips-builder:latest + docker tag "tips-builder:$TAG" tips-builder:latest - echo "โœ“ Built tips-builder:{{ ref }}" + echo "โœ“ Built tips-builder:$TAG (revision: $REVISION)" docker images | grep tips-builder From c10935d51a72ea88cb12648a0b56dd75d6d85666 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 2 Oct 2025 22:17:47 -0500 Subject: [PATCH 03/12] refactor: use tuple for bundle-simulation pairs Replace BundleWithLatestSimulation struct with a simple tuple (BundleWithMetadata, Simulation) for select_bundles_with_latest_simulation return values. This eliminates awkward naming in consuming code by allowing direct destructuring instead of verbose field access patterns like `result.bundle_with_metadata.bundle.block_number`. --- crates/datastore/src/postgres.rs | 14 ++--------- crates/datastore/src/traits.rs | 6 ++--- crates/datastore/tests/datastore.rs | 38 ++++++++++++----------------- 3 files changed, 19 insertions(+), 39 deletions(-) diff --git a/crates/datastore/src/postgres.rs b/crates/datastore/src/postgres.rs index 6efe8dd..6d7b431 100644 --- a/crates/datastore/src/postgres.rs +++ b/crates/datastore/src/postgres.rs @@ -88,13 +88,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>; @@ -463,7 +456,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; @@ -562,10 +555,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(()) } From 9983cc34b1ea565e51eb436d0014530f097cb7a8 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 3 Oct 2025 12:29:35 -0500 Subject: [PATCH 04/12] Add more error checking to `just playground-env` --- justfile | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 13b3fca..27d03a6 100644 --- a/justfile +++ b/justfile @@ -109,13 +109,55 @@ ui: playground-env: sync-env #!/bin/bash set -euo pipefail - BUILDER_PLAYGROUND_HOST_IP=$(docker run --rm alpine nslookup host.docker.internal | awk '/Address: / && $2 !~ /:/ {print $2; exit}') - BUILDER_PLAYGROUND_PEER_ID=$(grep 'started p2p host' ~/.playground/devnet/logs/op-node.log | sed -n 's/.*peerID=\([^ ]*\).*/\1/p' | head -1) + + # 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) + + 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 builder stack (builder-cl + builder, simulator-cl + simulator) start-builder: playground-env (start-all "builder") From 3121ffc3a6cded0679f4b1cee86be92def55ed4c Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 6 Oct 2025 00:31:36 -0500 Subject: [PATCH 05/12] Use bundle IDs from datastore instead of generating new UUIDs --- crates/datastore/src/postgres.rs | 19 ++++++++++++------- crates/simulator/src/listeners/exex.rs | 10 ++-------- justfile | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/datastore/src/postgres.rs b/crates/datastore/src/postgres.rs index 6d7b431..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
, @@ -197,6 +199,7 @@ impl PostgresDatastore { .collect(); Ok(BundleWithMetadata { + id: row.id, bundle, txn_hashes: parsed_txn_hashes?, senders: parsed_senders?, @@ -305,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 "#, ) @@ -345,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) @@ -480,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 @@ -498,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>, @@ -530,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, 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/justfile b/justfile index 27d03a6..a21ed57 100644 --- a/justfile +++ b/justfile @@ -140,7 +140,7 @@ playground-env: sync-env # 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) + 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" From fd9bcb81a795a681cdbb3c1e00bdeb71eda021fd Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 6 Oct 2025 21:24:31 -0500 Subject: [PATCH 06/12] Enable sequencer mode for the shadow builder --- docker-compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 69ba93f..41c3cdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,12 @@ services: environment: # ENGINE CONFIGURATION (builder-specific, playground mode uses 4444) OP_NODE_L2_ENGINE_RPC: http://builder:4444 + # Enable sequencer mode so builder-cl generates payload attributes + # Using placeholder key so shadow builder blocks will be rejected by network + OP_NODE_SEQUENCER_ENABLED: "true" + OP_NODE_SEQUENCER_L1_CONFS: "0" + OP_NODE_VERIFIER_L1_CONFS: "0" + OP_NODE_P2P_SEQUENCER_KEY: "0000000000000000000000000000000000000000000000000000000000000001" builder: image: tips-builder:latest From afda51e791e2e7e446cbebb70dc1c87dbbf3c0d9 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 6 Oct 2025 22:18:25 -0500 Subject: [PATCH 07/12] Rename the builder containers to shadow-builder --- README.md | 16 +++++++++------- docker-compose.yml | 20 ++++++++++---------- justfile | 18 +++++++++--------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 882896a..dc79795 100644 --- a/README.md +++ b/README.md @@ -38,19 +38,21 @@ A Reth-based execution client that: - Syncs from production sequencer via an op-node instance (simulator-cl) - Used by the block builder stack to throttle transactions based on resource consumption -## ๐Ÿ—๏ธ Block Builder Stack +## ๐Ÿ—๏ธ Shadow Builder Stack -The block builder stack enables production-ready block building with TIPS bundle integration. It consists of: +The shadow builder stack enables production-ready block building with TIPS bundle integration. It consists of: -**builder-cl**: An op-node instance running in sequencer mode that: +**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) -**builder**: A modified op-rbuilder instance that: -- Receives Engine API calls from builder-cl +**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 @@ -61,8 +63,8 @@ The block builder stack enables production-ready block building with TIPS bundle # Build op-rbuilder (optionally from a specific branch) just build-rbuilder -# Start the builder stack (requires builder-playground running) +# Start the shadow builder stack (requires builder-playground running) just start-builder ``` -The builder-cl syncs from the production sequencer via P2P while op-rbuilder builds blocks with TIPS bundles. Built blocks are not submitted to L1, making this safe for testing and development. +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/docker-compose.yml b/docker-compose.yml index 41c3cdc..a1da1e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,11 +112,11 @@ services: # ENGINE CONFIGURATION (simulator-specific) OP_NODE_L2_ENGINE_RPC: http://simulator:4444 - builder-cl: + shadow-builder-cl: image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.13.7 - container_name: tips-builder-cl + container_name: tips-shadow-builder-cl depends_on: - builder: + shadow-builder: condition: service_started profiles: - builder @@ -130,18 +130,18 @@ services: env_file: - .env.docker environment: - # ENGINE CONFIGURATION (builder-specific, playground mode uses 4444) - OP_NODE_L2_ENGINE_RPC: http://builder:4444 - # Enable sequencer mode so builder-cl generates payload attributes + # ENGINE CONFIGURATION (shadow-builder-specific, playground mode uses 4444) + OP_NODE_L2_ENGINE_RPC: http://shadow-builder:4444 + # Enable sequencer mode so shadow-builder-cl generates payload attributes # Using placeholder key so shadow builder blocks will be rejected by network OP_NODE_SEQUENCER_ENABLED: "true" OP_NODE_SEQUENCER_L1_CONFS: "0" OP_NODE_VERIFIER_L1_CONFS: "0" OP_NODE_P2P_SEQUENCER_KEY: "0000000000000000000000000000000000000000000000000000000000000001" - builder: - image: tips-builder:latest - container_name: tips-builder + shadow-builder: + image: op-rbuilder:latest + container_name: tips-shadow-builder depends_on: postgres: condition: service_healthy @@ -154,7 +154,7 @@ services: - ~/.playground/devnet/jwtsecret:/data/jwtsecret:ro - ~/.playground/devnet/rollup.json:/data/rollup.json:ro - ~/.playground/devnet:/playground - command: ["node", "--datadir", "/playground/tips-builder"] + command: ["node", "--datadir", "/playground/tips-shadow-builder"] env_file: - .env.docker extra_hosts: diff --git a/justfile b/justfile index a21ed57..bd4b528 100644 --- a/justfile +++ b/justfile @@ -159,7 +159,7 @@ playground-env: sync-env echo "โœ“ Builder Playground environment configured successfully" -# Start builder stack (builder-cl + builder, simulator-cl + simulator) +# Start shadow builder stack (shadow-builder-cl + shadow-builder, simulator-cl + simulator) start-builder: playground-env (start-all "builder") ### BUILDER COMMANDS ### @@ -290,13 +290,13 @@ _build-rbuilder-common temp_dir tag revision: rm -f /tmp/tips-workspace-deps.txt echo "Building docker image (revision: $REVISION)..." - docker build -t "tips-builder:$TAG" . - + docker build -t "op-rbuilder:$TAG" . + # Tag with git revision - docker tag "tips-builder:$TAG" "tips-builder:$REVISION" - + docker tag "op-rbuilder:$TAG" "op-rbuilder:$REVISION" + # Tag as latest for convenience - docker tag "tips-builder:$TAG" tips-builder:latest - - echo "โœ“ Built tips-builder:$TAG (revision: $REVISION)" - docker images | grep tips-builder + docker tag "op-rbuilder:$TAG" op-rbuilder:latest + + echo "โœ“ Built op-rbuilder:$TAG (revision: $REVISION)" + docker images | grep op-rbuilder From 023e1e708b847c2d6cd1852821a4602d361096e4 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 9 Oct 2025 23:39:08 -0500 Subject: [PATCH 08/12] Add shadow-boost proxy for non-sequencer shadow building Introduces shadow-boost, an Engine API proxy that sits between a non-sequencer op-node and shadow builder. Intercepts forkchoiceUpdated to force block building while keeping op-node synced with canonical chain via P2P. Solves P2P block rejection and L1-triggered reorg issues that occur when running shadow builders in sequencer mode. --- .env.example | 2 +- Cargo.lock | 55 ++++++- Cargo.toml | 2 +- crates/shadow-boost/Cargo.toml | 28 ++++ crates/shadow-boost/Dockerfile | 40 +++++ crates/shadow-boost/README.md | 72 +++++++++ crates/shadow-boost/src/auth.rs | 31 ++++ crates/shadow-boost/src/config.rs | 27 ++++ crates/shadow-boost/src/main.rs | 40 +++++ crates/shadow-boost/src/proxy.rs | 239 ++++++++++++++++++++++++++++++ crates/shadow-boost/src/server.rs | 110 ++++++++++++++ docker-compose.tips.yml | 21 +++ docker-compose.yml | 12 +- 13 files changed, 662 insertions(+), 17 deletions(-) create mode 100644 crates/shadow-boost/Cargo.toml create mode 100644 crates/shadow-boost/Dockerfile create mode 100644 crates/shadow-boost/README.md create mode 100644 crates/shadow-boost/src/auth.rs create mode 100644 crates/shadow-boost/src/config.rs create mode 100644 crates/shadow-boost/src/main.rs create mode 100644 crates/shadow-boost/src/proxy.rs create mode 100644 crates/shadow-boost/src/server.rs diff --git a/.env.example b/.env.example index 7baa16a..2b5a9be 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,7 @@ 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 builder-cl +# 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 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/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..bc6d9bc --- /dev/null +++ b/crates/shadow-boost/src/proxy.rs @@ -0,0 +1,239 @@ +use crate::auth::generate_jwt_token; +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ + ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated, JwtSecret, PayloadId, PayloadStatus, + PayloadStatusEnum, +}; +use eyre::Result; +use jsonrpsee::{ + core::client::ClientT, + http_client::{HttpClient, HttpClientBuilder}, + types::ErrorObjectOwned, +}; +use op_alloy_rpc_types_engine::{OpExecutionPayloadEnvelopeV3, OpPayloadAttributes}; +use std::{sync::Arc, time::Duration}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +#[derive(Clone)] +pub struct ShadowBuilderProxy { + pub builder_client: Arc>, + builder_url: String, + jwt_secret: JwtSecret, + timeout_ms: u64, + fetch_timeout_ms: u64, +} + +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, + fetch_timeout_ms: timeout_ms, + }; + + 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"); + } + } + } + }); + } + + pub async fn handle_fcu( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> Result { + info!( + head_hash = %fork_choice_state.head_block_hash, + has_attrs = payload_attributes.is_some(), + "Received FCU from op-node" + ); + + let modified_attrs = payload_attributes.map(|mut attrs| { + let original_no_tx_pool = attrs.no_tx_pool.unwrap_or(false); + attrs.no_tx_pool = Some(false); + + info!( + original_no_tx_pool, + modified_no_tx_pool = false, + "Rewriting FCU attributes to trigger building" + ); + + attrs + }); + + let client = self.builder_client.read().await; + let response: ForkchoiceUpdated = ClientT::request( + &*client, + "engine_forkchoiceUpdatedV3", + (fork_choice_state, modified_attrs), + ) + .await + .map_err(|e| { + error!(error = %e, "Builder FCU failed"); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; + drop(client); + + if let Some(payload_id) = response.payload_id { + info!(%payload_id, "Builder initiated block building"); + + let builder_client = self.builder_client.clone(); + let timeout_ms = self.fetch_timeout_ms; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1000)).await; + + let fetch_result = tokio::time::timeout( + Duration::from_millis(timeout_ms), + Self::fetch_and_log_payload(builder_client, payload_id), + ) + .await; + + match fetch_result { + Ok(Ok(_)) => info!(%payload_id, "Successfully fetched shadow block"), + Ok(Err(e)) => warn!(%payload_id, error = %e, "Failed to fetch shadow block"), + Err(_) => warn!(%payload_id, "Timeout fetching shadow block"), + } + }); + } + + Ok(ForkchoiceUpdated::new(PayloadStatus::new( + PayloadStatusEnum::Valid, + None, + ))) + } + + async fn fetch_and_log_payload( + builder_client: Arc>, + payload_id: PayloadId, + ) -> Result<()> { + info!(%payload_id, "Fetching shadow block from builder"); + + let client = builder_client.read().await; + let payload: OpExecutionPayloadEnvelopeV3 = + ClientT::request(&*client, "engine_getPayloadV3", (payload_id,)).await?; + drop(client); + + let block_hash = payload + .execution_payload + .payload_inner + .payload_inner + .block_hash; + let block_number = payload + .execution_payload + .payload_inner + .payload_inner + .block_number; + let gas_used = payload + .execution_payload + .payload_inner + .payload_inner + .gas_used; + let tx_count = payload + .execution_payload + .payload_inner + .payload_inner + .transactions + .len(); + let block_value = payload.block_value; + + info!( + %payload_id, + %block_hash, + block_number, + gas_used, + tx_count, + %block_value, + "Shadow block built successfully" + ); + + Ok(()) + } + + pub async fn handle_new_payload( + &self, + payload: ExecutionPayloadV3, + versioned_hashes: Vec, + parent_beacon_block_root: B256, + ) -> Result { + let block_hash = payload.payload_inner.payload_inner.block_hash; + let block_number = payload.payload_inner.payload_inner.block_number; + + info!( + %block_hash, + block_number, + "Forwarding newPayload to builder" + ); + + let builder_client = self.builder_client.clone(); + tokio::spawn(async move { + let client = builder_client.read().await; + let result: Result = ClientT::request( + &*client, + "engine_newPayloadV3", + (payload, versioned_hashes, parent_beacon_block_root), + ) + .await; + drop(client); + + match result { + Ok(status) => info!("Builder accepted newPayload: {:?}", status.status), + Err(e) => warn!(error = %e, "Builder rejected newPayload"), + } + }); + + Ok(PayloadStatus::new(PayloadStatusEnum::Valid, None)) + } +} diff --git a/crates/shadow-boost/src/server.rs b/crates/shadow-boost/src/server.rs new file mode 100644 index 0000000..e791f4b --- /dev/null +++ b/crates/shadow-boost/src/server.rs @@ -0,0 +1,110 @@ +use crate::proxy::ShadowBuilderProxy; +use alloy_rpc_types_engine::PayloadId; +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, context, _| async move { + let (fork_choice_state, payload_attributes) = params.parse()?; + context + .handle_fcu(fork_choice_state, payload_attributes) + .await + }, + ) + .unwrap(); + + module + .register_async_method("engine_newPayloadV3", |params, context, _| async move { + let (payload, versioned_hashes, parent_beacon_block_root) = params.parse()?; + context + .handle_new_payload(payload, versioned_hashes, parent_beacon_block_root) + .await + }) + .unwrap(); + + module + .register_async_method("engine_getPayloadV3", |params, _context, _| async move { + let (payload_id,): (PayloadId,) = params.parse()?; + warn!(%payload_id, "op-node called getPayload unexpectedly (should never happen in non-sequencer mode)"); + Err::<(), _>(ErrorObjectOwned::owned( + -32601, + "getPayload not supported in shadow builder proxy", + None::<()>, + )) + }) + .unwrap(); + + add_passthrough_methods(&mut module); + + module +} + +fn add_passthrough_methods(module: &mut RpcModule) { + let methods = [ + "eth_chainId", + "eth_syncing", + "eth_getBlockByNumber", + "eth_getBlockByHash", + "engine_exchangeCapabilities", + "engine_forkchoiceUpdatedV1", + "engine_forkchoiceUpdatedV2", + "engine_forkchoiceUpdatedV4", + "engine_newPayloadV1", + "engine_newPayloadV2", + "engine_newPayloadV4", + "engine_getPayloadV1", + "engine_getPayloadV2", + "engine_getPayloadV4", + "engine_newPayloadWithWitnessV4", + "engine_getPayloadBodiesByHashV1", + "engine_getPayloadBodiesByRangeV1", + ]; + + for method in methods { + let method_name = method.to_string(); + module + .register_async_method(method, move |params: Params<'static>, context, _| { + let method = method_name.clone(); + async move { + info!(method, "Proxying method to builder"); + let mut params_vec = Vec::new(); + let mut seq = params.sequence(); + while let Ok(Some(value)) = seq.optional_next::() { + params_vec.push(value); + } + let client = context.builder_client.read().await; + let result: Value = client + .request(&method, params_vec) + .await + .map_err(|e| ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>))?; + 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/docker-compose.tips.yml b/docker-compose.tips.yml index 062272d..3074455 100644 --- a/docker-compose.tips.yml +++ b/docker-compose.tips.yml @@ -69,3 +69,24 @@ services: retries: 10 profiles: - builder + + shadow-boost: + build: + context: . + dockerfile: crates/shadow-boost/Dockerfile + image: shadow-boost:latest + 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 a1da1e1..6fe19b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,7 +116,7 @@ services: image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.13.7 container_name: tips-shadow-builder-cl depends_on: - shadow-builder: + shadow-boost: condition: service_started profiles: - builder @@ -130,14 +130,8 @@ services: env_file: - .env.docker environment: - # ENGINE CONFIGURATION (shadow-builder-specific, playground mode uses 4444) - OP_NODE_L2_ENGINE_RPC: http://shadow-builder:4444 - # Enable sequencer mode so shadow-builder-cl generates payload attributes - # Using placeholder key so shadow builder blocks will be rejected by network - OP_NODE_SEQUENCER_ENABLED: "true" - OP_NODE_SEQUENCER_L1_CONFS: "0" - OP_NODE_VERIFIER_L1_CONFS: "0" - OP_NODE_P2P_SEQUENCER_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + OP_NODE_L2_ENGINE_RPC: http://shadow-boost:8554 + shadow-builder: image: op-rbuilder:latest From d92d483aa8fbb122395682789e66a1e48c338c6c Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 10 Oct 2025 00:34:02 -0500 Subject: [PATCH 09/12] Inject synthetic payload attributes when FCU has none When op-node sends FCU without payload attributes (follower mode), shadow-boost now synthesizes attributes from the last newPayload to trigger continuous shadow building. Tracks payload info (timestamp, gas_limit, randao, etc.) and injects them with updated timestamps to maintain building cadence. Improves logging throughout proxy and server with structured fields and clearer messages. --- crates/shadow-boost/src/proxy.rs | 172 ++++++++++++++++++++++++++---- crates/shadow-boost/src/server.rs | 29 +++-- docker-compose.tips.yml | 1 - 3 files changed, 175 insertions(+), 27 deletions(-) diff --git a/crates/shadow-boost/src/proxy.rs b/crates/shadow-boost/src/proxy.rs index bc6d9bc..ddd0e2d 100644 --- a/crates/shadow-boost/src/proxy.rs +++ b/crates/shadow-boost/src/proxy.rs @@ -1,8 +1,8 @@ use crate::auth::generate_jwt_token; use alloy_primitives::B256; use alloy_rpc_types_engine::{ - ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated, JwtSecret, PayloadId, PayloadStatus, - PayloadStatusEnum, + ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated, JwtSecret, PayloadAttributes, + PayloadId, PayloadStatus, PayloadStatusEnum, }; use eyre::Result; use jsonrpsee::{ @@ -15,6 +15,16 @@ use std::{sync::Arc, time::Duration}; use tokio::sync::RwLock; use tracing::{error, info, warn}; +#[derive(Clone, Default)] +struct LastPayloadInfo { + timestamp: u64, + prev_randao: B256, + fee_recipient: alloy_primitives::Address, + gas_limit: u64, + eip_1559_params: Option, + last_block_hash: B256, +} + #[derive(Clone)] pub struct ShadowBuilderProxy { pub builder_client: Arc>, @@ -22,6 +32,7 @@ pub struct ShadowBuilderProxy { jwt_secret: JwtSecret, timeout_ms: u64, fetch_timeout_ms: u64, + last_payload_info: Arc>>, } impl ShadowBuilderProxy { @@ -57,6 +68,7 @@ impl ShadowBuilderProxy { jwt_secret, timeout_ms, fetch_timeout_ms: timeout_ms, + last_payload_info: Arc::new(RwLock::new(None)), }; proxy.start_token_refresh_task(); @@ -95,24 +107,76 @@ impl ShadowBuilderProxy { fork_choice_state: ForkchoiceState, payload_attributes: Option, ) -> Result { + let has_original_attrs = payload_attributes.is_some(); + info!( head_hash = %fork_choice_state.head_block_hash, - has_attrs = payload_attributes.is_some(), + safe_hash = %fork_choice_state.safe_block_hash, + finalized_hash = %fork_choice_state.finalized_block_hash, + has_attrs = has_original_attrs, "Received FCU from op-node" ); - let modified_attrs = payload_attributes.map(|mut attrs| { - let original_no_tx_pool = attrs.no_tx_pool.unwrap_or(false); - attrs.no_tx_pool = Some(false); + let injected_attrs = if !has_original_attrs { + info!("No payload attributes provided - injecting synthetic attributes to trigger shadow building"); + true + } else { + info!("FCU has payload attributes - will rewrite no_tx_pool to trigger building"); + false + }; - info!( - original_no_tx_pool, - modified_no_tx_pool = false, - "Rewriting FCU attributes to trigger building" - ); + let modified_attrs = match payload_attributes { + Some(mut attrs) => { + let original_no_tx_pool = attrs.no_tx_pool.unwrap_or(false); + attrs.no_tx_pool = Some(false); + info!( + timestamp = attrs.payload_attributes.timestamp, + original_no_tx_pool, + modified_no_tx_pool = false, + "Rewrote no_tx_pool in existing payload attributes" + ); + Some(attrs) + } + None => { + let last_info = self.last_payload_info.read().await; + if let Some(info) = last_info.as_ref() { + use std::time::{SystemTime, UNIX_EPOCH}; + let current_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let timestamp = current_timestamp.max(info.timestamp + 2); + + info!( + timestamp, + gas_limit = info.gas_limit, + ?info.eip_1559_params, + "Created synthetic payload attributes from last newPayload" + ); + + Some(OpPayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp, + prev_randao: info.prev_randao, + suggested_fee_recipient: info.fee_recipient, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: None, + no_tx_pool: Some(false), + gas_limit: Some(info.gas_limit), + eip_1559_params: info.eip_1559_params, + min_base_fee: None, + }) + } else { + warn!("No payload attributes and no previous newPayload - cannot build shadow block yet"); + None + } + } + }; - attrs - }); + info!("Sending FCU with modified attributes to shadow builder"); let client = self.builder_client.read().await; let response: ForkchoiceUpdated = ClientT::request( @@ -122,17 +186,22 @@ impl ShadowBuilderProxy { ) .await .map_err(|e| { - error!(error = %e, "Builder FCU failed"); + error!(error = %e, "Shadow builder FCU failed"); ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) })?; drop(client); if let Some(payload_id) = response.payload_id { - info!(%payload_id, "Builder initiated block building"); + info!( + %payload_id, + injected_attrs, + "Shadow builder initiated block building - spawning fetch task" + ); let builder_client = self.builder_client.clone(); let timeout_ms = self.fetch_timeout_ms; tokio::spawn(async move { + info!(%payload_id, "Waiting 1s before fetching shadow block"); tokio::time::sleep(Duration::from_millis(1000)).await; let fetch_result = tokio::time::timeout( @@ -142,11 +211,16 @@ impl ShadowBuilderProxy { .await; match fetch_result { - Ok(Ok(_)) => info!(%payload_id, "Successfully fetched shadow block"), + Ok(Ok(_)) => info!(%payload_id, "Successfully fetched and logged shadow block"), Ok(Err(e)) => warn!(%payload_id, error = %e, "Failed to fetch shadow block"), - Err(_) => warn!(%payload_id, "Timeout fetching shadow block"), + Err(_) => warn!(%payload_id, timeout_ms, "Timeout fetching shadow block"), } }); + } else { + warn!( + injected_attrs, + "Shadow builder FCU returned Valid but no payload_id - block building may not have started" + ); } Ok(ForkchoiceUpdated::new(PayloadStatus::new( @@ -210,13 +284,55 @@ impl ShadowBuilderProxy { ) -> Result { let block_hash = payload.payload_inner.payload_inner.block_hash; let block_number = payload.payload_inner.payload_inner.block_number; + let tx_count = payload.payload_inner.payload_inner.transactions.len(); + let gas_used = payload.payload_inner.payload_inner.gas_used; + let gas_limit = payload.payload_inner.payload_inner.gas_limit; + 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 extra_data = &payload.payload_inner.payload_inner.extra_data; info!( %block_hash, block_number, - "Forwarding newPayload to builder" + tx_count, + gas_used, + "Received newPayload from op-node - storing payload info and forwarding to shadow builder" ); + let eip_1559_params = if extra_data.len() >= 9 { + let params_bytes = &extra_data[1..9]; + Some(alloy_primitives::B64::from_slice(params_bytes)) + } else { + Some(alloy_primitives::B64::from_slice(&[ + 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, + ])) + }; + + let is_duplicate = { + let last_info = self.last_payload_info.read().await; + last_info.as_ref().map_or(false, |info| info.last_block_hash == block_hash) + }; + + if is_duplicate { + info!( + %block_hash, + block_number, + "Skipping duplicate newPayload (already forwarded to shadow builder)" + ); + return Ok(PayloadStatus::new(PayloadStatusEnum::Valid, None)); + } + + *self.last_payload_info.write().await = Some(LastPayloadInfo { + timestamp, + prev_randao, + fee_recipient, + gas_limit, + eip_1559_params, + last_block_hash: block_hash, + }); + let builder_client = self.builder_client.clone(); tokio::spawn(async move { let client = builder_client.read().await; @@ -229,11 +345,27 @@ impl ShadowBuilderProxy { drop(client); match result { - Ok(status) => info!("Builder accepted newPayload: {:?}", status.status), - Err(e) => warn!(error = %e, "Builder rejected newPayload"), + Ok(status) => info!( + %block_hash, + block_number, + status = ?status.status, + "Shadow builder accepted newPayload" + ), + Err(e) => error!( + %block_hash, + block_number, + error = %e, + "Shadow builder rejected newPayload" + ), } }); + info!( + %block_hash, + block_number, + "Returning Valid status to op-node immediately" + ); + Ok(PayloadStatus::new(PayloadStatusEnum::Valid, None)) } } diff --git a/crates/shadow-boost/src/server.rs b/crates/shadow-boost/src/server.rs index e791f4b..ff1a0f5 100644 --- a/crates/shadow-boost/src/server.rs +++ b/crates/shadow-boost/src/server.rs @@ -1,4 +1,5 @@ use crate::proxy::ShadowBuilderProxy; +use alloy_primitives::B256; use alloy_rpc_types_engine::PayloadId; use jsonrpsee::{ core::client::ClientT, @@ -33,6 +34,15 @@ pub fn build_rpc_module(proxy: ShadowBuilderProxy) -> RpcModule) = params.parse()?; + context + .handle_new_payload(payload, versioned_hashes, parent_beacon_block_root) + .await + }) + .unwrap(); + module .register_async_method("engine_getPayloadV3", |params, _context, _| async move { let (payload_id,): (PayloadId,) = params.parse()?; @@ -62,7 +72,6 @@ fn add_passthrough_methods(module: &mut RpcModule) { "engine_forkchoiceUpdatedV4", "engine_newPayloadV1", "engine_newPayloadV2", - "engine_newPayloadV4", "engine_getPayloadV1", "engine_getPayloadV2", "engine_getPayloadV4", @@ -77,17 +86,25 @@ fn add_passthrough_methods(module: &mut RpcModule) { .register_async_method(method, move |params: Params<'static>, context, _| { let method = method_name.clone(); async move { - info!(method, "Proxying method to builder"); 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(), + "Proxying method to shadow builder" + ); + let client = context.builder_client.read().await; - let result: Value = client - .request(&method, params_vec) - .await - .map_err(|e| ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>))?; + let result: Value = client.request(&method, params_vec).await.map_err(|e| { + warn!(method, error = %e, "Shadow builder method call failed"); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; + + info!(method, "Shadow builder method call succeeded"); Ok::(result) } }) diff --git a/docker-compose.tips.yml b/docker-compose.tips.yml index 3074455..dfbdea3 100644 --- a/docker-compose.tips.yml +++ b/docker-compose.tips.yml @@ -74,7 +74,6 @@ services: build: context: . dockerfile: crates/shadow-boost/Dockerfile - image: shadow-boost:latest container_name: tips-shadow-boost depends_on: shadow-builder: From 9b4f102d4941ff7cda27e97e736fda0e48cfebdc Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 10 Oct 2025 10:27:39 -0500 Subject: [PATCH 10/12] Fix shadow-boost synthetic payload timestamp calculation and historical sync handling Fixes two critical issues with synthetic payload attribute injection: 1. Timestamp skew: Changed synthetic timestamp calculation from SystemTime::now() to last_block_timestamp + 2, eliminating large time differences (300-700+ seconds) that caused "FCU arrived too late" errors in the shadow builder. 2. Historical P2P sync: Added 30-second age threshold to detect and skip synthetic attribute injection for historical blocks during P2P sync. Proxy still forwards FCU to builder for chain state updates (head/safe/finalized) without triggering unnecessary block building. Changes: - Use chain-relative timestamps instead of wall clock time - Calculate block_age_seconds to detect historical sync - Skip synthetic attributes for blocks >30s old - Always forward FCU to builder (with or without attributes) - Log block age for monitoring and debugging - Suppress warnings when intentionally skipping builds Results: - Eliminated FCU timing errors during normal operation - Prevented unnecessary shadow building during catchup - Maintained proper chain state in builder during historical sync - Reduced resource usage during P2P sync --- crates/shadow-boost/src/proxy.rs | 74 ++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/crates/shadow-boost/src/proxy.rs b/crates/shadow-boost/src/proxy.rs index ddd0e2d..ef4d794 100644 --- a/crates/shadow-boost/src/proxy.rs +++ b/crates/shadow-boost/src/proxy.rs @@ -146,29 +146,42 @@ impl ShadowBuilderProxy { .unwrap() .as_secs(); - let timestamp = current_timestamp.max(info.timestamp + 2); - - info!( - timestamp, - gas_limit = info.gas_limit, - ?info.eip_1559_params, - "Created synthetic payload attributes from last newPayload" - ); - - Some(OpPayloadAttributes { - payload_attributes: PayloadAttributes { + let block_age_seconds = current_timestamp.saturating_sub(info.timestamp); + + if block_age_seconds > 30 { + info!( + last_block_timestamp = info.timestamp, + block_age_seconds, + "Skipping synthetic attributes - block is too old (likely historical P2P sync)" + ); + None + } else { + let timestamp = info.timestamp + 2; + + info!( timestamp, - prev_randao: info.prev_randao, - suggested_fee_recipient: info.fee_recipient, - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(B256::ZERO), - }, - transactions: None, - no_tx_pool: Some(false), - gas_limit: Some(info.gas_limit), - eip_1559_params: info.eip_1559_params, - min_base_fee: None, - }) + gas_limit = info.gas_limit, + ?info.eip_1559_params, + last_block_timestamp = info.timestamp, + block_age_seconds, + "Created synthetic payload attributes from last newPayload" + ); + + Some(OpPayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp, + prev_randao: info.prev_randao, + suggested_fee_recipient: info.fee_recipient, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + }, + transactions: None, + no_tx_pool: Some(false), + gas_limit: Some(info.gas_limit), + eip_1559_params: info.eip_1559_params, + min_base_fee: None, + }) + } } else { warn!("No payload attributes and no previous newPayload - cannot build shadow block yet"); None @@ -176,7 +189,13 @@ impl ShadowBuilderProxy { } }; - info!("Sending FCU with modified attributes to shadow builder"); + let should_skip_build = modified_attrs.is_none() && !has_original_attrs; + + if should_skip_build { + info!("Forwarding FCU without attributes to shadow builder (no building, just chain state update)"); + } else { + info!("Sending FCU with modified attributes to shadow builder"); + } let client = self.builder_client.read().await; let response: ForkchoiceUpdated = ClientT::request( @@ -216,7 +235,7 @@ impl ShadowBuilderProxy { Err(_) => warn!(%payload_id, timeout_ms, "Timeout fetching shadow block"), } }); - } else { + } else if !should_skip_build { warn!( injected_attrs, "Shadow builder FCU returned Valid but no payload_id - block building may not have started" @@ -305,14 +324,15 @@ impl ShadowBuilderProxy { Some(alloy_primitives::B64::from_slice(params_bytes)) } else { Some(alloy_primitives::B64::from_slice(&[ - 0x00, 0x00, 0x00, 0x08, - 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, ])) }; let is_duplicate = { let last_info = self.last_payload_info.read().await; - last_info.as_ref().map_or(false, |info| info.last_block_hash == block_hash) + last_info + .as_ref() + .is_some_and(|info| info.last_block_hash == block_hash) }; if is_duplicate { From 4247f777084e674bb6bd6e5ec23d6a578a64f2f8 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 10 Oct 2025 10:28:32 -0500 Subject: [PATCH 11/12] fix lint errors --- crates/shadow-boost/src/server.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/shadow-boost/src/server.rs b/crates/shadow-boost/src/server.rs index ff1a0f5..712d1cc 100644 --- a/crates/shadow-boost/src/server.rs +++ b/crates/shadow-boost/src/server.rs @@ -36,7 +36,12 @@ pub fn build_rpc_module(proxy: ShadowBuilderProxy) -> RpcModule) = params.parse()?; + let (payload, versioned_hashes, parent_beacon_block_root, _blob_versioned_hashes): ( + _, + _, + _, + Vec, + ) = params.parse()?; context .handle_new_payload(payload, versioned_hashes, parent_beacon_block_root) .await From 9a65732616806b0cf48adf06aec6d205a0169bd9 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 10 Oct 2025 17:36:58 -0500 Subject: [PATCH 12/12] Simplify shadow-boost to pass-through proxy with synthetic attribute injection Refactor shadow-boost to a minimal pass-through proxy that logs all Engine API traffic and injects synthetic payload attributes to trigger shadow building in non-sequencer mode. Key changes: - Switch to generic JSON Value handling for all Engine API methods - Remove response modification (except payload_id suppression for injected attrs) - Remove request modification of existing payload attributes - Remove async newPayload forwarding, historical sync detection, duplicate detection, and shadow block fetching - Store parent_beacon_block_root in LastPayloadInfo for synthetic attributes Behavior: - FCU without payload attributes: inject synthetic attributes from last newPayload to trigger shadow building, suppress payload_id in response - FCU with payload attributes: pass through unchanged - All other methods: pass through with request/response logging --- crates/shadow-boost/src/proxy.rs | 431 ++++++++++++------------------ crates/shadow-boost/src/server.rs | 133 +++++---- 2 files changed, 232 insertions(+), 332 deletions(-) diff --git a/crates/shadow-boost/src/proxy.rs b/crates/shadow-boost/src/proxy.rs index ef4d794..4120b5d 100644 --- a/crates/shadow-boost/src/proxy.rs +++ b/crates/shadow-boost/src/proxy.rs @@ -1,38 +1,44 @@ use crate::auth::generate_jwt_token; use alloy_primitives::B256; -use alloy_rpc_types_engine::{ - ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated, JwtSecret, PayloadAttributes, - PayloadId, PayloadStatus, PayloadStatusEnum, -}; +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::{OpExecutionPayloadEnvelopeV3, OpPayloadAttributes}; +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)] -struct LastPayloadInfo { - timestamp: u64, - prev_randao: B256, - fee_recipient: alloy_primitives::Address, - gas_limit: u64, - eip_1559_params: Option, - last_block_hash: B256, +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, - fetch_timeout_ms: u64, - last_payload_info: Arc>>, + pub last_payload_info: Arc>>, } impl ShadowBuilderProxy { @@ -67,7 +73,6 @@ impl ShadowBuilderProxy { builder_url: builder_url.to_string(), jwt_secret, timeout_ms, - fetch_timeout_ms: timeout_ms, last_payload_info: Arc::new(RwLock::new(None)), }; @@ -102,290 +107,194 @@ impl ShadowBuilderProxy { }); } - pub async fn handle_fcu( - &self, - fork_choice_state: ForkchoiceState, - payload_attributes: Option, - ) -> Result { - let has_original_attrs = payload_attributes.is_some(); + /// 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!( - head_hash = %fork_choice_state.head_block_hash, - safe_hash = %fork_choice_state.safe_block_hash, - finalized_hash = %fork_choice_state.finalized_block_hash, - has_attrs = has_original_attrs, - "Received FCU from op-node" + method = "engine_forkchoiceUpdatedV3", + params_count = params_vec.len(), + params = ?params_vec, + "JSON-RPC request (original from op-node)" ); - let injected_attrs = if !has_original_attrs { - info!("No payload attributes provided - injecting synthetic attributes to trigger shadow building"); - true - } else { - info!("FCU has payload attributes - will rewrite no_tx_pool to trigger building"); - false - }; + 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, + }; - let modified_attrs = match payload_attributes { - Some(mut attrs) => { - let original_no_tx_pool = attrs.no_tx_pool.unwrap_or(false); - attrs.no_tx_pool = Some(false); info!( - timestamp = attrs.payload_attributes.timestamp, - original_no_tx_pool, - modified_no_tx_pool = false, - "Rewrote no_tx_pool in existing payload attributes" + timestamp, + gas_limit = info.gas_limit, + "Injected synthetic payload attributes to trigger shadow building" ); - Some(attrs) - } - None => { - let last_info = self.last_payload_info.read().await; - if let Some(info) = last_info.as_ref() { - use std::time::{SystemTime, UNIX_EPOCH}; - let current_timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let block_age_seconds = current_timestamp.saturating_sub(info.timestamp); - - if block_age_seconds > 30 { - info!( - last_block_timestamp = info.timestamp, - block_age_seconds, - "Skipping synthetic attributes - block is too old (likely historical P2P sync)" - ); - None - } else { - let timestamp = info.timestamp + 2; - - info!( - timestamp, - gas_limit = info.gas_limit, - ?info.eip_1559_params, - last_block_timestamp = info.timestamp, - block_age_seconds, - "Created synthetic payload attributes from last newPayload" - ); - - Some(OpPayloadAttributes { - payload_attributes: PayloadAttributes { - timestamp, - prev_randao: info.prev_randao, - suggested_fee_recipient: info.fee_recipient, - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(B256::ZERO), - }, - transactions: None, - no_tx_pool: Some(false), - gas_limit: Some(info.gas_limit), - eip_1559_params: info.eip_1559_params, - min_base_fee: None, - }) - } - } else { - warn!("No payload attributes and no previous newPayload - cannot build shadow block yet"); - None - } + + 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 }; - let should_skip_build = modified_attrs.is_none() && !has_original_attrs; - - if should_skip_build { - info!("Forwarding FCU without attributes to shadow builder (no building, just chain state update)"); - } else { - info!("Sending FCU with modified attributes to shadow builder"); + 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 response: ForkchoiceUpdated = ClientT::request( - &*client, - "engine_forkchoiceUpdatedV3", - (fork_choice_state, modified_attrs), - ) - .await - .map_err(|e| { - error!(error = %e, "Shadow builder FCU failed"); - ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) - })?; + 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); - if let Some(payload_id) = response.payload_id { - info!( - %payload_id, - injected_attrs, - "Shadow builder initiated block building - spawning fetch task" - ); + let builder_payload_id = response.payload_id; - let builder_client = self.builder_client.clone(); - let timeout_ms = self.fetch_timeout_ms; - tokio::spawn(async move { - info!(%payload_id, "Waiting 1s before fetching shadow block"); - tokio::time::sleep(Duration::from_millis(1000)).await; - - let fetch_result = tokio::time::timeout( - Duration::from_millis(timeout_ms), - Self::fetch_and_log_payload(builder_client, payload_id), - ) - .await; - - match fetch_result { - Ok(Ok(_)) => info!(%payload_id, "Successfully fetched and logged shadow block"), - Ok(Err(e)) => warn!(%payload_id, error = %e, "Failed to fetch shadow block"), - Err(_) => warn!(%payload_id, timeout_ms, "Timeout fetching shadow block"), - } - }); - } else if !should_skip_build { - warn!( - injected_attrs, - "Shadow builder FCU returned Valid but no payload_id - block building may not have started" + 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; } - Ok(ForkchoiceUpdated::new(PayloadStatus::new( - PayloadStatusEnum::Valid, - None, - ))) - } - - async fn fetch_and_log_payload( - builder_client: Arc>, - payload_id: PayloadId, - ) -> Result<()> { - info!(%payload_id, "Fetching shadow block from builder"); - - let client = builder_client.read().await; - let payload: OpExecutionPayloadEnvelopeV3 = - ClientT::request(&*client, "engine_getPayloadV3", (payload_id,)).await?; - drop(client); - - let block_hash = payload - .execution_payload - .payload_inner - .payload_inner - .block_hash; - let block_number = payload - .execution_payload - .payload_inner - .payload_inner - .block_number; - let gas_used = payload - .execution_payload - .payload_inner - .payload_inner - .gas_used; - let tx_count = payload - .execution_payload - .payload_inner - .payload_inner - .transactions - .len(); - let block_value = payload.block_value; + let response_value = serde_json::to_value(response).unwrap(); info!( - %payload_id, - %block_hash, - block_number, - gas_used, - tx_count, - %block_value, - "Shadow block built successfully" + method = "engine_forkchoiceUpdatedV3", + response = ?response_value, + "JSON-RPC response (returned to op-node)" ); - Ok(()) + 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, - payload: ExecutionPayloadV3, - versioned_hashes: Vec, - parent_beacon_block_root: B256, - ) -> Result { - let block_hash = payload.payload_inner.payload_inner.block_hash; - let block_number = payload.payload_inner.payload_inner.block_number; - let tx_count = payload.payload_inner.payload_inner.transactions.len(); - let gas_used = payload.payload_inner.payload_inner.gas_used; - let gas_limit = payload.payload_inner.payload_inner.gas_limit; - 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 extra_data = &payload.payload_inner.payload_inner.extra_data; - + params_vec: Vec, + ) -> Result { info!( - %block_hash, - block_number, - tx_count, - gas_used, - "Received newPayload from op-node - storing payload info and forwarding to shadow builder" + method = "engine_newPayloadV4", + params_count = params_vec.len(), + params = ?params_vec, + "JSON-RPC request" ); - let eip_1559_params = if extra_data.len() >= 9 { - let params_bytes = &extra_data[1..9]; - Some(alloy_primitives::B64::from_slice(params_bytes)) - } else { - Some(alloy_primitives::B64::from_slice(&[ - 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, - ])) - }; + 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 + }; - let is_duplicate = { - let last_info = self.last_payload_info.read().await; - last_info - .as_ref() - .is_some_and(|info| info.last_block_hash == block_hash) - }; + 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; - if is_duplicate { - info!( - %block_hash, - block_number, - "Skipping duplicate newPayload (already forwarded to shadow builder)" - ); - return Ok(PayloadStatus::new(PayloadStatusEnum::Valid, None)); + 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" + ); + } + } } - *self.last_payload_info.write().await = Some(LastPayloadInfo { - timestamp, - prev_randao, - fee_recipient, - gas_limit, - eip_1559_params, - last_block_hash: block_hash, - }); - - let builder_client = self.builder_client.clone(); - tokio::spawn(async move { - let client = builder_client.read().await; - let result: Result = ClientT::request( - &*client, - "engine_newPayloadV3", - (payload, versioned_hashes, parent_beacon_block_root), - ) - .await; - drop(client); - - match result { - Ok(status) => info!( - %block_hash, - block_number, - status = ?status.status, - "Shadow builder accepted newPayload" - ), - Err(e) => error!( - %block_hash, - block_number, + 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, - "Shadow builder rejected newPayload" - ), - } - }); + "JSON-RPC request failed" + ); + ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) + })?; info!( - %block_hash, - block_number, - "Returning Valid status to op-node immediately" + method = "engine_newPayloadV4", + response = ?result, + "JSON-RPC response" ); - Ok(PayloadStatus::new(PayloadStatusEnum::Valid, None)) + Ok(result) } } diff --git a/crates/shadow-boost/src/server.rs b/crates/shadow-boost/src/server.rs index 712d1cc..6e9031d 100644 --- a/crates/shadow-boost/src/server.rs +++ b/crates/shadow-boost/src/server.rs @@ -1,6 +1,4 @@ use crate::proxy::ShadowBuilderProxy; -use alloy_primitives::B256; -use alloy_rpc_types_engine::PayloadId; use jsonrpsee::{ core::client::ClientT, server::Server, @@ -16,69 +14,52 @@ pub fn build_rpc_module(proxy: ShadowBuilderProxy) -> RpcModule, 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_newPayloadV3", |params, context, _| async move { - let (payload, versioned_hashes, parent_beacon_block_root) = params.parse()?; - context - .handle_new_payload(payload, versioned_hashes, parent_beacon_block_root) - .await - }) - .unwrap(); - - module - .register_async_method("engine_newPayloadV4", |params, context, _| async move { - let (payload, versioned_hashes, parent_beacon_block_root, _blob_versioned_hashes): ( - _, - _, - _, - Vec, - ) = params.parse()?; - context - .handle_new_payload(payload, versioned_hashes, parent_beacon_block_root) - .await - }) - .unwrap(); - - module - .register_async_method("engine_getPayloadV3", |params, _context, _| async move { - let (payload_id,): (PayloadId,) = params.parse()?; - warn!(%payload_id, "op-node called getPayload unexpectedly (should never happen in non-sequencer mode)"); - Err::<(), _>(ErrorObjectOwned::owned( - -32601, - "getPayload not supported in shadow builder proxy", - None::<()>, - )) - }) + .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(); - add_passthrough_methods(&mut module); - - module -} - -fn add_passthrough_methods(module: &mut RpcModule) { 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", @@ -86,35 +67,45 @@ fn add_passthrough_methods(module: &mut RpcModule) { ]; for method in methods { - let method_name = method.to_string(); - module - .register_async_method(method, move |params: Params<'static>, context, _| { - let method = method_name.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(), - "Proxying method to shadow builder" - ); + register_passthrough_method(&mut module, method); + } - let client = context.builder_client.read().await; - let result: Value = client.request(&method, params_vec).await.map_err(|e| { - warn!(method, error = %e, "Shadow builder method call failed"); - ErrorObjectOwned::owned(-32603, e.to_string(), None::<()>) - })?; + module +} - info!(method, "Shadow builder method call succeeded"); - Ok::(result) +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); } - }) - .unwrap(); - } + + 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(