From 26480139d583fc2acbec8c91553eafe731699456 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 1 Sep 2026 16:26:13 +0800 Subject: [PATCH 1/4] feat: support split L2 HTTP and WebSocket RPC --- common/src/config/mod.rs | 79 ++++++++++++++++++++++++++++++++++++++++ realtime/src/lib.rs | 2 +- shasta/src/lib.rs | 2 +- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index 2a0ab348..d72b1be5 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -27,6 +27,7 @@ pub struct Config { pub preconf_heartbeat_ms: u64, // L2 pub l2_rpc_url: String, + pub l2_ws_rpc_url: String, pub l2_auth_rpc_url: String, pub l2_driver_url: String, /// jwt secret file path for L2 EL and L2 driver @@ -121,6 +122,34 @@ fn get_env_with_deprecation(new_key: &str, deprecated_key: &str) -> Option, +) -> Result { + let (source, url) = match configured_l2_ws_rpc_url { + Some(url) => ("L2_WS_RPC_URL", url), + None => ("L2_RPC_URL", l2_rpc_url.to_string()), + }; + + let parsed = reqwest::Url::parse(&url) + .map_err(|error| anyhow::anyhow!("{source} must be a valid URL: {error}"))?; + + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(url); + } + + if source == "L2_RPC_URL" { + return Err(anyhow::anyhow!( + "L2_WS_RPC_URL must be set to a ws:// or wss:// URL when L2_RPC_URL does not use WebSocket" + )); + } + + Err(anyhow::anyhow!( + "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", + parsed.scheme() + )) +} + impl Config { pub fn read_env_variables() -> Result { // Load environment variables from .env file @@ -501,6 +530,9 @@ impl Config { "ws://127.0.0.1:1234".to_string() }); + let l2_ws_rpc_url = + resolve_l2_ws_rpc_url(&l2_rpc_url, std::env::var("L2_WS_RPC_URL").ok())?; + let l2_auth_rpc_url = get_env_with_deprecation("L2_AUTH_RPC_URL", "TAIKO_GETH_AUTH_RPC_URL").unwrap_or_else( || { @@ -518,6 +550,7 @@ impl Config { let config = Self { preconfer_address, l2_rpc_url, + l2_ws_rpc_url, l2_auth_rpc_url, l2_driver_url, catalyst_node_ecdsa_private_key, @@ -580,6 +613,7 @@ impl Config { r#" Configuration:{} L2 RPC URL: {}, +L2 WebSocket RPC URL: {}, L2 auth RPC URL: {}, L2 driver URL: {}, L1 RPC URL: {}, @@ -636,6 +670,7 @@ internal server port: {} "".to_string() }, config.l2_rpc_url, + config.l2_ws_rpc_url, config.l2_auth_rpc_url, config.l2_driver_url, match config.l1_rpc_urls.split_first() { @@ -697,3 +732,47 @@ internal server port: {} Ok(config) } } + +#[cfg(test)] +mod tests { + use super::resolve_l2_ws_rpc_url; + + #[test] + fn explicit_l2_ws_rpc_url_is_used_with_http_rpc() { + let resolved = resolve_l2_ws_rpc_url( + "http://127.0.0.1:8545", + Some("ws://127.0.0.1:8546".to_string()), + ) + .unwrap(); + + assert_eq!(resolved, "ws://127.0.0.1:8546"); + } + + #[test] + fn legacy_websocket_l2_rpc_url_is_reused() { + let resolved = resolve_l2_ws_rpc_url("wss://l2.example", None).unwrap(); + + assert_eq!(resolved, "wss://l2.example"); + } + + #[test] + fn http_l2_rpc_url_requires_explicit_websocket_url() { + let error = resolve_l2_ws_rpc_url("https://l2.example", None) + .unwrap_err() + .to_string(); + + assert!(error.contains("L2_WS_RPC_URL must be set")); + } + + #[test] + fn explicit_l2_ws_rpc_url_rejects_non_websocket_scheme() { + let error = resolve_l2_ws_rpc_url( + "http://127.0.0.1:8545", + Some("http://127.0.0.1:8546".to_string()), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("L2_WS_RPC_URL must use the ws or wss scheme")); + } +} diff --git a/realtime/src/lib.rs b/realtime/src/lib.rs index 821f8ad2..d5cea51e 100644 --- a/realtime/src/lib.rs +++ b/realtime/src/lib.rs @@ -111,7 +111,7 @@ pub async fn create_realtime_node( .first() .ok_or_else(|| anyhow::anyhow!("L1 RPC URL is required"))? .clone(), - config.l2_rpc_url.clone(), + config.l2_ws_rpc_url.clone(), realtime_config.realtime_inbox, cancel_token.clone(), "ProposedAndProved", diff --git a/shasta/src/lib.rs b/shasta/src/lib.rs index 0939d4b8..b59ac8af 100644 --- a/shasta/src/lib.rs +++ b/shasta/src/lib.rs @@ -125,7 +125,7 @@ pub async fn create_shasta_node( .first() .expect("L1 RPC URL is required") .clone(), - config.l2_rpc_url.clone(), + config.l2_ws_rpc_url.clone(), shasta_config.shasta_inbox, cancel_token.clone(), "Proposed", From e8866a031df7805e10dc5c4bc43d196acad52a60 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 1 Sep 2026 16:52:52 +0800 Subject: [PATCH 2/4] fix: harden split RPC configuration --- .env.sample | 6 ++- CHANGELOG.md | 7 +++ common/src/config/mod.rs | 70 ++++++++++++++++++++----- common/src/shared/alloy_tools.rs | 87 ++++++++++++++++++++------------ 4 files changed, 122 insertions(+), 48 deletions(-) diff --git a/.env.sample b/.env.sample index 6fefb3d5..a00b8a67 100644 --- a/.env.sample +++ b/.env.sample @@ -4,10 +4,12 @@ PRECONF_ROUTER_ADDRESS=0x8dDEA87FfA296B951881aDacbAe8011090C54cAC TAIKO_INBOX_ADDRESS=0xc2DD6e8DC8d0558F00Cc1FA6A16FFF1A62Cc436B ANCHOR_ADDRESS=0x1670100000000000000000000000000000010001 VALIDATOR_INDEX=1 -L2_RPC_URL=ws://127.0.0.1:8546 +# Ordinary execution RPC; Shasta and Realtime subscriptions use L2_WS_RPC_URL. +L2_RPC_URL=http://127.0.0.1:8545 +L2_WS_RPC_URL=ws://127.0.0.1:8546 L2_AUTH_RPC_URL=http://127.0.0.1:8551 L2_DRIVER_URL=http://127.0.0.1:1235 L1_RPC_URLS=ws://127.0.0.1:32003,wss://123.123.123.123:32001 L1_BEACON_URL=http://127.0.0.1:33001 RUST_LOG=debug,reqwest=info,hyper=info,alloy_transport=info,alloy_rpc_client=info,alloy_provider=info -JWT_SECRET_FILE_PATH=/some/path/jwtsecret.hex \ No newline at end of file +JWT_SECRET_FILE_PATH=/some/path/jwtsecret.hex diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dde9972..f11da8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Catalyst are documented here, organized by release version. +## [Unreleased] + +### Features +- Add `L2_WS_RPC_URL` to split WebSocket subscriptions from ordinary L2 execution RPC requests (#2) + +--- + ## [v1.41.0] — 2026-06-25 ### Features diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index d72b1be5..610fbde8 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -126,27 +126,36 @@ fn resolve_l2_ws_rpc_url( l2_rpc_url: &str, configured_l2_ws_rpc_url: Option, ) -> Result { - let (source, url) = match configured_l2_ws_rpc_url { - Some(url) => ("L2_WS_RPC_URL", url), - None => ("L2_RPC_URL", l2_rpc_url.to_string()), - }; + let parsed_l2_rpc_url = reqwest::Url::parse(l2_rpc_url) + .map_err(|error| anyhow::anyhow!("L2_RPC_URL must be a valid URL: {error}"))?; + if !matches!(parsed_l2_rpc_url.scheme(), "http" | "https" | "ws" | "wss") { + return Err(anyhow::anyhow!( + "L2_RPC_URL must use the http, https, ws or wss scheme, got '{}'", + parsed_l2_rpc_url.scheme() + )); + } - let parsed = reqwest::Url::parse(&url) - .map_err(|error| anyhow::anyhow!("{source} must be a valid URL: {error}"))?; + let configured_l2_ws_rpc_url = configured_l2_ws_rpc_url.filter(|url| !url.trim().is_empty()); + if let Some(url) = configured_l2_ws_rpc_url { + let parsed = reqwest::Url::parse(&url) + .map_err(|error| anyhow::anyhow!("L2_WS_RPC_URL must be a valid URL: {error}"))?; - if matches!(parsed.scheme(), "ws" | "wss") { - return Ok(url); - } + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(url); + } - if source == "L2_RPC_URL" { return Err(anyhow::anyhow!( - "L2_WS_RPC_URL must be set to a ws:// or wss:// URL when L2_RPC_URL does not use WebSocket" + "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", + parsed.scheme() )); } + if matches!(parsed_l2_rpc_url.scheme(), "ws" | "wss") { + return Ok(l2_rpc_url.to_string()); + } + Err(anyhow::anyhow!( - "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", - parsed.scheme() + "L2_WS_RPC_URL must be set to a ws:// or wss:// URL when L2_RPC_URL does not use WebSocket" )) } @@ -755,6 +764,13 @@ mod tests { assert_eq!(resolved, "wss://l2.example"); } + #[test] + fn empty_l2_ws_rpc_url_reuses_legacy_websocket_l2_rpc_url() { + let resolved = resolve_l2_ws_rpc_url("wss://l2.example", Some(String::new())).unwrap(); + + assert_eq!(resolved, "wss://l2.example"); + } + #[test] fn http_l2_rpc_url_requires_explicit_websocket_url() { let error = resolve_l2_ws_rpc_url("https://l2.example", None) @@ -764,6 +780,24 @@ mod tests { assert!(error.contains("L2_WS_RPC_URL must be set")); } + #[test] + fn malformed_l2_rpc_url_is_rejected_with_explicit_websocket_url() { + let error = resolve_l2_ws_rpc_url("not a valid URL", Some("wss://l2.example".to_string())) + .unwrap_err() + .to_string(); + + assert!(error.contains("L2_RPC_URL must be a valid URL")); + } + + #[test] + fn l2_rpc_url_rejects_unsupported_scheme() { + let error = resolve_l2_ws_rpc_url("ftp://l2.example", Some("wss://l2.example".to_string())) + .unwrap_err() + .to_string(); + + assert!(error.contains("L2_RPC_URL must use the http, https, ws or wss scheme")); + } + #[test] fn explicit_l2_ws_rpc_url_rejects_non_websocket_scheme() { let error = resolve_l2_ws_rpc_url( @@ -775,4 +809,14 @@ mod tests { assert!(error.contains("L2_WS_RPC_URL must use the ws or wss scheme")); } + + #[test] + fn explicit_l2_ws_rpc_url_rejects_malformed_url() { + let error = + resolve_l2_ws_rpc_url("http://127.0.0.1:8545", Some("not a valid URL".to_string())) + .unwrap_err() + .to_string(); + + assert!(error.contains("L2_WS_RPC_URL must be a valid URL")); + } } diff --git a/common/src/shared/alloy_tools.rs b/common/src/shared/alloy_tools.rs index 3bc8df9a..ed6af045 100644 --- a/common/src/shared/alloy_tools.rs +++ b/common/src/shared/alloy_tools.rs @@ -105,47 +105,68 @@ pub async fn construct_alloy_provider( } } +#[derive(Debug)] +enum RpcTransport { + Http(reqwest::Url), + WebSocket, +} + +fn parse_rpc_transport(url: &str) -> Result { + let parsed = + reqwest::Url::parse(url).map_err(|error| anyhow::anyhow!("Invalid RPC URL: {error}"))?; + + match parsed.scheme() { + "http" | "https" => Ok(RpcTransport::Http(parsed)), + "ws" | "wss" => Ok(RpcTransport::WebSocket), + scheme => Err(anyhow::anyhow!( + "Invalid RPC URL scheme '{scheme}', only websocket and http are supported" + )), + } +} + async fn create_alloy_provider_with_wallet( wallet: EthereumWallet, url: &str, ) -> Result { - if url.contains("ws://") || url.contains("wss://") { - let ws = WsConnect::new(url); - Ok(ProviderBuilder::new() + match parse_rpc_transport(url)? { + RpcTransport::Http(url) => Ok(ProviderBuilder::new() .wallet(wallet) - .connect_ws(ws.clone()) - .await - .map_err(|e| Error::msg(format!("Execution layer: Failed to connect to WS: {e}")))? - .erased()) - } else if url.contains("http://") || url.contains("https://") { - Ok(ProviderBuilder::new() - .wallet(wallet) - .connect_http(url.parse::()?) - .erased()) - } else { - Err(anyhow::anyhow!( - "Invalid URL, only websocket and http are supported: {}", - url - )) + .connect_http(url) + .erased()), + RpcTransport::WebSocket => { + let ws = WsConnect::new(url); + Ok(ProviderBuilder::new() + .wallet(wallet) + .connect_ws(ws.clone()) + .await + .map_err(|e| Error::msg(format!("Execution layer: Failed to connect to WS: {e}")))? + .erased()) + } } } pub async fn create_alloy_provider_without_wallet(url: &str) -> Result { - if url.contains("ws://") || url.contains("wss://") { - let ws = WsConnect::new(url); - Ok(ProviderBuilder::new() - .connect_ws(ws.clone()) - .await - .map_err(|e| Error::msg(format!("Execution layer: Failed to connect to WS: {e}")))? - .erased()) - } else if url.contains("http://") || url.contains("https://") { - Ok(ProviderBuilder::new() - .connect_http(url.parse::()?) - .erased()) - } else { - Err(anyhow::anyhow!( - "Invalid URL, only websocket and http are supported: {}", - url - )) + match parse_rpc_transport(url)? { + RpcTransport::Http(url) => Ok(ProviderBuilder::new().connect_http(url).erased()), + RpcTransport::WebSocket => { + let ws = WsConnect::new(url); + Ok(ProviderBuilder::new() + .connect_ws(ws.clone()) + .await + .map_err(|e| Error::msg(format!("Execution layer: Failed to connect to WS: {e}")))? + .erased()) + } + } +} + +#[cfg(test)] +mod tests { + use super::{RpcTransport, parse_rpc_transport}; + + #[test] + fn http_url_with_websocket_text_in_path_uses_http_transport() { + let transport = parse_rpc_transport("https://l2.example/ws://archive").unwrap(); + + assert!(matches!(transport, RpcTransport::Http(_))); } } From 110adaa58293e3565ca95169a785459e1de41210 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 1 Sep 2026 17:25:42 +0800 Subject: [PATCH 3/4] fix: normalize RPC URLs before connecting --- common/src/config/mod.rs | 67 +++++++++++++++++++++--------- common/src/shared/alloy_tools.rs | 70 +++++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 40 deletions(-) diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index 610fbde8..f76cf2dc 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -1,6 +1,7 @@ mod config_trait; pub use config_trait::ConfigTrait; +use crate::shared::alloy_tools::{RpcTransport, parse_rpc_transport}; use alloy::primitives::Address; use anyhow::Error; use std::str::FromStr; @@ -126,32 +127,23 @@ fn resolve_l2_ws_rpc_url( l2_rpc_url: &str, configured_l2_ws_rpc_url: Option, ) -> Result { - let parsed_l2_rpc_url = reqwest::Url::parse(l2_rpc_url) - .map_err(|error| anyhow::anyhow!("L2_RPC_URL must be a valid URL: {error}"))?; - if !matches!(parsed_l2_rpc_url.scheme(), "http" | "https" | "ws" | "wss") { - return Err(anyhow::anyhow!( - "L2_RPC_URL must use the http, https, ws or wss scheme, got '{}'", - parsed_l2_rpc_url.scheme() - )); - } + let l2_rpc_transport = parse_rpc_transport("L2_RPC_URL", l2_rpc_url)?; let configured_l2_ws_rpc_url = configured_l2_ws_rpc_url.filter(|url| !url.trim().is_empty()); if let Some(url) = configured_l2_ws_rpc_url { - let parsed = reqwest::Url::parse(&url) - .map_err(|error| anyhow::anyhow!("L2_WS_RPC_URL must be a valid URL: {error}"))?; - - if matches!(parsed.scheme(), "ws" | "wss") { - return Ok(url); + match parse_rpc_transport("L2_WS_RPC_URL", &url)? { + RpcTransport::WebSocket(_) => return Ok(url.trim().to_string()), + RpcTransport::Http(parsed) => { + return Err(anyhow::anyhow!( + "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", + parsed.scheme() + )); + } } - - return Err(anyhow::anyhow!( - "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", - parsed.scheme() - )); } - if matches!(parsed_l2_rpc_url.scheme(), "ws" | "wss") { - return Ok(l2_rpc_url.to_string()); + if matches!(l2_rpc_transport, RpcTransport::WebSocket(_)) { + return Ok(l2_rpc_url.trim().to_string()); } Err(anyhow::anyhow!( @@ -757,6 +749,17 @@ mod tests { assert_eq!(resolved, "ws://127.0.0.1:8546"); } + #[test] + fn explicit_l2_ws_rpc_url_trims_surrounding_whitespace() { + let resolved = resolve_l2_ws_rpc_url( + "http://127.0.0.1:8545", + Some(" wss://l2.example ".to_string()), + ) + .unwrap(); + + assert_eq!(resolved, "wss://l2.example"); + } + #[test] fn legacy_websocket_l2_rpc_url_is_reused() { let resolved = resolve_l2_ws_rpc_url("wss://l2.example", None).unwrap(); @@ -764,6 +767,13 @@ mod tests { assert_eq!(resolved, "wss://l2.example"); } + #[test] + fn legacy_websocket_l2_rpc_url_trims_surrounding_whitespace() { + let resolved = resolve_l2_ws_rpc_url(" wss://l2.example ", None).unwrap(); + + assert_eq!(resolved, "wss://l2.example"); + } + #[test] fn empty_l2_ws_rpc_url_reuses_legacy_websocket_l2_rpc_url() { let resolved = resolve_l2_ws_rpc_url("wss://l2.example", Some(String::new())).unwrap(); @@ -771,6 +781,14 @@ mod tests { assert_eq!(resolved, "wss://l2.example"); } + #[test] + fn whitespace_only_l2_ws_rpc_url_reuses_legacy_websocket_l2_rpc_url() { + let resolved = + resolve_l2_ws_rpc_url("wss://l2.example", Some(" \t ".to_string())).unwrap(); + + assert_eq!(resolved, "wss://l2.example"); + } + #[test] fn http_l2_rpc_url_requires_explicit_websocket_url() { let error = resolve_l2_ws_rpc_url("https://l2.example", None) @@ -789,6 +807,15 @@ mod tests { assert!(error.contains("L2_RPC_URL must be a valid URL")); } + #[test] + fn empty_l2_rpc_url_has_a_specific_error() { + let error = resolve_l2_ws_rpc_url("", Some("wss://l2.example".to_string())) + .unwrap_err() + .to_string(); + + assert_eq!(error, "L2_RPC_URL must not be empty"); + } + #[test] fn l2_rpc_url_rejects_unsupported_scheme() { let error = resolve_l2_ws_rpc_url("ftp://l2.example", Some("wss://l2.example".to_string())) diff --git a/common/src/shared/alloy_tools.rs b/common/src/shared/alloy_tools.rs index ed6af045..8e6a6c24 100644 --- a/common/src/shared/alloy_tools.rs +++ b/common/src/shared/alloy_tools.rs @@ -75,22 +75,22 @@ fn find_errors_from_trace(trace_str: &str) -> Option { pub async fn construct_alloy_provider( signer: &Signer, - execution_ws_rpc_url: &str, + execution_rpc_url: &str, ) -> Result { match signer { Signer::PrivateKey(private_key, _) => { debug!( "Creating alloy provider with URL: {} and private key signer.", - execution_ws_rpc_url + execution_rpc_url ); let signer = PrivateKeySigner::from_str(private_key.as_str())?; - Ok(create_alloy_provider_with_wallet(signer.into(), execution_ws_rpc_url).await?) + Ok(create_alloy_provider_with_wallet(signer.into(), execution_rpc_url).await?) } Signer::Web3signer(web3signer, address) => { debug!( "Creating alloy provider with URL: {} and web3signer signer.", - execution_ws_rpc_url + execution_rpc_url ); let preconfer_address = *address; @@ -100,26 +100,30 @@ pub async fn construct_alloy_provider( )?; let wallet = EthereumWallet::new(tx_signer); - Ok(create_alloy_provider_with_wallet(wallet, execution_ws_rpc_url).await?) + Ok(create_alloy_provider_with_wallet(wallet, execution_rpc_url).await?) } } } #[derive(Debug)] -enum RpcTransport { +pub(crate) enum RpcTransport { Http(reqwest::Url), - WebSocket, + WebSocket(reqwest::Url), } -fn parse_rpc_transport(url: &str) -> Result { - let parsed = - reqwest::Url::parse(url).map_err(|error| anyhow::anyhow!("Invalid RPC URL: {error}"))?; +pub(crate) fn parse_rpc_transport(source: &str, url: &str) -> Result { + if url.trim().is_empty() { + return Err(anyhow::anyhow!("{source} must not be empty")); + } + + let parsed = reqwest::Url::parse(url) + .map_err(|error| anyhow::anyhow!("{source} must be a valid URL: {error}"))?; match parsed.scheme() { "http" | "https" => Ok(RpcTransport::Http(parsed)), - "ws" | "wss" => Ok(RpcTransport::WebSocket), + "ws" | "wss" => Ok(RpcTransport::WebSocket(parsed)), scheme => Err(anyhow::anyhow!( - "Invalid RPC URL scheme '{scheme}', only websocket and http are supported" + "{source} must use the http, https, ws or wss scheme, got '{scheme}'" )), } } @@ -128,13 +132,13 @@ async fn create_alloy_provider_with_wallet( wallet: EthereumWallet, url: &str, ) -> Result { - match parse_rpc_transport(url)? { + match parse_rpc_transport("RPC URL", url)? { RpcTransport::Http(url) => Ok(ProviderBuilder::new() .wallet(wallet) .connect_http(url) .erased()), - RpcTransport::WebSocket => { - let ws = WsConnect::new(url); + RpcTransport::WebSocket(url) => { + let ws = WsConnect::new(url.as_str()); Ok(ProviderBuilder::new() .wallet(wallet) .connect_ws(ws.clone()) @@ -146,10 +150,10 @@ async fn create_alloy_provider_with_wallet( } pub async fn create_alloy_provider_without_wallet(url: &str) -> Result { - match parse_rpc_transport(url)? { + match parse_rpc_transport("RPC URL", url)? { RpcTransport::Http(url) => Ok(ProviderBuilder::new().connect_http(url).erased()), - RpcTransport::WebSocket => { - let ws = WsConnect::new(url); + RpcTransport::WebSocket(url) => { + let ws = WsConnect::new(url.as_str()); Ok(ProviderBuilder::new() .connect_ws(ws.clone()) .await @@ -161,12 +165,38 @@ pub async fn create_alloy_provider_without_wallet(url: &str) -> Result Date: Tue, 1 Sep 2026 18:10:09 +0800 Subject: [PATCH 4/4] fix: preserve canonical WebSocket URLs --- common/src/config/mod.rs | 60 +++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index f76cf2dc..59b5b8fd 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -131,19 +131,20 @@ fn resolve_l2_ws_rpc_url( let configured_l2_ws_rpc_url = configured_l2_ws_rpc_url.filter(|url| !url.trim().is_empty()); if let Some(url) = configured_l2_ws_rpc_url { - match parse_rpc_transport("L2_WS_RPC_URL", &url)? { - RpcTransport::WebSocket(_) => return Ok(url.trim().to_string()), - RpcTransport::Http(parsed) => { + let parsed = reqwest::Url::parse(&url) + .map_err(|error| anyhow::anyhow!("L2_WS_RPC_URL must be a valid URL: {error}"))?; + match parsed.scheme() { + "ws" | "wss" => return Ok(parsed.to_string()), + scheme => { return Err(anyhow::anyhow!( - "L2_WS_RPC_URL must use the ws or wss scheme, got '{}'", - parsed.scheme() + "L2_WS_RPC_URL must use the ws or wss scheme, got '{scheme}'" )); } } } - if matches!(l2_rpc_transport, RpcTransport::WebSocket(_)) { - return Ok(l2_rpc_url.trim().to_string()); + if let RpcTransport::WebSocket(parsed) = l2_rpc_transport { + return Ok(parsed.to_string()); } Err(anyhow::anyhow!( @@ -746,7 +747,7 @@ mod tests { ) .unwrap(); - assert_eq!(resolved, "ws://127.0.0.1:8546"); + assert_eq!(resolved, "ws://127.0.0.1:8546/"); } #[test] @@ -757,28 +758,46 @@ mod tests { ) .unwrap(); - assert_eq!(resolved, "wss://l2.example"); + assert_eq!(resolved, "wss://l2.example/"); + } + + #[test] + fn explicit_l2_ws_rpc_url_uses_canonical_serialization() { + let resolved = resolve_l2_ws_rpc_url( + "http://127.0.0.1:8545", + Some("wss://l2.example/pa th".to_string()), + ) + .unwrap(); + + assert_eq!(resolved, "wss://l2.example/pa%20th"); } #[test] fn legacy_websocket_l2_rpc_url_is_reused() { let resolved = resolve_l2_ws_rpc_url("wss://l2.example", None).unwrap(); - assert_eq!(resolved, "wss://l2.example"); + assert_eq!(resolved, "wss://l2.example/"); } #[test] fn legacy_websocket_l2_rpc_url_trims_surrounding_whitespace() { let resolved = resolve_l2_ws_rpc_url(" wss://l2.example ", None).unwrap(); - assert_eq!(resolved, "wss://l2.example"); + assert_eq!(resolved, "wss://l2.example/"); + } + + #[test] + fn legacy_websocket_l2_rpc_url_uses_canonical_serialization() { + let resolved = resolve_l2_ws_rpc_url("wss://l2.example/pa th", None).unwrap(); + + assert_eq!(resolved, "wss://l2.example/pa%20th"); } #[test] fn empty_l2_ws_rpc_url_reuses_legacy_websocket_l2_rpc_url() { let resolved = resolve_l2_ws_rpc_url("wss://l2.example", Some(String::new())).unwrap(); - assert_eq!(resolved, "wss://l2.example"); + assert_eq!(resolved, "wss://l2.example/"); } #[test] @@ -786,7 +805,7 @@ mod tests { let resolved = resolve_l2_ws_rpc_url("wss://l2.example", Some(" \t ".to_string())).unwrap(); - assert_eq!(resolved, "wss://l2.example"); + assert_eq!(resolved, "wss://l2.example/"); } #[test] @@ -837,6 +856,21 @@ mod tests { assert!(error.contains("L2_WS_RPC_URL must use the ws or wss scheme")); } + #[test] + fn explicit_l2_ws_rpc_url_rejects_unsupported_scheme_as_websocket_only() { + let error = resolve_l2_ws_rpc_url( + "http://127.0.0.1:8545", + Some("ftp://l2.example".to_string()), + ) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + "L2_WS_RPC_URL must use the ws or wss scheme, got 'ftp'" + ); + } + #[test] fn explicit_l2_ws_rpc_url_rejects_malformed_url() { let error =