tycho_simulation/evm/stream.rs
1//! Builder for configuring a multi-protocol stream.
2//!
3//! Provides a builder for creating a multi-protocol stream that produces
4//! protocol state update messages. It runs one synchronization worker per protocol
5//! and a supervisor that aggregates updates, ensuring gap‑free streaming
6//! and robust state tracking.
7//!
8//! ## Context
9//!
10//! This stream wraps a `TychoStream` from `tycho-client`. It decodes `FeedMessage`s
11//! into protocol state updates. Internally, each protocol runs in its own
12//! synchronization worker, and a supervisor aggregates their messages per block.
13//!
14//! ### Protocol Synchronization Worker
15//! A synchronization worker runs the snapshot + delta protocol from `tycho-indexer`.
16//! - It first downloads components and their snapshots.
17//! - It then streams deltas.
18//! - It reacts to new or paused components by pulling snapshots or removing them from the active
19//! set.
20//!
21//! Each worker emits snapshots and deltas to the supervisor.
22//!
23//! ### Stream Supervisor
24//! The supervisor aggregates worker messages by block and assigns sync status.
25//! - It ensures workers produce gap-free messages.
26//! - It flags late workers as `Delayed`, and marks them `Stale` if they exceed `max_missed_blocks`.
27//! - It marks workers with terminal errors as `Ended`.
28//!
29//! Aggregating by block adds small latency, since the supervisor waits briefly for
30//! all workers to emit. This latency only applies to workers in `Ready` or `Delayed`.
31//!
32//! The stream ends only when **all** workers are `Stale` or `Ended`.
33//!
34//! ## Configuration
35//!
36//! The builder lets you customize:
37//!
38//! ### Protocols
39//! Select which protocols to synchronize.
40//!
41//! ### Tokens & Minimum Token Quality
42//! Provide token metadata up front so the decoder can initialize protocol states from startup
43//! snapshots. `set_tokens` does not act as an ongoing filter — components arriving after startup
44//! include their own token metadata. To restrict processing to specific tokens, apply that filter
45//! in your consumer when reading `new_components`. New tokens arriving via stream deltas are added
46//! automatically when their quality exceeds `min_token_quality`.
47//!
48//! ### StreamEndPolicy
49//! Control when the stream ends based on worker states. By default, it ends when all
50//! workers are `Stale` or `Ended`.
51//!
52//! ## Stream
53//! The stream emits one protocol state update every `block_time`. Each update
54//! reports protocol synchronization states and any changes.
55//!
56//! The `new_components` field lists newly deployed components and their tokens.
57//!
58//! The stream aims to run indefinitely. Internal retry and reconnect logic handle
59//! most errors, so users should rarely need to restart it manually.
60//!
61//! ## Example
62//! ```no_run
63//! use tycho_common::models::Chain;
64//! use tycho_simulation::evm::stream::ProtocolStreamBuilder;
65//! use tycho_simulation::utils::load_all_tokens;
66//! use futures::StreamExt;
67//! use tycho_client::feed::component_tracker::ComponentFilter;
68//! use tycho_simulation::evm::protocol::uniswap_v2::state::UniswapV2State;
69//!
70//! #[tokio::main]
71//! async fn main() {
72//! let all_tokens = load_all_tokens(
73//! "tycho-beta.propellerheads.xyz",
74//! false,
75//! Some("sampletoken"),
76//! true,
77//! Chain::Ethereum,
78//! None,
79//! None,
80//! )
81//! .await
82//! .expect("Failed loading tokens");
83//!
84//! let protocol_stream =
85//! ProtocolStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
86//! .auth_key(Some("sampletoken".to_string()))
87//! .skip_state_decode_failures(true)
88//! .exchange::<UniswapV2State>(
89//! "uniswap_v2", ComponentFilter::with_tvl_range(5.0, 10.0), None
90//! )
91//! .set_tokens(all_tokens)
92//! .await
93//! .build()
94//! .await
95//! .expect("Failed building protocol stream");
96//! tokio::pin!(protocol_stream);
97//!
98//! // Loop through block updates
99//! while let Some(msg) = protocol_stream.next().await {
100//! dbg!(msg).expect("failed decoding");
101//! }
102//! }
103//! ```
104use std::{
105 collections::{HashMap, HashSet},
106 sync::Arc,
107 time,
108};
109
110use futures::{future::Either, stream, Stream, StreamExt};
111use tokio_stream::wrappers::ReceiverStream;
112use tracing::{debug, error, warn};
113use tycho_client::{
114 feed::{
115 component_tracker::ComponentFilter, synchronizer::ComponentWithState, BlockHeader,
116 BlockSynchronizerError, FeedMessage, SynchronizerState,
117 },
118 stream::{RetryConfiguration, StreamError, TychoStreamBuilder},
119};
120use tycho_common::{
121 models::{token::Token, Chain},
122 simulation::protocol_sim::ProtocolSim,
123 traits::TxDeltaIndexer,
124 Bytes,
125};
126
127use crate::{
128 evm::{
129 decoder::{StreamDecodeError, TychoStreamDecoder},
130 override_stream::{self, StateOverrideProvider},
131 pending::PendingBlockProcessor,
132 protocol::{
133 filters::uniswap_v4_non_angstrom_hook_pool_filter,
134 native_wrapper::state::NativeWrapperState,
135 uniswap_v4::hooks::hook_handler_creator::initialize_hook_handlers,
136 },
137 },
138 protocol::{
139 errors::InvalidSnapshotError,
140 models::{DecoderContext, TryFromWithBlock, Update},
141 },
142 utils::default_blocklist,
143};
144
145const EXCHANGES_REQUIRING_FILTER: [&str; 4] = ["vm:balancer_v2", "fluid_v1", "erc4626", "ekubo_v3"];
146
147/// The client-side filter applied to exchange `name` when the caller provides none.
148///
149/// `uniswap_v4_hooks`: without `ANGSTROM_API_KEY`, Angstrom swaps cannot be encoded (encoding
150/// fetches per-block attestations from the Angstrom API), so Angstrom pools are excluded up
151/// front rather than failing every route that selects them at encoding time.
152fn default_filter_fn(name: &str) -> Option<fn(&ComponentWithState) -> bool> {
153 if name == "uniswap_v4_hooks" && std::env::var("ANGSTROM_API_KEY").is_err() {
154 warn!(
155 "ANGSTROM_API_KEY is not set: excluding Angstrom pools from '{name}'. \
156 Set the key to include them."
157 );
158 return Some(uniswap_v4_non_angstrom_hook_pool_filter);
159 }
160 None
161}
162
163#[derive(Default, Debug, Clone, Copy)]
164pub enum StreamEndPolicy {
165 /// End stream if all states are Stale or Ended (default)
166 #[default]
167 AllEndedOrStale,
168 /// End stream if any protocol ended
169 AnyEnded,
170 /// End stream if any protocol ended or is stale
171 AnyEndedOrStale,
172 /// End stream if any protocol is stale
173 AnyStale,
174}
175
176impl StreamEndPolicy {
177 fn should_end<'a>(&self, states: impl IntoIterator<Item = &'a SynchronizerState>) -> bool {
178 let mut it = states.into_iter();
179 match self {
180 StreamEndPolicy::AllEndedOrStale => false,
181 StreamEndPolicy::AnyEnded => it.any(|s| matches!(s, SynchronizerState::Ended(_))),
182 StreamEndPolicy::AnyStale => it.any(|s| matches!(s, SynchronizerState::Stale(_))),
183 StreamEndPolicy::AnyEndedOrStale => {
184 it.any(|s| matches!(s, SynchronizerState::Stale(_) | SynchronizerState::Ended(_)))
185 }
186 }
187 }
188}
189
190/// Handle returned by [`ProtocolStreamBuilder::with_step_controller`] that gives external
191/// control over when each buffered block is released for decoding.
192///
193/// Intended for complex test scenarios where the caller needs to observe what the next
194/// block contains before allowing the decoder pipeline to process it.
195///
196/// ## Drop behaviour
197///
198/// Dropping this controller ungates the stream: the gating task detects the closed trigger
199/// channel, forwards the currently-buffered block (if any), then continues passing subsequent
200/// blocks through without waiting for triggers — exactly as if step-control had never been
201/// enabled. The stream runs to its natural end.
202pub struct BlockStepController {
203 /// Sends a trigger signal to release the next buffered block.
204 trigger_tx: tokio::sync::mpsc::UnboundedSender<()>,
205 /// Watch channel containing the next buffered raw message, or `None` if no block is pending.
206 peek_rx: tokio::sync::watch::Receiver<Option<FeedMessage<BlockHeader>>>,
207}
208
209impl BlockStepController {
210 /// Releases the next buffered block for decoding and emission.
211 ///
212 /// Returns an error if the stream has already ended and the sender is disconnected.
213 pub fn trigger_next_block(&self) -> Result<(), tokio::sync::mpsc::error::SendError<()>> {
214 // Send a unit value on the trigger channel to unblock the gating task.
215 self.trigger_tx.send(())
216 }
217
218 /// Returns the currently buffered block immediately, or `None` if no block is buffered yet.
219 pub fn try_peek_next_block(&self) -> Option<FeedMessage<BlockHeader>> {
220 self.peek_rx.borrow().clone()
221 }
222
223 /// Waits until a block is buffered and returns it without consuming it.
224 ///
225 /// Returns `None` only if the stream has ended and no further blocks will arrive.
226 /// If a block is already buffered when this is called, it returns immediately.
227 pub async fn peek_next_block(&self) -> Option<FeedMessage<BlockHeader>> {
228 // Clone so we don't hold a mutable borrow on self; wait_for checks the current
229 // value first, so this returns immediately if a block is already present.
230 let mut rx = self.peek_rx.clone();
231 let guard = rx
232 .wait_for(|v| v.is_some())
233 .await
234 .ok()?;
235 guard.clone()
236 }
237}
238
239/// Builds and configures the multi protocol stream described in the [module-level docs](self).
240///
241/// See the module documentation for details on protocols, configuration options, and
242/// stream behavior.
243pub struct ProtocolStreamBuilder {
244 decoder: TychoStreamDecoder<BlockHeader>,
245 stream_builder: TychoStreamBuilder,
246 stream_end_policy: StreamEndPolicy,
247 chain: Chain,
248 pending_indexers: HashMap<String, Box<dyn TxDeltaIndexer>>,
249 /// Watch sender used to publish the currently-buffered raw block so the controller can peek
250 /// at it before triggering. `Some` iff step-control mode is active.
251 step_peek_tx: Option<tokio::sync::watch::Sender<Option<FeedMessage<BlockHeader>>>>,
252 /// Receiver half of the trigger channel. Held here until `build()` / `build_with_pending()`
253 /// transfers ownership to the gating task. `Some` iff step-control mode is active.
254 step_trigger_rx: Option<tokio::sync::mpsc::UnboundedReceiver<()>>,
255 /// State-override providers explicitly registered by the consumer, keyed by `protocol_system`.
256 /// These take precedence over the built-in default registry and are installed onto the decoder
257 /// at build time.
258 override_providers: HashMap<String, Arc<dyn StateOverrideProvider>>,
259 /// Names of all exchanges registered on the builder, used to decide which built-in override
260 /// providers to auto-register at build time.
261 registered_exchanges: HashSet<String>,
262}
263
264impl ProtocolStreamBuilder {
265 /// Creates a new builder for a multi-protocol stream.
266 ///
267 /// The shipped pool blocklist is applied by default, excluding components known to break
268 /// simulation. Use [`blocklist_components`](Self::blocklist_components) to exclude additional
269 /// components.
270 ///
271 /// See the [module-level docs](self) for full details on stream behavior and configuration.
272 pub fn new(tycho_url: &str, chain: Chain) -> Self {
273 Self {
274 decoder: TychoStreamDecoder::new(),
275 stream_builder: TychoStreamBuilder::new(tycho_url, chain)
276 .blocklisted_ids(default_blocklist()),
277 stream_end_policy: StreamEndPolicy::default(),
278 chain,
279 pending_indexers: HashMap::new(),
280 step_peek_tx: None,
281 step_trigger_rx: None,
282 override_providers: HashMap::new(),
283 registered_exchanges: HashSet::new(),
284 }
285 }
286
287 /// Adds a specific exchange to the stream.
288 ///
289 /// This configures the builder to include a new protocol synchronizer for `name`,
290 /// filtering its components according to `filter` and optionally `filter_fn`.
291 ///
292 /// The type parameter `T` specifies the decoder type for this exchange. All
293 /// component states for this exchange will be decoded into instances of `T`.
294 ///
295 /// # Parameters
296 ///
297 /// - `name`: The protocol or exchange name (e.g., `"uniswap_v4"`, `"vm:balancer_v2"`).
298 /// - `filter`: Defines the set of components to include in the stream.
299 /// - `filter_fn`: Optional custom filter function for client-side filtering of components not
300 /// expressible in `filter`.
301 ///
302 /// # Notes
303 ///
304 /// For certain protocols (e.g., `"uniswap_v4"`, `"vm:balancer_v2"`, `"vm:curve"`), omitting
305 /// `filter_fn` may cause decoding errors or incorrect results. In these cases, a proper
306 /// filter function is required to ensure correct decoding and quoting logic.
307 pub fn exchange<T>(
308 mut self,
309 name: &str,
310 filter: ComponentFilter,
311 filter_fn: Option<fn(&ComponentWithState) -> bool>,
312 ) -> Self
313 where
314 T: ProtocolSim
315 + TryFromWithBlock<ComponentWithState, BlockHeader, Error = InvalidSnapshotError>
316 + Send
317 + 'static,
318 {
319 self.stream_builder = self
320 .stream_builder
321 .exchange(name, filter);
322 self.registered_exchanges
323 .insert(name.to_string());
324 self.decoder.register_decoder::<T>(name);
325 if let Some(predicate) = filter_fn {
326 self.decoder
327 .register_filter(name, predicate);
328 } else if let Some(predicate) = default_filter_fn(name) {
329 self.decoder
330 .register_filter(name, predicate);
331 }
332
333 if EXCHANGES_REQUIRING_FILTER.contains(&name) && filter_fn.is_none() {
334 warn!(
335 "Warning: For exchange type '{}', it is necessary to set a filter function because not all pools are supported. See all filters at src/evm/protocol/filters.rs",
336 name
337 );
338 }
339
340 self
341 }
342
343 /// Adds a specific exchange to the stream with decoder context.
344 ///
345 /// This configures the builder to include a new protocol synchronizer for `name`,
346 /// filtering its components according to `filter` and optionally `filter_fn`. It also registers
347 /// the DecoderContext (this is useful to test protocols that are not live yet)
348 ///
349 /// The type parameter `T` specifies the decoder type for this exchange. All
350 /// component states for this exchange will be decoded into instances of `T`.
351 ///
352 /// # Parameters
353 ///
354 /// - `name`: The protocol or exchange name (e.g., `"uniswap_v4"`, `"vm:balancer_v2"`).
355 /// - `filter`: Defines the set of components to include in the stream.
356 /// - `filter_fn`: Optional custom filter function for client-side filtering of components not
357 /// expressible in `filter`.
358 /// - `decoder_context`: The decoder context for this exchange
359 ///
360 /// # Notes
361 ///
362 /// For certain protocols (e.g., `"uniswap_v4"`, `"vm:balancer_v2"`, `"vm:curve"`), omitting
363 /// `filter_fn` may cause decoding errors or incorrect results. In these cases, a proper
364 /// filter function is required to ensure correct decoding and quoting logic.
365 pub fn exchange_with_decoder_context<T>(
366 mut self,
367 name: &str,
368 filter: ComponentFilter,
369 filter_fn: Option<fn(&ComponentWithState) -> bool>,
370 decoder_context: DecoderContext,
371 ) -> Self
372 where
373 T: ProtocolSim
374 + TryFromWithBlock<ComponentWithState, BlockHeader, Error = InvalidSnapshotError>
375 + Send
376 + 'static,
377 {
378 self.stream_builder = self
379 .stream_builder
380 .exchange(name, filter);
381 self.registered_exchanges
382 .insert(name.to_string());
383 self.decoder
384 .register_decoder_with_context::<T>(name, decoder_context);
385 if let Some(predicate) = filter_fn {
386 self.decoder
387 .register_filter(name, predicate);
388 } else if let Some(predicate) = default_filter_fn(name) {
389 self.decoder
390 .register_filter(name, predicate);
391 }
392
393 if EXCHANGES_REQUIRING_FILTER.contains(&name) && filter_fn.is_none() {
394 warn!(
395 "Warning: For exchange type '{}', it is necessary to set a filter function because not all pools are supported. See all filters at src/evm/protocol/filters.rs",
396 name
397 );
398 }
399
400 self
401 }
402
403 /// Sets the block time interval for the stream.
404 ///
405 /// This controls how often the stream produces updates.
406 pub fn block_time(mut self, block_time: u64) -> Self {
407 self.stream_builder = self
408 .stream_builder
409 .block_time(block_time);
410 self
411 }
412
413 /// Sets the network operation timeout (deprecated).
414 ///
415 /// Use [`latency_buffer()`](Self::latency_buffer) instead for controlling latency.
416 /// This method is retained for backwards compatibility.
417 #[deprecated = "Use latency_buffer instead"]
418 pub fn timeout(mut self, timeout: u64) -> Self {
419 self.stream_builder = self.stream_builder.timeout(timeout);
420 self
421 }
422
423 /// Sets the latency buffer to aggregate same-block messages.
424 ///
425 /// This allows the supervisor to wait a short interval for all synchronizers to emit
426 /// before aggregating.
427 pub fn latency_buffer(mut self, timeout: u64) -> Self {
428 self.stream_builder = self.stream_builder.timeout(timeout);
429 self
430 }
431
432 /// Sets the maximum number of blocks a synchronizer may miss before being marked as `Stale`.
433 pub fn max_missed_blocks(mut self, n: u64) -> Self {
434 self.stream_builder = self.stream_builder.max_missed_blocks(n);
435 self
436 }
437
438 /// Sets how long a synchronizer may take to process the initial message.
439 ///
440 /// Useful for data-intensive protocols where startup decoding takes longer.
441 pub fn startup_timeout(mut self, timeout: time::Duration) -> Self {
442 self.stream_builder = self
443 .stream_builder
444 .startup_timeout(timeout);
445 self
446 }
447
448 /// Configures the stream to exclude state updates.
449 ///
450 /// This reduces bandwidth and decoding workload if protocol state is not of
451 /// interest (e.g. only process new tokens).
452 pub fn no_state(mut self, no_state: bool) -> Self {
453 self.stream_builder = self.stream_builder.no_state(no_state);
454 self
455 }
456
457 /// Sets the API key for authenticating with the Tycho server.
458 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
459 self.stream_builder = self.stream_builder.auth_key(auth_key);
460 self
461 }
462
463 /// Adds client-metadata entries forwarded to the server in the `X-Tycho-Client-Metadata`
464 /// header.
465 ///
466 /// See [`TychoStreamBuilder::add_client_metadata`]. Values are self-reported and may surface in
467 /// the server's metrics and logs — do not include secrets or personally identifiable
468 /// information.
469 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
470 where
471 I: IntoIterator<Item = (K, V)>,
472 K: Into<String>,
473 V: Into<String>,
474 {
475 self.stream_builder = self
476 .stream_builder
477 .add_client_metadata(metadata);
478 self
479 }
480
481 /// Disables TLS/ SSL for the connection, using http and ws protocols.
482 ///
483 /// This is not recommended for production use.
484 pub fn no_tls(mut self, no_tls: bool) -> Self {
485 self.stream_builder = self.stream_builder.no_tls(no_tls);
486 self
487 }
488
489 /// Disable compression for the connection.
490 pub fn disable_compression(mut self) -> Self {
491 self.stream_builder = self
492 .stream_builder
493 .disable_compression();
494 self
495 }
496
497 /// Enables partial block updates (flashblocks).
498 pub fn enable_partial_blocks(mut self) -> Self {
499 self.stream_builder = self
500 .stream_builder
501 .enable_partial_blocks();
502 self
503 }
504
505 /// Exclude additional component IDs from all registered exchanges.
506 ///
507 /// These IDs are added to the shipped blocklist that is already applied by default (see
508 /// [`new`](Self::new)).
509 pub fn blocklist_components(mut self, ids: HashSet<String>) -> Self {
510 if !ids.is_empty() {
511 tracing::info!("Blocklisting {} components", ids.len());
512 self.stream_builder = self.stream_builder.blocklisted_ids(ids);
513 }
514 self
515 }
516
517 /// Sets the stream end policy.
518 ///
519 /// Controls when the stream should stop based on synchronizer states.
520 ///
521 /// ## Note
522 /// The stream always ends latest if all protocols are stale or ended independent of
523 /// this configuration. This allows you to end the stream earlier than that.
524 ///
525 /// See [self::StreamEndPolicy] for possible configuration options.
526 pub fn stream_end_policy(mut self, stream_end_policy: StreamEndPolicy) -> Self {
527 self.stream_end_policy = stream_end_policy;
528 self
529 }
530
531 /// Provides token metadata used to decode startup snapshots and initialize protocol states.
532 ///
533 /// This is not a stream filter — components arriving after startup include their own token
534 /// metadata. To restrict to specific tokens, filter in your consumer logic. New tokens
535 /// arriving via stream deltas are added automatically if they meet the quality threshold.
536 pub async fn set_tokens(self, tokens: HashMap<Bytes, Token>) -> Self {
537 self.decoder.set_tokens(tokens).await;
538 self
539 }
540
541 /// Skips decoding errors for component state updates.
542 ///
543 /// Allows the stream to continue processing even if some states fail to decode,
544 /// logging a warning instead of panicking.
545 pub fn skip_state_decode_failures(mut self, skip: bool) -> Self {
546 self.decoder
547 .skip_state_decode_failures(skip);
548 self
549 }
550
551 /// Sets the minimum token quality for tokens added via the stream.
552 ///
553 /// Tokens arriving in stream deltas below this threshold are ignored. Defaults to 100.
554 /// Set this to the same value used in [`load_all_tokens()`](crate::utils::load_all_tokens) to
555 /// apply consistent filtering.
556 pub fn min_token_quality(mut self, quality: u32) -> Self {
557 self.decoder.min_token_quality(quality);
558 self
559 }
560
561 /// Configures the retry policy for websocket reconnects.
562 pub fn websocket_retry_config(mut self, config: &RetryConfiguration) -> Self {
563 self.stream_builder = self
564 .stream_builder
565 .websockets_retry_config(config);
566 self
567 }
568
569 /// Configures the retry policy for state synchronization.
570 pub fn state_synchronizer_retry_config(mut self, config: &RetryConfiguration) -> Self {
571 self.stream_builder = self
572 .stream_builder
573 .state_synchronizer_retry_config(config);
574 self
575 }
576
577 pub fn get_decoder(&self) -> &TychoStreamDecoder<BlockHeader> {
578 &self.decoder
579 }
580
581 /// Registers a [`TxDeltaIndexer`] for ephemeral pending-block simulation.
582 ///
583 /// The indexer is associated with `extractor` (the protocol synchronizer name, e.g.
584 /// `"uniswap_v3"`). Use [`build_with_pending`](Self::build_with_pending) to obtain both
585 /// the confirmed stream and the pending processor.
586 ///
587 /// Returns an error if `extractor` names a VM protocol (prefix `"vm:"`), which requires
588 /// `update_engine()` and cannot be simulated natively.
589 pub fn with_pending_indexer(
590 mut self,
591 extractor: &str,
592 indexer: Box<dyn TxDeltaIndexer>,
593 ) -> Result<Self, StreamError> {
594 if extractor.starts_with("vm:") {
595 return Err(StreamError::SetUpError(format!(
596 "extractor '{extractor}' is a VM protocol; TxDeltaIndexer only supports native protocols"
597 )));
598 }
599 self.pending_indexers
600 .insert(extractor.to_string(), indexer);
601 Ok(self)
602 }
603
604 /// Enables controlled-step mode for testing.
605 ///
606 /// Returns a [`BlockStepController`] that lets the caller decide when each buffered block
607 /// is released for decoding. Call this before [`build`](Self::build) or
608 /// [`build_with_pending`](Self::build_with_pending) — both detect and wire up the gating
609 /// automatically.
610 ///
611 /// In production code, do not call this method; the stream runs at full speed.
612 pub fn with_step_controller(mut self) -> (Self, BlockStepController) {
613 let (trigger_tx, trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
614 let (peek_tx, peek_rx) =
615 tokio::sync::watch::channel::<Option<FeedMessage<BlockHeader>>>(None);
616
617 self.step_peek_tx = Some(peek_tx);
618 self.step_trigger_rx = Some(trigger_rx);
619
620 let controller = BlockStepController { trigger_tx, peek_rx };
621 (self, controller)
622 }
623
624 /// Spawns a background task that gates `FeedMessage` delivery.
625 ///
626 /// The task buffers each incoming message, publishes it to `peek_tx` so the
627 /// [`BlockStepController`] can inspect it, waits for a trigger, then forwards the message to
628 /// `output_tx` for the decode pipeline. If `advance_tx` is `Some`, a clone of the message is
629 /// also forwarded there (used by the pending-processor path) before the decode step.
630 /// When the input channel closes or a terminal error is received according to
631 /// `stream_end_policy`, the task exits and all output channels are dropped.
632 fn run_gating_task(
633 raw_rx: tokio::sync::mpsc::Receiver<
634 Result<FeedMessage<BlockHeader>, BlockSynchronizerError>,
635 >,
636 mut trigger_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
637 peek_tx: tokio::sync::watch::Sender<Option<FeedMessage<BlockHeader>>>,
638 output_tx: tokio::sync::mpsc::Sender<FeedMessage<BlockHeader>>,
639 stream_end_policy: StreamEndPolicy,
640 ) {
641 tokio::spawn(async move {
642 let mut raw_stream = ReceiverStream::new(raw_rx);
643 loop {
644 let msg = match raw_stream.next().await {
645 Some(Ok(msg)) => msg,
646 Some(Err(e)) => {
647 error!("Block stream ended with terminal error: {e}");
648 break;
649 }
650 None => break,
651 };
652
653 if stream_end_policy.should_end(msg.sync_states.values()) {
654 error!(
655 "Block stream ended due to {:?}: {:?}",
656 stream_end_policy, msg.sync_states
657 );
658 break;
659 }
660
661 // Publish the buffered message so the caller can peek before triggering.
662 let _ = peek_tx.send(Some(msg.clone()));
663
664 // Block until the controller fires trigger_next_block(), or until it is dropped.
665 if trigger_rx.recv().await.is_none() {
666 // Controller dropped — forward the buffered message and drain the rest
667 // without gating, so the stream continues to its natural end.
668 let _ = peek_tx.send(None);
669 if output_tx.send(msg).await.is_err() {
670 break;
671 }
672 while let Some(item) = raw_stream.next().await {
673 let Ok(msg) = item else { break };
674 if stream_end_policy.should_end(msg.sync_states.values()) {
675 break;
676 }
677 if output_tx.send(msg).await.is_err() {
678 break;
679 }
680 }
681 break;
682 }
683
684 // Clear the peek slot before decoding so callers see None between blocks.
685 let _ = peek_tx.send(None);
686
687 if output_tx.send(msg).await.is_err() {
688 break;
689 }
690 }
691 });
692 }
693
694 /// Registers `provider` as the live override source for `protocol_system`.
695 ///
696 /// Explicit registrations take precedence over the built-in default registry, so this is how
697 /// you swap a venue (e.g. `vm:bopamm`) onto a different provider. Registering the same provider
698 /// for several protocols is cheap — it is shared via `Arc`, not duplicated.
699 pub fn with_override_provider(
700 mut self,
701 protocol_system: impl Into<String>,
702 provider: Arc<dyn StateOverrideProvider>,
703 ) -> Self {
704 self.override_providers
705 .insert(protocol_system.into(), provider);
706 self
707 }
708
709 /// Installs override providers onto the decoder before the stream is built.
710 ///
711 /// Explicit consumer registrations win; the built-in default registry (see
712 /// [`default_override_providers`](crate::evm::override_stream::default_override_providers))
713 /// then fills every remaining protocol it can serve.
714 fn install_override_providers(&mut self) {
715 let explicit = std::mem::take(&mut self.override_providers);
716 // Protocols eligible for a built-in default provider: registered exchanges not explicitly
717 // overridden by the consumer.
718 let uncovered = self
719 .registered_exchanges
720 .clone()
721 .into_iter()
722 .filter(|exchange| !explicit.contains_key(exchange));
723 let defaults = override_stream::default_override_providers(uncovered);
724 for (protocol_system, provider) in defaults.into_iter().chain(explicit) {
725 self.decoder
726 .set_override_provider(protocol_system, provider);
727 }
728 }
729
730 /// Builds the confirmed protocol stream and a [`PendingBlockProcessor`] that stays
731 /// in sync with it automatically.
732 ///
733 /// The stream pipeline forwards every confirmed [`FeedMessage`] to the processor via an
734 /// internal unbounded channel — it never blocks waiting for the consumer. The consumer
735 /// owns the returned `PendingBlockProcessor` exclusively and may wrap it in whatever
736 /// synchronisation primitive suits their use case (e.g. `Mutex` for shared access,
737 /// nothing for single-threaded use).
738 ///
739 /// Call [`generate_pending_update`](PendingBlockProcessor::generate_pending_update) to
740 /// simulate a candidate bundle; it drains the channel automatically before computing.
741 pub async fn build_with_pending(
742 mut self,
743 ) -> Result<
744 (impl Stream<Item = Result<Update, StreamDecodeError>>, PendingBlockProcessor),
745 StreamError,
746 > {
747 initialize_hook_handlers().map_err(|e| {
748 StreamError::SetUpError(format!("Error initializing hook handlers: {e:?}"))
749 })?;
750 self.install_override_providers();
751 let (_, rx) = self.stream_builder.build().await?;
752 let decoder = Arc::new(self.decoder);
753
754 let (advance_tx, advance_rx) =
755 tokio::sync::mpsc::unbounded_channel::<FeedMessage<BlockHeader>>();
756 let pending = PendingBlockProcessor::new(
757 self.pending_indexers,
758 decoder.clone(),
759 self.chain,
760 advance_rx,
761 );
762
763 let chain = self.chain;
764 let stream_end_policy = self.stream_end_policy;
765
766 let decode_stream: Box<dyn Stream<Item = FeedMessage<BlockHeader>> + Send + Unpin> =
767 if let (Some(peek_tx), Some(trigger_rx)) = (self.step_peek_tx, self.step_trigger_rx) {
768 let (gated_tx, gated_rx) =
769 tokio::sync::mpsc::channel::<FeedMessage<BlockHeader>>(1);
770 Self::run_gating_task(rx, trigger_rx, peek_tx, gated_tx, stream_end_policy);
771 Box::new(ReceiverStream::new(gated_rx))
772 } else {
773 let normal = ReceiverStream::new(rx)
774 .take_while(move |msg| match msg {
775 Ok(msg) => {
776 let states = msg.sync_states.values();
777 if stream_end_policy.should_end(states) {
778 error!(
779 "Block stream ended due to {:?}: {:?}",
780 stream_end_policy, msg.sync_states
781 );
782 futures::future::ready(false)
783 } else {
784 futures::future::ready(true)
785 }
786 }
787 Err(e) => {
788 error!("Block stream ended with terminal error: {e}");
789 futures::future::ready(false)
790 }
791 })
792 .map(|msg| msg.expect("Safe since stream ends if we receive an error"));
793 Box::new(Box::pin(normal))
794 };
795
796 let stream = Box::pin(decode_stream.then({
797 let decoder = decoder.clone();
798 move |msg| {
799 let decoder = decoder.clone();
800 let advance_tx = advance_tx.clone();
801 async move {
802 let _ = advance_tx.send(msg.clone());
803 decoder.decode(&msg).await.map_err(|e| {
804 debug!(msg=?msg, "Decode error: {}", e);
805 e
806 })
807 }
808 }
809 }));
810 let stream = inject_native_wrapper(stream, chain);
811 Ok((stream, pending))
812 }
813
814 /// Builds and returns the configured protocol stream.
815 ///
816 /// See the module-level docs for details on stream behavior and emitted messages.
817 /// This method applies all builder settings and starts the stream.
818 pub async fn build(
819 mut self,
820 ) -> Result<impl Stream<Item = Result<Update, StreamDecodeError>>, StreamError> {
821 initialize_hook_handlers().map_err(|e| {
822 StreamError::SetUpError(format!("Error initializing hook handlers: {e:?}"))
823 })?;
824 self.install_override_providers();
825 let (_, rx) = self.stream_builder.build().await?;
826 let decoder = Arc::new(self.decoder);
827 let chain = self.chain;
828 let stream_end_policy = self.stream_end_policy;
829
830 let decode_stream: Box<dyn Stream<Item = FeedMessage<BlockHeader>> + Send + Unpin> =
831 if let (Some(peek_tx), Some(trigger_rx)) = (self.step_peek_tx, self.step_trigger_rx) {
832 let (gated_tx, gated_rx) =
833 tokio::sync::mpsc::channel::<FeedMessage<BlockHeader>>(1);
834 Self::run_gating_task(rx, trigger_rx, peek_tx, gated_tx, stream_end_policy);
835 Box::new(ReceiverStream::new(gated_rx))
836 } else {
837 let normal = ReceiverStream::new(rx)
838 .take_while(move |msg| match msg {
839 Ok(msg) => {
840 let states = msg.sync_states.values();
841 if stream_end_policy.should_end(states) {
842 error!(
843 "Block stream ended due to {:?}: {:?}",
844 stream_end_policy, msg.sync_states
845 );
846 futures::future::ready(false)
847 } else {
848 futures::future::ready(true)
849 }
850 }
851 Err(e) => {
852 error!("Block stream ended with terminal error: {e}");
853 futures::future::ready(false)
854 }
855 })
856 .map(|msg| msg.expect("Safe since stream ends if we receive an error"));
857 Box::new(Box::pin(normal))
858 };
859
860 let stream = Box::pin(decode_stream.then({
861 let decoder = decoder.clone();
862 move |msg| {
863 let decoder = decoder.clone();
864 async move {
865 decoder.decode(&msg).await.map_err(|e| {
866 debug!(msg=?msg, "Decode error: {}", e);
867 e
868 })
869 }
870 }
871 }));
872 let stream = inject_native_wrapper(stream, chain);
873 Ok(stream)
874 }
875}
876
877/// Wraps a decoded protocol stream to inject a `NativeWrapperState` component
878/// on the first successful update.
879///
880/// Skips injection for chains where the native and wrapped-native tokens share
881/// the same address (e.g. Starknet).
882fn inject_native_wrapper(
883 inner: impl Stream<Item = Result<Update, StreamDecodeError>> + Unpin + Send + 'static,
884 chain: Chain,
885) -> impl Stream<Item = Result<Update, StreamDecodeError>> + Send {
886 let has_distinct_wrapper = chain.native_token().address != chain.wrapped_native_token().address;
887 if !has_distinct_wrapper {
888 return Either::Left(inner);
889 }
890
891 Either::Right(
892 stream::once(async move {
893 let mut inner = inner;
894 let first = inner.next().await;
895 let modified = first.into_iter().map(move |result| {
896 result.map(|mut update| {
897 let component = NativeWrapperState::component(chain);
898 let id = component.id.to_string();
899 update
900 .new_pairs
901 .insert(id.clone(), component);
902 update
903 .states
904 .insert(id, Box::new(NativeWrapperState::new(chain)));
905 debug!("Injected native_wrapper component for {chain}");
906 update
907 })
908 });
909 stream::iter(modified).chain(inner)
910 })
911 .flatten(),
912 )
913}
914
915#[cfg(test)]
916mod tests {
917 use std::collections::HashMap;
918
919 use futures::{stream, StreamExt};
920 use tycho_common::models::Chain;
921
922 use super::*;
923 use crate::protocol::models::Update;
924
925 fn empty_update(block: u64) -> Update {
926 Update::new(block, HashMap::new(), HashMap::new())
927 }
928
929 #[tokio::test]
930 async fn test_inject_native_wrapper_first_message_only() {
931 let updates = vec![Ok(empty_update(1)), Ok(empty_update(2)), Ok(empty_update(3))];
932 let input = stream::iter(updates);
933
934 let results: Vec<_> = inject_native_wrapper(input, Chain::Ethereum)
935 .collect()
936 .await;
937
938 assert_eq!(results.len(), 3);
939
940 let expected_id = NativeWrapperState::component(Chain::Ethereum)
941 .id
942 .to_string();
943
944 let first = results[0]
945 .as_ref()
946 .expect("first update ok");
947 assert!(
948 first
949 .new_pairs
950 .contains_key(&expected_id),
951 "first message should have native_wrapper component"
952 );
953 assert!(
954 first.states.contains_key(&expected_id),
955 "first message should have native_wrapper state"
956 );
957
958 let second = results[1]
959 .as_ref()
960 .expect("second update ok");
961 assert!(
962 !second
963 .new_pairs
964 .contains_key(&expected_id),
965 "second message should NOT have native_wrapper component"
966 );
967 assert!(
968 !second.states.contains_key(&expected_id),
969 "second message should NOT have native_wrapper state"
970 );
971 }
972
973 /// Verifies that `with_step_controller` returns both a modified builder and a controller.
974 ///
975 /// This test only checks that the builder method is callable and that the returned controller
976 /// compiles — it does not start any network connection.
977 #[tokio::test]
978 async fn test_with_step_controller_returns_controller() {
979 let builder = ProtocolStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum);
980 let (_builder, controller) = builder.with_step_controller();
981 // The controller was successfully returned — verifying the public API is callable.
982 drop(controller);
983 }
984
985 /// Connects to a live Tycho instance, verifies that the stream blocks until
986 /// `trigger_next_block` is called, and that `peek_next_block` exposes the buffered message.
987 #[ignore = "requires live Tycho connection (TYCHO_AUTH_TOKEN env var)"]
988 #[tokio::test]
989 async fn test_step_controller_trigger_releases_block() {
990 use std::{env, time::Duration};
991
992 use crate::evm::protocol::uniswap_v2::state::UniswapV2State;
993
994 let auth = env::var("TYCHO_AUTH_TOKEN").expect("TYCHO_AUTH_TOKEN must be set");
995
996 // Track a single well-known pool to minimise startup latency.
997 let usdc_weth_v2 = "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc".to_string();
998 let (builder, controller) =
999 ProtocolStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
1000 .auth_key(Some(auth))
1001 .exchange::<UniswapV2State>(
1002 "uniswap_v2",
1003 ComponentFilter::Ids(vec![usdc_weth_v2]),
1004 None,
1005 )
1006 .with_step_controller();
1007
1008 let (stream, _pending) = builder
1009 .build_with_pending()
1010 .await
1011 .expect("build_with_pending failed");
1012 tokio::pin!(stream);
1013
1014 // Wait up to 60 s for the first block to arrive in the gating buffer.
1015 let peeked = tokio::time::timeout(Duration::from_secs(60), controller.peek_next_block())
1016 .await
1017 .expect("timed out waiting for first block to buffer")
1018 .expect("stream ended before a block arrived");
1019
1020 assert!(!peeked.sync_states.is_empty(), "peeked block should carry sync states");
1021
1022 // Stream must be empty before we trigger — the gating task should be holding the block.
1023 let pre_trigger = tokio::time::timeout(Duration::from_millis(200), stream.next()).await;
1024 assert!(
1025 pre_trigger.is_err(),
1026 "stream should be blocked before trigger_next_block, got an item"
1027 );
1028
1029 // Release the block.
1030 controller
1031 .trigger_next_block()
1032 .expect("trigger_next_block failed");
1033
1034 // Stream should now yield the decoded update within one block time.
1035 let update = tokio::time::timeout(Duration::from_secs(30), stream.next())
1036 .await
1037 .expect("timed out waiting for update after trigger")
1038 .expect("stream ended unexpectedly");
1039
1040 assert!(update.is_ok(), "decoded update should be Ok, got: {:?}", update);
1041 }
1042}