Skip to main content

tycho_simulation/price_level_stream/
stream.rs

1use std::{
2    collections::{hash_map::Entry, HashMap, HashSet},
3    time::Duration,
4};
5
6use chrono::Utc;
7use num_bigint::BigUint;
8use tokio_stream::{Stream, StreamExt};
9use tycho_common::{
10    models::{token::Token, Chain},
11    simulation::protocol_sim::ProtocolSim,
12    Bytes,
13};
14
15use super::{
16    config::{
17        default_denied_pamms, default_served_pamms, PriceLevelStreamConfig,
18        DEFAULT_AUTO_DETECTED_GAS_COST,
19    },
20    state::{PriceLevelStreamQuote, PriceLevelStreamState},
21    titan::{
22        self, ConnectionSettings, TitanPairLevels, TitanPammLevels, TitanPriceLevel,
23        TitanPriceLevelMessage, TITAN_PRICE_LEVEL_URL,
24    },
25};
26use crate::protocol::models::{ProtocolComponent, Update};
27
28/// Static attribute under which each emitted component carries its pAMM venue address.
29pub const PAMM_ADDRESS_ATTRIBUTE: &str = "pamm_address";
30
31/// Builds a stream of [`Update`]s from the Titan pAMM price level WebSocket.
32///
33/// A new builder serves no pAMMs: register the known venues via
34/// [`with_known_pamms`](Self::with_known_pamms), individual ones via
35/// [`add_pamm`](Self::add_pamm), or opt into serving unknown streamed venues via
36/// [`auto_detect`](Self::auto_detect); [`with_tokens`](Self::with_tokens) provides the token
37/// metadata pairs are interpreted with.
38///
39/// One component is emitted per (pAMM, token pair), identified by the concatenation
40/// `pamm ++ token0 ++ token1` (tokens sorted ascending), under the protocol system
41/// `fallback:{pamm}` — or `pricelevelstream:{pamm}` after
42/// [`without_fallback_router`](Self::without_fallback_router). The venue address is exposed
43/// through the [`PAMM_ADDRESS_ATTRIBUTE`] static attribute for downstream encoding.
44pub struct PriceLevelStreamBuilder {
45    registry: HashMap<Bytes, PriceLevelStreamConfig>,
46    denied: HashSet<Bytes>,
47    tokens: HashMap<Bytes, Token>,
48    url: Option<String>,
49    auto_detect: bool,
50    auto_detected_gas_cost: Option<BigUint>,
51    connection: ConnectionSettings,
52    /// Whether components are emitted under the `fallback:` family, executed through
53    /// `TychoFallbackRouter`, instead of the direct `pricelevelstream:` family.
54    fallback_router: bool,
55}
56
57impl Default for PriceLevelStreamBuilder {
58    fn default() -> Self {
59        Self {
60            registry: HashMap::new(),
61            denied: HashSet::new(),
62            tokens: HashMap::new(),
63            url: None,
64            auto_detect: false,
65            auto_detected_gas_cost: None,
66            connection: ConnectionSettings::default(),
67            fallback_router: true,
68        }
69    }
70}
71
72impl PriceLevelStreamBuilder {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Enables serving pAMMs that are not registered via
78    /// [`with_known_pamms`](Self::with_known_pamms) or [`add_pamm`](Self::add_pamm)
79    /// (disabled by default).
80    ///
81    /// When enabled, any unknown streamed venue — except denied ones (see
82    /// [`deny_pamm`](Self::deny_pamm)) — is served under its full lowercase hex address
83    /// as the name, with the default gas cost. A venue's protocol system therefore changes from
84    /// the address form (`pricelevelstream:{0xaddress}`) to a name (`pricelevelstream:{name}`)
85    /// once it gets registered — via [`add_pamm`](Self::add_pamm) or a release's
86    /// [`default_served_pamms`] recognizing it; the name-independent identifiers — the component id
87    /// and the [`PAMM_ADDRESS_ATTRIBUTE`] — stay stable across such renames.
88    pub fn auto_detect(mut self, enabled: bool) -> Self {
89        self.auto_detect = enabled;
90        self
91    }
92
93    /// Overrides the per-swap gas cost that auto-detected pAMMs (see
94    /// [`auto_detect`](Self::auto_detect)) are served with. Defaults to the maximum over the
95    /// known venue profiles, as the conservative choice. Registered venues are unaffected —
96    /// their gas cost comes from their [`PriceLevelStreamConfig`].
97    pub fn auto_detected_gas_cost(mut self, gas_cost: BigUint) -> Self {
98        self.auto_detected_gas_cost = Some(gas_cost);
99        self
100    }
101
102    /// Overrides the stream endpoint, e.g. to connect to a closer Titan region than the default
103    /// (see <https://docs.titanbuilder.xyz/propamms/takers>).
104    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
105        self.url = Some(url.into());
106        self
107    }
108
109    /// Overrides how long a single connection attempt may take before it is aborted and retried
110    /// (default: 10s), so a hung TCP/TLS handshake cannot block the stream forever.
111    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
112        self.connection.connect_timeout = timeout;
113        self
114    }
115
116    /// Overrides the longest gap between Titan messages tolerated before the connection is
117    /// treated as dead and re-established (default: 30s). Titan pushes several updates per
118    /// second, so a multi-second silence means a stalled or half-open connection.
119    pub fn read_idle_timeout(mut self, timeout: Duration) -> Self {
120        self.connection.read_idle_timeout = timeout;
121        self
122    }
123
124    /// Overrides the cap on the exponential reconnect backoff of `2^attempt` seconds
125    /// (default: 32s).
126    pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
127        self.connection.max_backoff = max_backoff;
128        self
129    }
130
131    /// Registers a pAMM to be served under the given configuration, overriding any default,
132    /// denied, or auto-detected one for the same address.
133    ///
134    /// Between [`add_pamm`](Self::add_pamm) and [`deny_pamm`](Self::deny_pamm) for the same
135    /// address, the later call wins; the defaults applied by
136    /// [`with_known_pamms`](Self::with_known_pamms) never override either, in any call order.
137    pub fn add_pamm(mut self, config: PriceLevelStreamConfig) -> Self {
138        self.denied.remove(&config.address);
139        self.registry
140            .insert(config.address.clone(), config);
141        self
142    }
143
144    /// Excludes a venue from being served: drops its current registration (default or explicit)
145    /// and blocks auto-detecting it.
146    ///
147    /// Between [`add_pamm`](Self::add_pamm) and [`deny_pamm`](Self::deny_pamm) for the same
148    /// address, the later call wins; the defaults applied by
149    /// [`with_known_pamms`](Self::with_known_pamms) never override either, in any call order —
150    /// so denying a venue from the default set works whether the denial comes before or after
151    /// [`with_known_pamms`](Self::with_known_pamms).
152    pub fn deny_pamm(mut self, address: Bytes) -> Self {
153        self.registry.remove(&address);
154        self.denied.insert(address);
155        self
156    }
157
158    /// Applies what is known about the streamed venues: registers the known-good ones
159    /// ([`default_served_pamms`]) to be served and denies the known-bad ones
160    /// ([`default_denied_pamms`]) — venues that stream quotes but whose swaps are not executable.
161    ///
162    /// These defaults never override an explicit [`add_pamm`](Self::add_pamm) or
163    /// [`deny_pamm`](Self::deny_pamm) for the same address, regardless of call order.
164    pub fn with_known_pamms(mut self) -> Self {
165        for config in default_served_pamms() {
166            if self.denied.contains(&config.address) {
167                continue;
168            }
169            self.registry
170                .entry(config.address.clone())
171                .or_insert(config);
172        }
173        for address in default_denied_pamms() {
174            if self.registry.contains_key(&address) {
175                continue;
176            }
177            self.denied.insert(address);
178        }
179        self
180    }
181
182    /// Provides the token metadata used to build components and interpret amounts. Pairs whose
183    /// tokens are missing here are skipped.
184    pub fn with_tokens(mut self, tokens: HashMap<Bytes, Token>) -> Self {
185        self.tokens = tokens;
186        self
187    }
188
189    /// Keeps every venue on the direct `pricelevelstream:{name}` path, so swaps execute on the
190    /// venues themselves and a stale maker quote reverts the route.
191    ///
192    /// By default components are emitted under `fallback:{name}`, so tycho-execution routes
193    /// their swaps through `TychoFallbackRouter`. Opt out when the direct call is what you want
194    /// to measure or execute.
195    pub fn without_fallback_router(mut self) -> Self {
196        self.fallback_router = false;
197        self
198    }
199
200    /// Consumes the builder and opens the stream.
201    ///
202    /// Components are emitted under `fallback:{name}`, so tycho-execution routes their swaps
203    /// through `TychoFallbackRouter`, which retries a reverted pAMM swap — a stale maker quote
204    /// reverts in any simulation against a mined block — on the fallback pool the solver names.
205    /// [`without_fallback_router`](Self::without_fallback_router) keeps them on the direct
206    /// `pricelevelstream:` path.
207    ///
208    /// The connection is established lazily on first poll and maintained (with reconnects) for as
209    /// long as the stream is polled; it never terminates on its own, and dropping the stream
210    /// closes the connection. Frames that contain no served pAMM produce no update.
211    ///
212    /// Each streamed frame is a complete snapshot of everything Titan currently streams, so
213    /// every update carries the full set of the frame's pair states, with `new_pairs` /
214    /// `removed_pairs` derived by diffing against the previous frame — a pair (or a whole
215    /// venue) the stream stops serving is removed. Frames older than an already processed one
216    /// are skipped, so updates never move backwards in block number. Pairs whose tokens are
217    /// missing from the provided token metadata are skipped.
218    pub fn build(self) -> impl Stream<Item = Update> + Send {
219        let Self {
220            registry,
221            denied,
222            tokens,
223            url,
224            auto_detect,
225            auto_detected_gas_cost,
226            connection,
227            fallback_router,
228        } = self;
229        if registry.is_empty() && !auto_detect {
230            tracing::warn!(
231                "No pAMMs registered and auto-detection is off; the stream will never produce \
232                 an update"
233            );
234        }
235        if tokens.is_empty() {
236            tracing::warn!(
237                "No token metadata provided; every streamed pair will be skipped and the stream \
238                 will never produce an update"
239            );
240        }
241        let url = url.unwrap_or_else(|| TITAN_PRICE_LEVEL_URL.to_string());
242        let auto_detected_gas_cost =
243            auto_detected_gas_cost.unwrap_or_else(|| BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST));
244
245        let mut tracker = SnapshotTracker::new(
246            registry,
247            denied,
248            tokens,
249            auto_detect,
250            auto_detected_gas_cost,
251            fallback_router,
252        );
253        titan::messages(url, connection).filter_map(move |message| tracker.process(message))
254    }
255}
256
257/// Turns Titan frames into [`Update`]s, tracking the previously emitted components so pair
258/// additions and removals can be diffed against the last snapshot.
259struct SnapshotTracker {
260    registry: HashMap<Bytes, PriceLevelStreamConfig>,
261    /// Venues excluded from auto-detection. The builder keeps this disjoint from the registry:
262    /// denying removes any registration and registering removes any denial.
263    denied: HashSet<Bytes>,
264    tokens: HashMap<Bytes, Token>,
265    /// Whether frames from pAMMs absent from the registry get an address-named configuration
266    /// synthesized (and cached in the registry) instead of being skipped.
267    auto_detect: bool,
268    /// The per-swap gas cost synthesized auto-detected configurations are served with.
269    auto_detected_gas_cost: BigUint,
270    /// Whether components are emitted under the `fallback:` family, so their swaps execute
271    /// through `TychoFallbackRouter` instead of the venue directly.
272    via_fallback_router: bool,
273    /// Components of the last emitted snapshot, across all pAMMs. A frame is a complete
274    /// snapshot of everything Titan currently streams, so removals are diffed globally: a
275    /// known component a frame does not re-emit is gone — including when its venue vanishes
276    /// from the stream entirely.
277    components: HashMap<String, ProtocolComponent>,
278    /// The newest block number processed so far. Frames targeting an older block (e.g.
279    /// delivered around a reconnect) are stale and skipped wholesale — processing one would
280    /// emit superseded states and churn the global diff.
281    newest_block: u64,
282}
283
284impl SnapshotTracker {
285    fn new(
286        registry: HashMap<Bytes, PriceLevelStreamConfig>,
287        denied: HashSet<Bytes>,
288        tokens: HashMap<Bytes, Token>,
289        auto_detect: bool,
290        auto_detected_gas_cost: BigUint,
291        via_fallback_router: bool,
292    ) -> Self {
293        Self {
294            registry,
295            denied,
296            tokens,
297            auto_detect,
298            auto_detected_gas_cost,
299            via_fallback_router,
300            components: HashMap::new(),
301            newest_block: 0,
302        }
303    }
304
305    /// Processes one frame into an [`Update`], or `None` if the frame targets an older block
306    /// than an already processed one or contains nothing relevant (no registered pAMM with at
307    /// least one known pair or a pair removal).
308    fn process(&mut self, message: TitanPriceLevelMessage) -> Option<Update> {
309        if message.block_number < self.newest_block {
310            tracing::warn!(
311                block_number = message.block_number,
312                newest_block = self.newest_block,
313                "Skipping out-of-order price level frame"
314            );
315            return None;
316        }
317        self.newest_block = message.block_number;
318
319        let mut states: HashMap<String, Box<dyn ProtocolSim>> = HashMap::new();
320        let mut new_pairs = HashMap::new();
321        // The frame is a complete snapshot: every known component is presumed gone until the
322        // frame re-emits it below.
323        let mut previous = std::mem::take(&mut self.components);
324
325        for TitanPammLevels { pamm, pairs } in message.pamms {
326            let config = match self.registry.entry(pamm.clone()) {
327                Entry::Occupied(entry) => &*entry.into_mut(),
328                Entry::Vacant(entry) => {
329                    if !self.auto_detect {
330                        tracing::debug!(%pamm, "Skipping unregistered pAMM");
331                        continue;
332                    }
333                    if self.denied.contains(&pamm) {
334                        tracing::debug!(%pamm, "Skipping denied pAMM");
335                        continue;
336                    }
337                    tracing::info!(%pamm, "Serving auto-detected pAMM");
338                    &*entry.insert(PriceLevelStreamConfig::auto_detected(
339                        pamm.clone(),
340                        self.auto_detected_gas_cost.clone(),
341                    ))
342                }
343            };
344
345            // Merge the frame's per-direction ladders into one entry per unordered token pair.
346            let mut merged_pairs: HashMap<(Bytes, Bytes), (Vec<_>, Vec<_>)> = HashMap::new();
347            for TitanPairLevels { token_in, token_out, order_book } in pairs {
348                if !self.tokens.contains_key(&token_in) || !self.tokens.contains_key(&token_out) {
349                    tracing::debug!(%token_in, %token_out, "Skipping pair with unknown token");
350                    continue;
351                }
352                let sells_token0 = token_in < token_out;
353                let key = if sells_token0 {
354                    (token_in.clone(), token_out.clone())
355                } else {
356                    (token_out.clone(), token_in.clone())
357                };
358                let quotes = order_book
359                    .into_iter()
360                    .map(|TitanPriceLevel { amount_in, amount_out }| {
361                        PriceLevelStreamQuote::new(amount_in, amount_out)
362                    })
363                    .collect();
364                let entry = merged_pairs.entry(key).or_default();
365                if sells_token0 {
366                    entry.0 = quotes;
367                } else {
368                    entry.1 = quotes;
369                }
370            }
371
372            for ((token0, token1), (quotes_0_to_1, quotes_1_to_0)) in merged_pairs {
373                let id = component_id(&config.address, &token0, &token1);
374                let id_string = id.to_string();
375                let component = previous
376                    .remove(&id_string)
377                    .unwrap_or_else(|| {
378                        let component = build_component(
379                            &self.tokens,
380                            config,
381                            id,
382                            &token0,
383                            &token1,
384                            self.via_fallback_router,
385                        );
386                        new_pairs.insert(id_string.clone(), component.clone());
387                        component
388                    });
389
390                let state = PriceLevelStreamState::new(
391                    token0,
392                    token1,
393                    quotes_0_to_1,
394                    quotes_1_to_0,
395                    config.gas_cost.clone(),
396                );
397
398                states.insert(id_string.clone(), Box::new(state));
399                self.components
400                    .insert(id_string, component);
401            }
402        }
403
404        // Every re-emitted pair was moved back into `self.components` above — whatever remains
405        // is gone: the pair, or its whole venue, is no longer streamed.
406        let removed_pairs = previous;
407
408        if states.is_empty() && new_pairs.is_empty() && removed_pairs.is_empty() {
409            return None;
410        }
411
412        Some(
413            // Quotes target the block currently being built, hence partial. Sync states stay
414            // empty (like the RFQ path) because no full block header is available.
415            Update::new(message.block_number, states, new_pairs)
416                .set_is_partial(true)
417                .set_removed_pairs(removed_pairs),
418        )
419    }
420}
421
422fn build_component(
423    tokens: &HashMap<Bytes, Token>,
424    config: &PriceLevelStreamConfig,
425    id: Bytes,
426    token0: &Bytes,
427    token1: &Bytes,
428    via_router: bool,
429) -> ProtocolComponent {
430    let protocol_system =
431        if via_router { config.fallback_protocol_system() } else { config.protocol_system() };
432    ProtocolComponent::new(
433        id,
434        protocol_system.clone(),
435        protocol_system,
436        // Titan builds Ethereum L1 blocks; the stream carries no other chains.
437        Chain::Ethereum,
438        vec![tokens[token0].clone(), tokens[token1].clone()],
439        vec![config.address.clone()],
440        HashMap::from([(PAMM_ADDRESS_ATTRIBUTE.to_string(), config.address.clone())]),
441        Bytes::default(),
442        Utc::now().naive_utc(),
443    )
444}
445
446/// The component identity of a (pAMM, pair) combination: `pamm ++ token0 ++ token1`.
447fn component_id(pamm: &Bytes, token0: &Bytes, token1: &Bytes) -> Bytes {
448    Bytes::from([pamm.as_ref(), token0.as_ref(), token1.as_ref()].concat())
449}
450
451#[cfg(test)]
452mod tests {
453    use std::str::FromStr;
454
455    use num_bigint::BigUint;
456
457    use super::*;
458
459    const PAMM: &str = "0x5979458912f80b96d30d4220af8e2e4925a33320";
460    const WBTC: &str = "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599";
461    const USDC: &str = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
462    const WETH: &str = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
463
464    fn token(address: &str, symbol: &str, decimals: u32) -> Token {
465        Token::new(
466            &Bytes::from_str(address).unwrap(),
467            symbol,
468            decimals,
469            0,
470            &[Some(10_000)],
471            Chain::Ethereum,
472            100,
473        )
474    }
475
476    fn tokens() -> HashMap<Bytes, Token> {
477        [token(WBTC, "WBTC", 8), token(USDC, "USDC", 6), token(WETH, "WETH", 18)]
478            .into_iter()
479            .map(|token| (token.address.clone(), token))
480            .collect()
481    }
482
483    fn tracker() -> SnapshotTracker {
484        let config = PriceLevelStreamConfig::new(
485            "fermiswap",
486            Bytes::from_str(PAMM).unwrap(),
487            BigUint::from(120_000u64),
488        );
489        SnapshotTracker::new(
490            HashMap::from([(config.address.clone(), config)]),
491            HashSet::new(),
492            tokens(),
493            false,
494            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
495            false,
496        )
497    }
498
499    fn level(amount_in: u64, amount_out: u64) -> TitanPriceLevel {
500        TitanPriceLevel {
501            amount_in: BigUint::from(amount_in),
502            amount_out: BigUint::from(amount_out),
503        }
504    }
505
506    fn pair_levels(
507        token_in: &str,
508        token_out: &str,
509        order_book: Vec<TitanPriceLevel>,
510    ) -> TitanPairLevels {
511        TitanPairLevels {
512            token_in: Bytes::from_str(token_in).unwrap(),
513            token_out: Bytes::from_str(token_out).unwrap(),
514            order_book,
515        }
516    }
517
518    fn message(block_number: u64, pairs: Vec<TitanPairLevels>) -> TitanPriceLevelMessage {
519        TitanPriceLevelMessage {
520            block_number,
521            pamms: vec![TitanPammLevels { pamm: Bytes::from_str(PAMM).unwrap(), pairs }],
522        }
523    }
524
525    fn wbtc_usdc_pairs() -> Vec<TitanPairLevels> {
526        vec![
527            pair_levels(WBTC, USDC, vec![level(100_000_000, 100_000_000_000)]),
528            pair_levels(USDC, WBTC, vec![level(100_000_000_000, 99_000_000)]),
529        ]
530    }
531
532    fn expected_id() -> String {
533        // pamm ++ token0 ++ token1 with WBTC < USDC.
534        format!("{PAMM}{}{}", &WBTC[2..], &USDC[2..])
535    }
536
537    #[test]
538    fn first_snapshot_emits_new_pair_with_both_directions() {
539        let mut tracker = tracker();
540        let Update {
541            block_number_or_timestamp,
542            is_partial,
543            sync_states,
544            states,
545            new_pairs,
546            removed_pairs,
547        } = tracker
548            .process(message(100, wbtc_usdc_pairs()))
549            .expect("update expected");
550
551        assert_eq!(block_number_or_timestamp, 100);
552        assert!(is_partial);
553        assert!(sync_states.is_empty());
554        assert!(removed_pairs.is_empty());
555
556        let id = expected_id();
557        let component = &new_pairs[&id];
558        assert_eq!(component.protocol_system, "pricelevelstream:fermiswap");
559        assert_eq!(
560            component.static_attributes[PAMM_ADDRESS_ATTRIBUTE],
561            Bytes::from_str(PAMM).unwrap()
562        );
563
564        let PriceLevelStreamState { token0, token1, quotes_0_to_1, quotes_1_to_0, gas_cost } =
565            states[&id]
566                .as_any()
567                .downcast_ref::<PriceLevelStreamState>()
568                .expect("price level state");
569        assert_eq!(token0, &Bytes::from_str(WBTC).unwrap());
570        assert_eq!(token1, &Bytes::from_str(USDC).unwrap());
571        assert_eq!(quotes_0_to_1.len(), 1);
572        assert_eq!(quotes_1_to_0.len(), 1);
573        assert_eq!(quotes_0_to_1[0].amount_in, BigUint::from(100_000_000u64));
574        assert_eq!(gas_cost, &BigUint::from(120_000u64));
575    }
576
577    #[test]
578    fn repeated_snapshot_is_not_a_new_pair() {
579        let mut tracker = tracker();
580        tracker
581            .process(message(100, wbtc_usdc_pairs()))
582            .expect("update expected");
583        let update = tracker
584            .process(message(101, wbtc_usdc_pairs()))
585            .expect("update expected");
586
587        assert!(update.new_pairs.is_empty());
588        assert!(update.removed_pairs.is_empty());
589        assert!(update
590            .states
591            .contains_key(&expected_id()));
592    }
593
594    #[test]
595    fn dropped_pair_is_removed() {
596        let mut tracker = tracker();
597        tracker
598            .process(message(100, wbtc_usdc_pairs()))
599            .expect("update expected");
600        let weth_usdc =
601            vec![pair_levels(WETH, USDC, vec![level(1_000_000_000_000_000_000, 3_000_000_000)])];
602        let update = tracker
603            .process(message(101, weth_usdc))
604            .expect("update expected");
605
606        assert_eq!(update.removed_pairs.len(), 1);
607        assert!(update
608            .removed_pairs
609            .contains_key(&expected_id()));
610        assert_eq!(update.new_pairs.len(), 1);
611        assert_eq!(update.states.len(), 1);
612    }
613
614    #[test]
615    fn out_of_order_frame_is_skipped() {
616        let mut tracker = tracker();
617        tracker
618            .process(message(101, wbtc_usdc_pairs()))
619            .expect("update expected");
620
621        // A frame for an older block is stale: no update, and the caches stay untouched even
622        // though the frame's snapshot differs completely.
623        let stale =
624            vec![pair_levels(WETH, USDC, vec![level(1_000_000_000_000_000_000, 3_000_000_000)])];
625        assert!(tracker
626            .process(message(100, stale))
627            .is_none());
628
629        // The next current frame diffs against the pre-stale state: nothing was added or
630        // removed in between.
631        let update = tracker
632            .process(message(102, wbtc_usdc_pairs()))
633            .expect("update expected");
634        assert!(update.new_pairs.is_empty());
635        assert!(update.removed_pairs.is_empty());
636    }
637
638    #[test]
639    fn vanished_pamm_has_its_pairs_removed() {
640        let mut tracker = tracker();
641        tracker
642            .process(message(100, wbtc_usdc_pairs()))
643            .expect("update expected");
644
645        // The next frame no longer contains the pAMM at all: a complete snapshot without a
646        // venue means the venue is gone, pairs and all.
647        let update = tracker
648            .process(TitanPriceLevelMessage { block_number: 101, pamms: vec![] })
649            .expect("update expected");
650        assert!(update.states.is_empty());
651        assert!(update.new_pairs.is_empty());
652        assert_eq!(update.removed_pairs.len(), 1);
653        assert!(update
654            .removed_pairs
655            .contains_key(&expected_id()));
656
657        // Nothing served and nothing changed: no update.
658        assert!(tracker
659            .process(TitanPriceLevelMessage { block_number: 102, pamms: vec![] })
660            .is_none());
661
662        // A venue that reappears is a new pair again.
663        let update = tracker
664            .process(message(103, wbtc_usdc_pairs()))
665            .expect("update expected");
666        assert!(update
667            .new_pairs
668            .contains_key(&expected_id()));
669    }
670
671    #[test]
672    fn unregistered_pamm_produces_no_update_without_auto_detection() {
673        let mut tracker = SnapshotTracker::new(
674            HashMap::new(),
675            HashSet::new(),
676            tokens(),
677            false,
678            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
679            false,
680        );
681        assert!(tracker
682            .process(message(100, wbtc_usdc_pairs()))
683            .is_none());
684    }
685
686    #[test]
687    fn denied_pamm_is_not_auto_detected() {
688        let denied = HashSet::from([Bytes::from_str(PAMM).unwrap()]);
689        let mut tracker = SnapshotTracker::new(
690            HashMap::new(),
691            denied,
692            tokens(),
693            true,
694            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
695            false,
696        );
697        assert!(tracker
698            .process(message(100, wbtc_usdc_pairs()))
699            .is_none());
700    }
701
702    #[test]
703    fn explicit_add_and_deny_are_last_wins() {
704        let address = Bytes::from_str(PAMM).unwrap();
705        let custom =
706            || PriceLevelStreamConfig::new("custom", Bytes::from_str(PAMM).unwrap(), 1u64.into());
707
708        let builder = PriceLevelStreamBuilder::new()
709            .add_pamm(custom())
710            .deny_pamm(address.clone());
711        assert!(!builder.registry.contains_key(&address));
712        assert!(builder.denied.contains(&address));
713
714        let builder = PriceLevelStreamBuilder::new()
715            .deny_pamm(address.clone())
716            .add_pamm(custom());
717        assert_eq!(builder.registry[&address].protocol, "custom");
718        assert!(builder.denied.is_empty());
719    }
720
721    #[test]
722    fn defaults_never_override_explicit_calls() {
723        // Denying a venue from the default set works in either call order.
724        let fermiswap_router = Bytes::from_str(PAMM).unwrap();
725        for builder in [
726            PriceLevelStreamBuilder::new()
727                .deny_pamm(fermiswap_router.clone())
728                .with_known_pamms(),
729            PriceLevelStreamBuilder::new()
730                .with_known_pamms()
731                .deny_pamm(fermiswap_router.clone()),
732        ] {
733            assert!(!builder
734                .registry
735                .contains_key(&fermiswap_router));
736            assert!(builder
737                .denied
738                .contains(&fermiswap_router));
739            // The other defaults are unaffected.
740            assert!(!builder.registry.is_empty());
741        }
742
743        // Registering a venue from the default deny set works in either call order. Any one of
744        // them exercises that; the set is empty while every streamed venue is executable.
745        let Some(denied_venue) = default_denied_pamms().pop() else { return };
746        let custom = || PriceLevelStreamConfig::new("custom", denied_venue.clone(), 1u64.into());
747        for builder in [
748            PriceLevelStreamBuilder::new()
749                .add_pamm(custom())
750                .with_known_pamms(),
751            PriceLevelStreamBuilder::new()
752                .with_known_pamms()
753                .add_pamm(custom()),
754        ] {
755            assert_eq!(builder.registry[&denied_venue].protocol, "custom");
756            assert!(!builder.denied.contains(&denied_venue));
757        }
758    }
759
760    #[test]
761    fn auto_detected_pamm_is_served_under_its_address() {
762        let mut tracker = SnapshotTracker::new(
763            HashMap::new(),
764            HashSet::new(),
765            tokens(),
766            true,
767            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
768            false,
769        );
770        let update = tracker
771            .process(message(100, wbtc_usdc_pairs()))
772            .expect("update expected");
773
774        let component = &update.new_pairs[&expected_id()];
775        assert_eq!(component.protocol_system, format!("pricelevelstream:{PAMM}"));
776        let state = update.states[&expected_id()]
777            .as_any()
778            .downcast_ref::<PriceLevelStreamState>()
779            .expect("price level state");
780        assert_eq!(state.gas_cost, BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST));
781
782        // The synthesized config is cached: the next snapshot is not a new pair again.
783        let update = tracker
784            .process(message(101, wbtc_usdc_pairs()))
785            .expect("update expected");
786        assert!(update.new_pairs.is_empty());
787    }
788
789    #[test]
790    fn auto_detected_gas_cost_override_applies() {
791        let mut tracker = SnapshotTracker::new(
792            HashMap::new(),
793            HashSet::new(),
794            tokens(),
795            true,
796            BigUint::from(42_000u64),
797            false,
798        );
799        let update = tracker
800            .process(message(100, wbtc_usdc_pairs()))
801            .expect("update expected");
802
803        let state = update.states[&expected_id()]
804            .as_any()
805            .downcast_ref::<PriceLevelStreamState>()
806            .expect("price level state");
807        assert_eq!(state.gas_cost, BigUint::from(42_000u64));
808    }
809
810    #[test]
811    fn with_known_pamms_registers_known_venues() {
812        // PAMM is the FermiSwap router, one of the default venues.
813        let fermiswap_router = Bytes::from_str(PAMM).unwrap();
814
815        let builder = PriceLevelStreamBuilder::new();
816        assert!(builder.registry.is_empty());
817        assert!(builder.denied.is_empty());
818
819        let builder = builder.with_known_pamms();
820        assert_eq!(builder.registry[&fermiswap_router].protocol, "fermiswap");
821        // The known-bad venues get denied alongside, and never overlap the served defaults.
822        assert_eq!(
823            builder.denied,
824            default_denied_pamms()
825                .into_iter()
826                .collect()
827        );
828        assert!(builder.denied.is_disjoint(
829            &builder
830                .registry
831                .keys()
832                .cloned()
833                .collect()
834        ));
835
836        // An `add_pamm` entry wins over the default for the same address, in either call order.
837        let custom =
838            || PriceLevelStreamConfig::new("custom", fermiswap_router.clone(), BigUint::from(1u64));
839        for builder in [
840            PriceLevelStreamBuilder::new()
841                .add_pamm(custom())
842                .with_known_pamms(),
843            PriceLevelStreamBuilder::new()
844                .with_known_pamms()
845                .add_pamm(custom()),
846        ] {
847            assert_eq!(builder.registry[&fermiswap_router].protocol, "custom");
848            assert_eq!(builder.registry[&fermiswap_router].gas_cost, BigUint::from(1u64));
849        }
850    }
851
852    /// Components are emitted under `fallback:{name}`, so their swaps execute through
853    /// `TychoFallbackRouter`; identity and attributes are the same as on the direct path.
854    #[test]
855    fn venues_are_served_under_the_fallback_family() {
856        let config = PriceLevelStreamConfig::new(
857            "fermiswap",
858            Bytes::from_str(PAMM).unwrap(),
859            BigUint::from(120_000u64),
860        );
861        let mut tracker = SnapshotTracker::new(
862            HashMap::from([(config.address.clone(), config)]),
863            HashSet::new(),
864            tokens(),
865            false,
866            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
867            true,
868        );
869
870        let update = tracker
871            .process(message(100, wbtc_usdc_pairs()))
872            .expect("update expected");
873
874        let component = &update.new_pairs[&expected_id()];
875        assert_eq!(component.protocol_system, "fallback:fermiswap");
876        assert_eq!(
877            component.static_attributes[PAMM_ADDRESS_ATTRIBUTE],
878            Bytes::from_str(PAMM).unwrap()
879        );
880    }
881
882    /// Auto-detected, address-named venues take the fallback family too.
883    #[test]
884    fn auto_detected_venue_is_served_under_the_fallback_family() {
885        let mut tracker = SnapshotTracker::new(
886            HashMap::new(),
887            HashSet::new(),
888            tokens(),
889            true,
890            BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
891            true,
892        );
893
894        let update = tracker
895            .process(message(100, wbtc_usdc_pairs()))
896            .expect("update expected");
897
898        let component = &update.new_pairs[&expected_id()];
899        assert_eq!(component.protocol_system, format!("fallback:{PAMM}"));
900    }
901
902    /// Off the fallback router, a venue keeps the direct `pricelevelstream:{name}` family.
903    #[test]
904    fn without_fallback_router_keeps_the_direct_family() {
905        let mut tracker = tracker();
906
907        let update = tracker
908            .process(message(100, wbtc_usdc_pairs()))
909            .expect("update expected");
910
911        assert_eq!(update.new_pairs[&expected_id()].protocol_system, "pricelevelstream:fermiswap");
912    }
913
914    /// The fallback router path is the default; `without_fallback_router` is the way off it.
915    #[test]
916    fn fallback_router_is_on_unless_opted_out() {
917        assert!(PriceLevelStreamBuilder::new().fallback_router);
918        assert!(
919            !PriceLevelStreamBuilder::new()
920                .without_fallback_router()
921                .fallback_router
922        );
923    }
924
925    /// The families this stream emits are the ones tycho-execution resolves an encoder for. A
926    /// drift between the two makes every route through a pAMM fail to encode.
927    #[test]
928    fn families_match_the_execution_side_prefixes() {
929        use tycho_execution::encoding::evm::{FALLBACK_PREFIX, PRICE_LEVEL_STREAM_PREFIX};
930
931        use super::super::config::{FALLBACK_FAMILY, PRICE_LEVEL_STREAM_FAMILY};
932
933        assert_eq!(format!("{PRICE_LEVEL_STREAM_FAMILY}:"), PRICE_LEVEL_STREAM_PREFIX);
934        assert_eq!(format!("{FALLBACK_FAMILY}:"), FALLBACK_PREFIX);
935    }
936
937    #[test]
938    fn unknown_tokens_are_skipped() {
939        let mut tracker = tracker();
940        let unknown = vec![pair_levels(
941            "0x1111111111111111111111111111111111111111",
942            USDC,
943            vec![level(1, 1)],
944        )];
945        assert!(tracker
946            .process(message(100, unknown))
947            .is_none());
948    }
949}