use std::ops::RangeInclusive;
use std::{fmt, num::NonZeroUsize, time::Duration};
use alloy::{
eips::BlockNumberOrTag,
primitives::{Address, FixedBytes},
providers::{DynProvider, Provider},
rpc::types::{Filter, FilterSet, Log, Topic},
};
use futures::{
Stream,
stream::{self, StreamExt, TryStreamExt},
};
use serde::Deserialize;
use tokio::sync::broadcast::error::RecvError;
use crate::web3;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChainCursor {
block: u64,
index: u64,
}
impl fmt::Display for ChainCursor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_genesis() {
f.write_str("Cursor(genesis)")
} else {
f.write_fmt(format_args!(
"Cursor(block={}, idx={})",
self.block, self.index
))
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EventStreamError {
#[error("eth_subscribe stream lagging behind - maybe backfill took too long")]
Lagging,
#[error("Cannot fetch newHead")]
CannotFetchHead,
#[error("Synchronizing between HTTP and WS ran into timeout")]
SynchronizingHttpWsTimeout,
#[error("missing block number on log")]
BlockNumberMissing,
#[error("missing index number on log")]
IndexNumberMissing,
#[error(transparent)]
TransportError(#[from] alloy::transports::TransportError),
}
impl ChainCursor {
#[must_use]
pub fn new(block: u64, index: u64) -> Self {
Self { block, index }
}
#[inline]
#[must_use]
pub fn block(&self) -> u64 {
self.block
}
#[inline]
#[must_use]
pub fn index(&self) -> u64 {
self.index
}
#[inline]
#[must_use]
pub fn is_genesis(&self) -> bool {
self.block == 0 && self.index == 0
}
#[inline]
#[must_use]
pub fn is_before(self, other: Self) -> bool {
self < other
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[allow(
clippy::exhaustive_enums,
reason = "Enum is either yes or no - not planned to extend this"
)]
#[serde(rename_all = "lowercase")]
pub enum SkipBackfill {
Yes,
#[default]
No,
}
impl From<bool> for SkipBackfill {
fn from(value: bool) -> Self {
if value {
SkipBackfill::Yes
} else {
SkipBackfill::No
}
}
}
pub struct EventStreamBuilder<T> {
chain_cursor: ChainCursor,
contract_address: Address,
http_provider: web3::HttpRpcProvider,
ws_provider: DynProvider,
topic: T,
config: EventStreamConfig,
}
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize)]
pub struct EventStreamConfig {
#[serde(default)]
pub skip_backfill: SkipBackfill,
#[serde(default = "EventStreamConfig::default_channel_size")]
pub channel_size: NonZeroUsize,
#[serde(default = "EventStreamConfig::default_chunk_size")]
pub chunk_size: NonZeroUsize,
#[serde(default = "EventStreamConfig::default_new_head_timeout")]
#[serde(with = "humantime_serde")]
pub new_head_timeout: Duration,
#[serde(default = "EventStreamConfig::default_sync_timeout")]
#[serde(with = "humantime_serde")]
pub sync_timeout: Duration,
#[serde(default = "EventStreamConfig::default_sync_poll_interval")]
#[serde(with = "humantime_serde")]
pub sync_poll_interval: Duration,
#[serde(default = "EventStreamConfig::default_confirmations_after_sync_block")]
pub confirmations_after_sync_block: NonZeroUsize,
}
impl EventStreamConfig {
#[must_use]
pub fn with_default_values() -> Self {
Self::default()
}
fn default_channel_size() -> NonZeroUsize {
NonZeroUsize::new(1024).expect("1024 is non-zero")
}
fn default_chunk_size() -> NonZeroUsize {
NonZeroUsize::new(1024).expect("1024 is non-zero")
}
fn default_new_head_timeout() -> Duration {
Duration::from_secs(60)
}
fn default_sync_timeout() -> Duration {
Duration::from_secs(20)
}
fn default_sync_poll_interval() -> Duration {
Duration::from_secs(2)
}
fn default_confirmations_after_sync_block() -> NonZeroUsize {
NonZeroUsize::new(5).expect("5 is non-zero")
}
}
impl Default for EventStreamConfig {
fn default() -> Self {
Self {
skip_backfill: SkipBackfill::default(),
channel_size: Self::default_channel_size(),
chunk_size: Self::default_chunk_size(),
new_head_timeout: Self::default_new_head_timeout(),
sync_timeout: Self::default_sync_timeout(),
sync_poll_interval: Self::default_sync_poll_interval(),
confirmations_after_sync_block: Self::default_confirmations_after_sync_block(),
}
}
}
impl<T> EventStreamBuilder<T>
where
T: Into<Topic>,
{
#[must_use]
pub fn new(
chain_cursor: ChainCursor,
contract_address: Address,
http_provider: web3::HttpRpcProvider,
ws_provider: DynProvider,
topic: T,
) -> Self {
Self::with_config(
chain_cursor,
contract_address,
http_provider,
ws_provider,
topic,
EventStreamConfig::default(),
)
}
#[must_use]
pub fn with_config(
chain_cursor: ChainCursor,
contract_address: Address,
http_provider: web3::HttpRpcProvider,
ws_provider: DynProvider,
topic: T,
config: EventStreamConfig,
) -> Self {
Self {
chain_cursor,
contract_address,
http_provider,
ws_provider,
topic,
config,
}
}
#[must_use]
pub fn skip_backfill(mut self, skip_backfill: SkipBackfill) -> Self {
self.config.skip_backfill = skip_backfill;
self
}
#[must_use]
pub fn new_head_timeout(mut self, new_head_timeout: Duration) -> Self {
self.config.new_head_timeout = new_head_timeout;
self
}
#[must_use]
pub fn sync_timeout(mut self, sync_timeout: Duration) -> Self {
self.config.sync_timeout = sync_timeout;
self
}
#[must_use]
pub fn sync_poll_interval(mut self, sync_poll_interval: Duration) -> Self {
self.config.sync_poll_interval = sync_poll_interval;
self
}
#[must_use]
pub fn channel_size(mut self, channel_size: NonZeroUsize) -> Self {
self.config.channel_size = channel_size;
self
}
#[must_use]
pub fn chunk_size(mut self, chunk_size: NonZeroUsize) -> Self {
self.config.chunk_size = chunk_size;
self
}
#[must_use]
pub fn confirmations_after_sync_block(
mut self,
confirmations_after_sync_block: NonZeroUsize,
) -> Self {
self.config.confirmations_after_sync_block = confirmations_after_sync_block;
self
}
pub async fn build(
self,
) -> Result<impl Stream<Item = Result<Log, EventStreamError>>, EventStreamError> {
let Self {
chain_cursor,
contract_address,
http_provider,
ws_provider,
topic,
config,
} = self;
let EventStreamConfig {
skip_backfill,
channel_size,
chunk_size,
new_head_timeout,
sync_timeout,
sync_poll_interval,
confirmations_after_sync_block,
} = config;
let chain_id = http_provider.get_chain_id().await?;
let topic = topic.into();
let subscription = ws_provider
.subscribe_logs(
&Filter::new()
.address(contract_address)
.from_block(BlockNumberOrTag::Latest)
.event_signature(topic.clone()),
)
.channel_size(channel_size.get())
.await?;
let ws_stream = stream::unfold(subscription, move |mut rx| async move {
match rx.recv().await {
Ok(x) => Some((Ok(x), rx)),
Err(RecvError::Lagged(_)) => Some((Err(EventStreamError::Lagging), rx)),
Err(RecvError::Closed) => None,
}
});
if chain_cursor.is_genesis() && chain_id != 31_337 {
tracing::debug!("chain event cursor is genesis - starting at current block");
return Ok(ws_stream.boxed());
} else if skip_backfill == SkipBackfill::Yes {
tracing::debug!("skipping backfill as requested");
return Ok(ws_stream.boxed());
}
let block_subscription = ws_provider.subscribe_blocks().await?;
let block_sub_id = *block_subscription.local_id();
let backfill_cutoff = tokio::time::timeout(new_head_timeout, async {
block_subscription
.into_stream()
.take(confirmations_after_sync_block.get() + 1)
.map(|h| h.number)
.collect::<Vec<_>>()
.await
.first()
.copied()
.ok_or(EventStreamError::CannotFetchHead)
})
.await
.map_err(|_| EventStreamError::CannotFetchHead);
ws_provider.unsubscribe(block_sub_id).await?;
let backfill_cutoff = backfill_cutoff??;
tracing::debug!("backfill cutoff at block: {backfill_cutoff}");
tokio::time::timeout(
sync_timeout,
block_until_cutoff(&http_provider, backfill_cutoff, sync_poll_interval),
)
.await
.map_err(|_| EventStreamError::SynchronizingHttpWsTimeout)??;
let backfill_stream = stream::iter(block_ranges(
chain_cursor.block,
backfill_cutoff,
chunk_size,
))
.then(move |range| {
fetch_logs(
contract_address,
range,
http_provider.clone(),
topic.clone(),
chain_cursor,
)
})
.map_ok(|logs| stream::iter(logs.into_iter().map(Ok)))
.try_flatten();
let ws_stream = ws_stream.filter_map(move |log| async move {
match log {
Ok(x) => match x.block_number {
None => Some(Err(EventStreamError::BlockNumberMissing)),
Some(n) if n <= backfill_cutoff => {
tracing::debug!("skipping event at block {n} - already backfilled");
None
}
Some(_) => Some(Ok(x)),
},
Err(e) => Some(Err(e)),
}
});
Ok(backfill_stream.chain(ws_stream).boxed())
}
}
async fn block_until_cutoff(
http_provider: &web3::HttpRpcProvider,
cutoff: u64,
poll_interval: Duration,
) -> Result<(), EventStreamError> {
loop {
let block_number = http_provider.inner().get_block_number().await?;
if block_number >= cutoff {
break Ok(());
}
tokio::time::sleep(poll_interval).await;
}
}
fn block_ranges(
start: u64,
end: u64,
chunk_size: NonZeroUsize,
) -> impl Iterator<Item = RangeInclusive<u64>> {
let chunk_size_u64 = u64::try_from(chunk_size.get()).expect("usize should fit into u64");
(start..=end)
.step_by(chunk_size.get())
.map(move |from| from..=from.saturating_add(chunk_size_u64 - 1).min(end))
}
async fn fetch_logs(
contract_address: Address,
range: RangeInclusive<u64>,
http_provider: web3::HttpRpcProvider,
event_signature: FilterSet<FixedBytes<32>>,
chain_cursor: ChainCursor,
) -> Result<Vec<Log>, EventStreamError> {
tracing::trace!("fetching logs!");
let filter = Filter::new()
.address(contract_address)
.from_block(BlockNumberOrTag::Number(*range.start()))
.to_block(BlockNumberOrTag::Number(*range.end()))
.event_signature(event_signature);
tracing::trace!("get logs for range: {range:?}");
let logs = http_provider.get_logs(&filter).await?;
tracing::trace!("got {} logs", logs.len());
logs.into_iter()
.filter_map(|log| match filter_block_index(&log, chain_cursor) {
Ok(true) => {
let block_number = log
.block_number
.expect("Must be there after filter_block_index");
tracing::debug!("received backfill event: {block_number}",);
Some(Ok(log))
}
Ok(false) => None,
Err(err) => Some(Err(err)),
})
.collect()
}
#[inline]
fn filter_block_index(log: &Log, chain_cursor: ChainCursor) -> Result<bool, EventStreamError> {
let block_number_log = log
.block_number
.ok_or_else(|| EventStreamError::BlockNumberMissing)?;
let idx_log = log
.log_index
.ok_or_else(|| EventStreamError::IndexNumberMissing)?;
Ok(chain_cursor.is_before(ChainCursor::new(block_number_log, idx_log)))
}
#[cfg(test)]
mod tests {
use crate::{Environment, web3::HttpRpcProviderBuilder};
use super::*;
use std::time::Duration;
use alloy::{
network::EthereumWallet,
node_bindings::Anvil,
primitives::U256,
providers::{Provider, ProviderBuilder, WsConnect, ext::AnvilApi},
signers::local::PrivateKeySigner,
sol_types::SolEvent as _,
};
alloy::sol! {
#[sol(rpc, bytecode = "6080604052348015600e575f5ffd5b5060b480601a5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c80634d43bec914602a575b5f5ffd5b603960353660046068565b603b565b005b60405181907f1440c4dd67b4344ea1905ec0318995133b550f168b4ee959a0da6b503d7d2414905f90a250565b5f602082840312156077575f5ffd5b503591905056fea2646970667358221220728c746521e437c8e3d44198c6a5d227ed87df09e46ebb8fcb494b485f362a6364736f6c634300081e0033")]
contract TestEmitter {
event TestEvent(uint256 indexed value);
function emitEvent(uint256 value) external;
}
}
const TIMEOUT: Duration = Duration::from_secs(5);
struct TestHarness {
_anvil: alloy::node_bindings::AnvilInstance,
http_provider: web3::HttpRpcProvider,
ws_provider: DynProvider,
contract_address: Address,
}
impl TestHarness {
async fn new() -> eyre::Result<Self> {
Self::with_chain_id(31_337).await
}
async fn with_chain_id(chain_id: u64) -> eyre::Result<Self> {
let anvil = Anvil::new().chain_id(chain_id).spawn();
let signer: PrivateKeySigner = anvil.keys()[0].clone().into();
let http_provider =
HttpRpcProviderBuilder::with_default_values([anvil.endpoint_url()])?
.environment(Environment::Dev)
.wallet(EthereumWallet::from(signer))
.chain_id(chain_id)
.build()?;
let ws_provider = ProviderBuilder::new()
.connect_ws(WsConnect::new(anvil.ws_endpoint()))
.await?
.erased();
let contract = TestEmitter::deploy(http_provider.inner()).await?;
ws_provider.anvil_set_interval_mining(2).await?;
Ok(Self {
_anvil: anvil,
http_provider,
ws_provider,
contract_address: *contract.address(),
})
}
fn contract(&self) -> TestEmitter::TestEmitterInstance<DynProvider> {
TestEmitter::new(self.contract_address, self.http_provider.inner())
}
async fn emit_event(&self, value: u64) -> eyre::Result<u64> {
let receipt = self
.contract()
.emitEvent(U256::from(value))
.send()
.await?
.get_receipt()
.await?;
receipt
.block_number
.ok_or_else(|| eyre::eyre!("missing block number on receipt"))
}
async fn emit_events_in_one_block(&self, values: &[u64]) -> eyre::Result<u64> {
self.ws_provider.anvil_set_interval_mining(0).await?;
let mut pending = Vec::new();
for &v in values {
let tx = self.contract().emitEvent(U256::from(v)).send().await?;
pending.push(tx);
}
self.ws_provider.anvil_mine(Some(1), None).await?;
self.ws_provider.anvil_set_interval_mining(2).await?;
let receipt = pending
.into_iter()
.last()
.expect("At least one receipt there")
.get_receipt()
.await?;
receipt
.block_number
.ok_or_else(|| eyre::eyre!("missing block number on receipt"))
}
async fn emit_events_in_blocks(
&self,
values: &[u64],
events_per_block: usize,
) -> eyre::Result<u64> {
eyre::ensure!(
events_per_block > 0,
"events_per_block must be greater than zero"
);
let mut blocks = values.chunks(events_per_block);
let first_block = self
.emit_events_in_one_block(
blocks
.next()
.ok_or_else(|| eyre::eyre!("must emit at least one event"))?,
)
.await?;
for block in blocks {
self.emit_events_in_one_block(block).await?;
}
Ok(first_block)
}
fn builder(&self, cursor: ChainCursor) -> EventStreamBuilder<Vec<FixedBytes<32>>> {
EventStreamBuilder::new(
cursor,
self.contract_address,
self.http_provider.clone(),
self.ws_provider.clone(),
vec![TestEmitter::TestEvent::SIGNATURE_HASH],
)
}
}
async fn next_log(
stream: &mut (impl Stream<Item = Result<Log, EventStreamError>> + Unpin),
) -> Log {
tokio::time::timeout(TIMEOUT, stream.next())
.await
.expect("timed out waiting for log")
.expect("stream ended unexpectedly")
.expect("stream yielded an error")
}
async fn next_log_and_decode(
stream: &mut (impl Stream<Item = Result<Log, EventStreamError>> + Unpin),
) -> U256 {
decode_log(&next_log(stream).await)
}
fn decode_log(log: &Log) -> U256 {
log.log_decode::<TestEmitter::TestEvent>()
.expect("Should be able to decode TestEvent")
.inner
.data
.value
}
#[tokio::test]
async fn test_receives_live_events() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let mut stream = h.builder(ChainCursor::default()).build().await?;
h.emit_event(42).await?;
let log = next_log(&mut stream).await;
assert_eq!(log.topic0(), Some(&TestEmitter::TestEvent::SIGNATURE_HASH));
Ok(())
}
#[tokio::test]
async fn test_backfills_historical_events() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let block = h.emit_events_in_one_block(&[1, 2, 3]).await?;
let cursor = ChainCursor {
block: block.saturating_sub(1),
index: 0,
};
let mut stream = h.builder(cursor).build().await?;
let log1 = next_log_and_decode(&mut stream).await;
let log2 = next_log_and_decode(&mut stream).await;
let log3 = next_log_and_decode(&mut stream).await;
assert_eq!(log1, U256::from(1));
assert_eq!(log2, U256::from(2));
assert_eq!(log3, U256::from(3));
Ok(())
}
#[tokio::test]
async fn test_backfills_historical_events_batch_size_one() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let expected = (1..=100).collect::<Vec<_>>();
let block = h.emit_events_in_blocks(&expected, 5).await?;
let cursor = ChainCursor {
block: block.saturating_sub(1),
index: 0,
};
let mut stream = h
.builder(cursor)
.chunk_size(NonZeroUsize::try_from(1).expect("1 is non-zero"))
.build()
.await?;
let backfilled = tokio::time::timeout(
TIMEOUT,
stream
.by_ref()
.take(expected.len())
.map_ok(|log| decode_log(&log).to::<u64>())
.try_collect::<Vec<_>>(),
)
.await
.expect("timed out waiting for backfilled logs")?;
assert_eq!(backfilled, expected);
Ok(())
}
#[tokio::test]
async fn test_backfills_historical_events_index_cursor() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let block0 = h.emit_events_in_one_block(&[1, 2, 3]).await?;
h.emit_events_in_one_block(&[4, 5]).await?;
let cursor = ChainCursor {
block: block0,
index: 1,
};
let mut stream = h.builder(cursor).build().await?;
let log3 = next_log_and_decode(&mut stream).await;
let log4 = next_log_and_decode(&mut stream).await;
let log5 = next_log_and_decode(&mut stream).await;
assert_eq!(log3, U256::from(3));
assert_eq!(log4, U256::from(4));
assert_eq!(log5, U256::from(5));
Ok(())
}
#[tokio::test]
async fn test_return_lagging() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let cursor = ChainCursor::new(0, 1);
let mut stream = h
.builder(cursor)
.channel_size(NonZeroUsize::try_from(1).expect("1 is non-zero"))
.build()
.await?;
h.emit_events_in_one_block(&[1, 2, 3]).await?;
let error = tokio::time::timeout(
TIMEOUT,
stream
.by_ref()
.take(3)
.map_ok(|log| decode_log(&log).to::<u64>())
.try_collect::<Vec<_>>(),
)
.await
.expect("timed out waiting for logs")
.expect_err("should be lagging behind");
assert!(matches!(error, EventStreamError::Lagging));
Ok(())
}
#[tokio::test]
async fn test_skip_backfill_ignores_history() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let block = h.emit_event(1).await?;
h.emit_event(2).await?;
let cursor = ChainCursor {
block: block.saturating_sub(1),
index: 0,
};
let mut stream = h
.builder(cursor)
.skip_backfill(SkipBackfill::Yes)
.build()
.await?;
h.emit_event(99).await?;
let log = next_log_and_decode(&mut stream).await;
assert_eq!(log, U256::from(99));
Ok(())
}
#[tokio::test]
async fn test_backfill_then_live() -> eyre::Result<()> {
let h = TestHarness::new().await?;
let block = h.emit_events_in_one_block(&[1, 2]).await?;
let cursor = ChainCursor {
block: block.saturating_sub(1),
index: 0,
};
let mut stream = h.builder(cursor).build().await?;
h.emit_event(3).await?;
let backfilled0 = next_log_and_decode(&mut stream).await;
let backfilled1 = next_log_and_decode(&mut stream).await;
let live = next_log_and_decode(&mut stream).await;
assert_eq!(backfilled0, U256::from(1));
assert_eq!(backfilled1, U256::from(2));
assert_eq!(live, U256::from(3));
Ok(())
}
#[test]
fn test_block_ranges_chunk_size_one() {
let ranges: Vec<_> =
block_ranges(0, 3, NonZeroUsize::new(1).expect("1 is non-zero")).collect();
assert_eq!(ranges, vec![0..=0, 1..=1, 2..=2, 3..=3]);
}
#[test]
fn test_block_ranges_chunk_larger_than_range() {
let ranges: Vec<_> =
block_ranges(5, 8, NonZeroUsize::new(100).expect("100 is non-zero")).collect();
assert_eq!(ranges, vec![5..=8]);
}
#[test]
fn test_block_ranges_exact_multiple() {
let ranges: Vec<_> =
block_ranges(0, 3, NonZeroUsize::new(2).expect("2 is non-zero")).collect();
assert_eq!(ranges, vec![0..=1, 2..=3]);
}
#[test]
fn test_block_ranges_start_greater_than_end() {
let ranges: Vec<_> =
block_ranges(10, 5, NonZeroUsize::new(3).expect("3 is non-zero")).collect();
assert!(ranges.is_empty());
}
#[tokio::test]
async fn test_genesis_cursor_starts_from_now() -> eyre::Result<()> {
let h = TestHarness::with_chain_id(1).await?;
h.emit_event(1).await?;
h.emit_event(2).await?;
let mut stream = h.builder(ChainCursor::default()).build().await?;
h.emit_event(99).await?;
let decoded = next_log_and_decode(&mut stream).await;
assert_eq!(decoded, U256::from(99));
Ok(())
}
#[test]
fn test_block_ranges_single_block() {
let ranges: Vec<_> =
block_ranges(5, 5, NonZeroUsize::new(10).expect("10 is non-zero")).collect();
assert_eq!(ranges, vec![5..=5]);
}
#[tokio::test]
async fn test_new_head_timeout_returns_error() -> eyre::Result<()> {
let h = TestHarness::new().await?;
h.ws_provider.anvil_set_auto_mine(false).await?;
h.ws_provider.anvil_set_interval_mining(0).await?;
let cursor = ChainCursor::new(1, 0);
let result = h
.builder(cursor)
.new_head_timeout(Duration::from_millis(100))
.build()
.await;
assert!(matches!(result, Err(EventStreamError::CannotFetchHead)));
Ok(())
}
#[tokio::test]
async fn test_sync_timeout_returns_error() -> eyre::Result<()> {
let anvil_ws = Anvil::new().spawn();
let ws_provider = ProviderBuilder::new()
.connect_ws(WsConnect::new(anvil_ws.ws_endpoint()))
.await?
.erased();
ws_provider.anvil_set_interval_mining(2).await?;
let anvil_http = Anvil::new().spawn();
let http_freeze = ProviderBuilder::new()
.connect_http(anvil_http.endpoint_url())
.erased();
http_freeze.anvil_set_auto_mine(false).await?;
http_freeze.anvil_set_interval_mining(0).await?;
let signer: PrivateKeySigner = anvil_http.keys()[0].clone().into();
let http_provider =
HttpRpcProviderBuilder::with_default_values([anvil_http.endpoint_url()])?
.environment(Environment::Dev)
.wallet(EthereumWallet::from(signer))
.chain_id(31_337)
.build()?;
ws_provider.anvil_mine(Some(1), None).await?;
let cursor = ChainCursor::new(1, 0);
let result = EventStreamBuilder::new(
cursor,
Address::ZERO,
http_provider,
ws_provider,
vec![TestEmitter::TestEvent::SIGNATURE_HASH],
)
.sync_timeout(Duration::from_millis(200))
.sync_poll_interval(Duration::from_millis(50))
.build()
.await;
assert!(matches!(
result,
Err(EventStreamError::SynchronizingHttpWsTimeout)
));
Ok(())
}
}