1pub mod config;
25
26use std::{
27 collections::{HashMap, HashSet},
28 sync::LazyLock,
29 time::{Duration, Instant},
30};
31
32use config::WorkerPoolRouterConfig;
33use futures::stream::{FuturesUnordered, StreamExt};
34use metrics::{counter, histogram};
35use num_bigint::BigUint;
36use serde::{Deserialize, Serialize};
37use tracing::{debug, warn};
38use tycho_execution::encoding::{
39 evm::gas_estimator::estimate_gas_usage,
40 models::{Solution, Strategy},
41};
42use tycho_simulation::tycho_common::Bytes;
43
44use crate::{
45 encoding::encoder::Encoder, feed::exclusivity::is_exclusive, price_guard::guard::PriceGuard,
46 worker_pool::task_queue::TaskQueueHandle, BlockInfo, EncodingOptions, Order, OrderQuote, Quote,
47 QuoteOptions, QuoteRequest, QuoteStatus, SolveError, SolveParams, SurplusInfo,
48};
49
50const ENV_USER_IMPROVEMENT_SHARE_BPS: &str = "EXCLUSIVE_ROUTE_USER_SHARE_BPS";
53
54const DEFAULT_USER_IMPROVEMENT_SHARE_BPS: u32 = 1_000;
57
58const BPS_DENOMINATOR: u32 = 10_000;
60
61static USER_IMPROVEMENT_SHARE_BPS: LazyLock<u32> = LazyLock::new(user_improvement_share_bps_env);
63
64fn user_improvement_share_bps_env() -> u32 {
67 let Ok(raw) = std::env::var(ENV_USER_IMPROVEMENT_SHARE_BPS) else {
68 return DEFAULT_USER_IMPROVEMENT_SHARE_BPS;
69 };
70 match parse_user_improvement_share_bps(&raw) {
71 Some(bps) => bps,
72 None => {
73 warn!(
74 value = %raw,
75 default_bps = DEFAULT_USER_IMPROVEMENT_SHARE_BPS,
76 "{ENV_USER_IMPROVEMENT_SHARE_BPS} must be an integer from 0 to \
77 {BPS_DENOMINATOR} basis points; using the default",
78 );
79 DEFAULT_USER_IMPROVEMENT_SHARE_BPS
80 }
81 }
82}
83
84fn parse_user_improvement_share_bps(raw: &str) -> Option<u32> {
87 raw.trim()
88 .parse::<u32>()
89 .ok()
90 .filter(|bps| *bps <= BPS_DENOMINATOR)
91}
92
93fn user_margin(improvement: &BigUint, user_share_bps: u32) -> BigUint {
99 let denominator = BigUint::from(BPS_DENOMINATOR);
100 (improvement * BigUint::from(user_share_bps) + (&denominator - 1u32)) / denominator
101}
102
103const NO_PUBLIC_ROUTE_FEE_BPS: u32 = 5;
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum LiquidityScope {
129 #[default]
131 PublicOnly,
132 IncludeExclusive,
136}
137
138#[derive(Clone)]
140pub struct SolverPoolHandle {
141 name: String,
143 queue: TaskQueueHandle,
145 liquidity_scope: LiquidityScope,
147}
148
149impl SolverPoolHandle {
150 pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
152 Self { name: name.into(), queue, liquidity_scope: LiquidityScope::default() }
153 }
154
155 pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
157 self.liquidity_scope = scope;
158 self
159 }
160
161 pub fn name(&self) -> &str {
163 &self.name
164 }
165
166 pub fn queue(&self) -> &TaskQueueHandle {
168 &self.queue
169 }
170
171 pub fn liquidity_scope(&self) -> LiquidityScope {
173 self.liquidity_scope
174 }
175}
176
177#[derive(Debug)]
179pub(crate) struct OrderResponses {
180 order_id: String,
182 quotes: Vec<(String, OrderQuote)>,
184 failed_solvers: Vec<(String, SolveError)>,
187}
188
189impl OrderResponses {
190 fn public_only(&self, pool_scopes: &HashMap<String, LiquidityScope>) -> OrderResponses {
197 let quotes = self
198 .quotes
199 .iter()
200 .filter(|(pool, _)| pool_scopes.get(pool) != Some(&LiquidityScope::IncludeExclusive))
201 .cloned()
202 .collect();
203 OrderResponses {
204 order_id: self.order_id.clone(),
205 quotes,
206 failed_solvers: self.failed_solvers.clone(),
207 }
208 }
209}
210
211pub struct WorkerPoolRouter {
213 solver_pools: Vec<SolverPoolHandle>,
215 config: WorkerPoolRouterConfig,
217 encoder: Encoder,
219 price_guard: Option<PriceGuard>,
222}
223
224impl WorkerPoolRouter {
225 pub fn new(
227 solver_pools: Vec<SolverPoolHandle>,
228 config: WorkerPoolRouterConfig,
229 encoder: Encoder,
230 ) -> Self {
231 Self { solver_pools, config, encoder, price_guard: None }
232 }
233
234 pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
239 self.price_guard = Some(price_guard);
240 self
241 }
242
243 pub fn num_pools(&self) -> usize {
245 self.solver_pools.len()
246 }
247
248 fn surplus_routing_active(&self) -> bool {
252 self.solver_pools
253 .iter()
254 .any(|p| p.liquidity_scope() == LiquidityScope::IncludeExclusive) &&
255 self.solver_pools
256 .iter()
257 .any(|p| p.liquidity_scope() == LiquidityScope::PublicOnly)
258 }
259
260 pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
269 let start = Instant::now();
270 let deadline = start + self.effective_timeout(request.options());
271 let min_responses = request
272 .options()
273 .min_responses()
274 .unwrap_or(self.config.min_responses());
275
276 if self.solver_pools.is_empty() {
277 return Err(SolveError::Internal("no solver pools configured".to_string()));
278 }
279
280 let params = match request.options().state_label().cloned() {
281 Some(label) => SolveParams::default().with_state_label(label),
282 None => SolveParams::default(),
283 };
284
285 let order_futures: Vec<_> = request
287 .orders()
288 .iter()
289 .map(|order| self.solve_order(order.clone(), params.clone(), deadline, min_responses))
290 .collect();
291
292 let mut order_responses = futures::future::join_all(order_futures).await;
293
294 if let Some(encoding_options) = request.options().encoding_options() {
297 refine_gas_estimates(&mut order_responses, encoding_options)?;
298 }
299
300 let pool_scopes: HashMap<String, LiquidityScope> = self
303 .solver_pools
304 .iter()
305 .map(|p| (p.name().to_string(), p.liquidity_scope()))
306 .collect();
307 let surplus_routing_active = self.surplus_routing_active();
308
309 let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
316 .into_iter()
317 .map(|responses| {
318 if surplus_routing_active {
319 let public_ranked =
320 self.rank_quotes(&responses.public_only(&pool_scopes), request.options());
321 combine_with_surplus(
322 &responses,
323 &pool_scopes,
324 request.options(),
325 public_ranked,
326 *USER_IMPROVEMENT_SHARE_BPS,
327 )
328 } else {
329 self.rank_quotes(&responses, request.options())
330 }
331 })
332 .collect();
333
334 let price_guard_config = request
336 .options()
337 .encoding_options()
338 .map(|e| e.price_guard())
339 .filter(|c| c.enabled());
340
341 let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
342 (Some(guard), Some(config)) => guard
343 .validate(ranked_quotes, config)
344 .map_err(|e| {
345 warn!(error = %e, "price guard validation error");
346 SolveError::Internal(e.to_string())
347 })?,
348 (None, Some(_)) => {
349 return Err(SolveError::Internal(
350 "price guard config provided but price guard is not enabled on this server"
351 .to_string(),
352 ));
353 }
354 _ => ranked_quotes
355 .into_iter()
356 .filter_map(|candidates| candidates.into_iter().next())
357 .collect(),
358 };
359
360 if let Some(encoding_options) = request.options().encoding_options() {
362 let encode_start = Instant::now();
363 let encoded = self
364 .encoder
365 .encode(order_quotes, encoding_options.clone())
366 .await;
367 histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
368 order_quotes = match encoded {
369 Ok(quotes) => quotes,
370 Err(e) => {
371 counter!("encoding_failures_total").increment(1);
372 return Err(e);
373 }
374 };
375 }
376
377 let total_gas_estimate = order_quotes
379 .iter()
380 .map(|o| o.gas_estimate())
381 .fold(BigUint::ZERO, |acc, g| acc + g);
382
383 let solve_time_ms = start.elapsed().as_millis() as u64;
384
385 Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
386 }
387
388 async fn solve_order(
390 &self,
391 order: Order,
392 params: SolveParams,
393 deadline: Instant,
394 min_responses: usize,
395 ) -> OrderResponses {
396 let start_time = Instant::now();
397 let order_id = order.id().to_string();
398
399 let mut pending: FuturesUnordered<_> = self
403 .solver_pools
404 .iter()
405 .map(|pool| {
406 let order_clone = order.clone();
407 let pool_name = pool.name().to_string();
408 let queue = pool.queue().clone();
409 let task_params = params.clone();
410
411 async move {
412 let result = queue
413 .enqueue(order_clone, task_params)
414 .await;
415 (pool_name, result)
416 }
417 })
418 .collect();
419
420 let exclusive_access_pool_names: HashSet<String> = self
426 .solver_pools
427 .iter()
428 .filter(|p| p.liquidity_scope() == LiquidityScope::IncludeExclusive)
429 .map(|p| p.name().to_string())
430 .collect();
431 let surplus_routing_active = self.surplus_routing_active();
432
433 let mut quotes = Vec::new();
434 let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
435 let mut remaining_pools: HashSet<String> = self
436 .solver_pools
437 .iter()
438 .map(|p| p.name().to_string())
439 .collect();
440 let mut has_public_response = false;
441 let mut has_exclusive_access_response = false;
442
443 loop {
445 let deadline_instant = tokio::time::Instant::from_std(deadline);
446
447 tokio::select! {
448 biased;
450
451 _ = tokio::time::sleep_until(deadline_instant) => {
453 let elapsed_ms = deadline.saturating_duration_since(Instant::now())
455 .as_millis() as u64;
456 for pool_name in remaining_pools.drain() {
457 failed_solvers.push((
458 pool_name,
459 SolveError::Timeout { elapsed_ms },
460 ));
461 }
462 break;
463 }
464
465 result = pending.next() => {
467 match result {
468 Some((pool_name, Ok(single_quote))) => {
469 remaining_pools.remove(&pool_name);
471
472 if exclusive_access_pool_names.contains(&pool_name) {
473 has_exclusive_access_response = true;
474 } else {
475 has_public_response = true;
476 }
477
478 quotes.push((pool_name.clone(), single_quote.order().clone()));
480
481 let scope_ready = if surplus_routing_active {
486 has_public_response && has_exclusive_access_response
487 } else {
488 true
489 };
490 if min_responses > 0
491 && quotes.len() >= min_responses
492 && scope_ready
493 {
494 debug!(
495 order_id = %order_id,
496 responses = quotes.len(),
497 min_responses,
498 "early return: min_responses reached"
499 );
500 counter!("worker_router_early_returns_total").increment(1);
501 break;
502 }
503 }
504 Some((pool_name, Err(e))) => {
505 remaining_pools.remove(&pool_name);
506 if exclusive_access_pool_names.contains(&pool_name) {
511 has_exclusive_access_response = true;
512 }
513 debug!(
514 pool = %pool_name,
515 order_id = %order_id,
516 error = %e,
517 "solver pool failed"
518 );
519 failed_solvers.push((pool_name, e));
520 }
521 None => {
522 break;
524 }
525 }
526 }
527 }
528 }
529
530 let duration = start_time.elapsed().as_secs_f64();
532 histogram!("worker_router_solve_duration_seconds").record(duration);
533 histogram!("worker_router_solver_responses").record(quotes.len() as f64);
534
535 for (pool_name, error) in &failed_solvers {
537 let error_type = match error {
538 SolveError::Timeout { .. } => "timeout",
539 SolveError::NoRouteFound { .. } => "no_route",
540 SolveError::QueueFull => "queue_full",
541 SolveError::Internal(_) => "internal",
542 SolveError::PriceCheckFailed { .. } => "price_check_failed",
543 _ => "other",
544 };
545 counter!("worker_router_solver_failures_total", "pool" => pool_name.clone(), "error_type" => error_type).increment(1);
546 }
547
548 if !failed_solvers.is_empty() {
549 let timeout_count = failed_solvers
550 .iter()
551 .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
552 .count();
553 let other_count = failed_solvers.len() - timeout_count;
554 warn!(
555 order_id = %order_id,
556 timeout_count,
557 other_failures = other_count,
558 "some solver pools failed"
559 );
560 }
561
562 OrderResponses { order_id, quotes, failed_solvers }
563 }
564
565 fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
571 let mut valid_quotes: Vec<_> = responses
572 .quotes
573 .iter()
574 .filter(|(_, q)| q.status() == QuoteStatus::Success)
575 .filter(|(_, q)| {
576 options
577 .max_gas()
578 .map(|max| q.gas_estimate() <= max)
579 .unwrap_or(true)
580 })
581 .collect();
582
583 valid_quotes.sort_by(|(_, a), (_, b)| {
585 b.amount_out_net_gas()
586 .cmp(a.amount_out_net_gas())
587 });
588
589 if !valid_quotes.is_empty() {
590 counter!("worker_router_orders_total", "status" => "success").increment(1);
591 let (pool_name, best) = valid_quotes[0];
592 counter!("worker_router_best_quote_pool", "pool" => pool_name.clone()).increment(1);
593 debug!(
594 order_id = %best.order_id(),
595 number_of_candidates = valid_quotes.len(),
596 "ranked quotes"
597 );
598 return valid_quotes
599 .into_iter()
600 .map(|(_, q)| q.clone())
601 .collect();
602 }
603
604 let fallback = if let Some((_, any_q)) = responses.quotes.first() {
607 counter!("worker_router_orders_total", "status" => "no_route").increment(1);
608 let mut fallback = OrderQuote::new(
609 responses.order_id.clone(),
610 QuoteStatus::NoRouteFound,
611 any_q.amount_in().clone(),
612 BigUint::ZERO,
613 BigUint::ZERO,
614 BigUint::ZERO,
615 any_q.block().clone(),
616 String::new(),
617 any_q.sender().clone(),
618 any_q.receiver().clone(),
619 any_q.solved_against().clone(),
620 );
621 let over_max_gas = responses.quotes.iter().any(|(_, q)| {
625 options
626 .max_gas()
627 .is_some_and(|max| q.gas_estimate() > max)
628 });
629 fallback.set_no_route_cause(over_max_gas.then_some(SolveError::MaxGasExceeded));
630 fallback
631 } else {
632 let status = if responses.failed_solvers.is_empty() {
634 QuoteStatus::NoRouteFound
635 } else {
636 let all_timeouts = responses
639 .failed_solvers
640 .iter()
641 .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
642 let all_not_ready = responses
643 .failed_solvers
644 .iter()
645 .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
646 if all_timeouts {
647 QuoteStatus::Timeout
648 } else if all_not_ready {
649 QuoteStatus::NotReady
650 } else {
651 QuoteStatus::NoRouteFound
652 }
653 };
654
655 let status_label = match status {
657 QuoteStatus::Timeout => "timeout",
658 QuoteStatus::NotReady => "not_ready",
659 _ => "no_route",
660 };
661 counter!("worker_router_orders_total", "status" => status_label).increment(1);
662
663 let label = options
666 .state_label()
667 .cloned()
668 .unwrap_or_else(|| "0".to_string());
669 let mut fallback = OrderQuote::new(
670 responses.order_id.clone(),
671 status,
672 BigUint::ZERO,
673 BigUint::ZERO,
674 BigUint::ZERO,
675 BigUint::ZERO,
676 BlockInfo::new(0, String::new(), 0),
677 String::new(),
678 Bytes::default(),
679 Bytes::default(),
680 label,
681 );
682 fallback.set_no_route_cause(aggregate_no_route_cause(&responses.failed_solvers));
683 fallback
684 };
685 vec![fallback]
686 }
687
688 fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
690 options
691 .timeout_ms()
692 .map(Duration::from_millis)
693 .unwrap_or(self.config.default_timeout())
694 }
695}
696
697fn combine_with_surplus(
761 responses: &OrderResponses,
762 pool_scopes: &HashMap<String, LiquidityScope>,
763 options: &QuoteOptions,
764 public_ranked: Vec<OrderQuote>,
765 user_share_bps: u32,
766) -> Vec<OrderQuote> {
767 let Some(exclusive_candidate) = best_exclusive_candidate(responses, pool_scopes, options)
768 else {
769 return public_ranked;
770 };
771
772 let commitment = match public_ranked
773 .first()
774 .filter(|q| q.status() == QuoteStatus::Success)
775 {
776 Some(public_reference) => {
777 matched_commitment(public_reference, exclusive_candidate, user_share_bps)
778 }
779 None => default_fee_commitment(exclusive_candidate),
780 };
781 let Some(committed_amount_out) = commitment else {
782 return public_ranked;
783 };
784
785 let mut result = Vec::with_capacity(public_ranked.len() + 1);
786 result.push(pin_commitment(exclusive_candidate, committed_amount_out));
787 result.extend(public_ranked);
788 result
789}
790
791fn best_exclusive_candidate<'a>(
794 responses: &'a OrderResponses,
795 pool_scopes: &HashMap<String, LiquidityScope>,
796 options: &QuoteOptions,
797) -> Option<&'a OrderQuote> {
798 responses
799 .quotes
800 .iter()
801 .filter(|(pool, _)| pool_scopes.get(pool) == Some(&LiquidityScope::IncludeExclusive))
802 .filter(|(_, q)| q.status() == QuoteStatus::Success)
803 .filter(|(_, q)| {
804 options
805 .max_gas()
806 .map(|max| q.gas_estimate() <= max)
807 .unwrap_or(true)
808 })
809 .filter(|(_, q)| has_valid_exclusive_route(q))
810 .max_by(|(_, a), (_, b)| {
811 a.amount_out_net_gas()
812 .cmp(b.amount_out_net_gas())
813 })
814 .map(|(_, q)| q)
815}
816
817fn matched_commitment(
824 public_reference: &OrderQuote,
825 exclusive_candidate: &OrderQuote,
826 user_share_bps: u32,
827) -> Option<BigUint> {
828 let public_net_amount_out = public_reference.amount_out_net_gas();
831 if exclusive_candidate.amount_out_net_gas() < public_net_amount_out {
832 return None;
833 }
834 let improvement = exclusive_candidate.amount_out_net_gas() - public_net_amount_out;
835 let required_net_amount_out = public_net_amount_out + user_margin(&improvement, user_share_bps);
836
837 let public_amount_out = public_reference.amount_out();
842 if exclusive_candidate.amount_out() < public_amount_out {
843 return None;
844 }
845
846 let gas_cost = exclusive_candidate.amount_out() - exclusive_candidate.amount_out_net_gas();
848 Some((required_net_amount_out + gas_cost).max(public_amount_out.clone()))
849}
850
851fn default_fee_commitment(exclusive_candidate: &OrderQuote) -> Option<BigUint> {
860 let exclusive_leg_amount_out = exclusive_candidate
861 .route()?
862 .swaps()
863 .iter()
864 .find(|swap| is_exclusive(swap.protocol_component()))?
865 .amount_out();
866
867 let realized_amount_out = exclusive_candidate.amount_out();
868 let fee = exclusive_leg_amount_out * BigUint::from(NO_PUBLIC_ROUTE_FEE_BPS) /
869 BigUint::from(BPS_DENOMINATOR);
870 let committed_amount_out = realized_amount_out - fee;
871
872 let gas_cost = realized_amount_out - exclusive_candidate.amount_out_net_gas();
873 if committed_amount_out <= gas_cost {
874 return None;
875 }
876 Some(committed_amount_out)
877}
878
879fn pin_commitment(exclusive_candidate: &OrderQuote, committed_amount_out: BigUint) -> OrderQuote {
884 let exclusive_route_amount_out = exclusive_candidate.amount_out();
885 let exclusive_gas_cost = exclusive_route_amount_out - exclusive_candidate.amount_out_net_gas();
886 let surplus_amount = exclusive_route_amount_out - &committed_amount_out;
887
888 let mut surplus_quote = exclusive_candidate.clone();
889
890 let path_final_outs: Vec<BigUint> = surplus_quote
894 .route()
895 .map(|route| {
896 let swaps = route.swaps();
897 let mut finals = vec![BigUint::ZERO; swaps.len()];
898 let mut current_final = BigUint::ZERO;
899 for i in (0..swaps.len()).rev() {
900 let is_terminal =
901 i == swaps.len() - 1 || swaps[i + 1].token_in() != swaps[i].token_out();
902 if is_terminal {
903 current_final = swaps[i].amount_out().clone();
904 }
905 finals[i] = current_final.clone();
906 }
907 finals
908 })
909 .unwrap_or_default();
910
911 if let Some(route) = surplus_quote.route_mut() {
912 let mut surplus = exclusive_route_amount_out - &committed_amount_out;
915 for (i, swap) in route.swaps_mut().iter_mut().enumerate() {
916 if is_exclusive(swap.protocol_component()) {
917 let Some(path_final_out) = path_final_outs.get(i) else {
918 continue;
919 };
920 let captured = surplus
921 .clone()
922 .min(path_final_out.clone());
923 let captured_leg = if *path_final_out == BigUint::ZERO {
930 BigUint::ZERO
931 } else {
932 &captured * swap.amount_out() / path_final_out
933 };
934 debug_assert!(
935 captured_leg <= *swap.amount_out(),
936 "captured amount ({captured_leg}) must not exceed the leg's output ({})",
937 swap.amount_out(),
938 );
939 let committed_leg = swap.amount_out() - &captured_leg;
940 surplus -= captured;
941 swap.set_committed_amount_out(committed_leg);
942 }
943 }
944 }
945
946 surplus_quote.set_amount_out(committed_amount_out.clone());
947
948 surplus_quote.set_amount_out_net_gas(&committed_amount_out - &exclusive_gas_cost);
952
953 let surplus_info = SurplusInfo::new(surplus_amount, committed_amount_out);
954 surplus_quote.with_surplus(surplus_info)
955}
956
957fn has_valid_exclusive_route(quote: &OrderQuote) -> bool {
969 let Some(route) = quote.route() else {
970 return false;
971 };
972
973 let swaps = route.swaps();
974 if swaps.is_empty() {
975 return false;
976 }
977
978 let Some(output_token) = route.output_token() else {
979 return false;
980 };
981
982 let mut exclusive_count = 0;
983
984 for swap in swaps {
985 if !is_exclusive(swap.protocol_component()) {
986 continue;
987 }
988
989 if *swap.token_out() != output_token {
990 return false;
991 }
992 exclusive_count += 1;
993 }
994
995 exclusive_count == 1
996}
997
998fn aggregate_no_route_cause(failed_solvers: &[(String, SolveError)]) -> Option<SolveError> {
1003 failed_solvers
1004 .iter()
1005 .map(|(_, error)| error)
1006 .min_by_key(|error| cause_tier(error))
1007 .cloned()
1008}
1009
1010fn cause_tier(error: &SolveError) -> u8 {
1011 use crate::algorithm::NoPathReason;
1012 match error {
1013 SolveError::NoRouteFound { reason: Some(reason), .. } => match reason {
1014 NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => 0,
1015 NoPathReason::AmountTooSmall => 1,
1016 NoPathReason::NoScorablePaths => 2,
1017 NoPathReason::NoGraphPath => 3,
1018 },
1019 SolveError::InsufficientLiquidity { .. } | SolveError::MaxGasExceeded => 2,
1020 SolveError::MissingData(_) |
1021 SolveError::MarketDataStale { .. } |
1022 SolveError::ComputationFailed(_) |
1023 SolveError::NotReady(_) => 3,
1024 SolveError::SimulationFailed(_) | SolveError::AlgorithmError(_) => 4,
1025 SolveError::Timeout { .. } |
1026 SolveError::QueueFull |
1027 SolveError::Internal(_) |
1028 SolveError::InvalidOrder(_) |
1029 SolveError::FailedEncoding(_) |
1030 SolveError::EncodingUnavailable(_) |
1031 SolveError::PriceCheckFailed { .. } => 5,
1032 SolveError::NoRouteFound { reason: None, .. } => 6,
1033 }
1034}
1035
1036fn refine_gas_estimates(
1037 order_responses: &mut Vec<OrderResponses>,
1038 encoding_options: &EncodingOptions,
1039) -> Result<(), SolveError> {
1040 for responses in order_responses {
1041 for (_, quote) in &mut responses.quotes {
1042 if quote.status() != QuoteStatus::Success {
1043 continue;
1044 }
1045 let solution = Solution::try_from(&*quote)?
1046 .with_user_transfer_type(encoding_options.transfer_type().clone());
1047 let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
1048 let naive_gas = quote.gas_estimate().clone();
1049 if naive_gas > BigUint::ZERO {
1050 let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
1051 let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
1052 let new_net = if new_gas_cost <= *quote.amount_out() {
1053 quote.amount_out() - &new_gas_cost
1054 } else {
1055 BigUint::ZERO
1056 };
1057 quote.set_amount_out_net_gas(new_net);
1058 quote.set_gas_estimate(refined_gas);
1059 }
1060 }
1061 }
1062 Ok(())
1063}
1064
1065fn derive_strategy(quote: &OrderQuote) -> Strategy {
1066 let Some(route) = quote.route() else { return Strategy::Single };
1067 let swaps = route.swaps();
1068 if swaps.len() == 1 {
1069 Strategy::Single
1070 } else if swaps.iter().any(|s| *s.split() > 0.0) {
1071 Strategy::Split
1072 } else {
1073 Strategy::Sequential
1074 }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use std::collections::HashMap;
1080
1081 use rstest::rstest;
1082 use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
1083 use tycho_simulation::{
1084 tycho_common::models::Chain,
1085 tycho_core::{
1086 models::{token::Token, Address, Chain as SimChain},
1087 Bytes,
1088 },
1089 };
1090
1091 use super::*;
1092 use crate::{
1093 algorithm::test_utils::{component, MockProtocolSim},
1094 feed::exclusivity::mark_exclusive,
1095 types::internal::SolveTask,
1096 EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
1097 };
1098
1099 fn default_encoder() -> Encoder {
1100 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
1101 .add_default_encoders(None)
1102 .expect("default encoders should always succeed");
1103 let encoder =
1104 Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
1105 encoder
1107 .router_fees()
1108 .set(crate::encoding::router_fees::RouterFees::new(
1109 100_000_000,
1110 100_000,
1111 20_000_000,
1112 std::collections::HashMap::new(),
1113 ));
1114 encoder
1115 }
1116
1117 fn make_address(byte: u8) -> Address {
1118 Address::from([byte; 20])
1119 }
1120
1121 fn make_order() -> Order {
1122 Order::new(
1123 make_address(0x01),
1124 make_address(0x02),
1125 BigUint::from(1000u64),
1126 OrderSide::Sell,
1127 make_address(0xAA),
1128 )
1129 .with_id("test-order".to_string())
1130 }
1131
1132 fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
1133 let make_token = |addr: Address| Token {
1134 address: addr,
1135 symbol: "T".to_string(),
1136 decimals: 18,
1137 tax: Default::default(),
1138 gas: vec![],
1139 chain: SimChain::Ethereum,
1140 quality: 100,
1141 };
1142 let tin = make_address(0x01);
1143 let tout = make_address(0x02);
1144 let tin_token = make_token(tin.clone());
1145 let tout_token = make_token(tout.clone());
1146 let swap = Swap::new(
1147 "pool-1".to_string(),
1148 "uniswap_v2".to_string(),
1149 tin.clone(),
1150 tout.clone(),
1151 BigUint::from(1000u64),
1152 BigUint::from(990u64),
1153 BigUint::from(50_000u64),
1154 component(
1155 "0x0000000000000000000000000000000000000001",
1156 &[tin_token.clone(), tout_token.clone()],
1157 ),
1158 Box::new(MockProtocolSim::default()),
1159 );
1160 let mut tokens = HashMap::new();
1161 tokens.insert(tin, tin_token);
1162 tokens.insert(tout, tout_token);
1163 let quote = OrderQuote::new(
1164 "test-order".to_string(),
1165 QuoteStatus::Success,
1166 BigUint::from(1000u64),
1167 BigUint::from(990u64),
1168 BigUint::from(100_000u64),
1169 BigUint::from(amount_out_net_gas),
1170 BlockInfo::new(1, "0x123".to_string(), 1000),
1171 "test".to_string(),
1172 Bytes::from(make_address(0xAA).as_ref()),
1173 Bytes::from(make_address(0xAA).as_ref()),
1174 "1".to_string(),
1175 )
1176 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1177 SingleOrderQuote::new(quote, 5)
1178 }
1179
1180 fn create_mock_pool(
1182 name: &str,
1183 response: Result<SingleOrderQuote, SolveError>,
1184 delay_ms: u64,
1185 ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
1186 let (tx, rx) = async_channel::bounded::<SolveTask>(10);
1187 let handle = TaskQueueHandle::from_sender(tx);
1188
1189 let worker = tokio::spawn(async move {
1190 while let Ok(task) = rx.recv().await {
1191 if delay_ms > 0 {
1192 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1193 }
1194 task.respond(response.clone());
1195 }
1196 });
1197
1198 (SolverPoolHandle::new(name, handle), worker)
1199 }
1200
1201 #[test]
1202 fn test_config_default() {
1203 let config = WorkerPoolRouterConfig::default();
1204 assert_eq!(config.default_timeout(), Duration::from_secs(1));
1205 assert_eq!(config.min_responses(), 1);
1206 }
1207
1208 #[test]
1209 fn test_config_builder() {
1210 let config = WorkerPoolRouterConfig::default()
1211 .with_timeout(Duration::from_millis(500))
1212 .with_min_responses(2);
1213 assert_eq!(config.default_timeout(), Duration::from_millis(500));
1214 assert_eq!(config.min_responses(), 2);
1215 }
1216
1217 #[tokio::test]
1218 async fn test_router_no_pools() {
1219 let worker_router =
1220 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1221 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1222
1223 let result = worker_router.quote(request).await;
1224 assert!(matches!(result, Err(SolveError::Internal(_))));
1225 }
1226
1227 #[tokio::test]
1228 async fn test_router_single_pool_success() {
1229 let (pool, worker) = create_mock_pool("pool_a", Ok(make_single_quote(900)), 0);
1230
1231 let worker_router =
1232 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1233 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1234 let request = QuoteRequest::new(vec![make_order()], options);
1235
1236 let result = worker_router.quote(request).await;
1237 assert!(result.is_ok());
1238
1239 let quote = result.unwrap();
1240 assert_eq!(quote.orders().len(), 1);
1241 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1242 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
1244 assert!(!quote.orders()[0]
1245 .transaction()
1246 .unwrap()
1247 .data()
1248 .is_empty());
1249
1250 drop(worker_router);
1251 worker.abort();
1252 }
1253
1254 #[tokio::test]
1255 async fn test_router_selects_best_of_two() {
1256 let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
1258 let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
1260
1261 let config = WorkerPoolRouterConfig::default().with_min_responses(2);
1263 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1264 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1265 let request = QuoteRequest::new(vec![make_order()], options);
1266
1267 let result = worker_router.quote(request).await;
1268 assert!(result.is_ok());
1269
1270 let quote = result.unwrap();
1271 assert_eq!(quote.orders().len(), 1);
1272 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
1274 assert!(!quote.orders()[0]
1275 .transaction()
1276 .unwrap()
1277 .data()
1278 .is_empty());
1279
1280 drop(worker_router);
1281 worker_a.abort();
1282 worker_b.abort();
1283 }
1284
1285 #[tokio::test]
1286 async fn test_router_timeout() {
1287 let (pool, worker) = create_mock_pool("slow_pool", Ok(make_single_quote(900)), 500);
1289
1290 let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
1291 let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
1292 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1293
1294 let result = worker_router.quote(request).await;
1295 assert!(result.is_ok());
1296
1297 let quote = result.unwrap();
1298 assert_eq!(quote.orders().len(), 1);
1300 assert!(matches!(
1301 quote.orders()[0].status(),
1302 QuoteStatus::Timeout | QuoteStatus::NoRouteFound
1303 ));
1304
1305 drop(worker_router);
1306 worker.abort();
1307 }
1308
1309 #[tokio::test]
1310 async fn test_router_early_return_on_min_responses() {
1311 let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
1313 let (pool_b, worker_b) = create_mock_pool("slow_pool", Ok(make_single_quote(950)), 500);
1315
1316 let config = WorkerPoolRouterConfig::default()
1317 .with_timeout(Duration::from_millis(1000))
1318 .with_min_responses(1);
1319 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1320
1321 let start = Instant::now();
1322 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1323 let request = QuoteRequest::new(vec![make_order()], options);
1324
1325 let result = worker_router.quote(request).await;
1326 let elapsed = start.elapsed();
1327
1328 assert!(result.is_ok());
1329 assert!(elapsed < Duration::from_millis(200));
1331
1332 let quote = result.unwrap();
1334 assert_eq!(quote.orders().len(), 1);
1335 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1336 assert!(!quote.orders()[0]
1338 .transaction()
1339 .unwrap()
1340 .data()
1341 .is_empty());
1342
1343 drop(worker_router);
1344 worker_a.abort();
1345 worker_b.abort();
1346 }
1347
1348 #[rstest]
1349 #[case::pending_exclusive_pool(0, Some(300), true)]
1350 #[case::pending_public_pool(300, Some(0), true)]
1351 #[case::failed_exclusive_pool(0, None, false)]
1352 #[tokio::test]
1353 async fn test_router_early_return_scope_gating(
1354 #[case] public_delay_ms: u64,
1355 #[case] exclusive_delay_ms: Option<u64>,
1356 #[case] expect_surplus: bool,
1357 ) {
1358 let (public_pool, public_worker) =
1359 create_mock_pool("public_pool", Ok(make_single_quote(800)), public_delay_ms);
1360 let exclusive_response = match exclusive_delay_ms {
1361 Some(_) => Ok(make_exclusive_quote(1100)),
1362 None => {
1363 Err(SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None })
1364 }
1365 };
1366 let public_pool = public_pool.with_liquidity_scope(LiquidityScope::PublicOnly);
1367 let (exclusive_pool, exclusive_worker) =
1368 create_mock_pool("exclusive_pool", exclusive_response, exclusive_delay_ms.unwrap_or(0));
1369 let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::IncludeExclusive);
1370
1371 let config = WorkerPoolRouterConfig::default()
1372 .with_timeout(Duration::from_millis(2000))
1373 .with_min_responses(1);
1374 let worker_router =
1375 WorkerPoolRouter::new(vec![public_pool, exclusive_pool], config, default_encoder());
1376 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1377
1378 let start = Instant::now();
1379 let result = worker_router
1380 .quote(request)
1381 .await
1382 .expect("quote should succeed");
1383 let elapsed = start.elapsed();
1384
1385 assert!(elapsed < Duration::from_millis(500), "took {elapsed:?}");
1387 let order = &result.orders()[0];
1388 assert_eq!(order.status(), QuoteStatus::Success);
1389 assert_eq!(*order.amount_out(), BigUint::from(990u64));
1390 assert_eq!(order.surplus_amount().is_some(), expect_surplus);
1391
1392 drop(worker_router);
1393 public_worker.abort();
1394 exclusive_worker.abort();
1395 }
1396
1397 #[rstest]
1398 #[case::under_limit(100, Some(200), true)]
1399 #[case::at_limit(200, Some(200), true)]
1400 #[case::over_limit(300, Some(200), false)]
1401 #[case::no_limit(500, None, true)]
1402 fn test_max_gas_constraint(
1403 #[case] gas_estimate: u64,
1404 #[case] max_gas: Option<u64>,
1405 #[case] should_pass: bool,
1406 ) {
1407 let responses = OrderResponses {
1408 order_id: "test".to_string(),
1409 quotes: vec![(
1410 "pool".to_string(),
1411 OrderQuote::new(
1412 "test".to_string(),
1413 QuoteStatus::Success,
1414 BigUint::from(1000u64),
1415 BigUint::from(990u64),
1416 BigUint::from(gas_estimate),
1417 BigUint::from(900u64),
1418 BlockInfo::new(1, "0x123".to_string(), 1000),
1419 "test".to_string(),
1420 Bytes::from(make_address(0xAA).as_ref()),
1421 Bytes::from(make_address(0xAA).as_ref()),
1422 "1".to_string(),
1423 ),
1424 )],
1425 failed_solvers: vec![],
1426 };
1427
1428 let options = match max_gas {
1429 Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
1430 None => QuoteOptions::default(),
1431 };
1432
1433 let worker_router =
1434 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1435 let result = worker_router.rank_quotes(&responses, &options);
1436
1437 if should_pass {
1438 assert_eq!(result[0].status(), QuoteStatus::Success);
1439 } else {
1440 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1441 }
1442 }
1443
1444 #[tokio::test]
1445 async fn test_router_captures_solver_errors() {
1446 let (pool, worker) =
1448 create_mock_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
1449
1450 let worker_router =
1451 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1452 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1453
1454 let result = worker_router.quote(request).await;
1455 assert!(result.is_ok());
1456
1457 let quote = result.unwrap();
1458 assert_eq!(quote.orders().len(), 1);
1459 assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
1461
1462 drop(worker_router);
1463 worker.abort();
1464 }
1465
1466 #[test]
1467 fn test_rank_quotes_all_timeouts_returns_timeout_status() {
1468 let responses = OrderResponses {
1469 order_id: "test".to_string(),
1470 quotes: vec![],
1471 failed_solvers: vec![
1472 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1473 ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1474 ],
1475 };
1476
1477 let worker_router =
1478 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1479 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1480
1481 assert_eq!(result.len(), 1);
1482 assert_eq!(result[0].status(), QuoteStatus::Timeout);
1483 }
1484
1485 #[test]
1486 fn test_rank_quotes_mixed_failures_returns_no_route_found() {
1487 let responses = OrderResponses {
1488 order_id: "test".to_string(),
1489 quotes: vec![],
1490 failed_solvers: vec![
1491 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1492 ("pool_b".to_string(), SolveError::no_route_found("test")),
1493 ],
1494 };
1495
1496 let worker_router =
1497 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1498 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1499
1500 assert_eq!(result.len(), 1);
1501 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1502 }
1503
1504 #[test]
1505 fn test_rank_quotes_no_failures_returns_no_route_found() {
1506 let responses =
1507 OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
1508
1509 let worker_router =
1510 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1511 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1512
1513 assert_eq!(result.len(), 1);
1514 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1515 }
1516
1517 #[test]
1518 fn rank_quotes_attaches_no_route_reason() {
1519 use crate::algorithm::NoPathReason;
1520 let responses = OrderResponses {
1521 order_id: "test".to_string(),
1522 quotes: vec![],
1523 failed_solvers: vec![
1524 (
1525 "pool_a".to_string(),
1526 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1527 ),
1528 (
1529 "pool_b".to_string(),
1530 SolveError::no_route_found_with_reason(
1531 "test",
1532 NoPathReason::DestinationTokenNotInGraph,
1533 ),
1534 ),
1535 ],
1536 };
1537 let worker_router =
1538 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1539 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1540 assert_eq!(result.len(), 1);
1541 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1542 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
1544 }
1545
1546 #[test]
1547 fn test_rank_quotes_no_route_reason_single_failure() {
1548 use crate::algorithm::NoPathReason;
1549 let responses = OrderResponses {
1550 order_id: "test".to_string(),
1551 quotes: vec![],
1552 failed_solvers: vec![(
1553 "pool_a".to_string(),
1554 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1555 )],
1556 };
1557 let worker_router =
1558 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1559 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1560 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
1561 }
1562
1563 #[test]
1564 fn test_aggregate_cause_surfaces_amount_too_small() {
1565 use crate::algorithm::NoPathReason;
1566 let failed = vec![(
1567 "bf".to_string(),
1568 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1569 )];
1570 assert!(matches!(
1571 aggregate_no_route_cause(&failed),
1572 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1573 ));
1574 }
1575
1576 #[test]
1577 fn test_aggregate_no_route_cause_independent_of_pool_order() {
1578 use crate::algorithm::NoPathReason;
1579 let dust = || SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall);
1580 let no_path = || SolveError::no_route_found_with_reason("o1", NoPathReason::NoGraphPath);
1581 let forward = vec![("bf1".to_string(), no_path()), ("bf3".to_string(), dust())];
1582 let reversed = vec![("bf3".to_string(), dust()), ("bf1".to_string(), no_path())];
1583 for failed in [forward, reversed] {
1584 assert!(matches!(
1585 aggregate_no_route_cause(&failed),
1586 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1587 ));
1588 }
1589 }
1590
1591 #[test]
1592 fn test_aggregate_token_not_in_graph_wins_over_amount_too_small() {
1593 use crate::algorithm::NoPathReason;
1594 let failed = vec![
1595 (
1596 "bf".to_string(),
1597 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1598 ),
1599 (
1600 "ml".to_string(),
1601 SolveError::no_route_found_with_reason(
1602 "o2",
1603 NoPathReason::DestinationTokenNotInGraph,
1604 ),
1605 ),
1606 ];
1607 assert!(matches!(
1608 aggregate_no_route_cause(&failed),
1609 Some(SolveError::NoRouteFound {
1610 reason: Some(NoPathReason::DestinationTokenNotInGraph),
1611 ..
1612 })
1613 ));
1614 }
1615
1616 #[test]
1617 fn test_aggregate_prefers_liquidity_over_infra() {
1618 let failed = vec![
1619 ("a".to_string(), SolveError::QueueFull),
1620 ("b".to_string(), SolveError::insufficient_liquidity(1u32.into(), 0u32.into())),
1621 ];
1622 assert!(matches!(
1623 aggregate_no_route_cause(&failed),
1624 Some(SolveError::InsufficientLiquidity { .. })
1625 ));
1626 }
1627
1628 #[test]
1629 fn test_rank_quotes_max_gas_filtered_sets_max_gas_exceeded() {
1630 let responses = OrderResponses {
1631 order_id: "o1".to_string(),
1632 quotes: vec![(
1633 "pool".to_string(),
1634 OrderQuote::new(
1635 "o1".to_string(),
1636 QuoteStatus::Success,
1637 BigUint::from(1_000u64),
1638 BigUint::from(990u64),
1639 BigUint::from(100_000u64),
1640 BigUint::from(990u64),
1641 BlockInfo::new(1, "0xabc".to_string(), 0),
1642 "test".to_string(),
1643 Bytes::default(),
1644 Bytes::default(),
1645 "1".to_string(),
1646 ),
1647 )],
1648 failed_solvers: vec![],
1649 };
1650 let options = QuoteOptions::default().with_max_gas(BigUint::from(1u64));
1651 let worker_router =
1652 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1653 let result = worker_router.rank_quotes(&responses, &options);
1654 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1655 assert!(matches!(result[0].no_route_cause(), Some(SolveError::MaxGasExceeded)));
1656 }
1657
1658 #[test]
1659 fn test_rank_quotes_no_cause_when_gas_within_max() {
1660 let responses = OrderResponses {
1661 order_id: "o1".to_string(),
1662 quotes: vec![(
1663 "pool".to_string(),
1664 OrderQuote::new(
1665 "o1".to_string(),
1666 QuoteStatus::NoRouteFound,
1667 BigUint::from(1_000u64),
1668 BigUint::from(990u64),
1669 BigUint::from(100u64),
1670 BigUint::from(990u64),
1671 BlockInfo::new(1, "0xabc".to_string(), 0),
1672 "test".to_string(),
1673 Bytes::default(),
1674 Bytes::default(),
1675 "1".to_string(),
1676 ),
1677 )],
1678 failed_solvers: vec![],
1679 };
1680 let options = QuoteOptions::default().with_max_gas(BigUint::from(1_000u64));
1681 let worker_router =
1682 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1683 let result = worker_router.rank_quotes(&responses, &options);
1684 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1685 assert!(
1686 result[0].no_route_cause().is_none(),
1687 "gas within max_gas must not be labelled MaxGasExceeded: {:?}",
1688 result[0].no_route_cause()
1689 );
1690 }
1691
1692 #[test]
1693 fn test_all_timeout_fallback_carries_timeout_cause() {
1694 let responses = OrderResponses {
1695 order_id: "o1".to_string(),
1696 quotes: vec![],
1697 failed_solvers: vec![("pool".to_string(), SolveError::Timeout { elapsed_ms: 9 })],
1698 };
1699 let worker_router =
1700 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1701 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1702 assert_eq!(result[0].status(), QuoteStatus::Timeout);
1703 assert!(matches!(result[0].no_route_cause(), Some(SolveError::Timeout { .. })));
1704 }
1705
1706 #[test]
1707 fn test_rank_quotes_returns_sorted_candidates() {
1708 let responses = OrderResponses {
1709 order_id: "test".to_string(),
1710 quotes: vec![
1711 (
1712 "pool_a".to_string(),
1713 OrderQuote::new(
1714 "test".to_string(),
1715 QuoteStatus::Success,
1716 BigUint::from(1000u64),
1717 BigUint::from(800u64),
1718 BigUint::from(100_000u64),
1719 BigUint::from(800u64),
1720 BlockInfo::new(1, "0x123".to_string(), 1000),
1721 "test".to_string(),
1722 Bytes::from(make_address(0xAA).as_ref()),
1723 Bytes::from(make_address(0xAA).as_ref()),
1724 "1".to_string(),
1725 ),
1726 ),
1727 (
1728 "pool_b".to_string(),
1729 OrderQuote::new(
1730 "test".to_string(),
1731 QuoteStatus::Success,
1732 BigUint::from(1000u64),
1733 BigUint::from(950u64),
1734 BigUint::from(100_000u64),
1735 BigUint::from(950u64),
1736 BlockInfo::new(1, "0x123".to_string(), 1000),
1737 "test".to_string(),
1738 Bytes::from(make_address(0xAA).as_ref()),
1739 Bytes::from(make_address(0xAA).as_ref()),
1740 "1".to_string(),
1741 ),
1742 ),
1743 ],
1744 failed_solvers: vec![],
1745 };
1746
1747 let worker_router =
1748 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1749 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1750
1751 assert_eq!(result.len(), 2);
1752 assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
1753 assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
1754 }
1755
1756 fn make_exclusive_quote_with_leg(
1760 amount_out: u64,
1761 amount_out_net_gas: u64,
1762 leg_amount_out: u64,
1763 ) -> SingleOrderQuote {
1764 let make_token = |addr: Address| Token {
1765 address: addr,
1766 symbol: "T".to_string(),
1767 decimals: 18,
1768 tax: Default::default(),
1769 gas: vec![],
1770 chain: SimChain::Ethereum,
1771 quality: 100,
1772 };
1773 let tin = make_address(0x01);
1774 let tout = make_address(0x02);
1775 let tin_token = make_token(tin.clone());
1776 let tout_token = make_token(tout.clone());
1777 let mut comp = component(
1778 "0x0000000000000000000000000000000000000002",
1779 &[tin_token.clone(), tout_token.clone()],
1780 );
1781 mark_exclusive(&mut comp);
1782 let swap = Swap::new(
1783 "pool-perm".to_string(),
1784 "vm:exclusive".to_string(),
1785 tin.clone(),
1786 tout.clone(),
1787 BigUint::from(1000u64),
1788 BigUint::from(leg_amount_out),
1789 BigUint::from(50_000u64),
1790 comp,
1791 Box::new(MockProtocolSim::default()),
1792 );
1793 let mut tokens = HashMap::new();
1794 tokens.insert(tin, tin_token);
1795 tokens.insert(tout, tout_token);
1796 let quote = OrderQuote::new(
1797 "test-order".to_string(),
1798 QuoteStatus::Success,
1799 BigUint::from(1000u64),
1800 BigUint::from(amount_out),
1801 BigUint::from(100_000u64),
1802 BigUint::from(amount_out_net_gas),
1803 BlockInfo::new(1, "0x123".to_string(), 1000),
1804 "test".to_string(),
1805 Bytes::from(make_address(0xAA).as_ref()),
1806 Bytes::from(make_address(0xAA).as_ref()),
1807 "1".to_string(),
1808 )
1809 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1810 SingleOrderQuote::new(quote, 5)
1811 }
1812
1813 fn make_exclusive_quote(amount_out: u64) -> SingleOrderQuote {
1814 make_exclusive_quote_with_leg(amount_out, amount_out, amount_out)
1815 }
1816
1817 fn make_exclusive_split_quote(public_leg_out: u64, exclusive_leg_out: u64) -> SingleOrderQuote {
1821 let make_token = |addr: Address| Token {
1822 address: addr,
1823 symbol: "T".to_string(),
1824 decimals: 18,
1825 tax: Default::default(),
1826 gas: vec![],
1827 chain: SimChain::Ethereum,
1828 quality: 100,
1829 };
1830 let tin = make_address(0x01);
1831 let tout = make_address(0x02);
1832 let tin_token = make_token(tin.clone());
1833 let tout_token = make_token(tout.clone());
1834 let public_swap = Swap::new(
1835 "pool-pub".to_string(),
1836 "uniswap_v2".to_string(),
1837 tin.clone(),
1838 tout.clone(),
1839 BigUint::from(500u64),
1840 BigUint::from(public_leg_out),
1841 BigUint::from(50_000u64),
1842 component(
1843 "0x0000000000000000000000000000000000000001",
1844 &[tin_token.clone(), tout_token.clone()],
1845 ),
1846 Box::new(MockProtocolSim::default()),
1847 );
1848 let mut exclusive_comp = component(
1849 "0x0000000000000000000000000000000000000002",
1850 &[tin_token.clone(), tout_token.clone()],
1851 );
1852 mark_exclusive(&mut exclusive_comp);
1853 let exclusive_swap = Swap::new(
1854 "pool-perm".to_string(),
1855 "vm:exclusive".to_string(),
1856 tin.clone(),
1857 tout.clone(),
1858 BigUint::from(500u64),
1859 BigUint::from(exclusive_leg_out),
1860 BigUint::from(50_000u64),
1861 exclusive_comp,
1862 Box::new(MockProtocolSim::default()),
1863 );
1864 let mut tokens = HashMap::new();
1865 tokens.insert(tin, tin_token);
1866 tokens.insert(tout, tout_token);
1867 let total = public_leg_out + exclusive_leg_out;
1868 let quote = OrderQuote::new(
1869 "test-order".to_string(),
1870 QuoteStatus::Success,
1871 BigUint::from(1000u64),
1872 BigUint::from(total),
1873 BigUint::from(100_000u64),
1874 BigUint::from(total),
1875 BlockInfo::new(1, "0x123".to_string(), 1000),
1876 "test".to_string(),
1877 Bytes::from(make_address(0xAA).as_ref()),
1878 Bytes::from(make_address(0xAA).as_ref()),
1879 "1".to_string(),
1880 )
1881 .with_route(
1882 Route::new(vec![public_swap, exclusive_swap], tokens).expect("non-empty route"),
1883 );
1884 SingleOrderQuote::new(quote, 5)
1885 }
1886
1887 fn make_public_quote_with_net(amount_out: u64, amount_out_net_gas: u64) -> SingleOrderQuote {
1890 let make_token = |addr: Address| Token {
1891 address: addr,
1892 symbol: "T".to_string(),
1893 decimals: 18,
1894 tax: Default::default(),
1895 gas: vec![],
1896 chain: SimChain::Ethereum,
1897 quality: 100,
1898 };
1899 let tin = make_address(0x01);
1900 let tout = make_address(0x02);
1901 let tin_token = make_token(tin.clone());
1902 let tout_token = make_token(tout.clone());
1903 let swap = Swap::new(
1904 "pool-1".to_string(),
1905 "uniswap_v2".to_string(),
1906 tin.clone(),
1907 tout.clone(),
1908 BigUint::from(1000u64),
1909 BigUint::from(amount_out),
1910 BigUint::from(50_000u64),
1911 component(
1912 "0x0000000000000000000000000000000000000001",
1913 &[tin_token.clone(), tout_token.clone()],
1914 ),
1915 Box::new(MockProtocolSim::default()),
1916 );
1917 let mut tokens = HashMap::new();
1918 tokens.insert(tin, tin_token);
1919 tokens.insert(tout, tout_token);
1920 let quote = OrderQuote::new(
1921 "test-order".to_string(),
1922 QuoteStatus::Success,
1923 BigUint::from(1000u64),
1924 BigUint::from(amount_out),
1925 BigUint::from(100_000u64),
1926 BigUint::from(amount_out_net_gas),
1927 BlockInfo::new(1, "0x123".to_string(), 1000),
1928 "test".to_string(),
1929 Bytes::from(make_address(0xAA).as_ref()),
1930 Bytes::from(make_address(0xAA).as_ref()),
1931 "1".to_string(),
1932 )
1933 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1934 SingleOrderQuote::new(quote, 5)
1935 }
1936
1937 fn make_public_quote_zero_gas(amount_out: u64) -> SingleOrderQuote {
1938 make_public_quote_with_net(amount_out, amount_out)
1939 }
1940
1941 fn exclusive_access_responses(public_out: u64, exclusive_out: u64) -> OrderResponses {
1944 let public = make_public_quote_zero_gas(public_out)
1945 .order()
1946 .clone();
1947 let exclusive_access = make_exclusive_quote(exclusive_out)
1948 .order()
1949 .clone();
1950 OrderResponses {
1951 order_id: "test-order".to_string(),
1952 quotes: vec![
1953 ("public_pool".to_string(), public),
1954 ("exclusive_access_pool".to_string(), exclusive_access),
1955 ],
1956 failed_solvers: vec![],
1957 }
1958 }
1959
1960 fn exclusive_access_pool_scopes() -> HashMap<String, LiquidityScope> {
1961 HashMap::from([
1962 ("public_pool".to_string(), LiquidityScope::PublicOnly),
1963 ("exclusive_access_pool".to_string(), LiquidityScope::IncludeExclusive),
1964 ])
1965 }
1966
1967 #[rstest]
1972 #[case::exclusive_beats_public((900, 900), (950, 950), 1_000, (905, 905, Some(45)))]
1974 #[case::exclusive_below_public((950, 950), (900, 900), 1_000, (950, 950, None))]
1975 #[case::exclusive_ties_public_net((900, 900), (900, 900), 1_000, (900, 900, Some(0)))]
1977 #[case::exclusive_marginally_better((999, 999), (1000, 1000), 1_000, (1000, 1000, Some(0)))]
1979 #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), 1_000, (1000, 950, None))]
1980 #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), 1_000, (1091, 951, Some(9)))]
1983 #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), 1_000, (1001, 961, Some(99)))]
1986 #[case::sub_bps_markup((1_000_000, 1_000_000), (1_000_050, 1_000_050), 1_000,
1989 (1_000_005, 1_000_005, Some(45)))]
1990 #[case::zero_share((900, 900), (950, 950), 0, (900, 900, Some(50)))]
1992 #[case::full_share((900, 900), (950, 950), 10_000, (950, 950, Some(0)))]
1994 fn test_combine_head_selection(
1995 #[case] public: (u64, u64),
1996 #[case] exclusive: (u64, u64),
1997 #[case] user_share_bps: u32,
1998 #[case] expected: (u64, u64, Option<u64>),
1999 ) {
2000 let (public_out, public_net) = public;
2001 let (exclusive_out, exclusive_net) = exclusive;
2002 let (expected_amount_out, expected_net, expected_surplus) = expected;
2003
2004 let responses = responses_with_gas(public_out, public_net, exclusive_out, exclusive_net);
2005 let public_ranked = vec![make_public_quote_with_net(public_out, public_net)
2006 .order()
2007 .clone()];
2008 let combined = combine_with_surplus(
2009 &responses,
2010 &exclusive_access_pool_scopes(),
2011 &QuoteOptions::default(),
2012 public_ranked,
2013 user_share_bps,
2014 );
2015
2016 let expected_surplus = expected_surplus.map(BigUint::from);
2017 assert_eq!(combined.len(), if expected_surplus.is_some() { 2 } else { 1 });
2018 assert_eq!(*combined[0].amount_out(), BigUint::from(expected_amount_out));
2019 assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(expected_net));
2020 assert_eq!(combined[0].surplus_amount(), expected_surplus.as_ref());
2021 if expected_surplus.is_some() {
2022 assert_eq!(
2023 combined[0].committed_amount_out(),
2024 Some(&BigUint::from(expected_amount_out))
2025 );
2026 }
2027 }
2028
2029 #[rstest]
2030 #[case::over_max_gas(
2031 make_exclusive_quote(1100).order().clone(),
2032 Some(50_000)
2033 )]
2034 #[case::mid_route_exclusive_leg(
2035 make_route_quote(&[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)]),
2036 None
2037 )]
2038 fn test_combine_filters_exclusive_candidate(
2039 #[case] exclusive_quote: OrderQuote,
2040 #[case] max_gas: Option<u64>,
2041 ) {
2042 let mut options = QuoteOptions::default();
2043 if let Some(max) = max_gas {
2044 options = options.with_max_gas(BigUint::from(max));
2045 }
2046 let responses = OrderResponses {
2047 order_id: "test-order".to_string(),
2048 quotes: vec![
2049 (
2050 "public_pool".to_string(),
2051 make_public_quote_zero_gas(900)
2052 .order()
2053 .clone(),
2054 ),
2055 ("exclusive_access_pool".to_string(), exclusive_quote),
2056 ],
2057 failed_solvers: vec![],
2058 };
2059 let public_ranked = vec![make_public_quote_zero_gas(900)
2060 .order()
2061 .clone()];
2062 let combined = combine_with_surplus(
2063 &responses,
2064 &exclusive_access_pool_scopes(),
2065 &options,
2066 public_ranked,
2067 1_000,
2068 );
2069
2070 assert_eq!(combined.len(), 1);
2071 assert_eq!(*combined[0].amount_out(), BigUint::from(900u64));
2072 assert_eq!(combined[0].surplus_amount(), None);
2073 }
2074
2075 fn no_route_quote() -> OrderQuote {
2077 OrderQuote::new(
2078 "test-order".to_string(),
2079 QuoteStatus::NoRouteFound,
2080 BigUint::from(1000u64),
2081 BigUint::ZERO,
2082 BigUint::ZERO,
2083 BigUint::ZERO,
2084 BlockInfo::new(1, "0x123".to_string(), 1000),
2085 String::new(),
2086 Bytes::from(make_address(0xAA).as_ref()),
2087 Bytes::from(make_address(0xAA).as_ref()),
2088 "1".to_string(),
2089 )
2090 }
2091
2092 fn no_public_route_responses(exclusive: OrderQuote) -> OrderResponses {
2095 OrderResponses {
2096 order_id: "test-order".to_string(),
2097 quotes: vec![("exclusive_access_pool".to_string(), exclusive)],
2098 failed_solvers: vec![(
2099 "public_pool".to_string(),
2100 SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None },
2101 )],
2102 }
2103 }
2104
2105 #[test]
2106 fn test_combine_no_public_route_applies_default_fee() {
2107 let responses = no_public_route_responses(
2110 make_exclusive_quote(1_000_000)
2111 .order()
2112 .clone(),
2113 );
2114 let combined = combine_with_surplus(
2115 &responses,
2116 &exclusive_access_pool_scopes(),
2117 &QuoteOptions::default(),
2118 vec![no_route_quote()],
2119 1_000,
2120 );
2121
2122 assert_eq!(combined.len(), 2);
2123 assert_eq!(combined[0].status(), QuoteStatus::Success);
2124 assert_eq!(*combined[0].amount_out(), BigUint::from(999_500u64));
2125 assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(999_500u64));
2126 assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(500u64)));
2127
2128 let exclusive_leg = combined[0]
2129 .route()
2130 .expect("surplus quote should have a route")
2131 .swaps()
2132 .iter()
2133 .find(|s| is_exclusive(s.protocol_component()))
2134 .expect("should have an exclusive swap")
2135 .committed_amount_out()
2136 .cloned();
2137 assert_eq!(exclusive_leg, Some(BigUint::from(999_500u64)));
2138
2139 assert_eq!(combined[1].status(), QuoteStatus::NoRouteFound);
2142 }
2143
2144 #[test]
2145 fn test_combine_no_public_route_split_route_fee_on_exclusive_leg() {
2146 let responses = no_public_route_responses(
2150 make_exclusive_split_quote(600_000, 500_000)
2151 .order()
2152 .clone(),
2153 );
2154 let combined = combine_with_surplus(
2155 &responses,
2156 &exclusive_access_pool_scopes(),
2157 &QuoteOptions::default(),
2158 vec![no_route_quote()],
2159 1_000,
2160 );
2161
2162 assert_eq!(*combined[0].amount_out(), BigUint::from(1_099_750u64));
2163 assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(250u64)));
2164
2165 let route = combined[0]
2166 .route()
2167 .expect("surplus quote should have a route");
2168 let public_leg = route
2169 .swaps()
2170 .iter()
2171 .find(|s| !is_exclusive(s.protocol_component()))
2172 .expect("should have a public swap");
2173 assert_eq!(public_leg.committed_amount_out(), None);
2174
2175 let exclusive_leg = route
2176 .swaps()
2177 .iter()
2178 .find(|s| is_exclusive(s.protocol_component()))
2179 .expect("should have an exclusive swap");
2180 assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(499_750u64)));
2181 }
2182
2183 #[test]
2184 fn test_combine_no_public_route_fee_below_gas() {
2185 let responses = no_public_route_responses(
2188 make_exclusive_quote_with_leg(10_000, 3, 10_000)
2189 .order()
2190 .clone(),
2191 );
2192 let combined = combine_with_surplus(
2193 &responses,
2194 &exclusive_access_pool_scopes(),
2195 &QuoteOptions::default(),
2196 vec![no_route_quote()],
2197 1_000,
2198 );
2199
2200 assert_eq!(combined.len(), 1);
2201 assert_eq!(combined[0].status(), QuoteStatus::NoRouteFound);
2202 }
2203
2204 #[test]
2205 fn test_combine_stamps_per_leg_committed_amount_out() {
2206 let responses = exclusive_access_responses(900, 1000);
2207 let public_ranked = vec![make_public_quote_zero_gas(900)
2208 .order()
2209 .clone()];
2210 let combined = combine_with_surplus(
2211 &responses,
2212 &exclusive_access_pool_scopes(),
2213 &QuoteOptions::default(),
2214 public_ranked,
2215 1_000,
2216 );
2217
2218 let surplus_quote = &combined[0];
2219 let route = surplus_quote
2220 .route()
2221 .expect("surplus quote should have a route");
2222 let perm_swap = route
2223 .swaps()
2224 .iter()
2225 .find(|s| is_exclusive(s.protocol_component()))
2226 .expect("should have an exclusive swap");
2227
2228 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(910u64)),);
2231 }
2232
2233 #[test]
2234 fn test_combine_committed_leg_deduction() {
2235 let responses = OrderResponses {
2238 order_id: "test-order".to_string(),
2239 quotes: vec![
2240 (
2241 "public_pool".to_string(),
2242 make_public_quote_zero_gas(900)
2243 .order()
2244 .clone(),
2245 ),
2246 (
2247 "exclusive_access_pool".to_string(),
2248 make_exclusive_quote_with_leg(1000, 1000, 995)
2249 .order()
2250 .clone(),
2251 ),
2252 ],
2253 failed_solvers: vec![],
2254 };
2255 let public_ranked = vec![make_public_quote_zero_gas(900)
2256 .order()
2257 .clone()];
2258 let combined = combine_with_surplus(
2259 &responses,
2260 &exclusive_access_pool_scopes(),
2261 &QuoteOptions::default(),
2262 public_ranked,
2263 1_000,
2264 );
2265
2266 let route = combined[0]
2267 .route()
2268 .expect("surplus quote should have a route");
2269 let perm_swap = route
2270 .swaps()
2271 .iter()
2272 .find(|s| is_exclusive(s.protocol_component()))
2273 .expect("should have an exclusive swap");
2274 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(905u64)));
2275 }
2276
2277 #[rstest]
2278 #[case::zero("0", Some(0))]
2279 #[case::whole_improvement("10000", Some(10_000))]
2280 #[case::padded(" 2500 ", Some(2_500))]
2281 #[case::above_whole_improvement("10001", None)]
2282 #[case::negative("-1", None)]
2283 #[case::fractional("0.5", None)]
2284 #[case::empty("", None)]
2285 fn test_parse_user_improvement_share_bps(#[case] raw: &str, #[case] expected: Option<u32>) {
2286 assert_eq!(parse_user_improvement_share_bps(raw), expected);
2287 }
2288
2289 #[test]
2290 fn test_combine_split_route_attribution() {
2291 let responses = OrderResponses {
2295 order_id: "test-order".to_string(),
2296 quotes: vec![
2297 (
2298 "public_pool".to_string(),
2299 make_public_quote_zero_gas(1000)
2300 .order()
2301 .clone(),
2302 ),
2303 (
2304 "exclusive_access_pool".to_string(),
2305 make_exclusive_split_quote(600, 500)
2306 .order()
2307 .clone(),
2308 ),
2309 ],
2310 failed_solvers: vec![],
2311 };
2312 let public_ranked = vec![make_public_quote_zero_gas(1000)
2313 .order()
2314 .clone()];
2315 let combined = combine_with_surplus(
2316 &responses,
2317 &exclusive_access_pool_scopes(),
2318 &QuoteOptions::default(),
2319 public_ranked,
2320 1_000,
2321 );
2322
2323 assert_eq!(*combined[0].amount_out(), BigUint::from(1010u64));
2324 let route = combined[0]
2325 .route()
2326 .expect("surplus quote should have a route");
2327 let public_leg = route
2328 .swaps()
2329 .iter()
2330 .find(|s| !is_exclusive(s.protocol_component()))
2331 .expect("should have a public swap");
2332 assert_eq!(public_leg.committed_amount_out(), None);
2333
2334 let exclusive_leg = route
2339 .swaps()
2340 .iter()
2341 .find(|s| is_exclusive(s.protocol_component()))
2342 .expect("should have an exclusive swap");
2343 assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(410u64)));
2344 }
2345
2346 fn responses_with_gas(
2348 public_out: u64,
2349 public_net: u64,
2350 exclusive_out: u64,
2351 exclusive_net: u64,
2352 ) -> OrderResponses {
2353 OrderResponses {
2354 order_id: "test-order".to_string(),
2355 quotes: vec![
2356 (
2357 "public_pool".to_string(),
2358 make_public_quote_with_net(public_out, public_net)
2359 .order()
2360 .clone(),
2361 ),
2362 (
2363 "exclusive_access_pool".to_string(),
2364 make_exclusive_quote_with_leg(exclusive_out, exclusive_net, exclusive_out)
2365 .order()
2366 .clone(),
2367 ),
2368 ],
2369 failed_solvers: vec![],
2370 }
2371 }
2372
2373 fn make_route_quote(legs: &[(&str, u8, u8)]) -> OrderQuote {
2376 let make_token = |addr: &Address| Token {
2377 address: addr.clone(),
2378 symbol: "T".to_string(),
2379 decimals: 18,
2380 tax: Default::default(),
2381 gas: vec![],
2382 chain: SimChain::Ethereum,
2383 quality: 100,
2384 };
2385 let mut tokens = HashMap::new();
2386 let mut swaps = Vec::new();
2387 for (protocol_system, tin_byte, tout_byte) in legs {
2388 let tin = make_address(*tin_byte);
2389 let tout = make_address(*tout_byte);
2390 let tin_token = make_token(&tin);
2391 let tout_token = make_token(&tout);
2392 let mut comp = component(
2393 "0x0000000000000000000000000000000000000002",
2394 &[tin_token.clone(), tout_token.clone()],
2395 );
2396 comp.protocol_system = protocol_system.to_string();
2397 if *protocol_system == "vm:exclusive" {
2398 mark_exclusive(&mut comp);
2399 }
2400 swaps.push(Swap::new(
2401 format!("pool-{tin_byte}-{tout_byte}"),
2402 protocol_system.to_string(),
2403 tin.clone(),
2404 tout.clone(),
2405 BigUint::from(1000u64),
2406 BigUint::from(1000u64),
2407 BigUint::from(50_000u64),
2408 comp,
2409 Box::new(MockProtocolSim::default()),
2410 ));
2411 tokens.insert(tin, tin_token);
2412 tokens.insert(tout, tout_token);
2413 }
2414 OrderQuote::new(
2415 "test-order".to_string(),
2416 QuoteStatus::Success,
2417 BigUint::from(1000u64),
2418 BigUint::from(1000u64),
2419 BigUint::from(100_000u64),
2420 BigUint::from(1000u64),
2421 BlockInfo::new(1, "0x123".to_string(), 1000),
2422 "test".to_string(),
2423 Bytes::from(make_address(0xAA).as_ref()),
2424 Bytes::from(make_address(0xAA).as_ref()),
2425 "1".to_string(),
2426 )
2427 .with_route(Route::new(swaps, tokens).expect("non-empty route"))
2428 }
2429
2430 #[rstest]
2432 #[case::terminal_exclusive_leg(
2433 &[("uniswap_v2", 0x01, 0x02), ("vm:exclusive", 0x02, 0x03)], true)]
2434 #[case::mid_route_exclusive_leg(
2435 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2436 #[case::exclusive_leg_ending_its_path(
2440 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x01, 0x03), ("uniswap_v2", 0x03, 0x02)],
2441 true)]
2442 #[case::no_exclusive_leg(
2443 &[("uniswap_v2", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2444 #[case::two_exclusive_legs(
2446 &[("vm:exclusive", 0x01, 0x02), ("vm:exclusive", 0x01, 0x02)], false)]
2447 #[case::exclusive_leg_feeding_diamond_merge(
2451 &[
2452 ("vm:exclusive", 0x01, 0x02),
2453 ("uniswap_v2", 0x01, 0x03),
2454 ("uniswap_v2", 0x02, 0x04),
2455 ("uniswap_v2", 0x03, 0x04),
2456 ],
2457 false)]
2458 #[case::exclusive_leg_feeding_diamond_merge_reordered(
2461 &[
2462 ("vm:exclusive", 0x01, 0x02),
2463 ("uniswap_v2", 0x02, 0x04),
2464 ("uniswap_v2", 0x01, 0x03),
2465 ("uniswap_v2", 0x03, 0x04),
2466 ],
2467 false)]
2468 #[case::exclusive_leg_on_sibling_branch(
2473 &[
2474 ("uniswap_v2", 0x01, 0x02),
2475 ("uniswap_v2", 0x02, 0x03),
2476 ("uniswap_v2", 0x03, 0x04),
2477 ("uniswap_v2", 0x02, 0x05),
2478 ("vm:exclusive", 0x05, 0x04),
2479 ],
2480 true)]
2481 fn test_exclusive_route_validation(#[case] legs: &[(&str, u8, u8)], #[case] expected: bool) {
2482 let quote = make_route_quote(legs);
2483 assert_eq!(has_valid_exclusive_route("e), expected);
2484 }
2485}