1use std::collections::BTreeMap;
14
15use solana_address::Address;
16
17use super::{LegRecord, Prices, Samples};
18use crate::route_plan::{RouteHop, RoutePlan};
19
20fn in_amount(hop: &RouteHop) -> Option<u128> {
21 hop.swap_info.in_amount.as_ref()?.parse().ok()
22}
23
24#[derive(Debug, thiserror::Error)]
27pub enum RerouteReportError {
28 #[error(
29 "{asked} never appears in this stream.\nIt names {} venues; the busiest are {}.",
30 .venues.len(),
31 busiest(.venues),
32 )]
33 NothingMatched {
34 asked: String,
36 venues: Vec<(String, u64)>,
38 },
39}
40
41const NAMED_IN_ERROR: usize = 8;
42
43fn busiest(venues: &[(String, u64)]) -> String {
44 let mut ranked: Vec<_> = venues.iter().collect();
45 ranked.sort_unstable_by(|(a_label, a_hops), (b_label, b_hops)| {
46 b_hops.cmp(a_hops).then_with(|| a_label.cmp(b_label))
47 });
48 ranked
49 .iter()
50 .take(NAMED_IN_ERROR)
51 .map(|(label, hops)| format!("{label} ({hops})"))
52 .collect::<Vec<_>>()
53 .join(", ")
54}
55
56#[derive(Clone, Default, Debug)]
61pub struct Target {
62 label: Option<String>,
63 program: Option<(Address, String)>,
65}
66
67impl Target {
68 pub fn new(label: Option<String>, program: Option<Address>) -> Self {
69 Self {
70 label,
71 program: program.map(|program| (program, program.to_string())),
72 }
73 }
74
75 pub fn label(&self) -> Option<&str> {
76 self.label.as_deref()
77 }
78
79 pub fn program(&self) -> Option<Address> {
80 self.program.as_ref().map(|(program, _)| *program)
81 }
82
83 pub fn claims_quoted(&self, hop: &RouteHop) -> bool {
85 match &self.label {
86 Some(label) => hop.label() == Some(label.as_str()),
87 None => false,
88 }
89 }
90
91 pub fn claims_fill(&self, hop: &RouteHop) -> bool {
94 if let Some((_, key)) = &self.program {
95 return hop.program.as_deref() == Some(key.as_str());
96 }
97 self.claims_quoted(hop)
98 }
99
100 fn describe(&self) -> String {
101 if let Some(label) = &self.label {
102 format!("label {label:?}")
103 } else if let Some((program, _)) = &self.program {
104 format!("program {program}")
105 } else {
106 "that venue".to_string()
107 }
108 }
109}
110
111#[derive(Default, Clone, Copy, Debug)]
113pub struct Flow {
114 pub legs: u64,
116 pub usd: f64,
117 pub without_amount: u64,
118 pub without_price: u64,
120 share_usd: f64,
122}
123
124impl Flow {
125 fn merge(&mut self, other: Self) {
126 self.legs += other.legs;
127 self.usd += other.usd;
128 self.without_amount += other.without_amount;
129 self.without_price += other.without_price;
130 self.share_usd += other.share_usd;
131 }
132}
133
134#[derive(Default, Clone, Copy, Debug)]
136pub struct Direction {
137 pub l1: Flow,
138 pub requoted: Flow,
139 l1_all: f64,
141 requoted_all: f64,
142}
143
144impl Direction {
145 fn merge(&mut self, other: Self) {
146 self.l1.merge(other.l1);
147 self.requoted.merge(other.requoted);
148 self.l1_all += other.l1_all;
149 self.requoted_all += other.requoted_all;
150 }
151
152 pub fn share(&self) -> Option<(f64, f64)> {
155 (self.l1_all > 0.0 && self.requoted_all > 0.0).then(|| {
156 (
157 self.l1.share_usd / self.l1_all * 100.0,
158 self.requoted.share_usd / self.requoted_all * 100.0,
159 )
160 })
161 }
162
163 pub const fn present(&self) -> bool {
165 self.l1.legs > 0 || self.requoted.legs > 0
166 }
167
168 pub const fn unpriced(&self) -> bool {
169 self.l1.without_price > 0 || self.requoted.without_price > 0
170 }
171
172 pub fn fully_unpriced(&self) -> bool {
174 self.unpriced() && self.l1.usd == 0.0 && self.requoted.usd == 0.0
175 }
176
177 pub(super) fn rank(&self) -> (u64, u64) {
179 let usd = self.l1.usd.max(self.requoted.usd);
180 (usd as u64, self.l1.legs.max(self.requoted.legs))
181 }
182}
183
184pub(super) fn fold(directions: impl IntoIterator<Item = Direction>) -> Direction {
185 directions
186 .into_iter()
187 .fold(Direction::default(), |mut total, direction| {
188 total.merge(direction);
189 total
190 })
191}
192
193pub type Pair = (String, String);
195
196#[derive(Clone, Copy)]
197enum Side {
198 L1,
199 Requoted,
200}
201
202#[derive(Debug)]
204pub struct Report {
205 by_direction: BTreeMap<Pair, Direction>,
206 target: Target,
207 sol_usd: Option<f64>,
209}
210
211impl Report {
212 pub fn from_records<'a>(
214 target: Target,
215 records: impl IntoIterator<Item = &'a LegRecord>,
216 ) -> Result<Self, RerouteReportError> {
217 let mut builder = ReportBuilder::new(target);
218 for record in records {
219 builder.push(record);
220 }
221 builder.finish()
222 }
223
224 pub const fn target(&self) -> &Target {
225 &self.target
226 }
227
228 pub const fn sol_usd(&self) -> Option<f64> {
229 self.sol_usd
230 }
231
232 pub fn by_direction(&self) -> impl Iterator<Item = (&Pair, &Direction)> {
235 self.by_direction.iter()
236 }
237
238 pub fn total(&self) -> Direction {
241 fold(self.by_direction.values().copied())
242 }
243}
244
245pub struct ReportBuilder {
252 target: Target,
253 samples: Samples,
254 by_direction: Directions,
255 labels: BTreeMap<String, u64>,
257}
258
259impl ReportBuilder {
260 pub fn new(target: Target) -> Self {
261 Self {
262 target,
263 samples: Samples::default(),
264 by_direction: Directions::default(),
265 labels: BTreeMap::new(),
266 }
267 }
268
269 pub fn push(&mut self, record: &LegRecord) {
271 self.samples.push(record);
272
273 if record.failed || record.original_failed {
275 return;
276 }
277 let Self {
278 target,
279 by_direction,
280 labels,
281 ..
282 } = self;
283 fn hops_of(plan: &Option<RoutePlan>) -> Option<&[RouteHop]> {
284 plan.as_ref().map(RoutePlan::hops)
285 }
286 let quoted = hops_of(&record.route_plan);
288 for label in quoted.iter().copied().flatten().filter_map(RouteHop::label) {
289 match labels.get_mut(label) {
290 Some(hops) => *hops += 1,
291 None => {
292 labels.insert(label.to_string(), 1);
293 }
294 }
295 }
296
297 let (requoted, from_quote) = match hops_of(&record.realized_route_plan) {
299 Some(filled) => (filled, false),
300 None => (quoted.unwrap_or_default(), true),
301 };
302 let claimed = |hop: &RouteHop| match from_quote {
303 true => target.claims_quoted(hop),
304 false => target.claims_fill(hop),
305 };
306 for hop in requoted.iter().filter(|hop| claimed(hop)) {
307 by_direction.record(Side::Requoted, hop);
308 }
309
310 let l1 = hops_of(&record.original_route_plan);
311 for hop in l1
312 .iter()
313 .copied()
314 .flatten()
315 .filter(|hop| target.claims_fill(hop))
316 {
317 by_direction.record(Side::L1, hop);
318 }
319
320 let leg_amount = u128::from(record.amount) as f64;
323 by_direction.accumulate_share(
324 Side::Requoted,
325 requoted,
326 record.input_mint,
327 leg_amount,
328 claimed,
329 );
330 if let Some(l1) = l1 {
331 by_direction.accumulate_share(Side::L1, l1, record.input_mint, leg_amount, |hop| {
332 target.claims_fill(hop)
333 });
334 }
335 }
336
337 pub fn finish(self) -> Result<Report, RerouteReportError> {
339 let prices = self.samples.prices();
340 let report = Report {
341 sol_usd: prices.as_ref().map(Prices::sol_usd),
342 by_direction: self.by_direction.price(prices.as_ref()),
343 target: self.target,
344 };
345 let total = report.total();
346 if total.l1.legs == 0 && total.requoted.legs == 0 {
347 return Err(RerouteReportError::NothingMatched {
348 asked: report.target.describe(),
349 venues: self.labels.into_iter().collect(),
350 });
351 }
352 Ok(report)
353 }
354}
355
356#[derive(Default)]
359struct Directions(BTreeMap<Pair, DirectionAccum>);
360
361impl Directions {
362 fn record(&mut self, side: Side, hop: &RouteHop) {
364 let Some(key) = key_of(hop) else {
365 return;
366 };
367 let flow = &mut self.0.entry(key).or_default().side_mut(side).flow;
368 flow.legs += 1;
369 match in_amount(hop) {
370 Some(amount) => flow.raw += amount,
371 None => flow.without_amount += 1,
372 }
373 }
374
375 fn accumulate_share(
381 &mut self,
382 side: Side,
383 hops: &[RouteHop],
384 leg_mint: Address,
385 leg_amount: f64,
386 claimed: impl Fn(&RouteHop) -> bool,
387 ) {
388 let total: u64 = hops.iter().map(|hop| hop.percent).sum();
389 if total == 0 {
390 return;
391 }
392 for hop in hops {
393 let Some(key) = key_of(hop) else {
394 continue;
395 };
396 let value = leg_amount * hop.percent as f64 / total as f64;
397 let share = &mut self.0.entry(key).or_default().side_mut(side).share;
398 credit(&mut share.all, leg_mint, value);
399 if claimed(hop) {
400 credit(&mut share.claimed, leg_mint, value);
401 }
402 }
403 }
404
405 fn price(self, prices: Option<&Prices>) -> BTreeMap<Pair, Direction> {
408 self.0
409 .into_iter()
410 .map(|(pair, accum)| {
411 let direction = accum.price(&pair.0, prices);
412 (pair, direction)
413 })
414 .filter(|(_, direction)| {
415 direction.present() || direction.l1_all > 0.0 || direction.requoted_all > 0.0
416 })
417 .collect()
418 }
419}
420
421fn key_of(hop: &RouteHop) -> Option<Pair> {
424 Some((
425 hop.input_mint()?.to_string(),
426 hop.output_mint()?.to_string(),
427 ))
428}
429
430fn credit(bucket: &mut Vec<(Address, f64)>, mint: Address, value: f64) {
433 match bucket.iter_mut().find(|(held, _)| *held == mint) {
434 Some((_, total)) => *total += value,
435 None => bucket.push((mint, value)),
436 }
437}
438
439#[derive(Default)]
441struct FlowAccum {
442 legs: u64,
443 raw: u128,
445 without_amount: u64,
446}
447
448impl FlowAccum {
449 fn price(self, mint: &str, prices: Option<&Prices>, share_usd: f64) -> Flow {
450 let usd = prices.and_then(|prices| prices.usd_str(mint, self.raw as f64));
451 Flow {
452 legs: self.legs,
453 usd: usd.unwrap_or_default(),
454 without_amount: self.without_amount,
455 without_price: match usd {
457 Some(_) => 0,
458 None => self.legs - self.without_amount,
459 },
460 share_usd,
461 }
462 }
463}
464
465#[derive(Default)]
467struct ShareAccum {
468 all: Vec<(Address, f64)>,
469 claimed: Vec<(Address, f64)>,
470}
471
472impl ShareAccum {
473 fn price(&self, prices: Option<&Prices>) -> (f64, f64) {
475 let sum = |bucket: &[(Address, f64)]| {
476 bucket
477 .iter()
478 .filter_map(|(mint, raw)| prices?.usd(mint, *raw))
479 .fold(0.0, |total, usd| total + usd)
482 };
483 (sum(&self.all), sum(&self.claimed))
484 }
485}
486
487#[derive(Default)]
489struct SideAccum {
490 flow: FlowAccum,
491 share: ShareAccum,
492}
493
494#[derive(Default)]
495struct DirectionAccum {
496 l1: SideAccum,
497 requoted: SideAccum,
498}
499
500impl DirectionAccum {
501 fn side_mut(&mut self, side: Side) -> &mut SideAccum {
502 match side {
503 Side::L1 => &mut self.l1,
504 Side::Requoted => &mut self.requoted,
505 }
506 }
507
508 fn price(self, mint: &str, prices: Option<&Prices>) -> Direction {
509 let (l1_all, l1_claimed) = self.l1.share.price(prices);
510 let (requoted_all, requoted_claimed) = self.requoted.share.price(prices);
511 Direction {
512 l1: self.l1.flow.price(mint, prices, l1_claimed),
513 requoted: self.requoted.flow.price(mint, prices, requoted_claimed),
514 l1_all,
515 requoted_all,
516 }
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::{
524 mints,
525 reroute_report::testing::{hop, plan, record_of},
526 };
527
528 const SOL: &str = "So11111111111111111111111111111111111111112";
529 const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
530 const VENUE_PROGRAM: &str = "BiSoNHVpsVZW2F7rx2eQ59yQwKxzU5NvBcmKshCSUypi";
531 const OTHER_PROGRAM: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc";
532
533 fn anchors() -> Vec<LegRecord> {
536 (1..=3)
537 .map(|n| LegRecord {
538 original_realized_output_amount: Some(n * 100_000_000),
539 ..record_of(mints::SOL, mints::USDC, n * 1_000_000_000, None, None, None)
540 })
541 .collect()
542 }
543
544 fn venue() -> Target {
545 Target::new(
546 Some("BisonFi".to_string()),
547 Some(VENUE_PROGRAM.parse().expect("a venue program")),
548 )
549 }
550
551 fn fill(
552 program: &str,
553 label: &str,
554 input: &str,
555 output: &str,
556 amount: u64,
557 ) -> Option<RoutePlan> {
558 plan(vec![hop(Some(program), label, input, output, amount)])
559 }
560
561 fn report_of(records: &[LegRecord]) -> Report {
562 Report::from_records(venue(), records).expect("the venue appears")
563 }
564
565 fn priced(extra: impl IntoIterator<Item = LegRecord>) -> Vec<LegRecord> {
567 let mut records = anchors();
568 records.extend(extra);
569 records
570 }
571
572 fn won_leg(amount: u64) -> LegRecord {
574 let realized = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, amount);
575 let l1 = fill(OTHER_PROGRAM, "Whirlpool", SOL, USDC, amount);
576 record_of(mints::SOL, mints::USDC, amount, None, l1, realized)
577 }
578
579 #[test]
583 fn a_hop_with_an_unnamed_mint_opens_no_direction() {
584 let mut unnamed = hop(Some(VENUE_PROGRAM), "BisonFi", USDC, SOL, 1_000_000);
585 unnamed.swap_info.output_mint = None;
586 let l1 = plan(vec![
587 hop(Some(VENUE_PROGRAM), "BisonFi", SOL, USDC, 2_000_000_000),
588 unnamed,
589 ]);
590 let report = report_of(&priced([record_of(
591 mints::SOL,
592 mints::USDC,
593 2_000_000_000,
594 None,
595 l1,
596 None,
597 )]));
598
599 let pairs: Vec<&Pair> = report.by_direction().map(|(pair, _)| pair).collect();
600 assert_eq!(pairs, [&(SOL.to_string(), USDC.to_string())]);
601 assert_eq!(report.total().l1.legs, 1, "only the named hop counts");
602 }
603
604 #[test]
608 fn deferring_the_conversion_matches_pricing_each_hop_as_it_arrives() {
609 let report = report_of(&priced([won_leg(2_000_000_000), won_leg(3_000_000_000)]));
610 let total = report.total();
611
612 assert_eq!(report.sol_usd(), Some(100.0));
613 assert_eq!(total.requoted.legs, 2);
614 assert_eq!(total.requoted.usd, 500.0);
616 assert_eq!(total.l1.legs, 0, "L1 had these legs at another venue");
617 }
618
619 #[test]
622 fn the_result_does_not_depend_on_arrival_order() {
623 let forward = priced([won_leg(2_000_000_000), won_leg(3_000_000_000)]);
624 let mut backward = forward.clone();
625 backward.reverse();
626
627 let (forward, backward) = (report_of(&forward), report_of(&backward));
628 assert_eq!(forward.sol_usd(), backward.sol_usd());
629 assert_eq!(forward.total().requoted.usd, backward.total().requoted.usd);
630 assert_eq!(forward.render(None), backward.render(None));
631 }
632
633 #[test]
636 fn neither_side_counts_flow_that_never_landed() {
637 let records = priced([
638 won_leg(2_000_000_000),
639 LegRecord {
640 failed: true,
641 ..won_leg(9_000_000_000)
642 },
643 LegRecord {
644 original_failed: true,
645 ..won_leg(9_000_000_000)
646 },
647 ]);
648
649 let total = report_of(&records).total();
650 assert_eq!(total.requoted.legs, 1, "only the landed leg counts");
651 assert_eq!(total.requoted.usd, 200.0);
652 }
653
654 #[test]
657 fn a_share_splits_a_leg_where_notional_counts_it_twice() {
658 let two_hops = plan(vec![
659 hop(Some(VENUE_PROGRAM), "BisonFi", SOL, USDC, 1_000_000_000),
660 hop(Some(OTHER_PROGRAM), "Whirlpool", USDC, "BONK", 100_000_000),
661 ]);
662 let l1 = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000_000_000);
663 let record = record_of(mints::SOL, mints::USDC, 1_000_000_000, None, l1, two_hops);
664 let report = report_of(&priced([record]));
665
666 let total = report.total();
667 assert_eq!(total.requoted.usd, 100.0);
669 assert_eq!(total.requoted_all, 100.0, "the leg is counted once");
671 assert_eq!(
672 total.requoted.share_usd, 50.0,
673 "half the route was the venue's"
674 );
675 assert_eq!(total.share(), Some((100.0, 50.0)));
677
678 let (_, first) = report
680 .by_direction()
681 .find(|((input, output), _)| input == SOL && output == USDC)
682 .expect("the venue's direction");
683 assert_eq!(first.requoted.share_usd, 50.0);
684 assert_eq!(first.requoted_all, 50.0, "that direction held half the leg");
685 }
686
687 #[test]
690 fn a_fill_prefers_the_program_and_falls_back_to_the_label() {
691 let filled = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000);
692 let record = record_of(mints::SOL, mints::USDC, 1_000, None, filled.clone(), filled);
693 let hop = &record.original_route_plan.as_ref().expect("a plan").hops()[0];
694
695 let by_label = Target::new(Some("BisonFi".to_string()), None);
696 assert!(by_label.claims_fill(hop));
697 assert!(by_label.claims_quoted(hop));
698
699 let other = Target::new(Some("BisonFi".to_string()), Some(Address::new_unique()));
701 assert!(!other.claims_fill(hop));
702
703 let by_program = Target::new(None, Some(VENUE_PROGRAM.parse().expect("a program")));
704 assert!(by_program.claims_fill(hop));
705 assert!(!by_program.claims_quoted(hop));
707 }
708
709 #[test]
713 fn the_report_is_swaps_and_dollars_only() {
714 let sell = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000_000_000);
715 let buy = fill(VENUE_PROGRAM, "BisonFi", USDC, SOL, 100_000_000);
716 let report = report_of(&priced([
717 record_of(mints::SOL, mints::USDC, 1_000_000_000, None, None, sell),
718 record_of(mints::USDC, mints::SOL, 100_000_000, None, None, buy),
719 ]));
720
721 let json = report.to_json();
723 assert_eq!(json["total"]["l1"]["swaps"], 0);
724 assert_eq!(json["total"]["requoted"]["swaps"], 2);
725 assert_eq!(json["total"]["requoted"]["usd"], 200.0);
726
727 let table = report.render(None);
729 let totals = table.split('→').next().expect("a totals block");
730 assert!(totals.contains("swaps") && totals.contains("volume"));
731 assert!(!totals.contains("raw in"), "a raw amount reached the table");
732 assert!(!table.contains("quoted fill"));
734 }
735
736 #[test]
739 fn a_range_that_cannot_be_priced_reports_no_dollars() {
740 let report = report_of(&[won_leg(2_000_000_000)]);
741 let total = report.total();
742
743 assert_eq!(report.sol_usd(), None);
744 assert_eq!(total.requoted.legs, 1);
745 assert_eq!(total.requoted.without_price, 1);
746 assert!(total.fully_unpriced());
747
748 let table = report.render(None);
749 assert!(!table.contains("volume"), "a dollar row without a price");
750 assert!(table.contains("could not price"));
751 }
752
753 #[test]
756 fn a_selector_that_matches_nothing_says_so() {
757 let quote = plan(vec![hop(None, "Whirlpool", SOL, USDC, 1_000)]);
758 let records = vec![record_of(mints::SOL, mints::USDC, 1_000, quote, None, None)];
759
760 let target = Target::new(Some("Nope".to_string()), None);
761 let error = Report::from_records(target, &records).expect_err("the venue never appears");
762
763 let message = error.to_string();
764 assert!(message.contains("Nope"), "names what was asked for");
765 assert!(message.contains("Whirlpool (1)"));
767 }
768
769 #[test]
771 fn the_subtitle_is_the_callers() {
772 let report = report_of(&[won_leg(2_000_000_000)]);
773 assert!(report.render(Some("slots 1–100")).contains("slots 1–100"));
774 assert!(!report.render(None).contains("slots"));
775 }
776}