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
28pub const PAMM_ADDRESS_ATTRIBUTE: &str = "pamm_address";
30
31#[derive(Default)]
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}
53
54impl PriceLevelStreamBuilder {
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn auto_detect(mut self, enabled: bool) -> Self {
71 self.auto_detect = enabled;
72 self
73 }
74
75 pub fn auto_detected_gas_cost(mut self, gas_cost: BigUint) -> Self {
80 self.auto_detected_gas_cost = Some(gas_cost);
81 self
82 }
83
84 pub fn endpoint(mut self, url: impl Into<String>) -> Self {
87 self.url = Some(url.into());
88 self
89 }
90
91 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
94 self.connection.connect_timeout = timeout;
95 self
96 }
97
98 pub fn read_idle_timeout(mut self, timeout: Duration) -> Self {
102 self.connection.read_idle_timeout = timeout;
103 self
104 }
105
106 pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
109 self.connection.max_backoff = max_backoff;
110 self
111 }
112
113 pub fn add_pamm(mut self, config: PriceLevelStreamConfig) -> Self {
120 self.denied.remove(&config.address);
121 self.registry
122 .insert(config.address.clone(), config);
123 self
124 }
125
126 pub fn deny_pamm(mut self, address: Bytes) -> Self {
135 self.registry.remove(&address);
136 self.denied.insert(address);
137 self
138 }
139
140 pub fn with_known_pamms(mut self) -> Self {
147 for config in default_served_pamms() {
148 if self.denied.contains(&config.address) {
149 continue;
150 }
151 self.registry
152 .entry(config.address.clone())
153 .or_insert(config);
154 }
155 for address in default_denied_pamms() {
156 if self.registry.contains_key(&address) {
157 continue;
158 }
159 self.denied.insert(address);
160 }
161 self
162 }
163
164 pub fn with_tokens(mut self, tokens: HashMap<Bytes, Token>) -> Self {
167 self.tokens = tokens;
168 self
169 }
170
171 pub fn build(self) -> impl Stream<Item = Update> + Send {
184 if self.registry.is_empty() && !self.auto_detect {
185 tracing::warn!(
186 "No pAMMs registered and auto-detection is off; the stream will never produce \
187 an update"
188 );
189 }
190 if self.tokens.is_empty() {
191 tracing::warn!(
192 "No token metadata provided; every streamed pair will be skipped and the stream \
193 will never produce an update"
194 );
195 }
196 let url = self
197 .url
198 .unwrap_or_else(|| TITAN_PRICE_LEVEL_URL.to_string());
199 let auto_detected_gas_cost = self
200 .auto_detected_gas_cost
201 .unwrap_or_else(|| BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST));
202 let mut tracker = SnapshotTracker::new(
203 self.registry,
204 self.denied,
205 self.tokens,
206 self.auto_detect,
207 auto_detected_gas_cost,
208 );
209
210 titan::messages(url, self.connection).filter_map(move |message| tracker.process(message))
211 }
212}
213
214struct SnapshotTracker {
217 registry: HashMap<Bytes, PriceLevelStreamConfig>,
218 denied: HashSet<Bytes>,
221 tokens: HashMap<Bytes, Token>,
222 auto_detect: bool,
225 auto_detected_gas_cost: BigUint,
227 components: HashMap<String, ProtocolComponent>,
232 newest_block: u64,
236}
237
238impl SnapshotTracker {
239 fn new(
240 registry: HashMap<Bytes, PriceLevelStreamConfig>,
241 denied: HashSet<Bytes>,
242 tokens: HashMap<Bytes, Token>,
243 auto_detect: bool,
244 auto_detected_gas_cost: BigUint,
245 ) -> Self {
246 Self {
247 registry,
248 denied,
249 tokens,
250 auto_detect,
251 auto_detected_gas_cost,
252 components: HashMap::new(),
253 newest_block: 0,
254 }
255 }
256
257 fn process(&mut self, message: TitanPriceLevelMessage) -> Option<Update> {
261 if message.block_number < self.newest_block {
262 tracing::warn!(
263 block_number = message.block_number,
264 newest_block = self.newest_block,
265 "Skipping out-of-order price level frame"
266 );
267 return None;
268 }
269 self.newest_block = message.block_number;
270
271 let mut states: HashMap<String, Box<dyn ProtocolSim>> = HashMap::new();
272 let mut new_pairs = HashMap::new();
273 let mut previous = std::mem::take(&mut self.components);
276
277 for TitanPammLevels { pamm, pairs } in message.pamms {
278 let config = match self.registry.entry(pamm.clone()) {
279 Entry::Occupied(entry) => &*entry.into_mut(),
280 Entry::Vacant(entry) => {
281 if !self.auto_detect {
282 tracing::debug!(%pamm, "Skipping unregistered pAMM");
283 continue;
284 }
285 if self.denied.contains(&pamm) {
286 tracing::debug!(%pamm, "Skipping denied pAMM");
287 continue;
288 }
289 tracing::info!(%pamm, "Serving auto-detected pAMM");
290 &*entry.insert(PriceLevelStreamConfig::auto_detected(
291 pamm.clone(),
292 self.auto_detected_gas_cost.clone(),
293 ))
294 }
295 };
296
297 let mut merged_pairs: HashMap<(Bytes, Bytes), (Vec<_>, Vec<_>)> = HashMap::new();
299 for TitanPairLevels { token_in, token_out, order_book } in pairs {
300 if !self.tokens.contains_key(&token_in) || !self.tokens.contains_key(&token_out) {
301 tracing::debug!(%token_in, %token_out, "Skipping pair with unknown token");
302 continue;
303 }
304 let sells_token0 = token_in < token_out;
305 let key = if sells_token0 {
306 (token_in.clone(), token_out.clone())
307 } else {
308 (token_out.clone(), token_in.clone())
309 };
310 let quotes = order_book
311 .into_iter()
312 .map(|TitanPriceLevel { amount_in, amount_out }| {
313 PriceLevelStreamQuote::new(amount_in, amount_out)
314 })
315 .collect();
316 let entry = merged_pairs.entry(key).or_default();
317 if sells_token0 {
318 entry.0 = quotes;
319 } else {
320 entry.1 = quotes;
321 }
322 }
323
324 for ((token0, token1), (quotes_0_to_1, quotes_1_to_0)) in merged_pairs {
325 let id = component_id(&config.address, &token0, &token1);
326 let id_string = id.to_string();
327 let component = previous
328 .remove(&id_string)
329 .unwrap_or_else(|| {
330 let component = build_component(&self.tokens, config, id, &token0, &token1);
331 new_pairs.insert(id_string.clone(), component.clone());
332 component
333 });
334
335 let state = PriceLevelStreamState::new(
336 token0,
337 token1,
338 quotes_0_to_1,
339 quotes_1_to_0,
340 config.gas_cost.clone(),
341 );
342
343 states.insert(id_string.clone(), Box::new(state));
344 self.components
345 .insert(id_string, component);
346 }
347 }
348
349 let removed_pairs = previous;
352
353 if states.is_empty() && new_pairs.is_empty() && removed_pairs.is_empty() {
354 return None;
355 }
356
357 Some(
358 Update::new(message.block_number, states, new_pairs)
361 .set_is_partial(true)
362 .set_removed_pairs(removed_pairs),
363 )
364 }
365}
366
367fn build_component(
368 tokens: &HashMap<Bytes, Token>,
369 config: &PriceLevelStreamConfig,
370 id: Bytes,
371 token0: &Bytes,
372 token1: &Bytes,
373) -> ProtocolComponent {
374 let protocol_system = config.protocol_system();
375 ProtocolComponent::new(
376 id,
377 protocol_system.clone(),
378 protocol_system,
379 Chain::Ethereum,
381 vec![tokens[token0].clone(), tokens[token1].clone()],
382 vec![config.address.clone()],
383 HashMap::from([(PAMM_ADDRESS_ATTRIBUTE.to_string(), config.address.clone())]),
384 Bytes::default(),
385 Utc::now().naive_utc(),
386 )
387}
388
389fn component_id(pamm: &Bytes, token0: &Bytes, token1: &Bytes) -> Bytes {
391 Bytes::from([pamm.as_ref(), token0.as_ref(), token1.as_ref()].concat())
392}
393
394#[cfg(test)]
395mod tests {
396 use std::str::FromStr;
397
398 use num_bigint::BigUint;
399
400 use super::*;
401
402 const PAMM: &str = "0x5979458912f80b96d30d4220af8e2e4925a33320";
403 const WBTC: &str = "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599";
404 const USDC: &str = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
405 const WETH: &str = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
406
407 fn token(address: &str, symbol: &str, decimals: u32) -> Token {
408 Token::new(
409 &Bytes::from_str(address).unwrap(),
410 symbol,
411 decimals,
412 0,
413 &[Some(10_000)],
414 Chain::Ethereum,
415 100,
416 )
417 }
418
419 fn tokens() -> HashMap<Bytes, Token> {
420 [token(WBTC, "WBTC", 8), token(USDC, "USDC", 6), token(WETH, "WETH", 18)]
421 .into_iter()
422 .map(|token| (token.address.clone(), token))
423 .collect()
424 }
425
426 fn tracker() -> SnapshotTracker {
427 let config = PriceLevelStreamConfig::new(
428 "fermiswap",
429 Bytes::from_str(PAMM).unwrap(),
430 BigUint::from(120_000u64),
431 );
432 SnapshotTracker::new(
433 HashMap::from([(config.address.clone(), config)]),
434 HashSet::new(),
435 tokens(),
436 false,
437 BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
438 )
439 }
440
441 fn level(amount_in: u64, amount_out: u64) -> TitanPriceLevel {
442 TitanPriceLevel {
443 amount_in: BigUint::from(amount_in),
444 amount_out: BigUint::from(amount_out),
445 }
446 }
447
448 fn pair_levels(
449 token_in: &str,
450 token_out: &str,
451 order_book: Vec<TitanPriceLevel>,
452 ) -> TitanPairLevels {
453 TitanPairLevels {
454 token_in: Bytes::from_str(token_in).unwrap(),
455 token_out: Bytes::from_str(token_out).unwrap(),
456 order_book,
457 }
458 }
459
460 fn message(block_number: u64, pairs: Vec<TitanPairLevels>) -> TitanPriceLevelMessage {
461 TitanPriceLevelMessage {
462 block_number,
463 pamms: vec![TitanPammLevels { pamm: Bytes::from_str(PAMM).unwrap(), pairs }],
464 }
465 }
466
467 fn wbtc_usdc_pairs() -> Vec<TitanPairLevels> {
468 vec![
469 pair_levels(WBTC, USDC, vec![level(100_000_000, 100_000_000_000)]),
470 pair_levels(USDC, WBTC, vec![level(100_000_000_000, 99_000_000)]),
471 ]
472 }
473
474 fn expected_id() -> String {
475 format!("{PAMM}{}{}", &WBTC[2..], &USDC[2..])
477 }
478
479 #[test]
480 fn first_snapshot_emits_new_pair_with_both_directions() {
481 let mut tracker = tracker();
482 let Update {
483 block_number_or_timestamp,
484 is_partial,
485 sync_states,
486 states,
487 new_pairs,
488 removed_pairs,
489 } = tracker
490 .process(message(100, wbtc_usdc_pairs()))
491 .expect("update expected");
492
493 assert_eq!(block_number_or_timestamp, 100);
494 assert!(is_partial);
495 assert!(sync_states.is_empty());
496 assert!(removed_pairs.is_empty());
497
498 let id = expected_id();
499 let component = &new_pairs[&id];
500 assert_eq!(component.protocol_system, "pricelevelstream:fermiswap");
501 assert_eq!(
502 component.static_attributes[PAMM_ADDRESS_ATTRIBUTE],
503 Bytes::from_str(PAMM).unwrap()
504 );
505
506 let PriceLevelStreamState { token0, token1, quotes_0_to_1, quotes_1_to_0, gas_cost } =
507 states[&id]
508 .as_any()
509 .downcast_ref::<PriceLevelStreamState>()
510 .expect("price level state");
511 assert_eq!(token0, &Bytes::from_str(WBTC).unwrap());
512 assert_eq!(token1, &Bytes::from_str(USDC).unwrap());
513 assert_eq!(quotes_0_to_1.len(), 1);
514 assert_eq!(quotes_1_to_0.len(), 1);
515 assert_eq!(quotes_0_to_1[0].amount_in, BigUint::from(100_000_000u64));
516 assert_eq!(gas_cost, &BigUint::from(120_000u64));
517 }
518
519 #[test]
520 fn repeated_snapshot_is_not_a_new_pair() {
521 let mut tracker = tracker();
522 tracker
523 .process(message(100, wbtc_usdc_pairs()))
524 .expect("update expected");
525 let update = tracker
526 .process(message(101, wbtc_usdc_pairs()))
527 .expect("update expected");
528
529 assert!(update.new_pairs.is_empty());
530 assert!(update.removed_pairs.is_empty());
531 assert!(update
532 .states
533 .contains_key(&expected_id()));
534 }
535
536 #[test]
537 fn dropped_pair_is_removed() {
538 let mut tracker = tracker();
539 tracker
540 .process(message(100, wbtc_usdc_pairs()))
541 .expect("update expected");
542 let weth_usdc =
543 vec![pair_levels(WETH, USDC, vec![level(1_000_000_000_000_000_000, 3_000_000_000)])];
544 let update = tracker
545 .process(message(101, weth_usdc))
546 .expect("update expected");
547
548 assert_eq!(update.removed_pairs.len(), 1);
549 assert!(update
550 .removed_pairs
551 .contains_key(&expected_id()));
552 assert_eq!(update.new_pairs.len(), 1);
553 assert_eq!(update.states.len(), 1);
554 }
555
556 #[test]
557 fn out_of_order_frame_is_skipped() {
558 let mut tracker = tracker();
559 tracker
560 .process(message(101, wbtc_usdc_pairs()))
561 .expect("update expected");
562
563 let stale =
566 vec![pair_levels(WETH, USDC, vec![level(1_000_000_000_000_000_000, 3_000_000_000)])];
567 assert!(tracker
568 .process(message(100, stale))
569 .is_none());
570
571 let update = tracker
574 .process(message(102, wbtc_usdc_pairs()))
575 .expect("update expected");
576 assert!(update.new_pairs.is_empty());
577 assert!(update.removed_pairs.is_empty());
578 }
579
580 #[test]
581 fn vanished_pamm_has_its_pairs_removed() {
582 let mut tracker = tracker();
583 tracker
584 .process(message(100, wbtc_usdc_pairs()))
585 .expect("update expected");
586
587 let update = tracker
590 .process(TitanPriceLevelMessage { block_number: 101, pamms: vec![] })
591 .expect("update expected");
592 assert!(update.states.is_empty());
593 assert!(update.new_pairs.is_empty());
594 assert_eq!(update.removed_pairs.len(), 1);
595 assert!(update
596 .removed_pairs
597 .contains_key(&expected_id()));
598
599 assert!(tracker
601 .process(TitanPriceLevelMessage { block_number: 102, pamms: vec![] })
602 .is_none());
603
604 let update = tracker
606 .process(message(103, wbtc_usdc_pairs()))
607 .expect("update expected");
608 assert!(update
609 .new_pairs
610 .contains_key(&expected_id()));
611 }
612
613 #[test]
614 fn unregistered_pamm_produces_no_update_without_auto_detection() {
615 let mut tracker = SnapshotTracker::new(
616 HashMap::new(),
617 HashSet::new(),
618 tokens(),
619 false,
620 BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
621 );
622 assert!(tracker
623 .process(message(100, wbtc_usdc_pairs()))
624 .is_none());
625 }
626
627 #[test]
628 fn denied_pamm_is_not_auto_detected() {
629 let denied = HashSet::from([Bytes::from_str(PAMM).unwrap()]);
630 let mut tracker = SnapshotTracker::new(
631 HashMap::new(),
632 denied,
633 tokens(),
634 true,
635 BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
636 );
637 assert!(tracker
638 .process(message(100, wbtc_usdc_pairs()))
639 .is_none());
640 }
641
642 #[test]
643 fn explicit_add_and_deny_are_last_wins() {
644 let address = Bytes::from_str(PAMM).unwrap();
645 let custom =
646 || PriceLevelStreamConfig::new("custom", Bytes::from_str(PAMM).unwrap(), 1u64.into());
647
648 let builder = PriceLevelStreamBuilder::new()
649 .add_pamm(custom())
650 .deny_pamm(address.clone());
651 assert!(!builder.registry.contains_key(&address));
652 assert!(builder.denied.contains(&address));
653
654 let builder = PriceLevelStreamBuilder::new()
655 .deny_pamm(address.clone())
656 .add_pamm(custom());
657 assert_eq!(builder.registry[&address].protocol, "custom");
658 assert!(builder.denied.is_empty());
659 }
660
661 #[test]
662 fn defaults_never_override_explicit_calls() {
663 let fermiswap_router = Bytes::from_str(PAMM).unwrap();
665 for builder in [
666 PriceLevelStreamBuilder::new()
667 .deny_pamm(fermiswap_router.clone())
668 .with_known_pamms(),
669 PriceLevelStreamBuilder::new()
670 .with_known_pamms()
671 .deny_pamm(fermiswap_router.clone()),
672 ] {
673 assert!(!builder
674 .registry
675 .contains_key(&fermiswap_router));
676 assert!(builder
677 .denied
678 .contains(&fermiswap_router));
679 assert!(!builder.registry.is_empty());
681 }
682
683 let denied_venue = default_denied_pamms().remove(0);
685 let custom = || PriceLevelStreamConfig::new("custom", denied_venue.clone(), 1u64.into());
686 for builder in [
687 PriceLevelStreamBuilder::new()
688 .add_pamm(custom())
689 .with_known_pamms(),
690 PriceLevelStreamBuilder::new()
691 .with_known_pamms()
692 .add_pamm(custom()),
693 ] {
694 assert_eq!(builder.registry[&denied_venue].protocol, "custom");
695 assert!(!builder.denied.contains(&denied_venue));
696 }
697 }
698
699 #[test]
700 fn auto_detected_pamm_is_served_under_its_address() {
701 let mut tracker = SnapshotTracker::new(
702 HashMap::new(),
703 HashSet::new(),
704 tokens(),
705 true,
706 BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST),
707 );
708 let update = tracker
709 .process(message(100, wbtc_usdc_pairs()))
710 .expect("update expected");
711
712 let component = &update.new_pairs[&expected_id()];
713 assert_eq!(component.protocol_system, format!("pricelevelstream:{PAMM}"));
714 let state = update.states[&expected_id()]
715 .as_any()
716 .downcast_ref::<PriceLevelStreamState>()
717 .expect("price level state");
718 assert_eq!(state.gas_cost, BigUint::from(DEFAULT_AUTO_DETECTED_GAS_COST));
719
720 let update = tracker
722 .process(message(101, wbtc_usdc_pairs()))
723 .expect("update expected");
724 assert!(update.new_pairs.is_empty());
725 }
726
727 #[test]
728 fn auto_detected_gas_cost_override_applies() {
729 let mut tracker = SnapshotTracker::new(
730 HashMap::new(),
731 HashSet::new(),
732 tokens(),
733 true,
734 BigUint::from(42_000u64),
735 );
736 let update = tracker
737 .process(message(100, wbtc_usdc_pairs()))
738 .expect("update expected");
739
740 let state = update.states[&expected_id()]
741 .as_any()
742 .downcast_ref::<PriceLevelStreamState>()
743 .expect("price level state");
744 assert_eq!(state.gas_cost, BigUint::from(42_000u64));
745 }
746
747 #[test]
748 fn with_known_pamms_registers_known_venues() {
749 let fermiswap_router = Bytes::from_str(PAMM).unwrap();
751
752 let builder = PriceLevelStreamBuilder::new();
753 assert!(builder.registry.is_empty());
754 assert!(builder.denied.is_empty());
755
756 let builder = builder.with_known_pamms();
757 assert_eq!(builder.registry[&fermiswap_router].protocol, "fermiswap");
758 assert!(!builder.denied.is_empty());
760 assert!(builder.denied.is_disjoint(
761 &builder
762 .registry
763 .keys()
764 .cloned()
765 .collect()
766 ));
767
768 let custom =
770 || PriceLevelStreamConfig::new("custom", fermiswap_router.clone(), BigUint::from(1u64));
771 for builder in [
772 PriceLevelStreamBuilder::new()
773 .add_pamm(custom())
774 .with_known_pamms(),
775 PriceLevelStreamBuilder::new()
776 .with_known_pamms()
777 .add_pamm(custom()),
778 ] {
779 assert_eq!(builder.registry[&fermiswap_router].protocol, "custom");
780 assert_eq!(builder.registry[&fermiswap_router].gas_cost, BigUint::from(1u64));
781 }
782 }
783
784 #[test]
785 fn unknown_tokens_are_skipped() {
786 let mut tracker = tracker();
787 let unknown = vec![pair_levels(
788 "0x1111111111111111111111111111111111111111",
789 USDC,
790 vec![level(1, 1)],
791 )];
792 assert!(tracker
793 .process(message(100, unknown))
794 .is_none());
795 }
796}