1mod allocation;
25mod comparison_log;
26pub mod config;
27
28use std::{
29 sync::LazyLock,
30 time::{Duration, Instant},
31};
32
33pub use allocation::ExclusiveAccess;
34use allocation::{allocate, Allocation, OrderClass};
35use comparison_log::{log_quote_comparison, solver_error_label};
36use config::WorkerPoolRouterConfig;
37use futures::stream::{FuturesUnordered, StreamExt};
38use metrics::{counter, gauge, histogram};
39use num_bigint::BigUint;
40use num_traits::{CheckedSub, ToPrimitive};
41use rustc_hash::{FxHashMap, FxHashSet};
42use serde::{Deserialize, Serialize};
43use tracing::{debug, warn};
44use tycho_execution::encoding::{
45 evm::gas_estimator::estimate_gas_usage,
46 models::{Solution, Strategy},
47};
48use tycho_simulation::tycho_common::{models::Chain, Bytes};
49
50use crate::{
51 encoding::encoder::Encoder, feed::exclusivity::is_exclusive, price_guard::guard::PriceGuard,
52 worker_pool::task_queue::TaskQueueHandle, BlockInfo, EncodingOptions, Order, OrderQuote,
53 OrderSide, Quote, QuoteOptions, QuoteRequest, QuoteStatus, SolveError, SolveParams,
54 SurplusInfo, Swap,
55};
56
57const ENV_USER_IMPROVEMENT_SHARE_BPS: &str = "EXCLUSIVE_ROUTE_USER_SHARE_BPS";
60
61const DEFAULT_USER_IMPROVEMENT_SHARE_BPS: u32 = 1_000;
64
65const BPS_DENOMINATOR: u32 = 10_000;
67
68const WEI_PER_GAS_TOKEN: f64 = 1e18;
70
71static USER_IMPROVEMENT_SHARE_BPS: LazyLock<u32> = LazyLock::new(user_improvement_share_bps_env);
73
74fn user_improvement_share_bps_env() -> u32 {
77 let Ok(raw) = std::env::var(ENV_USER_IMPROVEMENT_SHARE_BPS) else {
78 return DEFAULT_USER_IMPROVEMENT_SHARE_BPS;
79 };
80 match parse_user_improvement_share_bps(&raw) {
81 Some(bps) => bps,
82 None => {
83 warn!(
84 value = %raw,
85 default_bps = DEFAULT_USER_IMPROVEMENT_SHARE_BPS,
86 "{ENV_USER_IMPROVEMENT_SHARE_BPS} must be an integer from 0 to \
87 {BPS_DENOMINATOR} basis points; using the default",
88 );
89 DEFAULT_USER_IMPROVEMENT_SHARE_BPS
90 }
91 }
92}
93
94fn parse_user_improvement_share_bps(raw: &str) -> Option<u32> {
97 raw.trim()
98 .parse::<u32>()
99 .ok()
100 .filter(|bps| *bps <= BPS_DENOMINATOR)
101}
102
103fn user_margin(improvement: &BigUint, user_share_bps: u32) -> BigUint {
109 let denominator = BigUint::from(BPS_DENOMINATOR);
110 (improvement * BigUint::from(user_share_bps) + (&denominator - 1u32)) / denominator
111}
112
113const NO_PUBLIC_ROUTE_FEE_BPS: u32 = 5;
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum LiquidityScope {
139 #[default]
141 PublicOnly,
142 IncludeExclusive,
146}
147
148#[derive(Clone)]
150pub struct SolverPoolHandle {
151 name: String,
153 queue: TaskQueueHandle,
155 liquidity_scope: LiquidityScope,
158}
159
160impl SolverPoolHandle {
161 pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
163 Self { name: name.into(), queue, liquidity_scope: LiquidityScope::default() }
164 }
165
166 pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
168 self.liquidity_scope = scope;
169 self
170 }
171
172 pub fn name(&self) -> &str {
174 &self.name
175 }
176
177 pub fn queue(&self) -> &TaskQueueHandle {
179 &self.queue
180 }
181
182 pub fn liquidity_scope(&self) -> LiquidityScope {
184 self.liquidity_scope
185 }
186}
187
188#[derive(Debug, Clone)]
193pub(crate) struct WorkerPoolQuote {
194 worker_pool: String,
196 quote: OrderQuote,
198 solve_time_ms: u64,
200}
201
202#[derive(Debug)]
204pub(crate) struct OrderResponses {
205 order_id: String,
207 quotes: Vec<WorkerPoolQuote>,
209 failed_solvers: Vec<(String, SolveError)>,
212}
213
214impl OrderResponses {
215 fn public_only(&self, pool_scopes: &FxHashMap<String, LiquidityScope>) -> OrderResponses {
222 let quotes = self
223 .quotes
224 .iter()
225 .filter(|wq| {
226 pool_scopes.get(&wq.worker_pool) != Some(&LiquidityScope::IncludeExclusive)
227 })
228 .cloned()
229 .collect();
230 OrderResponses {
231 order_id: self.order_id.clone(),
232 quotes,
233 failed_solvers: self.failed_solvers.clone(),
234 }
235 }
236}
237
238pub struct WorkerPoolRouter {
240 solver_pools: Vec<SolverPoolHandle>,
242 config: WorkerPoolRouterConfig,
244 encoder: Encoder,
246 price_guard: Option<PriceGuard>,
249}
250
251impl WorkerPoolRouter {
252 pub fn new(
254 solver_pools: Vec<SolverPoolHandle>,
255 config: WorkerPoolRouterConfig,
256 encoder: Encoder,
257 ) -> Self {
258 Self { solver_pools, config, encoder, price_guard: None }
259 }
260
261 pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
266 self.price_guard = Some(price_guard);
267 self
268 }
269
270 pub fn num_pools(&self) -> usize {
272 self.solver_pools.len()
273 }
274
275 pub async fn quote(
290 &self,
291 request: QuoteRequest,
292 access: ExclusiveAccess,
293 ) -> Result<Quote, SolveError> {
294 let start = Instant::now();
295 let deadline = start + self.effective_timeout(request.options());
296 let min_responses = request
297 .options()
298 .min_responses()
299 .unwrap_or(self.config.min_responses());
300
301 if self.solver_pools.is_empty() {
302 return Err(SolveError::Internal("no solver pools configured".to_string()));
303 }
304
305 let params = match request.options().state_label().cloned() {
306 Some(label) => SolveParams::default().with_state_label(label),
307 None => SolveParams::default(),
308 };
309
310 counter!(
311 "worker_router_exclusive_access_total",
312 "access" => match access {
313 ExclusiveAccess::Granted => "granted",
314 ExclusiveAccess::Denied => "denied",
315 }
316 )
317 .increment(1);
318
319 let class = OrderClass::new(access);
323 let allocations: Vec<Allocation<'_>> = request
324 .orders()
325 .iter()
326 .map(|_| allocate(&self.solver_pools, class))
327 .collect();
328
329 if allocations
330 .iter()
331 .any(Allocation::is_empty)
332 {
333 return Err(SolveError::Internal(format!(
334 "no solver pool serves this request: {class:?}"
335 )));
336 }
337
338 let order_futures: Vec<_> = request
340 .orders()
341 .iter()
342 .zip(&allocations)
343 .map(|(order, allocation)| {
344 self.solve_order(order.clone(), params.clone(), deadline, min_responses, allocation)
345 })
346 .collect();
347
348 let mut order_responses = futures::future::join_all(order_futures).await;
349
350 if let Some(encoding_options) = request.options().encoding_options() {
353 refine_gas_estimates(&mut order_responses, encoding_options)?;
354 }
355
356 let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
364 .iter()
365 .zip(&allocations)
366 .map(|(responses, allocation)| {
367 if allocation.exclusive_routing_active() {
368 let public_ranked = self.rank_quotes(
369 &responses.public_only(allocation.scopes()),
370 request.options(),
371 );
372 combine_with_surplus(
373 responses,
374 allocation.scopes(),
375 request.options(),
376 public_ranked,
377 *USER_IMPROVEMENT_SHARE_BPS,
378 self.encoder.chain(),
379 )
380 } else {
381 self.rank_quotes(responses, request.options())
382 }
383 })
384 .collect();
385
386 for (order, responses) in request
388 .orders()
389 .iter()
390 .zip(&order_responses)
391 {
392 log_quote_comparison(order, responses, request.options());
393 }
394
395 let price_guard_config = request
397 .options()
398 .encoding_options()
399 .map(|e| e.price_guard())
400 .filter(|c| c.enabled());
401
402 let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
403 (Some(guard), Some(config)) => guard
404 .validate(ranked_quotes, config)
405 .map_err(|e| {
406 warn!(error = %e, "price guard validation error");
407 SolveError::Internal(e.to_string())
408 })?,
409 (None, Some(_)) => {
410 return Err(SolveError::Internal(
411 "price guard config provided but price guard is not enabled on this server"
412 .to_string(),
413 ));
414 }
415 _ => ranked_quotes
416 .into_iter()
417 .filter_map(|candidates| candidates.into_iter().next())
418 .collect(),
419 };
420
421 if let Some(encoding_options) = request.options().encoding_options() {
423 let encode_start = Instant::now();
424 let encoded = self
425 .encoder
426 .encode(order_quotes, encoding_options.clone())
427 .await;
428 histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
429 order_quotes = match encoded {
430 Ok(quotes) => quotes,
431 Err(e) => {
432 counter!("encoding_failures_total").increment(1);
433 return Err(e);
434 }
435 };
436 }
437
438 let total_gas_estimate = order_quotes
440 .iter()
441 .map(|o| o.gas_estimate())
442 .fold(BigUint::ZERO, |acc, g| acc + g);
443
444 let solve_time_ms = start.elapsed().as_millis() as u64;
445
446 Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
447 }
448
449 async fn solve_order(
451 &self,
452 order: Order,
453 params: SolveParams,
454 deadline: Instant,
455 min_responses: usize,
456 allocation: &Allocation<'_>,
457 ) -> OrderResponses {
458 let start_time = Instant::now();
459 let order_id = order.id().to_string();
460
461 let allocated: Vec<(&str, LiquidityScope)> = allocation
462 .worker_pools()
463 .iter()
464 .map(|worker_pool| (worker_pool.name(), worker_pool.liquidity_scope()))
465 .collect();
466 debug!(
467 order_id = %order_id,
468 worker_pools = ?allocated,
469 "dispatching order to allocated worker pools"
470 );
471
472 let mut pending: FuturesUnordered<_> = allocation
475 .worker_pools()
476 .iter()
477 .map(|worker_pool| {
478 let order_clone = order.clone();
479 let worker_pool_name = worker_pool.name().to_string();
480 let queue = worker_pool.queue().clone();
481 let task_params = params.clone();
482
483 async move {
484 let result = queue
485 .enqueue(order_clone, task_params)
486 .await;
487 (worker_pool_name, result)
488 }
489 })
490 .collect();
491
492 let mut quotes = Vec::new();
493 let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
494 let mut remaining_worker_pools: FxHashSet<String> = allocation
495 .worker_pools()
496 .iter()
497 .map(|p| p.name().to_string())
498 .collect();
499 let mut has_public_response = false;
500 let mut has_exclusive_access_response = false;
501
502 loop {
504 let deadline_instant = tokio::time::Instant::from_std(deadline);
505
506 tokio::select! {
507 biased;
509
510 _ = tokio::time::sleep_until(deadline_instant) => {
512 let elapsed_ms = deadline.saturating_duration_since(Instant::now())
514 .as_millis() as u64;
515 for worker_pool_name in remaining_worker_pools.drain() {
516 failed_solvers.push((
517 worker_pool_name,
518 SolveError::Timeout { elapsed_ms },
519 ));
520 }
521 break;
522 }
523
524 result = pending.next() => {
526 match result {
527 Some((worker_pool_name, Ok(single_quote))) => {
528 remaining_worker_pools.remove(&worker_pool_name);
530
531 if allocation.is_exclusive(&worker_pool_name) {
532 has_exclusive_access_response = true;
533 } else {
534 has_public_response = true;
535 }
536
537 quotes.push(WorkerPoolQuote {
538 worker_pool: worker_pool_name.clone(),
539 quote: single_quote.order().clone(),
540 solve_time_ms: single_quote.solve_time_ms(),
541 });
542
543 let scope_ready = if allocation.exclusive_routing_active() {
549 has_public_response && has_exclusive_access_response
550 } else {
551 true
552 };
553 if min_responses > 0
554 && quotes.len() >= min_responses
555 && scope_ready
556 {
557 debug!(
558 order_id = %order_id,
559 responses = quotes.len(),
560 min_responses,
561 "early return: min_responses reached"
562 );
563 counter!("worker_router_early_returns_total").increment(1);
564 break;
565 }
566 }
567 Some((worker_pool_name, Err(e))) => {
568 remaining_worker_pools.remove(&worker_pool_name);
569 if allocation.is_exclusive(&worker_pool_name) {
574 has_exclusive_access_response = true;
575 }
576 debug!(
577 pool = %worker_pool_name,
578 order_id = %order_id,
579 error = %e,
580 "solver pool failed"
581 );
582 failed_solvers.push((worker_pool_name, e));
583 }
584 None => {
585 break;
587 }
588 }
589 }
590 }
591 }
592
593 let duration = start_time.elapsed().as_secs_f64();
595 histogram!("worker_router_solve_duration_seconds").record(duration);
596 histogram!("worker_router_solver_responses").record(quotes.len() as f64);
597
598 for (worker_pool_name, error) in &failed_solvers {
600 let error_type = solver_error_label(error);
601 counter!("worker_router_solver_failures_total", "pool" => worker_pool_name.clone(), "error_type" => error_type).increment(1);
602 }
603
604 if !failed_solvers.is_empty() {
605 let timeout_count = failed_solvers
606 .iter()
607 .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
608 .count();
609 let other_count = failed_solvers.len() - timeout_count;
610 warn!(
611 order_id = %order_id,
612 timeout_count,
613 other_failures = other_count,
614 "some solver pools failed"
615 );
616 }
617
618 OrderResponses { order_id, quotes, failed_solvers }
619 }
620
621 fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
627 let mut valid_quotes: Vec<_> = responses
628 .quotes
629 .iter()
630 .filter(|wq| is_rankable(&wq.quote, options))
631 .collect();
632
633 valid_quotes.sort_by(|a, b| {
635 b.quote
636 .amount_out_net_gas()
637 .cmp(a.quote.amount_out_net_gas())
638 });
639
640 if !valid_quotes.is_empty() {
641 counter!("worker_router_orders_total", "status" => "success").increment(1);
642 let best = valid_quotes[0];
643 counter!("worker_router_best_quote_pool", "pool" => best.worker_pool.clone())
644 .increment(1);
645 debug!(
646 order_id = %best.quote.order_id(),
647 number_of_candidates = valid_quotes.len(),
648 "ranked quotes"
649 );
650 return valid_quotes
651 .into_iter()
652 .map(|pq| pq.quote.clone())
653 .collect();
654 }
655
656 let fallback = if let Some(WorkerPoolQuote { quote: any_q, .. }) = responses.quotes.first()
659 {
660 counter!("worker_router_orders_total", "status" => "no_route").increment(1);
661 let mut fallback = OrderQuote::new(
662 responses.order_id.clone(),
663 QuoteStatus::NoRouteFound,
664 any_q.amount_in().clone(),
665 BigUint::ZERO,
666 BigUint::ZERO,
667 BigUint::ZERO,
668 any_q.block().clone(),
669 String::new(),
670 any_q.sender().clone(),
671 any_q.receiver().clone(),
672 any_q.solved_against().clone(),
673 );
674 let over_max_gas = responses.quotes.iter().any(|pq| {
678 options
679 .max_gas()
680 .is_some_and(|max| pq.quote.gas_estimate() > max)
681 });
682 fallback.set_no_route_cause(over_max_gas.then_some(SolveError::MaxGasExceeded));
683 fallback
684 } else {
685 let status = if responses.failed_solvers.is_empty() {
687 QuoteStatus::NoRouteFound
688 } else {
689 let all_timeouts = responses
692 .failed_solvers
693 .iter()
694 .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
695 let all_not_ready = responses
696 .failed_solvers
697 .iter()
698 .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
699 if all_timeouts {
700 QuoteStatus::Timeout
701 } else if all_not_ready {
702 QuoteStatus::NotReady
703 } else {
704 QuoteStatus::NoRouteFound
705 }
706 };
707
708 let status_label = match status {
710 QuoteStatus::Timeout => "timeout",
711 QuoteStatus::NotReady => "not_ready",
712 _ => "no_route",
713 };
714 counter!("worker_router_orders_total", "status" => status_label).increment(1);
715
716 let label = options
719 .state_label()
720 .cloned()
721 .unwrap_or_else(|| "0".to_string());
722 let mut fallback = OrderQuote::new(
723 responses.order_id.clone(),
724 status,
725 BigUint::ZERO,
726 BigUint::ZERO,
727 BigUint::ZERO,
728 BigUint::ZERO,
729 BlockInfo::new(0, String::new(), 0),
730 String::new(),
731 Bytes::default(),
732 Bytes::default(),
733 label,
734 );
735 fallback.set_no_route_cause(aggregate_no_route_cause(&responses.failed_solvers));
736 fallback
737 };
738 vec![fallback]
739 }
740
741 fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
743 options
744 .timeout_ms()
745 .map(Duration::from_millis)
746 .unwrap_or(self.config.default_timeout())
747 }
748}
749
750fn combine_with_surplus(
814 responses: &OrderResponses,
815 pool_scopes: &FxHashMap<String, LiquidityScope>,
816 options: &QuoteOptions,
817 public_ranked: Vec<OrderQuote>,
818 user_share_bps: u32,
819 chain: Chain,
820) -> Vec<OrderQuote> {
821 let Some(exclusive_candidate) =
822 best_exclusive_candidate(responses, pool_scopes, options, chain)
823 else {
824 return public_ranked;
825 };
826
827 let public_reference = public_ranked
828 .first()
829 .filter(|q| q.status() == QuoteStatus::Success);
830
831 let commitment = match public_reference {
832 Some(public_reference) => {
833 matched_commitment(public_reference, exclusive_candidate, user_share_bps)
834 }
835 None => default_fee_commitment(exclusive_candidate),
836 };
837 let Some(committed_amount_out) = commitment else {
838 return public_ranked;
839 };
840
841 if let Some(public_reference) = public_reference {
845 let user_savings = &committed_amount_out - public_reference.amount_out();
846 record_gas_token_amount(
847 "exclusive_user_savings_amount",
848 exclusive_candidate,
849 &user_savings,
850 );
851 }
852
853 let mut result = Vec::with_capacity(public_ranked.len() + 1);
854 result.push(pin_commitment(exclusive_candidate, committed_amount_out));
855 result.extend(public_ranked);
856 result
857}
858
859fn to_gas_token_amount(quote: &OrderQuote, amount: &BigUint) -> Option<BigUint> {
864 let gas_cost_out = quote
865 .amount_out()
866 .checked_sub(quote.amount_out_net_gas())?;
867 let gas_cost_wei = quote.gas_estimate() * quote.gas_price()?;
868 if gas_cost_out == BigUint::ZERO || gas_cost_wei == BigUint::ZERO {
869 return None;
870 }
871 Some(amount * gas_cost_wei / gas_cost_out)
872}
873
874fn record_gas_token_amount(metric: &'static str, quote: &OrderQuote, amount: &BigUint) {
880 let Some(gas_token_amount) = to_gas_token_amount(quote, amount) else {
881 counter!("exclusive_unpriced_output_total").increment(1);
882 return;
883 };
884 gauge!(metric).increment(gas_token_amount.to_f64().unwrap_or(0.0) / WEI_PER_GAS_TOKEN);
885}
886
887fn is_rankable(quote: &OrderQuote, options: &QuoteOptions) -> bool {
889 quote.status() == QuoteStatus::Success &&
890 options
891 .max_gas()
892 .map(|max| quote.gas_estimate() <= max)
893 .unwrap_or(true)
894}
895
896fn best_exclusive_candidate<'a>(
899 responses: &'a OrderResponses,
900 pool_scopes: &FxHashMap<String, LiquidityScope>,
901 options: &QuoteOptions,
902 chain: Chain,
903) -> Option<&'a OrderQuote> {
904 responses
905 .quotes
906 .iter()
907 .filter(|wq| pool_scopes.get(&wq.worker_pool) == Some(&LiquidityScope::IncludeExclusive))
908 .filter(|wq| wq.quote.status() == QuoteStatus::Success)
909 .filter(|wq| {
910 options
911 .max_gas()
912 .map(|max| wq.quote.gas_estimate() <= max)
913 .unwrap_or(true)
914 })
915 .filter(|wq| has_valid_exclusive_route(&wq.quote, chain))
916 .max_by(|a, b| {
917 a.quote
918 .amount_out_net_gas()
919 .cmp(b.quote.amount_out_net_gas())
920 })
921 .map(|wq| &wq.quote)
922}
923
924fn matched_commitment(
931 public_reference: &OrderQuote,
932 exclusive_candidate: &OrderQuote,
933 user_share_bps: u32,
934) -> Option<BigUint> {
935 let public_net_amount_out = public_reference.amount_out_net_gas();
938 if exclusive_candidate.amount_out_net_gas() < public_net_amount_out {
939 return None;
940 }
941 let improvement = exclusive_candidate.amount_out_net_gas() - public_net_amount_out;
942 let required_net_amount_out = public_net_amount_out + user_margin(&improvement, user_share_bps);
943
944 let public_amount_out = public_reference.amount_out();
949 if exclusive_candidate.amount_out() < public_amount_out {
950 return None;
951 }
952
953 let gas_cost = exclusive_candidate.amount_out() - exclusive_candidate.amount_out_net_gas();
955 Some((required_net_amount_out + gas_cost).max(public_amount_out.clone()))
956}
957
958fn default_fee_commitment(exclusive_candidate: &OrderQuote) -> Option<BigUint> {
967 let exclusive_leg_amount_out = exclusive_candidate
968 .route()?
969 .swaps()
970 .iter()
971 .find(|swap| is_exclusive(swap.protocol_component()))?
972 .amount_out();
973
974 let realized_amount_out = exclusive_candidate.amount_out();
975 let fee = exclusive_leg_amount_out * BigUint::from(NO_PUBLIC_ROUTE_FEE_BPS) /
976 BigUint::from(BPS_DENOMINATOR);
977 let committed_amount_out = realized_amount_out - fee;
978
979 let gas_cost = realized_amount_out - exclusive_candidate.amount_out_net_gas();
980 if committed_amount_out <= gas_cost {
981 return None;
982 }
983 Some(committed_amount_out)
984}
985
986fn pin_commitment(exclusive_candidate: &OrderQuote, committed_amount_out: BigUint) -> OrderQuote {
991 let exclusive_route_amount_out = exclusive_candidate.amount_out();
992 let exclusive_gas_cost = exclusive_route_amount_out - exclusive_candidate.amount_out_net_gas();
993 let surplus_amount = exclusive_route_amount_out - &committed_amount_out;
994
995 record_gas_token_amount("exclusive_fee_amount", exclusive_candidate, &surplus_amount);
996
997 let mut surplus_quote = exclusive_candidate.clone();
998
999 let path_final_outs: Vec<BigUint> = surplus_quote
1003 .route()
1004 .map(|route| {
1005 let swaps = route.swaps();
1006 let mut finals = vec![BigUint::ZERO; swaps.len()];
1007 let mut current_final = BigUint::ZERO;
1008 for i in (0..swaps.len()).rev() {
1009 let is_terminal =
1010 i == swaps.len() - 1 || swaps[i + 1].token_in() != swaps[i].token_out();
1011 if is_terminal {
1012 current_final = swaps[i].amount_out().clone();
1013 }
1014 finals[i] = current_final.clone();
1015 }
1016 finals
1017 })
1018 .unwrap_or_default();
1019
1020 if let Some(route) = surplus_quote.route_mut() {
1021 let mut surplus = exclusive_route_amount_out - &committed_amount_out;
1024 for (i, swap) in route.swaps_mut().iter_mut().enumerate() {
1025 if is_exclusive(swap.protocol_component()) {
1026 let Some(path_final_out) = path_final_outs.get(i) else {
1027 continue;
1028 };
1029 let captured = surplus
1030 .clone()
1031 .min(path_final_out.clone());
1032 let captured_leg = if *path_final_out == BigUint::ZERO {
1039 BigUint::ZERO
1040 } else {
1041 &captured * swap.amount_out() / path_final_out
1042 };
1043 debug_assert!(
1044 captured_leg <= *swap.amount_out(),
1045 "captured amount ({captured_leg}) must not exceed the leg's output ({})",
1046 swap.amount_out(),
1047 );
1048 let committed_leg = swap.amount_out() - &captured_leg;
1049 surplus -= captured;
1050 swap.set_committed_amount_out(committed_leg);
1051 }
1052 }
1053 }
1054
1055 surplus_quote.set_amount_out(committed_amount_out.clone());
1056
1057 surplus_quote.set_amount_out_net_gas(&committed_amount_out - &exclusive_gas_cost);
1061
1062 let surplus_info = SurplusInfo::new(surplus_amount, committed_amount_out);
1063 surplus_quote.with_surplus(surplus_info)
1064}
1065
1066fn has_valid_exclusive_route(quote: &OrderQuote, chain: Chain) -> bool {
1082 let Some(route) = quote.route() else {
1083 return false;
1084 };
1085
1086 let swaps = route.swaps();
1087 if swaps.is_empty() {
1088 return false;
1089 }
1090
1091 let Some(output_token) = route.output_token() else {
1092 return false;
1093 };
1094
1095 let mut exclusive_count = 0;
1096
1097 for (index, swap) in swaps.iter().enumerate() {
1098 if !is_exclusive(swap.protocol_component()) {
1099 continue;
1100 }
1101
1102 let reaches_output = *swap.token_out() == output_token ||
1106 swaps
1107 .get(index + 1)
1108 .is_some_and(|next| {
1109 next.token_in() == swap.token_out() &&
1110 next.token_out() == &output_token &&
1111 is_native_wrap(next, chain)
1112 });
1113 if !reaches_output {
1114 counter!("exclusive_route_invalid_shape_total").increment(1);
1115 return false;
1116 }
1117 exclusive_count += 1;
1118 }
1119
1120 exclusive_count == 1
1121}
1122
1123fn is_native_wrap(swap: &Swap, chain: Chain) -> bool {
1129 let (Ok(native), Ok(wrapped)) = (chain.try_native_token(), chain.try_wrapped_native_token())
1130 else {
1131 return false;
1132 };
1133
1134 let (token_in, token_out) = (swap.token_in(), swap.token_out());
1135 (*token_in == native.address && *token_out == wrapped.address) ||
1136 (*token_in == wrapped.address && *token_out == native.address)
1137}
1138
1139fn aggregate_no_route_cause(failed_solvers: &[(String, SolveError)]) -> Option<SolveError> {
1144 failed_solvers
1145 .iter()
1146 .map(|(_, error)| error)
1147 .min_by_key(|error| cause_tier(error))
1148 .cloned()
1149}
1150
1151fn cause_tier(error: &SolveError) -> u8 {
1152 use crate::algorithm::NoPathReason;
1153 match error {
1154 SolveError::NoRouteFound { reason: Some(reason), .. } => match reason {
1155 NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => 0,
1156 NoPathReason::AmountTooSmall => 1,
1157 NoPathReason::NoScorablePaths => 2,
1158 NoPathReason::NoGraphPath => 3,
1159 },
1160 SolveError::InsufficientLiquidity { .. } | SolveError::MaxGasExceeded => 2,
1161 SolveError::MissingData(_) |
1162 SolveError::MarketDataStale { .. } |
1163 SolveError::ComputationFailed(_) |
1164 SolveError::NotReady(_) => 3,
1165 SolveError::SimulationFailed(_) | SolveError::AlgorithmError(_) => 4,
1166 SolveError::Timeout { .. } |
1167 SolveError::QueueFull |
1168 SolveError::Internal(_) |
1169 SolveError::InvalidOrder(_) |
1170 SolveError::FailedEncoding(_) |
1171 SolveError::EncodingUnavailable(_) |
1172 SolveError::PriceCheckFailed { .. } => 5,
1173 SolveError::NoRouteFound { reason: None, .. } => 6,
1174 }
1175}
1176
1177fn refine_gas_estimates(
1178 order_responses: &mut Vec<OrderResponses>,
1179 encoding_options: &EncodingOptions,
1180) -> Result<(), SolveError> {
1181 for responses in order_responses {
1182 for WorkerPoolQuote { quote, .. } in &mut responses.quotes {
1183 if quote.status() != QuoteStatus::Success {
1184 continue;
1185 }
1186 let solution = Solution::try_from(&*quote)?
1187 .with_user_transfer_type(encoding_options.transfer_type().clone());
1188 let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
1189 let naive_gas = quote.gas_estimate().clone();
1190 if naive_gas > BigUint::ZERO {
1191 let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
1192 let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
1193 let new_net = if new_gas_cost <= *quote.amount_out() {
1194 quote.amount_out() - &new_gas_cost
1195 } else {
1196 BigUint::ZERO
1197 };
1198 quote.set_amount_out_net_gas(new_net);
1199 quote.set_gas_estimate(refined_gas);
1200 }
1201 }
1202 }
1203 Ok(())
1204}
1205
1206fn derive_strategy(quote: &OrderQuote) -> Strategy {
1207 let Some(route) = quote.route() else { return Strategy::Single };
1208 let swaps = route.swaps();
1209 if swaps.len() == 1 {
1210 Strategy::Single
1211 } else if swaps.iter().any(|s| *s.split() > 0.0) {
1212 Strategy::Split
1213 } else {
1214 Strategy::Sequential
1215 }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 use std::sync::{
1221 atomic::{AtomicUsize, Ordering},
1222 Arc,
1223 };
1224
1225 use rstest::rstest;
1226 use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
1227 use tycho_simulation::{
1228 tycho_common::models::Chain,
1229 tycho_core::{
1230 models::{token::Token, Address, Chain as SimChain},
1231 Bytes,
1232 },
1233 };
1234
1235 use super::*;
1236 use crate::{
1237 algorithm::test_utils::{component, MockProtocolSim},
1238 feed::exclusivity::mark_exclusive,
1239 types::internal::SolveTask,
1240 EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
1241 };
1242
1243 fn default_encoder() -> Encoder {
1244 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
1245 .add_default_encoders(None)
1246 .expect("default encoders should always succeed");
1247 let encoder =
1248 Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
1249 encoder
1251 .router_fees()
1252 .set(crate::encoding::router_fees::RouterFees::new(
1253 100_000_000,
1254 100_000,
1255 20_000_000,
1256 rustc_hash::FxHashMap::default(),
1257 ));
1258 encoder
1259 }
1260
1261 fn worker_quote((worker_pool, quote): (String, OrderQuote)) -> WorkerPoolQuote {
1264 timed_worker_quote(&worker_pool, quote, 0)
1265 }
1266
1267 fn timed_worker_quote(
1268 worker_pool: &str,
1269 quote: OrderQuote,
1270 solve_time_ms: u64,
1271 ) -> WorkerPoolQuote {
1272 WorkerPoolQuote { worker_pool: worker_pool.to_string(), quote, solve_time_ms }
1273 }
1274
1275 fn success_quote(net: u64) -> OrderQuote {
1277 OrderQuote::new(
1278 "o1".to_string(),
1279 QuoteStatus::Success,
1280 BigUint::from(1_000u64),
1281 BigUint::from(net + 10),
1282 BigUint::from(10u64),
1283 BigUint::from(net),
1284 BlockInfo::new(42, "0xabc".to_string(), 0),
1285 "algo".to_string(),
1286 Bytes::default(),
1287 Bytes::default(),
1288 "1".to_string(),
1289 )
1290 }
1291
1292 #[test]
1295 fn test_public_only_keeps_order_id_and_failures() {
1296 let responses = OrderResponses {
1297 order_id: "o1".to_string(),
1298 quotes: vec![
1299 timed_worker_quote("public", success_quote(1_000), 3),
1300 timed_worker_quote("excl", success_quote(900), 4),
1301 ],
1302 failed_solvers: vec![("c".to_string(), SolveError::QueueFull)],
1303 };
1304 let scopes = FxHashMap::from_iter([
1305 ("public".to_string(), LiquidityScope::PublicOnly),
1306 ("excl".to_string(), LiquidityScope::IncludeExclusive),
1307 ]);
1308 let public = responses.public_only(&scopes);
1309
1310 assert_eq!(public.order_id, "o1");
1311 assert_eq!(public.quotes.len(), 1);
1312 assert_eq!(public.quotes[0].worker_pool, "public");
1313 assert_eq!(public.quotes[0].solve_time_ms, 3);
1314 assert_eq!(public.failed_solvers.len(), 1);
1315 }
1316
1317 fn make_address(byte: u8) -> tycho_simulation::tycho_common::models::Address {
1318 Address::from([byte; 20])
1319 }
1320
1321 fn make_order() -> Order {
1322 Order::new(
1323 make_address(0x01),
1324 make_address(0x02),
1325 BigUint::from(1000u64),
1326 OrderSide::Sell,
1327 make_address(0xAA),
1328 )
1329 .with_id("test-order".to_string())
1330 }
1331
1332 fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
1333 let make_token = |addr: Address| Token {
1334 address: addr,
1335 symbol: "T".to_string(),
1336 decimals: 18,
1337 tax: Default::default(),
1338 gas: vec![],
1339 chain: SimChain::Ethereum,
1340 quality: 100,
1341 };
1342 let tin = make_address(0x01);
1343 let tout = make_address(0x02);
1344 let tin_token = make_token(tin.clone());
1345 let tout_token = make_token(tout.clone());
1346 let swap = Swap::new(
1347 "pool-1".to_string(),
1348 "uniswap_v2".to_string(),
1349 tin.clone(),
1350 tout.clone(),
1351 BigUint::from(1000u64),
1352 BigUint::from(990u64),
1353 BigUint::from(50_000u64),
1354 component(
1355 "0x0000000000000000000000000000000000000001",
1356 &[tin_token.clone(), tout_token.clone()],
1357 ),
1358 Box::new(MockProtocolSim::default()),
1359 );
1360 let mut tokens = FxHashMap::default();
1361 tokens.insert(tin, tin_token);
1362 tokens.insert(tout, tout_token);
1363 let quote = OrderQuote::new(
1364 "test-order".to_string(),
1365 QuoteStatus::Success,
1366 BigUint::from(1000u64),
1367 BigUint::from(990u64),
1368 BigUint::from(100_000u64),
1369 BigUint::from(amount_out_net_gas),
1370 BlockInfo::new(1, "0x123".to_string(), 1000),
1371 "test".to_string(),
1372 Bytes::from(make_address(0xAA).as_ref()),
1373 Bytes::from(make_address(0xAA).as_ref()),
1374 "1".to_string(),
1375 )
1376 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1377 SingleOrderQuote::new(quote, 5)
1378 }
1379
1380 fn create_mock_worker_pool(
1382 name: &str,
1383 response: Result<SingleOrderQuote, SolveError>,
1384 delay_ms: u64,
1385 ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
1386 let (pool, worker, _) = create_counting_mock_worker_pool(name, response, delay_ms);
1387 (pool, worker)
1388 }
1389
1390 fn create_counting_mock_worker_pool(
1393 name: &str,
1394 response: Result<SingleOrderQuote, SolveError>,
1395 delay_ms: u64,
1396 ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>, Arc<AtomicUsize>) {
1397 let (tx, rx) = async_channel::bounded::<SolveTask>(10);
1398 let handle = TaskQueueHandle::from_sender(tx);
1399 let received = Arc::new(AtomicUsize::new(0));
1400
1401 let worker = {
1402 let received = Arc::clone(&received);
1403 tokio::spawn(async move {
1404 while let Ok(task) = rx.recv().await {
1405 received.fetch_add(1, Ordering::SeqCst);
1406 if delay_ms > 0 {
1407 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1408 }
1409 task.respond(response.clone());
1410 }
1411 })
1412 };
1413
1414 (SolverPoolHandle::new(name, handle), worker, received)
1415 }
1416
1417 #[test]
1418 fn test_config_default() {
1419 let config = WorkerPoolRouterConfig::default();
1420 assert_eq!(config.default_timeout(), Duration::from_secs(1));
1421 assert_eq!(config.min_responses(), 1);
1422 }
1423
1424 #[test]
1425 fn test_config_builder() {
1426 let config = WorkerPoolRouterConfig::default()
1427 .with_timeout(Duration::from_millis(500))
1428 .with_min_responses(2);
1429 assert_eq!(config.default_timeout(), Duration::from_millis(500));
1430 assert_eq!(config.min_responses(), 2);
1431 }
1432
1433 #[tokio::test]
1434 async fn test_router_no_pools() {
1435 let worker_router =
1436 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1437 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1438
1439 let result = worker_router
1440 .quote(request, ExclusiveAccess::Denied)
1441 .await;
1442 assert!(matches!(result, Err(SolveError::Internal(_))));
1443 }
1444
1445 #[tokio::test]
1446 async fn test_router_single_pool_success() {
1447 let (pool, worker) = create_mock_worker_pool("pool_a", Ok(make_single_quote(900)), 0);
1448
1449 let worker_router =
1450 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1451 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1452 let request = QuoteRequest::new(vec![make_order()], options);
1453
1454 let result = worker_router
1455 .quote(request, ExclusiveAccess::Denied)
1456 .await;
1457 assert!(result.is_ok());
1458
1459 let quote = result.unwrap();
1460 assert_eq!(quote.orders().len(), 1);
1461 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1462 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
1464 assert!(!quote.orders()[0]
1465 .transaction()
1466 .unwrap()
1467 .data()
1468 .is_empty());
1469
1470 drop(worker_router);
1471 worker.abort();
1472 }
1473
1474 #[tokio::test]
1475 async fn test_router_selects_best_of_two() {
1476 let (pool_a, worker_a) = create_mock_worker_pool("pool_a", Ok(make_single_quote(800)), 0);
1478 let (pool_b, worker_b) = create_mock_worker_pool("pool_b", Ok(make_single_quote(950)), 0);
1480
1481 let config = WorkerPoolRouterConfig::default().with_min_responses(2);
1483 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1484 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1485 let request = QuoteRequest::new(vec![make_order()], options);
1486
1487 let result = worker_router
1488 .quote(request, ExclusiveAccess::Denied)
1489 .await;
1490 assert!(result.is_ok());
1491
1492 let quote = result.unwrap();
1493 assert_eq!(quote.orders().len(), 1);
1494 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
1496 assert!(!quote.orders()[0]
1497 .transaction()
1498 .unwrap()
1499 .data()
1500 .is_empty());
1501
1502 drop(worker_router);
1503 worker_a.abort();
1504 worker_b.abort();
1505 }
1506
1507 #[tokio::test]
1508 async fn test_router_timeout() {
1509 let (pool, worker) = create_mock_worker_pool("slow_pool", Ok(make_single_quote(900)), 500);
1511
1512 let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
1513 let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
1514 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1515
1516 let result = worker_router
1517 .quote(request, ExclusiveAccess::Denied)
1518 .await;
1519 assert!(result.is_ok());
1520
1521 let quote = result.unwrap();
1522 assert_eq!(quote.orders().len(), 1);
1524 assert!(matches!(
1525 quote.orders()[0].status(),
1526 QuoteStatus::Timeout | QuoteStatus::NoRouteFound
1527 ));
1528
1529 drop(worker_router);
1530 worker.abort();
1531 }
1532
1533 #[tokio::test]
1534 async fn test_router_early_return_on_min_responses() {
1535 let (pool_a, worker_a) =
1537 create_mock_worker_pool("fast_pool", Ok(make_single_quote(800)), 0);
1538 let (pool_b, worker_b) =
1540 create_mock_worker_pool("slow_pool", Ok(make_single_quote(950)), 500);
1541
1542 let config = WorkerPoolRouterConfig::default()
1543 .with_timeout(Duration::from_millis(1000))
1544 .with_min_responses(1);
1545 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1546
1547 let start = Instant::now();
1548 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1549 let request = QuoteRequest::new(vec![make_order()], options);
1550
1551 let result = worker_router
1552 .quote(request, ExclusiveAccess::Denied)
1553 .await;
1554 let elapsed = start.elapsed();
1555
1556 assert!(result.is_ok());
1557 assert!(elapsed < Duration::from_millis(200));
1559
1560 let quote = result.unwrap();
1562 assert_eq!(quote.orders().len(), 1);
1563 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1564 assert!(!quote.orders()[0]
1566 .transaction()
1567 .unwrap()
1568 .data()
1569 .is_empty());
1570
1571 drop(worker_router);
1572 worker_a.abort();
1573 worker_b.abort();
1574 }
1575
1576 #[rstest]
1580 #[case::pending_exclusive_pool(ExclusiveAccess::Granted, 0, Some(300), 1, true)]
1581 #[case::pending_public_pool(ExclusiveAccess::Granted, 300, Some(0), 1, true)]
1582 #[case::failed_exclusive_pool(ExclusiveAccess::Granted, 0, None, 1, false)]
1583 #[case::denied_access(ExclusiveAccess::Denied, 0, Some(500), 2, false)]
1584 #[tokio::test]
1585 async fn test_router_early_return_scope_gating(
1586 #[case] access: ExclusiveAccess,
1587 #[case] public_delay_ms: u64,
1588 #[case] exclusive_delay_ms: Option<u64>,
1589 #[case] min_responses: usize,
1590 #[case] expect_surplus: bool,
1591 ) {
1592 let (public_pool, public_worker) =
1593 create_mock_worker_pool("public_pool", Ok(make_single_quote(800)), public_delay_ms);
1594 let exclusive_response = match exclusive_delay_ms {
1595 Some(_) => Ok(make_exclusive_quote(1100)),
1596 None => {
1597 Err(SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None })
1598 }
1599 };
1600 let public_pool = public_pool.with_liquidity_scope(LiquidityScope::PublicOnly);
1601 let (exclusive_pool, exclusive_worker) = create_mock_worker_pool(
1602 "exclusive_pool",
1603 exclusive_response,
1604 exclusive_delay_ms.unwrap_or(0),
1605 );
1606 let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::IncludeExclusive);
1607
1608 let config = WorkerPoolRouterConfig::default()
1609 .with_timeout(Duration::from_millis(2000))
1610 .with_min_responses(min_responses);
1611 let worker_router =
1612 WorkerPoolRouter::new(vec![public_pool, exclusive_pool], config, default_encoder());
1613 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1614
1615 let start = Instant::now();
1616 let result = worker_router
1617 .quote(request, access)
1618 .await
1619 .expect("quote should succeed");
1620 let elapsed = start.elapsed();
1621
1622 assert!(elapsed < Duration::from_millis(500), "took {elapsed:?}");
1625 let order = &result.orders()[0];
1626 assert_eq!(order.status(), QuoteStatus::Success);
1627 assert_eq!(*order.amount_out(), BigUint::from(990u64));
1628 assert_eq!(order.surplus_amount().is_some(), expect_surplus);
1629
1630 drop(worker_router);
1631 public_worker.abort();
1632 exclusive_worker.abort();
1633 }
1634
1635 #[rstest]
1639 #[case::denied(ExclusiveAccess::Denied, false)]
1640 #[case::granted(ExclusiveAccess::Granted, true)]
1641 #[tokio::test]
1642 async fn test_router_exclusive_access_allocation(
1643 #[case] access: ExclusiveAccess,
1644 #[case] expect_exclusive_leg: bool,
1645 ) {
1646 let (public_pool, public_worker) =
1647 create_mock_worker_pool("public_pool", Ok(make_single_quote(800)), 0);
1648 let (exclusive_pool, exclusive_worker, exclusive_tasks) =
1649 create_counting_mock_worker_pool("exclusive_pool", Ok(make_exclusive_quote(1100)), 0);
1650 let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::IncludeExclusive);
1651
1652 let worker_router = WorkerPoolRouter::new(
1653 vec![public_pool, exclusive_pool],
1654 WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(2000)),
1655 default_encoder(),
1656 );
1657 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1658
1659 let result = worker_router
1660 .quote(request, access)
1661 .await
1662 .expect("quote should succeed");
1663
1664 assert_eq!(
1667 exclusive_tasks.load(Ordering::SeqCst) > 0,
1668 expect_exclusive_leg,
1669 "exclusive pool dispatch"
1670 );
1671
1672 let order = &result.orders()[0];
1673 assert_eq!(order.status(), QuoteStatus::Success);
1674
1675 let routes_through_exclusive = order
1676 .route()
1677 .expect("successful quote has a route")
1678 .swaps()
1679 .iter()
1680 .any(|swap| is_exclusive(swap.protocol_component()));
1681 assert_eq!(routes_through_exclusive, expect_exclusive_leg);
1682 assert_eq!(order.surplus_amount().is_some(), expect_exclusive_leg);
1683
1684 assert_eq!(*order.amount_out(), BigUint::from(990u64));
1687
1688 drop(worker_router);
1689 public_worker.abort();
1690 exclusive_worker.abort();
1691 }
1692
1693 #[rstest]
1694 #[case::under_limit(100, Some(200), true)]
1695 #[case::at_limit(200, Some(200), true)]
1696 #[case::over_limit(300, Some(200), false)]
1697 #[case::no_limit(500, None, true)]
1698 fn test_max_gas_constraint(
1699 #[case] gas_estimate: u64,
1700 #[case] max_gas: Option<u64>,
1701 #[case] should_pass: bool,
1702 ) {
1703 let responses = OrderResponses {
1704 order_id: "test".to_string(),
1705 quotes: vec![worker_quote((
1706 "pool".to_string(),
1707 OrderQuote::new(
1708 "test".to_string(),
1709 QuoteStatus::Success,
1710 BigUint::from(1000u64),
1711 BigUint::from(990u64),
1712 BigUint::from(gas_estimate),
1713 BigUint::from(900u64),
1714 BlockInfo::new(1, "0x123".to_string(), 1000),
1715 "test".to_string(),
1716 Bytes::from(make_address(0xAA).as_ref()),
1717 Bytes::from(make_address(0xAA).as_ref()),
1718 "1".to_string(),
1719 ),
1720 ))],
1721 failed_solvers: vec![],
1722 };
1723
1724 let options = match max_gas {
1725 Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
1726 None => QuoteOptions::default(),
1727 };
1728
1729 let worker_router =
1730 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1731 let result = worker_router.rank_quotes(&responses, &options);
1732
1733 if should_pass {
1734 assert_eq!(result[0].status(), QuoteStatus::Success);
1735 } else {
1736 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1737 }
1738 }
1739
1740 #[tokio::test]
1741 async fn test_router_captures_solver_errors() {
1742 let (pool, worker) =
1744 create_mock_worker_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
1745
1746 let worker_router =
1747 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1748 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1749
1750 let result = worker_router
1751 .quote(request, ExclusiveAccess::Denied)
1752 .await;
1753 assert!(result.is_ok());
1754
1755 let quote = result.unwrap();
1756 assert_eq!(quote.orders().len(), 1);
1757 assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
1759
1760 drop(worker_router);
1761 worker.abort();
1762 }
1763
1764 #[test]
1765 fn test_rank_quotes_all_timeouts_returns_timeout_status() {
1766 let responses = OrderResponses {
1767 order_id: "test".to_string(),
1768 quotes: vec![],
1769 failed_solvers: vec![
1770 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1771 ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1772 ],
1773 };
1774
1775 let worker_router =
1776 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1777 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1778
1779 assert_eq!(result.len(), 1);
1780 assert_eq!(result[0].status(), QuoteStatus::Timeout);
1781 }
1782
1783 #[test]
1784 fn test_rank_quotes_mixed_failures_returns_no_route_found() {
1785 let responses = OrderResponses {
1786 order_id: "test".to_string(),
1787 quotes: vec![],
1788 failed_solvers: vec![
1789 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1790 ("pool_b".to_string(), SolveError::no_route_found("test")),
1791 ],
1792 };
1793
1794 let worker_router =
1795 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1796 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1797
1798 assert_eq!(result.len(), 1);
1799 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1800 }
1801
1802 #[test]
1803 fn test_rank_quotes_no_failures_returns_no_route_found() {
1804 let responses =
1805 OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
1806
1807 let worker_router =
1808 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1809 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1810
1811 assert_eq!(result.len(), 1);
1812 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1813 }
1814
1815 #[test]
1816 fn rank_quotes_attaches_no_route_reason() {
1817 use crate::algorithm::NoPathReason;
1818 let responses = OrderResponses {
1819 order_id: "test".to_string(),
1820 quotes: vec![],
1821 failed_solvers: vec![
1822 (
1823 "pool_a".to_string(),
1824 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1825 ),
1826 (
1827 "pool_b".to_string(),
1828 SolveError::no_route_found_with_reason(
1829 "test",
1830 NoPathReason::DestinationTokenNotInGraph,
1831 ),
1832 ),
1833 ],
1834 };
1835 let worker_router =
1836 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1837 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1838 assert_eq!(result.len(), 1);
1839 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1840 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
1842 }
1843
1844 #[test]
1845 fn test_rank_quotes_no_route_reason_single_failure() {
1846 use crate::algorithm::NoPathReason;
1847 let responses = OrderResponses {
1848 order_id: "test".to_string(),
1849 quotes: vec![],
1850 failed_solvers: vec![(
1851 "pool_a".to_string(),
1852 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1853 )],
1854 };
1855 let worker_router =
1856 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1857 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1858 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
1859 }
1860
1861 #[test]
1862 fn test_aggregate_cause_surfaces_amount_too_small() {
1863 use crate::algorithm::NoPathReason;
1864 let failed = vec![(
1865 "bf".to_string(),
1866 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1867 )];
1868 assert!(matches!(
1869 aggregate_no_route_cause(&failed),
1870 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1871 ));
1872 }
1873
1874 #[test]
1875 fn test_aggregate_no_route_cause_independent_of_pool_order() {
1876 use crate::algorithm::NoPathReason;
1877 let dust = || SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall);
1878 let no_path = || SolveError::no_route_found_with_reason("o1", NoPathReason::NoGraphPath);
1879 let forward = vec![("bf1".to_string(), no_path()), ("bf3".to_string(), dust())];
1880 let reversed = vec![("bf3".to_string(), dust()), ("bf1".to_string(), no_path())];
1881 for failed in [forward, reversed] {
1882 assert!(matches!(
1883 aggregate_no_route_cause(&failed),
1884 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1885 ));
1886 }
1887 }
1888
1889 #[test]
1890 fn test_aggregate_token_not_in_graph_wins_over_amount_too_small() {
1891 use crate::algorithm::NoPathReason;
1892 let failed = vec![
1893 (
1894 "bf".to_string(),
1895 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1896 ),
1897 (
1898 "ml".to_string(),
1899 SolveError::no_route_found_with_reason(
1900 "o2",
1901 NoPathReason::DestinationTokenNotInGraph,
1902 ),
1903 ),
1904 ];
1905 assert!(matches!(
1906 aggregate_no_route_cause(&failed),
1907 Some(SolveError::NoRouteFound {
1908 reason: Some(NoPathReason::DestinationTokenNotInGraph),
1909 ..
1910 })
1911 ));
1912 }
1913
1914 #[test]
1915 fn test_aggregate_prefers_liquidity_over_infra() {
1916 let failed = vec![
1917 ("a".to_string(), SolveError::QueueFull),
1918 ("b".to_string(), SolveError::insufficient_liquidity(1u32.into(), 0u32.into())),
1919 ];
1920 assert!(matches!(
1921 aggregate_no_route_cause(&failed),
1922 Some(SolveError::InsufficientLiquidity { .. })
1923 ));
1924 }
1925
1926 #[test]
1927 fn test_rank_quotes_max_gas_filtered_sets_max_gas_exceeded() {
1928 let responses = OrderResponses {
1929 order_id: "o1".to_string(),
1930 quotes: vec![worker_quote((
1931 "pool".to_string(),
1932 OrderQuote::new(
1933 "o1".to_string(),
1934 QuoteStatus::Success,
1935 BigUint::from(1_000u64),
1936 BigUint::from(990u64),
1937 BigUint::from(100_000u64),
1938 BigUint::from(990u64),
1939 BlockInfo::new(1, "0xabc".to_string(), 0),
1940 "test".to_string(),
1941 Bytes::default(),
1942 Bytes::default(),
1943 "1".to_string(),
1944 ),
1945 ))],
1946 failed_solvers: vec![],
1947 };
1948 let options = QuoteOptions::default().with_max_gas(BigUint::from(1u64));
1949 let worker_router =
1950 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1951 let result = worker_router.rank_quotes(&responses, &options);
1952 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1953 assert!(matches!(result[0].no_route_cause(), Some(SolveError::MaxGasExceeded)));
1954 }
1955
1956 #[test]
1957 fn test_rank_quotes_no_cause_when_gas_within_max() {
1958 let responses = OrderResponses {
1959 order_id: "o1".to_string(),
1960 quotes: vec![worker_quote((
1961 "pool".to_string(),
1962 OrderQuote::new(
1963 "o1".to_string(),
1964 QuoteStatus::NoRouteFound,
1965 BigUint::from(1_000u64),
1966 BigUint::from(990u64),
1967 BigUint::from(100u64),
1968 BigUint::from(990u64),
1969 BlockInfo::new(1, "0xabc".to_string(), 0),
1970 "test".to_string(),
1971 Bytes::default(),
1972 Bytes::default(),
1973 "1".to_string(),
1974 ),
1975 ))],
1976 failed_solvers: vec![],
1977 };
1978 let options = QuoteOptions::default().with_max_gas(BigUint::from(1_000u64));
1979 let worker_router =
1980 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1981 let result = worker_router.rank_quotes(&responses, &options);
1982 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1983 assert!(
1984 result[0].no_route_cause().is_none(),
1985 "gas within max_gas must not be labelled MaxGasExceeded: {:?}",
1986 result[0].no_route_cause()
1987 );
1988 }
1989
1990 #[test]
1991 fn test_all_timeout_fallback_carries_timeout_cause() {
1992 let responses = OrderResponses {
1993 order_id: "o1".to_string(),
1994 quotes: vec![],
1995 failed_solvers: vec![("pool".to_string(), SolveError::Timeout { elapsed_ms: 9 })],
1996 };
1997 let worker_router =
1998 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1999 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
2000 assert_eq!(result[0].status(), QuoteStatus::Timeout);
2001 assert!(matches!(result[0].no_route_cause(), Some(SolveError::Timeout { .. })));
2002 }
2003
2004 #[test]
2005 fn test_rank_quotes_returns_sorted_candidates() {
2006 let responses = OrderResponses {
2007 order_id: "test".to_string(),
2008 quotes: vec![
2009 worker_quote((
2010 "pool_a".to_string(),
2011 OrderQuote::new(
2012 "test".to_string(),
2013 QuoteStatus::Success,
2014 BigUint::from(1000u64),
2015 BigUint::from(800u64),
2016 BigUint::from(100_000u64),
2017 BigUint::from(800u64),
2018 BlockInfo::new(1, "0x123".to_string(), 1000),
2019 "test".to_string(),
2020 Bytes::from(make_address(0xAA).as_ref()),
2021 Bytes::from(make_address(0xAA).as_ref()),
2022 "1".to_string(),
2023 ),
2024 )),
2025 worker_quote((
2026 "pool_b".to_string(),
2027 OrderQuote::new(
2028 "test".to_string(),
2029 QuoteStatus::Success,
2030 BigUint::from(1000u64),
2031 BigUint::from(950u64),
2032 BigUint::from(100_000u64),
2033 BigUint::from(950u64),
2034 BlockInfo::new(1, "0x123".to_string(), 1000),
2035 "test".to_string(),
2036 Bytes::from(make_address(0xAA).as_ref()),
2037 Bytes::from(make_address(0xAA).as_ref()),
2038 "1".to_string(),
2039 ),
2040 )),
2041 ],
2042 failed_solvers: vec![],
2043 };
2044
2045 let worker_router =
2046 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
2047 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
2048
2049 assert_eq!(result.len(), 2);
2050 assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
2051 assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
2052 }
2053
2054 fn make_exclusive_quote_with_leg(
2058 amount_out: u64,
2059 amount_out_net_gas: u64,
2060 leg_amount_out: u64,
2061 ) -> SingleOrderQuote {
2062 let make_token = |addr: Address| Token {
2063 address: addr,
2064 symbol: "T".to_string(),
2065 decimals: 18,
2066 tax: Default::default(),
2067 gas: vec![],
2068 chain: SimChain::Ethereum,
2069 quality: 100,
2070 };
2071 let tin = make_address(0x01);
2072 let tout = make_address(0x02);
2073 let tin_token = make_token(tin.clone());
2074 let tout_token = make_token(tout.clone());
2075 let mut comp = component(
2076 "0x0000000000000000000000000000000000000002",
2077 &[tin_token.clone(), tout_token.clone()],
2078 );
2079 mark_exclusive(&mut comp);
2080 let swap = Swap::new(
2081 "pool-perm".to_string(),
2082 "vm:exclusive".to_string(),
2083 tin.clone(),
2084 tout.clone(),
2085 BigUint::from(1000u64),
2086 BigUint::from(leg_amount_out),
2087 BigUint::from(50_000u64),
2088 comp,
2089 Box::new(MockProtocolSim::default()),
2090 );
2091 let mut tokens = FxHashMap::default();
2092 tokens.insert(tin, tin_token);
2093 tokens.insert(tout, tout_token);
2094 let quote = OrderQuote::new(
2095 "test-order".to_string(),
2096 QuoteStatus::Success,
2097 BigUint::from(1000u64),
2098 BigUint::from(amount_out),
2099 BigUint::from(100_000u64),
2100 BigUint::from(amount_out_net_gas),
2101 BlockInfo::new(1, "0x123".to_string(), 1000),
2102 "test".to_string(),
2103 Bytes::from(make_address(0xAA).as_ref()),
2104 Bytes::from(make_address(0xAA).as_ref()),
2105 "1".to_string(),
2106 )
2107 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
2108 SingleOrderQuote::new(quote, 5)
2109 }
2110
2111 fn make_exclusive_quote(amount_out: u64) -> SingleOrderQuote {
2112 make_exclusive_quote_with_leg(amount_out, amount_out, amount_out)
2113 }
2114
2115 fn make_exclusive_split_quote(public_leg_out: u64, exclusive_leg_out: u64) -> SingleOrderQuote {
2119 let make_token = |addr: Address| Token {
2120 address: addr,
2121 symbol: "T".to_string(),
2122 decimals: 18,
2123 tax: Default::default(),
2124 gas: vec![],
2125 chain: SimChain::Ethereum,
2126 quality: 100,
2127 };
2128 let tin = make_address(0x01);
2129 let tout = make_address(0x02);
2130 let tin_token = make_token(tin.clone());
2131 let tout_token = make_token(tout.clone());
2132 let public_swap = Swap::new(
2133 "pool-pub".to_string(),
2134 "uniswap_v2".to_string(),
2135 tin.clone(),
2136 tout.clone(),
2137 BigUint::from(500u64),
2138 BigUint::from(public_leg_out),
2139 BigUint::from(50_000u64),
2140 component(
2141 "0x0000000000000000000000000000000000000001",
2142 &[tin_token.clone(), tout_token.clone()],
2143 ),
2144 Box::new(MockProtocolSim::default()),
2145 );
2146 let mut exclusive_comp = component(
2147 "0x0000000000000000000000000000000000000002",
2148 &[tin_token.clone(), tout_token.clone()],
2149 );
2150 mark_exclusive(&mut exclusive_comp);
2151 let exclusive_swap = Swap::new(
2152 "pool-perm".to_string(),
2153 "vm:exclusive".to_string(),
2154 tin.clone(),
2155 tout.clone(),
2156 BigUint::from(500u64),
2157 BigUint::from(exclusive_leg_out),
2158 BigUint::from(50_000u64),
2159 exclusive_comp,
2160 Box::new(MockProtocolSim::default()),
2161 );
2162 let mut tokens = FxHashMap::default();
2163 tokens.insert(tin, tin_token);
2164 tokens.insert(tout, tout_token);
2165 let total = public_leg_out + exclusive_leg_out;
2166 let quote = OrderQuote::new(
2167 "test-order".to_string(),
2168 QuoteStatus::Success,
2169 BigUint::from(1000u64),
2170 BigUint::from(total),
2171 BigUint::from(100_000u64),
2172 BigUint::from(total),
2173 BlockInfo::new(1, "0x123".to_string(), 1000),
2174 "test".to_string(),
2175 Bytes::from(make_address(0xAA).as_ref()),
2176 Bytes::from(make_address(0xAA).as_ref()),
2177 "1".to_string(),
2178 )
2179 .with_route(
2180 Route::new(vec![public_swap, exclusive_swap], tokens).expect("non-empty route"),
2181 );
2182 SingleOrderQuote::new(quote, 5)
2183 }
2184
2185 fn make_public_quote_with_net(amount_out: u64, amount_out_net_gas: u64) -> SingleOrderQuote {
2188 let make_token = |addr: Address| Token {
2189 address: addr,
2190 symbol: "T".to_string(),
2191 decimals: 18,
2192 tax: Default::default(),
2193 gas: vec![],
2194 chain: SimChain::Ethereum,
2195 quality: 100,
2196 };
2197 let tin = make_address(0x01);
2198 let tout = make_address(0x02);
2199 let tin_token = make_token(tin.clone());
2200 let tout_token = make_token(tout.clone());
2201 let swap = Swap::new(
2202 "pool-1".to_string(),
2203 "uniswap_v2".to_string(),
2204 tin.clone(),
2205 tout.clone(),
2206 BigUint::from(1000u64),
2207 BigUint::from(amount_out),
2208 BigUint::from(50_000u64),
2209 component(
2210 "0x0000000000000000000000000000000000000001",
2211 &[tin_token.clone(), tout_token.clone()],
2212 ),
2213 Box::new(MockProtocolSim::default()),
2214 );
2215 let mut tokens = FxHashMap::default();
2216 tokens.insert(tin, tin_token);
2217 tokens.insert(tout, tout_token);
2218 let quote = OrderQuote::new(
2219 "test-order".to_string(),
2220 QuoteStatus::Success,
2221 BigUint::from(1000u64),
2222 BigUint::from(amount_out),
2223 BigUint::from(100_000u64),
2224 BigUint::from(amount_out_net_gas),
2225 BlockInfo::new(1, "0x123".to_string(), 1000),
2226 "test".to_string(),
2227 Bytes::from(make_address(0xAA).as_ref()),
2228 Bytes::from(make_address(0xAA).as_ref()),
2229 "1".to_string(),
2230 )
2231 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
2232 SingleOrderQuote::new(quote, 5)
2233 }
2234
2235 fn make_public_quote_zero_gas(amount_out: u64) -> SingleOrderQuote {
2236 make_public_quote_with_net(amount_out, amount_out)
2237 }
2238
2239 fn exclusive_access_responses(public_out: u64, exclusive_out: u64) -> OrderResponses {
2242 let public = make_public_quote_zero_gas(public_out)
2243 .order()
2244 .clone();
2245 let exclusive_access = make_exclusive_quote(exclusive_out)
2246 .order()
2247 .clone();
2248 OrderResponses {
2249 order_id: "test-order".to_string(),
2250 quotes: vec![
2251 worker_quote(("public_pool".to_string(), public)),
2252 worker_quote(("exclusive_access_pool".to_string(), exclusive_access)),
2253 ],
2254 failed_solvers: vec![],
2255 }
2256 }
2257
2258 fn exclusive_access_pool_scopes() -> FxHashMap<String, LiquidityScope> {
2259 FxHashMap::from_iter([
2260 ("public_pool".to_string(), LiquidityScope::PublicOnly),
2261 ("exclusive_access_pool".to_string(), LiquidityScope::IncludeExclusive),
2262 ])
2263 }
2264
2265 #[rstest]
2270 #[case::exclusive_beats_public((900, 900), (950, 950), 1_000, (905, 905, Some(45)))]
2272 #[case::exclusive_below_public((950, 950), (900, 900), 1_000, (950, 950, None))]
2273 #[case::exclusive_ties_public_net((900, 900), (900, 900), 1_000, (900, 900, Some(0)))]
2275 #[case::exclusive_marginally_better((999, 999), (1000, 1000), 1_000, (1000, 1000, Some(0)))]
2277 #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), 1_000, (1000, 950, None))]
2278 #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), 1_000, (1091, 951, Some(9)))]
2281 #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), 1_000, (1001, 961, Some(99)))]
2284 #[case::sub_bps_markup((1_000_000, 1_000_000), (1_000_050, 1_000_050), 1_000,
2287 (1_000_005, 1_000_005, Some(45)))]
2288 #[case::zero_share((900, 900), (950, 950), 0, (900, 900, Some(50)))]
2290 #[case::full_share((900, 900), (950, 950), 10_000, (950, 950, Some(0)))]
2292 fn test_combine_head_selection(
2293 #[case] public: (u64, u64),
2294 #[case] exclusive: (u64, u64),
2295 #[case] user_share_bps: u32,
2296 #[case] expected: (u64, u64, Option<u64>),
2297 ) {
2298 let (public_out, public_net) = public;
2299 let (exclusive_out, exclusive_net) = exclusive;
2300 let (expected_amount_out, expected_net, expected_surplus) = expected;
2301
2302 let responses = responses_with_gas(public_out, public_net, exclusive_out, exclusive_net);
2303 let public_ranked = vec![make_public_quote_with_net(public_out, public_net)
2304 .order()
2305 .clone()];
2306 let combined = combine_with_surplus(
2307 &responses,
2308 &exclusive_access_pool_scopes(),
2309 &QuoteOptions::default(),
2310 public_ranked,
2311 user_share_bps,
2312 SimChain::Ethereum,
2313 );
2314
2315 let expected_surplus = expected_surplus.map(BigUint::from);
2316 assert_eq!(combined.len(), if expected_surplus.is_some() { 2 } else { 1 });
2317 assert_eq!(*combined[0].amount_out(), BigUint::from(expected_amount_out));
2318 assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(expected_net));
2319 assert_eq!(combined[0].surplus_amount(), expected_surplus.as_ref());
2320 if expected_surplus.is_some() {
2321 assert_eq!(
2322 combined[0].committed_amount_out(),
2323 Some(&BigUint::from(expected_amount_out))
2324 );
2325 }
2326 }
2327
2328 #[rstest]
2329 #[case::over_max_gas(
2330 make_exclusive_quote(1100).order().clone(),
2331 Some(50_000)
2332 )]
2333 #[case::mid_route_exclusive_leg(
2334 make_route_quote(&[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)]),
2335 None
2336 )]
2337 fn test_combine_filters_exclusive_candidate(
2338 #[case] exclusive_quote: OrderQuote,
2339 #[case] max_gas: Option<u64>,
2340 ) {
2341 let mut options = QuoteOptions::default();
2342 if let Some(max) = max_gas {
2343 options = options.with_max_gas(BigUint::from(max));
2344 }
2345 let responses = OrderResponses {
2346 order_id: "test-order".to_string(),
2347 quotes: vec![
2348 worker_quote((
2349 "public_pool".to_string(),
2350 make_public_quote_zero_gas(900)
2351 .order()
2352 .clone(),
2353 )),
2354 worker_quote(("exclusive_access_pool".to_string(), exclusive_quote)),
2355 ],
2356 failed_solvers: vec![],
2357 };
2358 let public_ranked = vec![make_public_quote_zero_gas(900)
2359 .order()
2360 .clone()];
2361 let combined = combine_with_surplus(
2362 &responses,
2363 &exclusive_access_pool_scopes(),
2364 &options,
2365 public_ranked,
2366 1_000,
2367 SimChain::Ethereum,
2368 );
2369
2370 assert_eq!(combined.len(), 1);
2371 assert_eq!(*combined[0].amount_out(), BigUint::from(900u64));
2372 assert_eq!(combined[0].surplus_amount(), None);
2373 }
2374
2375 fn no_route_quote() -> OrderQuote {
2377 OrderQuote::new(
2378 "test-order".to_string(),
2379 QuoteStatus::NoRouteFound,
2380 BigUint::from(1000u64),
2381 BigUint::ZERO,
2382 BigUint::ZERO,
2383 BigUint::ZERO,
2384 BlockInfo::new(1, "0x123".to_string(), 1000),
2385 String::new(),
2386 Bytes::from(make_address(0xAA).as_ref()),
2387 Bytes::from(make_address(0xAA).as_ref()),
2388 "1".to_string(),
2389 )
2390 }
2391
2392 fn no_public_route_responses(exclusive: OrderQuote) -> OrderResponses {
2395 OrderResponses {
2396 order_id: "test-order".to_string(),
2397 quotes: vec![worker_quote(("exclusive_access_pool".to_string(), exclusive))],
2398 failed_solvers: vec![(
2399 "public_pool".to_string(),
2400 SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None },
2401 )],
2402 }
2403 }
2404
2405 #[test]
2406 fn test_combine_no_public_route_applies_default_fee() {
2407 let responses = no_public_route_responses(
2410 make_exclusive_quote(1_000_000)
2411 .order()
2412 .clone(),
2413 );
2414 let combined = combine_with_surplus(
2415 &responses,
2416 &exclusive_access_pool_scopes(),
2417 &QuoteOptions::default(),
2418 vec![no_route_quote()],
2419 1_000,
2420 SimChain::Ethereum,
2421 );
2422
2423 assert_eq!(combined.len(), 2);
2424 assert_eq!(combined[0].status(), QuoteStatus::Success);
2425 assert_eq!(*combined[0].amount_out(), BigUint::from(999_500u64));
2426 assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(999_500u64));
2427 assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(500u64)));
2428
2429 let exclusive_leg = combined[0]
2430 .route()
2431 .expect("surplus quote should have a route")
2432 .swaps()
2433 .iter()
2434 .find(|s| is_exclusive(s.protocol_component()))
2435 .expect("should have an exclusive swap")
2436 .committed_amount_out()
2437 .cloned();
2438 assert_eq!(exclusive_leg, Some(BigUint::from(999_500u64)));
2439
2440 assert_eq!(combined[1].status(), QuoteStatus::NoRouteFound);
2443 }
2444
2445 #[test]
2446 fn test_combine_no_public_route_split_route_fee_on_exclusive_leg() {
2447 let responses = no_public_route_responses(
2451 make_exclusive_split_quote(600_000, 500_000)
2452 .order()
2453 .clone(),
2454 );
2455 let combined = combine_with_surplus(
2456 &responses,
2457 &exclusive_access_pool_scopes(),
2458 &QuoteOptions::default(),
2459 vec![no_route_quote()],
2460 1_000,
2461 SimChain::Ethereum,
2462 );
2463
2464 assert_eq!(*combined[0].amount_out(), BigUint::from(1_099_750u64));
2465 assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(250u64)));
2466
2467 let route = combined[0]
2468 .route()
2469 .expect("surplus quote should have a route");
2470 let public_leg = route
2471 .swaps()
2472 .iter()
2473 .find(|s| !is_exclusive(s.protocol_component()))
2474 .expect("should have a public swap");
2475 assert_eq!(public_leg.committed_amount_out(), None);
2476
2477 let exclusive_leg = route
2478 .swaps()
2479 .iter()
2480 .find(|s| is_exclusive(s.protocol_component()))
2481 .expect("should have an exclusive swap");
2482 assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(499_750u64)));
2483 }
2484
2485 #[test]
2486 fn test_combine_no_public_route_fee_below_gas() {
2487 let responses = no_public_route_responses(
2490 make_exclusive_quote_with_leg(10_000, 3, 10_000)
2491 .order()
2492 .clone(),
2493 );
2494 let combined = combine_with_surplus(
2495 &responses,
2496 &exclusive_access_pool_scopes(),
2497 &QuoteOptions::default(),
2498 vec![no_route_quote()],
2499 1_000,
2500 SimChain::Ethereum,
2501 );
2502
2503 assert_eq!(combined.len(), 1);
2504 assert_eq!(combined[0].status(), QuoteStatus::NoRouteFound);
2505 }
2506
2507 #[test]
2508 fn test_combine_stamps_per_leg_committed_amount_out() {
2509 let responses = exclusive_access_responses(900, 1000);
2510 let public_ranked = vec![make_public_quote_zero_gas(900)
2511 .order()
2512 .clone()];
2513 let combined = combine_with_surplus(
2514 &responses,
2515 &exclusive_access_pool_scopes(),
2516 &QuoteOptions::default(),
2517 public_ranked,
2518 1_000,
2519 SimChain::Ethereum,
2520 );
2521
2522 let surplus_quote = &combined[0];
2523 let route = surplus_quote
2524 .route()
2525 .expect("surplus quote should have a route");
2526 let perm_swap = route
2527 .swaps()
2528 .iter()
2529 .find(|s| is_exclusive(s.protocol_component()))
2530 .expect("should have an exclusive swap");
2531
2532 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(910u64)),);
2535 }
2536
2537 #[test]
2538 fn test_combine_committed_leg_deduction() {
2539 let responses = OrderResponses {
2542 order_id: "test-order".to_string(),
2543 quotes: vec![
2544 worker_quote((
2545 "public_pool".to_string(),
2546 make_public_quote_zero_gas(900)
2547 .order()
2548 .clone(),
2549 )),
2550 worker_quote((
2551 "exclusive_access_pool".to_string(),
2552 make_exclusive_quote_with_leg(1000, 1000, 995)
2553 .order()
2554 .clone(),
2555 )),
2556 ],
2557 failed_solvers: vec![],
2558 };
2559 let public_ranked = vec![make_public_quote_zero_gas(900)
2560 .order()
2561 .clone()];
2562 let combined = combine_with_surplus(
2563 &responses,
2564 &exclusive_access_pool_scopes(),
2565 &QuoteOptions::default(),
2566 public_ranked,
2567 1_000,
2568 SimChain::Ethereum,
2569 );
2570
2571 let route = combined[0]
2572 .route()
2573 .expect("surplus quote should have a route");
2574 let perm_swap = route
2575 .swaps()
2576 .iter()
2577 .find(|s| is_exclusive(s.protocol_component()))
2578 .expect("should have an exclusive swap");
2579 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(905u64)));
2580 }
2581
2582 #[rstest]
2583 #[case::zero("0", Some(0))]
2584 #[case::whole_improvement("10000", Some(10_000))]
2585 #[case::padded(" 2500 ", Some(2_500))]
2586 #[case::above_whole_improvement("10001", None)]
2587 #[case::negative("-1", None)]
2588 #[case::fractional("0.5", None)]
2589 #[case::empty("", None)]
2590 fn test_parse_user_improvement_share_bps(#[case] raw: &str, #[case] expected: Option<u32>) {
2591 assert_eq!(parse_user_improvement_share_bps(raw), expected);
2592 }
2593
2594 #[test]
2595 fn test_combine_split_route_attribution() {
2596 let responses = OrderResponses {
2600 order_id: "test-order".to_string(),
2601 quotes: vec![
2602 worker_quote((
2603 "public_pool".to_string(),
2604 make_public_quote_zero_gas(1000)
2605 .order()
2606 .clone(),
2607 )),
2608 worker_quote((
2609 "exclusive_access_pool".to_string(),
2610 make_exclusive_split_quote(600, 500)
2611 .order()
2612 .clone(),
2613 )),
2614 ],
2615 failed_solvers: vec![],
2616 };
2617 let public_ranked = vec![make_public_quote_zero_gas(1000)
2618 .order()
2619 .clone()];
2620 let combined = combine_with_surplus(
2621 &responses,
2622 &exclusive_access_pool_scopes(),
2623 &QuoteOptions::default(),
2624 public_ranked,
2625 1_000,
2626 SimChain::Ethereum,
2627 );
2628
2629 assert_eq!(*combined[0].amount_out(), BigUint::from(1010u64));
2630 let route = combined[0]
2631 .route()
2632 .expect("surplus quote should have a route");
2633 let public_leg = route
2634 .swaps()
2635 .iter()
2636 .find(|s| !is_exclusive(s.protocol_component()))
2637 .expect("should have a public swap");
2638 assert_eq!(public_leg.committed_amount_out(), None);
2639
2640 let exclusive_leg = route
2645 .swaps()
2646 .iter()
2647 .find(|s| is_exclusive(s.protocol_component()))
2648 .expect("should have an exclusive swap");
2649 assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(410u64)));
2650 }
2651
2652 fn responses_with_gas(
2654 public_out: u64,
2655 public_net: u64,
2656 exclusive_out: u64,
2657 exclusive_net: u64,
2658 ) -> OrderResponses {
2659 OrderResponses {
2660 order_id: "test-order".to_string(),
2661 quotes: vec![
2662 worker_quote((
2663 "public_pool".to_string(),
2664 make_public_quote_with_net(public_out, public_net)
2665 .order()
2666 .clone(),
2667 )),
2668 worker_quote((
2669 "exclusive_access_pool".to_string(),
2670 make_exclusive_quote_with_leg(exclusive_out, exclusive_net, exclusive_out)
2671 .order()
2672 .clone(),
2673 )),
2674 ],
2675 failed_solvers: vec![],
2676 }
2677 }
2678
2679 fn make_route_quote(legs: &[(&str, u8, u8)]) -> OrderQuote {
2682 let legs: Vec<(&str, Address, Address)> = legs
2683 .iter()
2684 .map(|(protocol_system, token_in, token_out)| {
2685 (*protocol_system, make_address(*token_in), make_address(*token_out))
2686 })
2687 .collect();
2688 make_route_quote_for_tokens(&legs)
2689 }
2690
2691 fn make_route_quote_for_tokens(legs: &[(&str, Address, Address)]) -> OrderQuote {
2692 let make_token = |addr: &Address| Token {
2693 address: addr.clone(),
2694 symbol: "T".to_string(),
2695 decimals: 18,
2696 tax: Default::default(),
2697 gas: vec![],
2698 chain: SimChain::Ethereum,
2699 quality: 100,
2700 };
2701 let mut tokens = FxHashMap::default();
2702 let mut swaps = Vec::new();
2703 for (protocol_system, tin, tout) in legs {
2704 let (tin, tout) = (tin.clone(), tout.clone());
2705 let tin_token = make_token(&tin);
2706 let tout_token = make_token(&tout);
2707 let mut comp = component(
2708 "0x0000000000000000000000000000000000000002",
2709 &[tin_token.clone(), tout_token.clone()],
2710 );
2711 comp.protocol_system = protocol_system.to_string();
2712 if *protocol_system == "vm:exclusive" {
2713 mark_exclusive(&mut comp);
2714 }
2715 swaps.push(Swap::new(
2716 format!("pool-{tin}-{tout}"),
2717 protocol_system.to_string(),
2718 tin.clone(),
2719 tout.clone(),
2720 BigUint::from(1000u64),
2721 BigUint::from(1000u64),
2722 BigUint::from(50_000u64),
2723 comp,
2724 Box::new(MockProtocolSim::default()),
2725 ));
2726 tokens.insert(tin, tin_token);
2727 tokens.insert(tout, tout_token);
2728 }
2729 OrderQuote::new(
2730 "test-order".to_string(),
2731 QuoteStatus::Success,
2732 BigUint::from(1000u64),
2733 BigUint::from(1000u64),
2734 BigUint::from(100_000u64),
2735 BigUint::from(1000u64),
2736 BlockInfo::new(1, "0x123".to_string(), 1000),
2737 "test".to_string(),
2738 Bytes::from(make_address(0xAA).as_ref()),
2739 Bytes::from(make_address(0xAA).as_ref()),
2740 "1".to_string(),
2741 )
2742 .with_route(Route::new(swaps, tokens).expect("non-empty route"))
2743 }
2744
2745 #[rstest]
2747 #[case::terminal_exclusive_leg(
2748 &[("uniswap_v2", 0x01, 0x02), ("vm:exclusive", 0x02, 0x03)], true)]
2749 #[case::mid_route_exclusive_leg(
2750 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2751 #[case::exclusive_leg_ending_its_path(
2755 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x01, 0x03), ("uniswap_v2", 0x03, 0x02)],
2756 true)]
2757 #[case::no_exclusive_leg(
2758 &[("uniswap_v2", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2759 #[case::two_exclusive_legs(
2761 &[("vm:exclusive", 0x01, 0x02), ("vm:exclusive", 0x01, 0x02)], false)]
2762 #[case::exclusive_leg_feeding_diamond_merge(
2766 &[
2767 ("vm:exclusive", 0x01, 0x02),
2768 ("uniswap_v2", 0x01, 0x03),
2769 ("uniswap_v2", 0x02, 0x04),
2770 ("uniswap_v2", 0x03, 0x04),
2771 ],
2772 false)]
2773 #[case::exclusive_leg_feeding_diamond_merge_reordered(
2776 &[
2777 ("vm:exclusive", 0x01, 0x02),
2778 ("uniswap_v2", 0x02, 0x04),
2779 ("uniswap_v2", 0x01, 0x03),
2780 ("uniswap_v2", 0x03, 0x04),
2781 ],
2782 false)]
2783 #[case::exclusive_leg_on_sibling_branch(
2788 &[
2789 ("uniswap_v2", 0x01, 0x02),
2790 ("uniswap_v2", 0x02, 0x03),
2791 ("uniswap_v2", 0x03, 0x04),
2792 ("uniswap_v2", 0x02, 0x05),
2793 ("vm:exclusive", 0x05, 0x04),
2794 ],
2795 true)]
2796 fn test_exclusive_route_validation(#[case] legs: &[(&str, u8, u8)], #[case] expected: bool) {
2797 let quote = make_route_quote(legs);
2798 assert_eq!(has_valid_exclusive_route("e, SimChain::Ethereum), expected);
2799 }
2800
2801 fn wrap_tokens() -> (Address, Address, Address) {
2804 (
2805 SimChain::Ethereum
2806 .native_token()
2807 .address,
2808 SimChain::Ethereum
2809 .wrapped_native_token()
2810 .address,
2811 make_address(0x07),
2812 )
2813 }
2814
2815 #[test]
2816 fn test_exclusive_leg_wrapped_into_output() {
2817 let (native, wrapped, other) = wrap_tokens();
2818 let quote = make_route_quote_for_tokens(&[
2819 ("vm:exclusive", other, native.clone()),
2820 ("uniswap_v2", native, wrapped),
2821 ]);
2822
2823 assert!(has_valid_exclusive_route("e, SimChain::Ethereum));
2824 }
2825
2826 #[test]
2827 fn test_exclusive_leg_unwrapped_into_output() {
2828 let (native, wrapped, other) = wrap_tokens();
2829 let quote = make_route_quote_for_tokens(&[
2830 ("vm:exclusive", other, wrapped.clone()),
2831 ("uniswap_v2", wrapped, native),
2832 ]);
2833
2834 assert!(has_valid_exclusive_route("e, SimChain::Ethereum));
2835 }
2836
2837 #[test]
2838 fn test_exclusive_leg_followed_by_non_wrap() {
2839 let (native, _, other) = wrap_tokens();
2840 let quote = make_route_quote_for_tokens(&[
2841 ("vm:exclusive", other, native.clone()),
2842 ("uniswap_v2", native, make_address(0x08)),
2843 ]);
2844
2845 assert!(!has_valid_exclusive_route("e, SimChain::Ethereum));
2846 }
2847
2848 #[test]
2849 fn test_exclusive_leg_wrap_not_producing_output() {
2850 let (native, wrapped, other) = wrap_tokens();
2851 let quote = make_route_quote_for_tokens(&[
2852 ("vm:exclusive", other, native.clone()),
2853 ("uniswap_v2", native, wrapped.clone()),
2854 ("uniswap_v2", wrapped, make_address(0x08)),
2855 ]);
2856
2857 assert!(!has_valid_exclusive_route("e, SimChain::Ethereum));
2858 }
2859
2860 #[test]
2861 fn test_exclusive_leg_wrap_of_another_path() {
2862 let (native, wrapped, other) = wrap_tokens();
2863 let quote = make_route_quote_for_tokens(&[
2864 ("vm:exclusive", other, make_address(0x08)),
2865 ("uniswap_v2", native, wrapped),
2866 ]);
2867
2868 assert!(!has_valid_exclusive_route("e, SimChain::Ethereum));
2869 }
2870
2871 fn make_rate_quote(amount_out: u64, amount_out_net_gas: u64, gas_estimate: u64) -> OrderQuote {
2873 OrderQuote::new(
2874 "rate-order".to_string(),
2875 QuoteStatus::Success,
2876 BigUint::from(1_000u64),
2877 BigUint::from(amount_out),
2878 BigUint::from(gas_estimate),
2879 BigUint::from(amount_out_net_gas),
2880 BlockInfo::new(1, "0x123".to_string(), 1000),
2881 "test".to_string(),
2882 Bytes::from(make_address(0xAA).as_ref()),
2883 Bytes::from(make_address(0xAA).as_ref()),
2884 "1".to_string(),
2885 )
2886 }
2887
2888 fn usdc_quote() -> OrderQuote {
2890 make_rate_quote(100_000_000, 97_000_000, 300_000)
2891 .with_gas_price(BigUint::from(5_000_000_000u64))
2892 }
2893
2894 #[test]
2895 fn test_to_gas_token_amount() {
2896 let wei = to_gas_token_amount(&usdc_quote(), &BigUint::from(100_000_000u64));
2898
2899 assert_eq!(wei, Some(BigUint::from(50_000_000_000_000_000u64)));
2900 }
2901
2902 #[test]
2903 fn test_to_gas_token_amount_of_zero() {
2904 assert_eq!(to_gas_token_amount(&usdc_quote(), &BigUint::ZERO), Some(BigUint::ZERO));
2905 }
2906
2907 #[test]
2908 fn test_to_gas_token_amount_unpriced_output() {
2909 let quote = make_rate_quote(100_000_000, 100_000_000, 300_000)
2911 .with_gas_price(BigUint::from(5_000_000_000u64));
2912
2913 assert_eq!(to_gas_token_amount("e, &BigUint::from(100_000_000u64)), None);
2914 }
2915
2916 #[test]
2917 fn test_to_gas_token_amount_without_gas_price() {
2918 let quote = make_rate_quote(100_000_000, 97_000_000, 300_000);
2919
2920 assert_eq!(to_gas_token_amount("e, &BigUint::from(100_000_000u64)), None);
2921 }
2922
2923 #[test]
2924 fn test_to_gas_token_amount_without_gas_estimate() {
2925 let quote = make_rate_quote(100_000_000, 97_000_000, 0).with_gas_price(BigUint::from(5u64));
2926
2927 assert_eq!(to_gas_token_amount("e, &BigUint::from(100_000_000u64)), None);
2928 }
2929
2930 #[test]
2931 fn test_to_gas_token_amount_net_gas_above_output() {
2932 let quote = make_rate_quote(97_000_000, 100_000_000, 300_000)
2934 .with_gas_price(BigUint::from(5_000_000_000u64));
2935
2936 assert_eq!(to_gas_token_amount("e, &BigUint::from(100_000_000u64)), None);
2937 }
2938}