From b9356bd5aa7541778798b599706b17524fe2ebb5 Mon Sep 17 00:00:00 2001 From: avalonche Date: Wed, 22 Apr 2026 10:48:03 -0700 Subject: [PATCH 1/3] feat: map L2 -> builder payload id for get_payload translation --- Cargo.lock | 12 +- crates/rollup-boost-types/src/payload.rs | 17 ++- .../rollup-boost/src/flashblocks/inbound.rs | 13 +- crates/rollup-boost/src/proxy.rs | 4 +- crates/rollup-boost/src/server.rs | 135 +++++++++++++++--- crates/websocket-proxy/src/registry.rs | 8 +- 6 files changed, 145 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae7363f8..d256142a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,9 +769,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -779,9 +779,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -4607,9 +4607,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/crates/rollup-boost-types/src/payload.rs b/crates/rollup-boost-types/src/payload.rs index 8efad848..e84a9725 100644 --- a/crates/rollup-boost-types/src/payload.rs +++ b/crates/rollup-boost-types/src/payload.rs @@ -235,7 +235,7 @@ impl PayloadSource { #[derive(Debug, Clone)] pub struct PayloadTrace { - pub builder_has_payload: bool, + pub builder_payload_id: Option, pub trace_id: Option, } @@ -263,14 +263,14 @@ impl PayloadTraceContext { &self, payload_id: PayloadId, parent_hash: B256, - builder_has_payload: bool, + builder_payload_id: Option, trace_id: Option, ) { self.payload_id .insert( payload_id, PayloadTrace { - builder_has_payload, + builder_payload_id, trace_id, }, ) @@ -312,12 +312,17 @@ impl PayloadTraceContext { .and_then(|x| x.trace_id) } - pub async fn has_builder_payload(&self, payload_id: &PayloadId) -> bool { + /// Returns the builder's payload id for the given local (L2) payload id, if any. + /// + /// Builder and L2 may compute different payload ids (e.g. when the builder augments + /// attributes with flashblocks-specific fields). Callers that need to forward + /// `engine_getPayload` to the builder must translate the incoming (L2) id through + /// this map rather than passing it verbatim. + pub async fn builder_payload_id(&self, payload_id: &PayloadId) -> Option { self.payload_id .get(payload_id) .await - .map(|x| x.builder_has_payload) - .unwrap_or_default() + .and_then(|x| x.builder_payload_id) } pub async fn remove_by_parent_hash(&self, block_hash: &B256) { diff --git a/crates/rollup-boost/src/flashblocks/inbound.rs b/crates/rollup-boost/src/flashblocks/inbound.rs index e2a28e7a..f6db4666 100644 --- a/crates/rollup-boost/src/flashblocks/inbound.rs +++ b/crates/rollup-boost/src/flashblocks/inbound.rs @@ -297,12 +297,9 @@ mod tests { write.send(Message::Text(utf8_bytes)).await.expect("message sent"); }, msg = read.next() => { - match msg { - // we need to read for the library to handle pong messages - Some(Ok(Message::Ping(_))) => { - send_ping_tx.send(()).await.expect("ping notification sent"); - }, - _ => {} + // we need to read for the library to handle pong messages + if let Some(Ok(Message::Ping(_))) = msg { + send_ping_tx.send(()).await.expect("ping notification sent"); } } _ = term_rx.changed() => { @@ -417,7 +414,7 @@ mod tests { flashblock_builder_ws_connect_timeout_ms: 5000, }; let service = FlashblocksReceiverService::new(url, tx, config); - let _ = tokio::spawn(async move { + tokio::spawn(async move { service.run().await; }); @@ -471,7 +468,7 @@ mod tests { let (tx, _rx) = mpsc::channel(100); let service = FlashblocksReceiverService::new(url, tx, config); - let _ = tokio::spawn(async move { + tokio::spawn(async move { service.run().await; }); diff --git a/crates/rollup-boost/src/proxy.rs b/crates/rollup-boost/src/proxy.rs index 0606dd16..bd4e44a2 100644 --- a/crates/rollup-boost/src/proxy.rs +++ b/crates/rollup-boost/src/proxy.rs @@ -159,7 +159,7 @@ mod tests { // A JSON-RPC error is retriable if error.code ∉ (-32700, -32600] fn is_retriable_code(code: i32) -> bool { - code < -32700 || code > -32600 + !(-32700..=-32600).contains(&code) } struct TestHarness { @@ -889,7 +889,7 @@ mod tests { { let l2_requests = l2.requests.lock().await; assert!( - l2_requests.len() >= 1, + !l2_requests.is_empty(), "L2 server should have received requests" ); assert_eq!(l2_requests[0]["method"], "mock_forwardedMethod"); diff --git a/crates/rollup-boost/src/server.rs b/crates/rollup-boost/src/server.rs index a75fbb0e..1eb50002 100644 --- a/crates/rollup-boost/src/server.rs +++ b/crates/rollup-boost/src/server.rs @@ -246,24 +246,38 @@ impl RollupBoostServer { if let Some(cause) = self.payload_trace_context.trace_id(&payload_id).await { tracing::Span::current().follows_from(cause); } - if !self + let builder_payload_id = match self .payload_trace_context - .has_builder_payload(&payload_id) + .builder_payload_id(&payload_id) .await { - info!(message = "builder has no payload, skipping get_payload call to builder"); - tracing::Span::current().record("builder_has_payload", false); - return BuilderResult::Ok(BuilderPayloadResult { - payload: None, - builder_api_failed: true, - }); - } + Some(id) => id, + None => { + info!(message = "builder has no payload, skipping get_payload call to builder"); + tracing::Span::current().record("builder_has_payload", false); + return BuilderResult::Ok(BuilderPayloadResult { + payload: None, + builder_api_failed: true, + }); + } + }; // Get payload and validate with the local l2 client tracing::Span::current().record("builder_has_payload", true); + if builder_payload_id != payload_id { + tracing::info!( + message = "translating L2 payload id to builder payload id for get_payload", + l2_payload_id = %payload_id, + %builder_payload_id, + ); + } info!(message = "builder has payload, calling get_payload on builder"); - let payload = match self.builder_client.get_payload(payload_id, version).await { + let payload = match self + .builder_client + .get_payload(builder_payload_id, version) + .await + { Ok(payload) => payload, Err(e) => { error!(message = "error getting payload from builder", error = %e); @@ -554,7 +568,7 @@ impl EngineApiServer for RollupBoostServer { .store( payload_id, fork_choice_state.head_block_hash, - false, + None, span.id(), ) .await; @@ -572,18 +586,30 @@ impl EngineApiServer for RollupBoostServer { let (l2_result, builder_result) = tokio::join!(l2_fut, builder_fut); let l2_response = l2_result?; + let builder_payload_id = builder_result.as_ref().ok().and_then(|r| r.payload_id); + if let Some(payload_id) = l2_response.payload_id { info!( message = "block building started", "payload_id" = %payload_id, - "builder_building" = builder_result.is_ok(), + "builder_building" = builder_payload_id.is_some(), ); + if let Some(bid) = builder_payload_id + && bid != payload_id + { + tracing::warn!( + message = "builder returned a different payload id than L2", + l2_payload_id = %payload_id, + builder_payload_id = %bid, + ); + } + self.payload_trace_context .store( payload_id, fork_choice_state.head_block_hash, - builder_result.is_ok(), + builder_payload_id, span.id(), ) .await; @@ -1085,10 +1111,10 @@ pub mod tests { fcu_requests.push(params); let mut response = mock_engine_server.fcu_response.clone(); - if let Ok(ref mut fcu_response) = response { - if let Some(override_id) = mock_engine_server.override_payload_id { - fcu_response.payload_id = Some(override_id); - } + if let Ok(ref mut fcu_response) = response + && let Some(override_id) = mock_engine_server.override_payload_id + { + fcu_response.payload_id = Some(override_id); } response @@ -1256,6 +1282,81 @@ pub mod tests { test_harness.cleanup().await; } + #[tokio::test] + async fn builder_payload_id_translation_on_mismatch() { + // the builder may compute a different payload_id than the L2 + // client. On get_payload, rollup-boost must translate the incoming + // L2 id back to the builder's id when forwarding to the builder. + let l2_payload_id: PayloadId = PayloadId::new([0, 0, 0, 0, 0, 0, 0, 1]); + let builder_payload_id: PayloadId = PayloadId::new([0, 0, 0, 0, 0, 0, 0, 2]); + + let mut l2_mock = MockEngineServer::new(); + l2_mock.fcu_response = Ok(ForkchoiceUpdated::new(PayloadStatus::from_status( + PayloadStatusEnum::Valid, + )) + .with_payload_id(l2_payload_id)); + l2_mock.get_payload_responses[0] = + l2_mock.get_payload_responses[0].clone().map(|mut payload| { + payload.block_value = U256::from(10); + payload + }); + + let mut builder_mock = MockEngineServer::new(); + builder_mock.fcu_response = Ok(ForkchoiceUpdated::new(PayloadStatus::from_status( + PayloadStatusEnum::Valid, + )) + .with_payload_id(builder_payload_id)); + builder_mock.get_payload_responses[0] = + builder_mock.get_payload_responses[0] + .clone() + .map(|mut payload| { + payload.block_value = U256::from(15); + payload + }); + + let test_harness = + TestHarness::new(Some(l2_mock.clone()), Some(builder_mock.clone())).await; + let fcu = ForkchoiceState { + head_block_hash: FixedBytes::random(), + safe_block_hash: FixedBytes::random(), + finalized_block_hash: FixedBytes::random(), + }; + let payload_attributes = OpPayloadAttributes { + gas_limit: Some(1000000), + ..Default::default() + }; + + test_harness + .rpc_client + .fork_choice_updated_v3(fcu, Some(payload_attributes)) + .await + .unwrap(); + + // op-node calls get_payload with the L2 id; rollup-boost should translate + // to the builder id before forwarding, and return the builder's payload. + let get_payload_response = test_harness + .rpc_client + .get_payload_v3(l2_payload_id) + .await + .unwrap(); + assert_eq!(get_payload_response.block_value, U256::from(15)); + + // The builder must have been called with its own id, not the L2's. + { + let builder_gp_reqs = builder_mock.get_payload_requests.lock(); + assert_eq!(builder_gp_reqs.len(), 1); + assert_eq!(builder_gp_reqs[0], builder_payload_id); + } + // The L2 is always queried with the original id. + { + let l2_gp_reqs = l2_mock.get_payload_requests.lock(); + assert_eq!(l2_gp_reqs.len(), 1); + assert_eq!(l2_gp_reqs[0], l2_payload_id); + } + + test_harness.cleanup().await; + } + #[tokio::test] async fn l2_client_fails_fcu() { // If the canonical l2 client fails the FCU call, it does not matter what the builder returns diff --git a/crates/websocket-proxy/src/registry.rs b/crates/websocket-proxy/src/registry.rs index 8b09d26a..3c5ec246 100644 --- a/crates/websocket-proxy/src/registry.rs +++ b/crates/websocket-proxy/src/registry.rs @@ -108,11 +108,9 @@ impl Registry { tokio::select! { msg = ws_receiver.next() => { match msg { - Some(Ok(Message::Pong(_))) => { - if ping_enabled { - trace!(message = "received pong from client", client = client_id); - last_pong = Instant::now(); - } + Some(Ok(Message::Pong(_))) if ping_enabled => { + trace!(message = "received pong from client", client = client_id); + last_pong = Instant::now(); } Some(Ok(Message::Close(_))) => { trace!(message = "received close from client", client = client_id); From 2c7e0f67faab22402c554b5ce185082216b2ee47 Mon Sep 17 00:00:00 2001 From: avalonche Date: Thu, 23 Apr 2026 11:11:29 -0700 Subject: [PATCH 2/3] reduce logging --- crates/rollup-boost/src/flashblocks/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rollup-boost/src/flashblocks/service.rs b/crates/rollup-boost/src/flashblocks/service.rs index c5c4cdb6..5914046f 100644 --- a/crates/rollup-boost/src/flashblocks/service.rs +++ b/crates/rollup-boost/src/flashblocks/service.rs @@ -328,7 +328,7 @@ impl EngineApiExt for FlashblocksService { if let Some(payload_id) = resp.payload_id { let current_payload = *self.current_payload_id.read().await; if current_payload != Some(payload_id) { - tracing::error!( + tracing::debug!( message = "Payload id returned by builder differs from calculated. Using builder payload id", builder_payload_id = %payload_id, calculated_payload_id = %current_payload.unwrap_or_default(), From 5a5092468a8713c867bc2a87fed97c1cc5094cfc Mon Sep 17 00:00:00 2001 From: avalonche Date: Thu, 23 Apr 2026 12:46:57 -0700 Subject: [PATCH 3/3] fix flashblocks args --- crates/rollup-boost/src/cli.rs | 111 +++++++++++++++++++- crates/rollup-boost/src/flashblocks/args.rs | 7 +- crates/rollup-boost/src/server.rs | 5 +- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/rollup-boost/src/cli.rs b/crates/rollup-boost/src/cli.rs index 7e147ecb..f0ae2c8a 100644 --- a/crates/rollup-boost/src/cli.rs +++ b/crates/rollup-boost/src/cli.rs @@ -39,7 +39,7 @@ pub struct RollupBoostLibArgs { pub ignore_unhealthy_builders: bool, #[clap(flatten)] - pub flashblocks_ws: Option, + pub flashblocks_ws: FlashblocksWsArgs, #[clap(flatten)] pub flashblocks_p2p: Option, @@ -218,7 +218,7 @@ pub mod tests { assert!(!args.metrics); assert_eq!(args.rpc_host, "127.0.0.1"); assert_eq!(args.rpc_port, 8081); - assert!(args.lib.flashblocks_ws.is_none()); + assert!(!args.lib.flashblocks_ws.flashblocks_ws); assert!(args.lib.flashblocks_p2p.is_none()); Ok(()) @@ -354,6 +354,113 @@ pub mod tests { Ok(()) } + #[test] + fn test_parse_args_with_flashblocks_ws_flag() -> Result<(), Box> { + // `--flashblocks` must enable the WS client and materialize nested + // FlashblocksWebsocketConfig with defaults. Previously broken when + // FlashblocksWsArgs was wrapped in Option<> — clap's Option detection + // does not work with nested #[command(flatten)]. + let args = RollupBoostServiceArgs::try_parse_from([ + "rollup-boost", + "--builder-jwt-token", + SECRET, + "--l2-jwt-token", + SECRET, + "--flashblocks", + ])?; + + let ws = &args.lib.flashblocks_ws; + assert!(ws.flashblocks_ws, "--flashblocks should set bool true"); + assert_eq!(ws.flashblocks_host, "127.0.0.1"); + assert_eq!(ws.flashblocks_port, 1112); + assert_eq!( + ws.flashblocks_ws_config + .flashblock_builder_ws_ping_interval_ms, + 500, + "nested config default must be populated" + ); + + Ok(()) + } + + #[test] + fn test_parse_args_flashblocks_ws_absent_defaults_false() + -> Result<(), Box> { + let args = RollupBoostServiceArgs::try_parse_from([ + "rollup-boost", + "--builder-jwt-token", + SECRET, + "--l2-jwt-token", + SECRET, + ])?; + + assert!(!args.lib.flashblocks_ws.flashblocks_ws); + Ok(()) + } + + #[test] + fn test_parse_args_flashblocks_ws_custom_config() -> Result<(), Box> { + let args = RollupBoostServiceArgs::try_parse_from([ + "rollup-boost", + "--builder-jwt-token", + SECRET, + "--l2-jwt-token", + SECRET, + "--flashblocks", + "--flashblocks-builder-url", + "ws://builder:9999", + "--flashblocks-host", + "0.0.0.0", + "--flashblocks-port", + "2222", + "--flashblock-builder-ws-ping-interval-ms", + "777", + "--flashblock-builder-ws-pong-timeout-ms", + "1234", + ])?; + + let ws = &args.lib.flashblocks_ws; + assert!(ws.flashblocks_ws); + assert_eq!(ws.flashblocks_builder_url.as_str(), "ws://builder:9999/"); + assert_eq!(ws.flashblocks_host, "0.0.0.0"); + assert_eq!(ws.flashblocks_port, 2222); + assert_eq!( + ws.flashblocks_ws_config + .flashblock_builder_ws_ping_interval_ms, + 777 + ); + assert_eq!( + ws.flashblocks_ws_config + .flashblock_builder_ws_pong_timeout_ms, + 1234 + ); + + Ok(()) + } + + #[test] + fn test_parse_args_flashblocks_ws_conflicts_with_p2p() { + // `--flashblocks` and `--flashblocks-p2p` are mutually exclusive. + let result = RollupBoostServiceArgs::try_parse_from([ + "rollup-boost", + "--builder-jwt-token", + SECRET, + "--l2-jwt-token", + SECRET, + "--flashblocks", + "--flashblocks-p2p", + "--flashblocks-authorizer-sk", + FLASHBLOCKS_SK, + "--flashblocks-builder-vk", + FLASHBLOCKS_VK, + ]); + + assert!( + result.is_err(), + "--flashblocks and --flashblocks-p2p must conflict" + ); + } + #[test] fn test_parse_args_missing_jwt_succeeds_at_parse_time() { // JWT validation happens at runtime, not parse time, so this should succeed diff --git a/crates/rollup-boost/src/flashblocks/args.rs b/crates/rollup-boost/src/flashblocks/args.rs index 8b535599..e23a4b71 100644 --- a/crates/rollup-boost/src/flashblocks/args.rs +++ b/crates/rollup-boost/src/flashblocks/args.rs @@ -1,5 +1,5 @@ use backoff::{ExponentialBackoff, ExponentialBackoffBuilder}; -use clap::{Args, Parser}; +use clap::Args; use ed25519_dalek::{SigningKey, VerifyingKey}; use std::time::Duration; use url::Url; @@ -7,7 +7,6 @@ use url::Url; use hex::FromHex; #[derive(Args, Clone, Debug)] -#[group(requires = "flashblocks_ws")] pub struct FlashblocksWsArgs { /// Enable Flashblocks Websocket client #[arg( @@ -16,7 +15,7 @@ pub struct FlashblocksWsArgs { id = "flashblocks_ws", conflicts_with = "flashblocks_p2p", env, - default_value = "false" + required = false )] pub flashblocks_ws: bool, @@ -37,7 +36,7 @@ pub struct FlashblocksWsArgs { pub flashblocks_ws_config: FlashblocksWebsocketConfig, } -#[derive(Parser, Debug, Clone, Copy)] +#[derive(Args, Debug, Clone, Copy)] pub struct FlashblocksWebsocketConfig { /// Minimum time for exponential backoff for timeout if builder disconnected #[arg(long, env, default_value = "10")] diff --git a/crates/rollup-boost/src/server.rs b/crates/rollup-boost/src/server.rs index 1eb50002..17d61ef0 100644 --- a/crates/rollup-boost/src/server.rs +++ b/crates/rollup-boost/src/server.rs @@ -83,7 +83,8 @@ impl RollupBoostServer { let execution_mode = Arc::new(Mutex::new(rollup_boost_args.execution_mode.clone())); let builder_client: Arc = - if let Some(flashblocks_ws) = rollup_boost_args.flashblocks_ws { + if rollup_boost_args.flashblocks_ws.flashblocks_ws { + let flashblocks_ws = rollup_boost_args.flashblocks_ws; let inbound_url = flashblocks_ws.flashblocks_builder_url; let outbound_addr = SocketAddr::new( IpAddr::from_str(&flashblocks_ws.flashblocks_host)?, @@ -598,7 +599,7 @@ impl EngineApiServer for RollupBoostServer { if let Some(bid) = builder_payload_id && bid != payload_id { - tracing::warn!( + tracing::info!( message = "builder returned a different payload id than L2", l2_payload_id = %payload_id, builder_payload_id = %bid,