Summary
alloy_pubsub silently drops every notification when the server returns a subscription ID shorter than 32 bytes. The subscription itself is reported as successful, the WebSocket stream stays open, but subscribe_blocks().into_stream() never yields a single header.
This affects spec-compliant Ethereum nodes — the JSON-RPC subscription spec (eth eth_subscribe) does not constrain the subscription ID to a fixed width. Some providers (notably wss://ethereum-rpc.publicnode.com) return a 16-byte hex ID; alloy's subscribe_blocks() deserialises the response into FixedBytes<32>, which causes a key mismatch in the pubsub frontend's subscription registry.
Reproduction
// Cargo.toml
// alloy = { version = "2", features = ["full"] }
// tokio = { version = "1", features = ["full"] }
// futures = "0.3"
use alloy::providers::{Provider, ProviderBuilder};
use alloy::transports::ws::WsConnect;
use futures::StreamExt;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = "wss://ethereum-rpc.publicnode.com";
let provider = ProviderBuilder::new()
.connect_ws(WsConnect::new(url))
.await?;
let sub = provider.subscribe_blocks().await?;
let mut stream = sub.into_stream();
println!("subscribed; waiting up to 60s for a header…");
match tokio::time::timeout(Duration::from_secs(60), stream.next()).await {
Ok(Some(header)) => println!("OK — got header {}", header.number),
Ok(None) => eprintln!("FAIL — stream closed without yielding"),
Err(_) => eprintln!("FAIL — no header received in 60s"),
}
Ok(())
}
Run with:
RUST_LOG=alloy=trace cargo run
websocat against the same endpoint receives eth_subscription notifications every ~12 s, confirming the endpoint is healthy and pushing data. Alchemy and Infura (which return 32-byte hex IDs) work correctly with the same code.
Expected behaviour
stream.next() yields a new header per block (~12 s on Ethereum mainnet).
Actual behaviour
stream.next() blocks forever; no headers are emitted.
Trace evidence
Subscription ID gets corrupted between the WS layer and the pubsub frontend:
| Layer |
Subscription ID seen |
alloy_transport_ws deserialised message |
0x7413bf1aeb8f1c0087c36b4243f7a41a (16 bytes — server response) |
alloy_pubsub::frontend retrieved response |
0x77ee11bf27e54080a8ae7be2e6b2fa08f48f6f45c9660376b0b7e2e3cc41677a (32 bytes — derived) |
alloy_pubsub::service registers |
GetSub(0x77ee...) |
| Incoming notifications carry |
subscription: Number(154292977224430911757972734000217170970) (= original 16-byte ID as u128) |
The deserialize_response span shows the type alloy is forcing the response into:
"ty":"alloy_primitives::bits::fixed::FixedBytes<32>"
Lookup key (32-byte derived value) ≠ notification key (original 16-byte ID) → notifications are silently dropped.
Environment
- alloy:
2.0.4 (workspace dep alloy = { version = "2", features = ["full"] })
- rustc:
1.95.0 (59807616e 2026-04-14)
- OS: macOS 14 (arm64, Apple Silicon)
- Endpoint:
wss://ethereum-rpc.publicnode.com
Possible direction
The subscription ID type in alloy_pubsub could be loosened from FixedBytes<32> (i.e. B256) to a SubscriptionId enum that accepts variable-length hex strings or numeric IDs — matching EthNotification::subscription which is already typed as a flexible Number / String variant.
Happy to PR if there's a preferred direction.
Summary
alloy_pubsubsilently drops every notification when the server returns a subscription ID shorter than 32 bytes. The subscription itself is reported as successful, the WebSocket stream stays open, butsubscribe_blocks().into_stream()never yields a single header.This affects spec-compliant Ethereum nodes — the JSON-RPC subscription spec (eth
eth_subscribe) does not constrain the subscription ID to a fixed width. Some providers (notablywss://ethereum-rpc.publicnode.com) return a 16-byte hex ID; alloy'ssubscribe_blocks()deserialises the response intoFixedBytes<32>, which causes a key mismatch in the pubsub frontend's subscription registry.Reproduction
Run with:
websocatagainst the same endpoint receiveseth_subscriptionnotifications every ~12 s, confirming the endpoint is healthy and pushing data. Alchemy and Infura (which return 32-byte hex IDs) work correctly with the same code.Expected behaviour
stream.next()yields a new header per block (~12 s on Ethereum mainnet).Actual behaviour
stream.next()blocks forever; no headers are emitted.Trace evidence
Subscription ID gets corrupted between the WS layer and the pubsub frontend:
alloy_transport_wsdeserialised message0x7413bf1aeb8f1c0087c36b4243f7a41a(16 bytes — server response)alloy_pubsub::frontendretrieved response0x77ee11bf27e54080a8ae7be2e6b2fa08f48f6f45c9660376b0b7e2e3cc41677a(32 bytes — derived)alloy_pubsub::serviceregistersGetSub(0x77ee...)subscription: Number(154292977224430911757972734000217170970)(= original 16-byte ID asu128)The
deserialize_responsespan shows the type alloy is forcing the response into:Lookup key (32-byte derived value) ≠ notification key (original 16-byte ID) → notifications are silently dropped.
Environment
2.0.4(workspace depalloy = { version = "2", features = ["full"] })1.95.0 (59807616e 2026-04-14)wss://ethereum-rpc.publicnode.comPossible direction
The subscription ID type in
alloy_pubsubcould be loosened fromFixedBytes<32>(i.e.B256) to aSubscriptionIdenum that accepts variable-length hex strings or numeric IDs — matchingEthNotification::subscriptionwhich is already typed as a flexibleNumber/Stringvariant.Happy to PR if there's a preferred direction.