Skip to main content

fynd_core/feed/
protocol_registry.rs

1use std::{collections::HashMap, env, fmt, time::Duration};
2
3use tokio_stream::Stream;
4use tracing::{info, warn};
5use tycho_simulation::{
6    evm::{
7        engine_db::tycho_db::PreCachedDB,
8        protocol::{
9            aerodrome_slipstreams::state::AerodromeSlipstreamsState,
10            aerodrome_v1::state::AerodromeV1State,
11            curve::CurveState,
12            ekubo::state::EkuboState,
13            ekubo_v3::state::EkuboV3State,
14            erc4626::state::ERC4626State,
15            filters::{
16                balancer_v2_pool_filter, curve_filter, ekubo_v3_extension_filter,
17                ekubo_v3_extension_filter_with_signed_exclusive_swap, erc4626_filter,
18                fluid_v1_paused_pools_filter,
19            },
20            fluid::FluidV1,
21            lunarbase::state::LunarBaseState,
22            pancakeswap_v2::state::PancakeswapV2State,
23            uniswap_v2::state::UniswapV2State,
24            uniswap_v3::state::UniswapV3State,
25            uniswap_v4::state::UniswapV4State,
26            vm::state::EVMPoolState,
27        },
28        stream::ProtocolStreamBuilder,
29        tycho_models::Chain,
30    },
31    price_level_stream::{config::default_served_pamms, stream::PriceLevelStreamBuilder},
32    protocol::models::Update,
33    rfq::{
34        protocols::{
35            bebop::{client_builder::BebopClientBuilder, state::BebopState},
36            hashflow::{client_builder::HashflowClientBuilder, state::HashflowState},
37        },
38        stream::RFQStreamBuilder,
39    },
40    tycho_client::feed::component_tracker::ComponentFilter,
41    tycho_common::models::token::Token,
42    tycho_core::Bytes,
43};
44
45use super::DataFeedError;
46
47/// Opts a protocol into streaming its exclusive pools, e.g. `exclusive:ekubo_v3`.
48///
49/// Fynd-side only: stripped before registration, so Tycho sees the bare system name.
50const EXCLUSIVE_PREFIX: &str = "exclusive:";
51
52/// Protocol systems that offer an exclusive-liquidity stream variant, i.e. the ones that may be
53/// requested with the `exclusive:` prefix.
54const EXCLUSIVE_CAPABLE_PROTOCOLS: &[&str] = &["ekubo_v3"];
55
56/// Marks a `--protocols` entry served from the Titan pAMM price level stream rather than from
57/// Tycho, e.g. `pricelevelstream:fermiswap`.
58const PRICE_LEVEL_STREAM_PREFIX: &str = "pricelevelstream:";
59
60/// Marks a component whose swaps execute through Titan's PropAMMRouter rather than against the
61/// venue directly, e.g. `propammfallback:fermiswap`.
62///
63/// tycho-simulation gives a venue on the router's on-chain whitelist this family instead of
64/// [`PRICE_LEVEL_STREAM_PREFIX`], so one `pricelevelstream:{venue}` entry can bring in components
65/// under either prefix depending on the whitelist. Fynd never requests this family: it names the
66/// venue, and the stream decides which of the two labels its components carry.
67const PROPAMM_FALLBACK_PREFIX: &str = "propammfallback:";
68
69/// Marks a `--protocols` entry served from an RFQ client rather than from Tycho, e.g.
70/// `rfq:bebop`.
71const RFQ_PREFIX: &str = "rfq:";
72
73/// Marks a `--protocols` entry that drops a protocol system from the list rather than adding one,
74/// e.g. `exclude:vm:fermiswap`.
75pub const EXCLUDE_PREFIX: &str = "exclude:";
76
77/// The only chain the Titan pAMM price level stream serves.
78///
79/// Tracks tycho-simulation's `default_served_pamms`, whose venue addresses are all Ethereum
80/// mainnet deployments; it carries no chain of its own, so this has to move when it gains a venue
81/// elsewhere.
82const PRICE_LEVEL_STREAM_CHAIN: Chain = Chain::Ethereum;
83
84/// Whether any requested protocol is streamed from Tycho.
85///
86/// The RFQ clients and the pAMM price level stream each connect to their own endpoint, so a list
87/// naming only those needs no Tycho protocol stream at all.
88pub(crate) fn has_tycho_protocols(protocols: &[String]) -> bool {
89    protocols.iter().any(|protocol| {
90        !protocol.starts_with(RFQ_PREFIX) && !protocol.starts_with(PRICE_LEVEL_STREAM_PREFIX)
91    })
92}
93
94/// Whether the components labelled `protocol_system` are the ones a `--protocols` entry asked for.
95///
96/// Most entries name their own label. A `pricelevelstream:{venue}` entry names the venue to
97/// stream, and its components arrive labelled `propammfallback:{venue}` when that venue is on the
98/// PropAMMRouter whitelist, so both prefixes answer for the same entry.
99pub fn matches_streamed_system(entry: &str, protocol_system: &str) -> bool {
100    if entry == protocol_system {
101        return true;
102    }
103    match (
104        entry.strip_prefix(PRICE_LEVEL_STREAM_PREFIX),
105        protocol_system.strip_prefix(PROPAMM_FALLBACK_PREFIX),
106    ) {
107        (Some(requested_venue), Some(streamed_venue)) => requested_venue == streamed_venue,
108        _ => false,
109    }
110}
111
112/// Whether any requested protocol is served by an RFQ client.
113pub(crate) fn has_rfq_protocols(protocols: &[String]) -> bool {
114    protocols
115        .iter()
116        .any(|protocol| protocol.starts_with(RFQ_PREFIX))
117}
118
119/// The `exclusive:` prefix was applied to a protocol system that has no exclusive variant.
120#[derive(Debug, thiserror::Error)]
121#[error(
122    "protocol '{requested}' has no exclusive-liquidity variant; '{EXCLUSIVE_PREFIX}' is only \
123     supported for: {supported}",
124    supported = EXCLUSIVE_CAPABLE_PROTOCOLS.join(", ")
125)]
126pub struct UnsupportedExclusiveProtocol {
127    /// The protocol system the prefix was applied to.
128    requested: String,
129}
130
131/// A requested protocol system together with the liquidity variant to stream for it.
132///
133/// `parse` and the `Display` impl round-trip: displaying one yields a `--protocols` entry that
134/// parses back to the same value.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ProtocolSpec {
137    /// Tycho protocol system name. Never carries the `exclusive:` prefix.
138    pub system: String,
139    /// Whether to register the filter that also admits exclusive pools.
140    pub exclusive: bool,
141}
142
143impl ProtocolSpec {
144    /// A protocol system streaming public liquidity only.
145    pub fn public(system: impl Into<String>) -> Self {
146        Self { system: system.into(), exclusive: false }
147    }
148
149    /// Parses a single `--protocols` entry.
150    ///
151    /// # Errors
152    ///
153    /// Returns `UnsupportedExclusiveProtocol` when the `exclusive:` prefix is applied to a protocol
154    /// system that has no exclusive variant. Unrecognised protocol systems without the prefix are
155    /// accepted here and skipped with a warning during registration.
156    pub fn parse(entry: &str) -> Result<Self, UnsupportedExclusiveProtocol> {
157        let Some(system) = entry.strip_prefix(EXCLUSIVE_PREFIX) else {
158            return Ok(Self::public(entry));
159        };
160        if !EXCLUSIVE_CAPABLE_PROTOCOLS.contains(&system) {
161            return Err(UnsupportedExclusiveProtocol { requested: system.to_string() });
162        }
163        Ok(Self { system: system.to_string(), exclusive: true })
164    }
165}
166
167/// Parses a `--protocols` entry that drops a protocol system, e.g. `exclude:vm:fermiswap`.
168///
169/// Returns `None` for an entry that names a protocol to stream instead. The part after the prefix
170/// goes through [`ProtocolSpec::parse`], so `exclude:ekubo_v3` and `exclude:exclusive:ekubo_v3`
171/// both name the system `ekubo_v3` and a malformed exclusion fails the same way a malformed
172/// request does. An entry naming nothing (`exclude:`) yields an empty system for the caller to
173/// reject.
174///
175/// # Errors
176///
177/// Returns `UnsupportedExclusiveProtocol` when the excluded entry carries the `exclusive:` prefix
178/// for a protocol system that has no exclusive variant.
179pub fn parse_exclusion(entry: &str) -> Option<Result<String, UnsupportedExclusiveProtocol>> {
180    let excluded = entry.strip_prefix(EXCLUDE_PREFIX)?;
181    Some(ProtocolSpec::parse(excluded).map(|protocol| protocol.system))
182}
183
184impl fmt::Display for ProtocolSpec {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        if self.exclusive {
187            write!(f, "{EXCLUSIVE_PREFIX}{}", self.system)
188        } else {
189            f.write_str(&self.system)
190        }
191    }
192}
193
194/// Register DEX protocol decoders for test tooling (record-market).
195///
196/// Wrapper over [`register_exchanges`] so the recorder builds the same protocol stream as
197/// production without exposing the crate-private `DataFeedError`.
198#[cfg(feature = "test-utils")]
199pub fn register_exchanges_for_recording(
200    builder: ProtocolStreamBuilder,
201    tvl_filter: ComponentFilter,
202    entries: &[String],
203) -> Result<ProtocolStreamBuilder, String> {
204    register_exchanges(builder, tvl_filter, entries).map_err(|e| e.to_string())
205}
206
207/// Parses every `--protocols` entry, rejecting a list with no unambiguous reading.
208///
209/// Registration is keyed by protocol system, so naming one system both with and without the
210/// `exclusive:` prefix would silently keep whichever entry came last. Callers that expand a
211/// protocol list (`fynd_rpc::protocols::resolve_protocols`) merge the variants before getting here;
212/// a hand-assembled list gets an error instead of an order-dependent stream.
213fn parse_protocols(entries: &[String]) -> Result<Vec<ProtocolSpec>, DataFeedError> {
214    let mut protocols = Vec::with_capacity(entries.len());
215    for entry in entries {
216        protocols
217            .push(ProtocolSpec::parse(entry).map_err(|e| DataFeedError::Config(e.to_string()))?);
218    }
219
220    let mut variants: HashMap<&str, bool> = HashMap::new();
221    for protocol in &protocols {
222        if variants
223            .insert(protocol.system.as_str(), protocol.exclusive)
224            .is_some_and(|previous| previous != protocol.exclusive)
225        {
226            return Err(DataFeedError::Config(format!(
227                "protocol '{}' requested both with and without the '{EXCLUSIVE_PREFIX}' prefix",
228                protocol.system
229            )));
230        }
231    }
232    Ok(protocols)
233}
234
235/// Register DEX protocol decoders on a [`ProtocolStreamBuilder`].
236///
237/// Entries may carry the `exclusive:` prefix to select the protocol's exclusive-liquidity stream
238/// variant; doing so for a protocol without one is a configuration error, as is naming one protocol
239/// both with and without the prefix.
240pub(crate) fn register_exchanges(
241    mut builder: ProtocolStreamBuilder,
242    tvl_filter: ComponentFilter,
243    entries: &[String],
244) -> Result<ProtocolStreamBuilder, DataFeedError> {
245    for protocol in parse_protocols(entries)? {
246        match protocol.system.as_str() {
247            "uniswap_v2" => {
248                builder =
249                    builder.exchange::<UniswapV2State>("uniswap_v2", tvl_filter.clone(), None);
250            }
251            "sushiswap_v2" => {
252                builder =
253                    builder.exchange::<UniswapV2State>("sushiswap_v2", tvl_filter.clone(), None);
254            }
255            "pancakeswap_v2" => {
256                builder = builder.exchange::<PancakeswapV2State>(
257                    "pancakeswap_v2",
258                    tvl_filter.clone(),
259                    None,
260                );
261            }
262            "uniswap_v3" => {
263                builder =
264                    builder.exchange::<UniswapV3State>("uniswap_v3", tvl_filter.clone(), None);
265            }
266            "pancakeswap_v3" => {
267                builder =
268                    builder.exchange::<UniswapV3State>("pancakeswap_v3", tvl_filter.clone(), None);
269            }
270            "vm:balancer_v2" => {
271                builder = builder.exchange::<EVMPoolState<PreCachedDB>>(
272                    "vm:balancer_v2",
273                    tvl_filter.clone(),
274                    Some(balancer_v2_pool_filter),
275                );
276            }
277            "uniswap_v4" => {
278                builder =
279                    builder.exchange::<UniswapV4State>("uniswap_v4", tvl_filter.clone(), None);
280            }
281            "ekubo_v2" => {
282                builder = builder.exchange::<EkuboState>("ekubo_v2", tvl_filter.clone(), None);
283            }
284            "vm:curve" => {
285                // The hybrid CurveState with tycho-simulation's own curve_filter, which drops
286                // the components CurveState cannot quote correctly (oracle/rate-bearing/rebasing
287                // coins) — the source of the overestimation that forced the temporary
288                // full-EVM fallback (see #318); fixed upstream in tycho-simulation 0.338.0.
289                builder = builder.exchange::<CurveState>(
290                    "vm:curve",
291                    tvl_filter.clone(),
292                    Some(curve_filter),
293                );
294            }
295            "uniswap_v4_hooks" => {
296                builder = builder.exchange::<UniswapV4State>(
297                    "uniswap_v4_hooks",
298                    tvl_filter.clone(),
299                    None,
300                );
301            }
302            "vm:maverick_v2" => {
303                builder = builder.exchange::<EVMPoolState<PreCachedDB>>(
304                    "vm:maverick_v2",
305                    tvl_filter.clone(),
306                    None,
307                );
308            }
309            "vm:bopamm" => {
310                builder = builder.exchange::<EVMPoolState<PreCachedDB>>(
311                    "vm:bopamm",
312                    tvl_filter.clone(),
313                    None,
314                );
315            }
316            "vm:fermiswap" => {
317                builder = builder.exchange::<EVMPoolState<PreCachedDB>>(
318                    "vm:fermiswap",
319                    tvl_filter.clone(),
320                    None,
321                );
322            }
323            "fluid_v1" => {
324                builder = builder.exchange::<FluidV1>(
325                    "fluid_v1",
326                    tvl_filter.clone(),
327                    Some(fluid_v1_paused_pools_filter),
328                );
329            }
330            "aerodrome_v1" => {
331                builder =
332                    builder.exchange::<AerodromeV1State>("aerodrome_v1", tvl_filter.clone(), None);
333            }
334            "aerodrome_slipstreams" => {
335                builder = builder.exchange::<AerodromeSlipstreamsState>(
336                    "aerodrome_slipstreams",
337                    tvl_filter.clone(),
338                    None,
339                );
340            }
341            "erc4626" => {
342                builder = builder.exchange::<ERC4626State>(
343                    "erc4626",
344                    tvl_filter.clone(),
345                    Some(erc4626_filter),
346                );
347            }
348            "velodrome_slipstreams" => {
349                builder = builder.exchange::<AerodromeSlipstreamsState>(
350                    "velodrome_slipstreams",
351                    tvl_filter.clone(),
352                    None,
353                );
354            }
355            "ekubo_v3" => {
356                // SignedExclusiveSwap pools need a controller signature per swap, so they are
357                // only streamed when the deployment explicitly opts in.
358                let filter = if protocol.exclusive {
359                    info!("Including exclusive liquidity for ekubo_v3");
360                    ekubo_v3_extension_filter_with_signed_exclusive_swap
361                } else {
362                    ekubo_v3_extension_filter
363                };
364                builder =
365                    builder.exchange::<EkuboV3State>("ekubo_v3", tvl_filter.clone(), Some(filter));
366            }
367            "quickswap_v2" => {
368                builder =
369                    builder.exchange::<UniswapV2State>("quickswap_v2", tvl_filter.clone(), None);
370            }
371            "lunarbase" => {
372                builder = builder.exchange::<LunarBaseState>("lunarbase", tvl_filter.clone(), None);
373            }
374            p if p.starts_with(RFQ_PREFIX) || p.starts_with(PRICE_LEVEL_STREAM_PREFIX) => {
375                // Handled by register_rfq and open_price_level_stream, which stream from their
376                // own endpoints rather than from Tycho.
377                continue;
378            }
379            _ => {
380                warn!("Skipping unknown protocol: {}", protocol);
381            }
382        }
383    }
384    Ok(builder)
385}
386
387pub(crate) fn register_rfq(
388    mut rfq_stream_builder: RFQStreamBuilder,
389    chain: Chain,
390    min_tvl: f64,
391    protocols: &[String],
392    rfq_tokens: std::collections::HashSet<Bytes>,
393) -> Result<RFQStreamBuilder, DataFeedError> {
394    for protocol in protocols {
395        match protocol.as_str() {
396            "rfq:bebop" => {
397                let key = get_env("BEBOP_KEY")?;
398                info!("Adding {protocol} RFQ client...");
399                let bebop_client = BebopClientBuilder::new(chain, key)
400                    .tokens(rfq_tokens.clone())
401                    .tvl_threshold(min_tvl)
402                    .build()
403                    .map_err(|e| DataFeedError::StreamError(e.to_string()))?;
404                rfq_stream_builder =
405                    rfq_stream_builder.add_client::<BebopState>("bebop", Box::new(bebop_client));
406            }
407            "rfq:hashflow" => {
408                let user = get_env("HASHFLOW_USER")?;
409                let key = get_env("HASHFLOW_KEY")?;
410                info!("Adding {protocol} RFQ client...");
411                let hashflow_client = HashflowClientBuilder::new(chain, user, key)
412                    .tokens(rfq_tokens.clone())
413                    .tvl_threshold(min_tvl)
414                    .poll_time(Duration::from_secs(30))
415                    .build()
416                    .map_err(|e| DataFeedError::StreamError(e.to_string()))?;
417                rfq_stream_builder = rfq_stream_builder
418                    .add_client::<HashflowState>("hashflow", Box::new(hashflow_client));
419            }
420            p if p.starts_with(RFQ_PREFIX) => {
421                warn!("Skipping unknown RFQ protocol: {}", p);
422            }
423            _ => {}
424        }
425    }
426    Ok(rfq_stream_builder)
427}
428
429/// Opens the Titan pAMM price level stream for the requested `pricelevelstream:` venues.
430///
431/// Returns `None` when no entry names the stream. Every named venue must be one of the venues
432/// tycho-simulation knows how to execute against ([`default_served_pamms`]); a name outside that
433/// set is a configuration error rather than a warning, because these entries are always written
434/// by hand and a typo would otherwise silently stream nothing.
435///
436/// The stream reconnects on its own for as long as it is polled, so — unlike the RFQ clients —
437/// it needs no supervising task.
438///
439/// A venue served here may also be integrated as a Tycho protocol system (FermiSwap is also
440/// `vm:fermiswap`), in which case both price the same maker inventory. Streaming both
441/// double-counts that liquidity, so drop the Tycho one from `--protocols` instead.
442///
443/// # Errors
444///
445/// Returns [`DataFeedError::Config`] if the chain is not [`PRICE_LEVEL_STREAM_CHAIN`], or if an
446/// entry names a venue that is not served.
447pub(crate) fn open_price_level_stream(
448    chain: Chain,
449    protocols: &[String],
450    tokens: &HashMap<Bytes, Token>,
451) -> Result<Option<impl Stream<Item = Update> + Send>, DataFeedError> {
452    let venues: Vec<&str> = protocols
453        .iter()
454        .filter_map(|protocol| protocol.strip_prefix(PRICE_LEVEL_STREAM_PREFIX))
455        .collect();
456    if venues.is_empty() {
457        return Ok(None);
458    }
459    if chain != PRICE_LEVEL_STREAM_CHAIN {
460        return Err(DataFeedError::Config(format!(
461            "the pAMM price level stream serves {PRICE_LEVEL_STREAM_CHAIN} only, but this feed \
462             runs on {chain}"
463        )));
464    }
465
466    let served = default_served_pamms();
467    let mut builder = PriceLevelStreamBuilder::new().with_tokens(tokens.clone());
468    for venue in venues {
469        let Some(config) = served
470            .iter()
471            .find(|config| config.protocol == venue)
472        else {
473            return Err(DataFeedError::Config(format!(
474                "unknown pAMM '{venue}' for the price level stream; served venues are: {}",
475                served
476                    .iter()
477                    .map(|config| config.protocol.as_str())
478                    .collect::<Vec<_>>()
479                    .join(", ")
480            )));
481        };
482        info!("Adding {PRICE_LEVEL_STREAM_PREFIX}{venue} price level venue...");
483        builder = builder.add_pamm(config.clone());
484    }
485    Ok(Some(builder.build()))
486}
487
488/// Opens the pAMM price level stream for test tooling (the benchmark's live capture).
489///
490/// Wrapper over [`open_price_level_stream`] so the capture serves the same venues as
491/// production without exposing the crate-private `DataFeedError`.
492#[cfg(feature = "test-utils")]
493pub fn open_price_level_stream_for_recording(
494    chain: Chain,
495    protocols: &[String],
496    tokens: &HashMap<Bytes, Token>,
497) -> Result<Option<impl Stream<Item = Update> + Send>, String> {
498    open_price_level_stream(chain, protocols, tokens).map_err(|e| e.to_string())
499}
500
501fn get_env(var: &str) -> Result<String, DataFeedError> {
502    env::var(var).map_err(|_| DataFeedError::Config(format!("{} env var not set", var)))
503}
504
505#[cfg(test)]
506mod tests {
507    use tycho_simulation::price_level_stream::config::PRICE_LEVEL_STREAM_FAMILY;
508
509    use super::*;
510
511    fn register(entries: &[&str]) -> Result<ProtocolStreamBuilder, DataFeedError> {
512        register_exchanges(
513            ProtocolStreamBuilder::new("localhost:0", Chain::Ethereum),
514            ComponentFilter::with_tvl_range(1.0, 10.0),
515            &entries
516                .iter()
517                .map(|entry| (*entry).to_string())
518                .collect::<Vec<_>>(),
519        )
520    }
521
522    fn price_level_stream(
523        chain: Chain,
524        entries: &[&str],
525    ) -> Result<Option<impl Stream<Item = Update> + Send>, DataFeedError> {
526        open_price_level_stream(
527            chain,
528            &entries
529                .iter()
530                .map(|entry| (*entry).to_string())
531                .collect::<Vec<_>>(),
532            &HashMap::new(),
533        )
534    }
535
536    #[test]
537    fn test_parse_plain_protocol() {
538        let protocol = ProtocolSpec::parse("uniswap_v3").unwrap();
539        assert_eq!(protocol, ProtocolSpec { system: "uniswap_v3".to_string(), exclusive: false });
540    }
541
542    #[test]
543    fn test_parse_exclusive_protocol() {
544        let protocol = ProtocolSpec::parse("exclusive:ekubo_v3").unwrap();
545        assert_eq!(protocol, ProtocolSpec { system: "ekubo_v3".to_string(), exclusive: true });
546    }
547
548    #[test]
549    fn test_parse_exclusive_unsupported_protocol() {
550        let err = ProtocolSpec::parse("exclusive:uniswap_v3").unwrap_err();
551        assert!(err
552            .to_string()
553            .contains("has no exclusive-liquidity variant"));
554    }
555
556    #[test]
557    fn test_parse_exclusive_without_protocol() {
558        assert!(ProtocolSpec::parse("exclusive:").is_err());
559    }
560
561    #[test]
562    fn test_parse_leaves_other_prefixes_intact() {
563        assert_eq!(
564            ProtocolSpec::parse("rfq:bebop").unwrap(),
565            ProtocolSpec { system: "rfq:bebop".to_string(), exclusive: false }
566        );
567        assert_eq!(
568            ProtocolSpec::parse("vm:curve").unwrap(),
569            ProtocolSpec { system: "vm:curve".to_string(), exclusive: false }
570        );
571    }
572
573    #[test]
574    fn test_parse_exclusion() {
575        assert_eq!(
576            parse_exclusion("exclude:vm:fermiswap")
577                .unwrap()
578                .unwrap(),
579            "vm:fermiswap"
580        );
581    }
582
583    #[test]
584    fn test_parse_exclusion_strips_the_exclusive_prefix() {
585        assert_eq!(
586            parse_exclusion("exclude:exclusive:ekubo_v3")
587                .unwrap()
588                .unwrap(),
589            "ekubo_v3"
590        );
591    }
592
593    #[test]
594    fn test_parse_exclusion_rejects_unsupported_exclusive() {
595        assert!(parse_exclusion("exclude:exclusive:uniswap_v3")
596            .unwrap()
597            .is_err());
598    }
599
600    #[test]
601    fn test_parse_exclusion_without_protocol() {
602        assert_eq!(
603            parse_exclusion("exclude:")
604                .unwrap()
605                .unwrap(),
606            ""
607        );
608    }
609
610    #[test]
611    fn test_parse_exclusion_of_a_plain_entry() {
612        assert!(parse_exclusion("uniswap_v3").is_none());
613    }
614
615    #[test]
616    fn test_display_round_trips() {
617        for entry in ["uniswap_v3", "exclusive:ekubo_v3", "rfq:bebop", "vm:curve"] {
618            let protocol = ProtocolSpec::parse(entry).unwrap();
619            assert_eq!(protocol.to_string(), entry);
620            assert_eq!(ProtocolSpec::parse(&protocol.to_string()).unwrap(), protocol);
621        }
622    }
623
624    #[test]
625    fn test_register_exchanges_accepts_exclusive_ekubo_v3() {
626        assert!(register(&["uniswap_v3", "exclusive:ekubo_v3"]).is_ok());
627    }
628
629    #[test]
630    fn test_register_exchanges_rejects_unsupported_exclusive() {
631        let Err(err) = register(&["exclusive:uniswap_v3"]) else {
632            panic!("expected `exclusive:uniswap_v3` to be rejected");
633        };
634        assert!(matches!(err, DataFeedError::Config(_)), "expected a config error, got {err:?}");
635    }
636
637    #[test]
638    fn test_register_exchanges_skips_unknown_protocol() {
639        assert!(register(&["not_a_protocol"]).is_ok());
640    }
641
642    #[test]
643    fn test_register_exchanges_rejects_conflicting_variants() {
644        for protocols in [["ekubo_v3", "exclusive:ekubo_v3"], ["exclusive:ekubo_v3", "ekubo_v3"]] {
645            let Err(err) = register(&protocols) else {
646                panic!("expected {protocols:?} to be rejected");
647            };
648            assert!(
649                err.to_string()
650                    .contains("both with and without"),
651                "unexpected error for {protocols:?}: {err}"
652            );
653        }
654    }
655
656    #[test]
657    fn test_register_exchanges_allows_repeated_protocol() {
658        assert!(register(&["uniswap_v3", "uniswap_v3"]).is_ok());
659    }
660    #[test]
661    fn test_price_level_stream_prefix_matches_family() {
662        assert_eq!(PRICE_LEVEL_STREAM_PREFIX, format!("{PRICE_LEVEL_STREAM_FAMILY}:"));
663    }
664
665    #[test]
666    fn test_matches_streamed_system() {
667        assert!(matches_streamed_system("uniswap_v3", "uniswap_v3"));
668        assert!(matches_streamed_system(
669            "pricelevelstream:fermiswap",
670            "pricelevelstream:fermiswap"
671        ));
672        // The whitelisted venue arrives under the router's family for the same entry.
673        assert!(matches_streamed_system("pricelevelstream:fermiswap", "propammfallback:fermiswap"));
674    }
675
676    #[test]
677    fn test_matches_streamed_system_rejects_another_venue() {
678        assert!(!matches_streamed_system("pricelevelstream:fermiswap", "propammfallback:kipseli"));
679        assert!(!matches_streamed_system("pricelevelstream:fermiswap", "vm:fermiswap"));
680        assert!(!matches_streamed_system("uniswap_v3", "propammfallback:fermiswap"));
681        assert!(!matches_streamed_system("vm:fermiswap", "propammfallback:fermiswap"));
682    }
683
684    #[test]
685    fn test_has_tycho_protocols() {
686        assert!(has_tycho_protocols(&["uniswap_v3".to_string()]));
687        assert!(has_tycho_protocols(&["rfq:bebop".to_string(), "uniswap_v3".to_string()]));
688        assert!(!has_tycho_protocols(&[
689            "rfq:bebop".to_string(),
690            "pricelevelstream:fermiswap".to_string(),
691        ]));
692        assert!(!has_tycho_protocols(&[]));
693    }
694
695    #[test]
696    fn test_register_exchanges_skips_price_level_entries() {
697        assert!(register(&["uniswap_v3", "pricelevelstream:fermiswap"]).is_ok());
698    }
699
700    #[test]
701    fn test_open_price_level_stream_without_entries() {
702        let Ok(None) = price_level_stream(Chain::Ethereum, &["uniswap_v3", "rfq:bebop"]) else {
703            panic!("expected no price level stream without a `pricelevelstream:` entry");
704        };
705    }
706
707    #[test]
708    fn test_open_price_level_stream_served_venue() {
709        let Ok(Some(_)) = price_level_stream(Chain::Ethereum, &["pricelevelstream:fermiswap"])
710        else {
711            panic!("expected fermiswap to be served");
712        };
713    }
714
715    #[test]
716    fn test_open_price_level_stream_several_venues() {
717        let Ok(Some(_)) = price_level_stream(
718            Chain::Ethereum,
719            &["pricelevelstream:fermiswap", "pricelevelstream:kipseli"],
720        ) else {
721            panic!("expected both venues to be served");
722        };
723    }
724
725    #[test]
726    fn test_open_price_level_stream_unknown_venue() {
727        for entries in [
728            vec!["pricelevelstream:nope"],
729            vec!["pricelevelstream:fermiswap", "pricelevelstream:nope"],
730        ] {
731            let Err(err) = price_level_stream(Chain::Ethereum, &entries) else {
732                panic!("expected an unserved venue to be rejected in {entries:?}");
733            };
734            assert!(
735                err.to_string()
736                    .contains("unknown pAMM 'nope'"),
737                "got {err}"
738            );
739        }
740    }
741
742    #[test]
743    fn test_open_price_level_stream_without_entries_off_ethereum() {
744        let Ok(None) = price_level_stream(Chain::Base, &["uniswap_v3"]) else {
745            panic!("expected chains without a `pricelevelstream:` entry to be left alone");
746        };
747    }
748
749    #[test]
750    fn test_open_price_level_stream_other_chain() {
751        let Err(err) = price_level_stream(Chain::Base, &["pricelevelstream:fermiswap"]) else {
752            panic!("expected the price level stream to be rejected off Ethereum");
753        };
754        assert!(
755            err.to_string()
756                .contains("serves ethereum only"),
757            "got {err}"
758        );
759    }
760}