1pub mod config;
25
26use std::{
27 collections::{HashMap, HashSet},
28 time::{Duration, Instant},
29};
30
31use config::WorkerPoolRouterConfig;
32use futures::stream::{FuturesUnordered, StreamExt};
33use metrics::{counter, histogram};
34use num_bigint::BigUint;
35use serde::{Deserialize, Serialize};
36use tracing::{debug, warn};
37use tycho_execution::encoding::{
38 evm::gas_estimator::estimate_gas_usage,
39 models::{Solution, Strategy},
40};
41use tycho_simulation::tycho_common::Bytes;
42
43use crate::{
44 encoding::encoder::Encoder, feed::exclusivity::ExclusivityPolicy,
45 price_guard::guard::PriceGuard, worker_pool::task_queue::TaskQueueHandle, BlockInfo,
46 EncodingOptions, Order, OrderQuote, Quote, QuoteOptions, QuoteRequest, QuoteStatus, SolveError,
47 SolveParams, SurplusInfo,
48};
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum LiquidityScope {
66 #[default]
68 PublicOnly,
69 All,
72}
73
74#[derive(Clone)]
76pub struct SolverPoolHandle {
77 name: String,
79 queue: TaskQueueHandle,
81 liquidity_scope: LiquidityScope,
83}
84
85impl SolverPoolHandle {
86 pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
88 Self { name: name.into(), queue, liquidity_scope: LiquidityScope::PublicOnly }
89 }
90
91 pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
95 self.liquidity_scope = scope;
96 self
97 }
98
99 pub fn name(&self) -> &str {
101 &self.name
102 }
103
104 pub fn queue(&self) -> &TaskQueueHandle {
106 &self.queue
107 }
108
109 pub fn liquidity_scope(&self) -> LiquidityScope {
111 self.liquidity_scope
112 }
113}
114
115#[derive(Debug)]
117pub(crate) struct OrderResponses {
118 order_id: String,
120 quotes: Vec<(String, OrderQuote)>,
122 failed_solvers: Vec<(String, SolveError)>,
125}
126
127impl OrderResponses {
128 fn public_only(&self, pool_scopes: &HashMap<String, LiquidityScope>) -> OrderResponses {
135 let quotes = self
136 .quotes
137 .iter()
138 .filter(|(pool, _)| pool_scopes.get(pool) != Some(&LiquidityScope::All))
139 .cloned()
140 .collect();
141 OrderResponses {
142 order_id: self.order_id.clone(),
143 quotes,
144 failed_solvers: self.failed_solvers.clone(),
145 }
146 }
147}
148
149pub struct WorkerPoolRouter {
151 solver_pools: Vec<SolverPoolHandle>,
153 config: WorkerPoolRouterConfig,
155 encoder: Encoder,
157 price_guard: Option<PriceGuard>,
160 exclusivity_policy: Option<ExclusivityPolicy>,
163}
164
165impl WorkerPoolRouter {
166 pub fn new(
168 solver_pools: Vec<SolverPoolHandle>,
169 config: WorkerPoolRouterConfig,
170 encoder: Encoder,
171 ) -> Self {
172 Self { solver_pools, config, encoder, price_guard: None, exclusivity_policy: None }
173 }
174
175 pub fn with_exclusivity_policy(mut self, policy: ExclusivityPolicy) -> Self {
177 self.exclusivity_policy = Some(policy);
178 self
179 }
180
181 pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
186 self.price_guard = Some(price_guard);
187 self
188 }
189
190 pub fn num_pools(&self) -> usize {
192 self.solver_pools.len()
193 }
194
195 pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
204 let start = Instant::now();
205 let deadline = start + self.effective_timeout(request.options());
206 let min_responses = request
207 .options()
208 .min_responses()
209 .unwrap_or(self.config.min_responses());
210
211 if self.solver_pools.is_empty() {
212 return Err(SolveError::Internal("no solver pools configured".to_string()));
213 }
214
215 let params = match request.options().state_label().cloned() {
216 Some(label) => SolveParams::default().with_state_label(label),
217 None => SolveParams::default(),
218 };
219
220 let order_futures: Vec<_> = request
222 .orders()
223 .iter()
224 .map(|order| self.solve_order(order.clone(), params.clone(), deadline, min_responses))
225 .collect();
226
227 let mut order_responses = futures::future::join_all(order_futures).await;
228
229 if let Some(encoding_options) = request.options().encoding_options() {
232 refine_gas_estimates(&mut order_responses, encoding_options)?;
233 }
234
235 let pool_scopes: HashMap<String, LiquidityScope> = self
238 .solver_pools
239 .iter()
240 .map(|p| (p.name().to_string(), p.liquidity_scope()))
241 .collect();
242 let has_exclusive_access_pool = pool_scopes
243 .values()
244 .any(|r| *r == LiquidityScope::All);
245
246 let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
253 .into_iter()
254 .map(|responses| {
255 if has_exclusive_access_pool {
256 let public_ranked =
257 self.rank_quotes(&responses.public_only(&pool_scopes), request.options());
258 combine_with_surplus(
259 &responses,
260 &pool_scopes,
261 request.options(),
262 public_ranked,
263 self.exclusivity_policy.as_ref(),
264 )
265 } else {
266 self.rank_quotes(&responses, request.options())
267 }
268 })
269 .collect();
270
271 let price_guard_config = request
273 .options()
274 .encoding_options()
275 .map(|e| e.price_guard())
276 .filter(|c| c.enabled());
277
278 let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
279 (Some(guard), Some(config)) => guard
280 .validate(ranked_quotes, config)
281 .map_err(|e| {
282 warn!(error = %e, "price guard validation error");
283 SolveError::Internal(e.to_string())
284 })?,
285 (None, Some(_)) => {
286 return Err(SolveError::Internal(
287 "price guard config provided but price guard is not enabled on this server"
288 .to_string(),
289 ));
290 }
291 _ => ranked_quotes
292 .into_iter()
293 .filter_map(|candidates| candidates.into_iter().next())
294 .collect(),
295 };
296
297 if let Some(encoding_options) = request.options().encoding_options() {
299 let encode_start = Instant::now();
300 let encoded = self
301 .encoder
302 .encode(order_quotes, encoding_options.clone())
303 .await;
304 histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
305 order_quotes = match encoded {
306 Ok(quotes) => quotes,
307 Err(e) => {
308 counter!("encoding_failures_total").increment(1);
309 return Err(e);
310 }
311 };
312 }
313
314 let total_gas_estimate = order_quotes
316 .iter()
317 .map(|o| o.gas_estimate())
318 .fold(BigUint::ZERO, |acc, g| acc + g);
319
320 let solve_time_ms = start.elapsed().as_millis() as u64;
321
322 Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
323 }
324
325 async fn solve_order(
327 &self,
328 order: Order,
329 params: SolveParams,
330 deadline: Instant,
331 min_responses: usize,
332 ) -> OrderResponses {
333 let start_time = Instant::now();
334 let order_id = order.id().to_string();
335
336 let mut pending: FuturesUnordered<_> = self
340 .solver_pools
341 .iter()
342 .map(|pool| {
343 let order_clone = order.clone();
344 let pool_name = pool.name().to_string();
345 let queue = pool.queue().clone();
346 let task_params = params.clone();
347
348 async move {
349 let result = queue
350 .enqueue(order_clone, task_params)
351 .await;
352 (pool_name, result)
353 }
354 })
355 .collect();
356
357 let exclusive_access_pool_names: HashSet<String> = self
360 .solver_pools
361 .iter()
362 .filter(|p| p.liquidity_scope() == LiquidityScope::All)
363 .map(|p| p.name().to_string())
364 .collect();
365 let has_exclusive_access_pool = !exclusive_access_pool_names.is_empty();
366
367 let mut quotes = Vec::new();
368 let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
369 let mut remaining_pools: HashSet<String> = self
370 .solver_pools
371 .iter()
372 .map(|p| p.name().to_string())
373 .collect();
374 let mut has_public_response = false;
375 let mut has_exclusive_access_response = false;
376
377 loop {
379 let deadline_instant = tokio::time::Instant::from_std(deadline);
380
381 tokio::select! {
382 biased;
384
385 _ = tokio::time::sleep_until(deadline_instant) => {
387 let elapsed_ms = deadline.saturating_duration_since(Instant::now())
389 .as_millis() as u64;
390 for pool_name in remaining_pools.drain() {
391 failed_solvers.push((
392 pool_name,
393 SolveError::Timeout { elapsed_ms },
394 ));
395 }
396 break;
397 }
398
399 result = pending.next() => {
401 match result {
402 Some((pool_name, Ok(single_quote))) => {
403 remaining_pools.remove(&pool_name);
405
406 if exclusive_access_pool_names.contains(&pool_name) {
407 has_exclusive_access_response = true;
408 } else {
409 has_public_response = true;
410 }
411
412 quotes.push((pool_name.clone(), single_quote.order().clone()));
414
415 let scope_ready = if has_exclusive_access_pool {
421 has_public_response && has_exclusive_access_response
422 } else {
423 true
424 };
425 if min_responses > 0
426 && quotes.len() >= min_responses
427 && scope_ready
428 {
429 debug!(
430 order_id = %order_id,
431 responses = quotes.len(),
432 min_responses,
433 "early return: min_responses reached"
434 );
435 counter!("worker_router_early_returns_total").increment(1);
436 break;
437 }
438 }
439 Some((pool_name, Err(e))) => {
440 remaining_pools.remove(&pool_name);
441 if exclusive_access_pool_names.contains(&pool_name) {
446 has_exclusive_access_response = true;
447 }
448 debug!(
449 pool = %pool_name,
450 order_id = %order_id,
451 error = %e,
452 "solver pool failed"
453 );
454 failed_solvers.push((pool_name, e));
455 }
456 None => {
457 break;
459 }
460 }
461 }
462 }
463 }
464
465 let duration = start_time.elapsed().as_secs_f64();
467 histogram!("worker_router_solve_duration_seconds").record(duration);
468 histogram!("worker_router_solver_responses").record(quotes.len() as f64);
469
470 for (pool_name, error) in &failed_solvers {
472 let error_type = match error {
473 SolveError::Timeout { .. } => "timeout",
474 SolveError::NoRouteFound { .. } => "no_route",
475 SolveError::QueueFull => "queue_full",
476 SolveError::Internal(_) => "internal",
477 SolveError::PriceCheckFailed { .. } => "price_check_failed",
478 _ => "other",
479 };
480 counter!("worker_router_solver_failures_total", "pool" => pool_name.clone(), "error_type" => error_type).increment(1);
481 }
482
483 if !failed_solvers.is_empty() {
484 let timeout_count = failed_solvers
485 .iter()
486 .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
487 .count();
488 let other_count = failed_solvers.len() - timeout_count;
489 warn!(
490 order_id = %order_id,
491 timeout_count,
492 other_failures = other_count,
493 "some solver pools failed"
494 );
495 }
496
497 OrderResponses { order_id, quotes, failed_solvers }
498 }
499
500 fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
506 let mut valid_quotes: Vec<_> = responses
507 .quotes
508 .iter()
509 .filter(|(_, q)| q.status() == QuoteStatus::Success)
510 .filter(|(_, q)| {
511 options
512 .max_gas()
513 .map(|max| q.gas_estimate() <= max)
514 .unwrap_or(true)
515 })
516 .collect();
517
518 valid_quotes.sort_by(|(_, a), (_, b)| {
520 b.amount_out_net_gas()
521 .cmp(a.amount_out_net_gas())
522 });
523
524 if !valid_quotes.is_empty() {
525 counter!("worker_router_orders_total", "status" => "success").increment(1);
526 let (pool_name, best) = valid_quotes[0];
527 counter!("worker_router_best_quote_pool", "pool" => pool_name.clone()).increment(1);
528 debug!(
529 order_id = %best.order_id(),
530 number_of_candidates = valid_quotes.len(),
531 "ranked quotes"
532 );
533 return valid_quotes
534 .into_iter()
535 .map(|(_, q)| q.clone())
536 .collect();
537 }
538
539 let fallback = if let Some((_, any_q)) = responses.quotes.first() {
542 counter!("worker_router_orders_total", "status" => "no_route").increment(1);
543 let mut fallback = OrderQuote::new(
544 responses.order_id.clone(),
545 QuoteStatus::NoRouteFound,
546 any_q.amount_in().clone(),
547 BigUint::ZERO,
548 BigUint::ZERO,
549 BigUint::ZERO,
550 any_q.block().clone(),
551 String::new(),
552 any_q.sender().clone(),
553 any_q.receiver().clone(),
554 any_q.solved_against().clone(),
555 );
556 let over_max_gas = responses.quotes.iter().any(|(_, q)| {
560 options
561 .max_gas()
562 .is_some_and(|max| q.gas_estimate() > max)
563 });
564 fallback.set_no_route_cause(over_max_gas.then_some(SolveError::MaxGasExceeded));
565 fallback
566 } else {
567 let status = if responses.failed_solvers.is_empty() {
569 QuoteStatus::NoRouteFound
570 } else {
571 let all_timeouts = responses
574 .failed_solvers
575 .iter()
576 .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
577 let all_not_ready = responses
578 .failed_solvers
579 .iter()
580 .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
581 if all_timeouts {
582 QuoteStatus::Timeout
583 } else if all_not_ready {
584 QuoteStatus::NotReady
585 } else {
586 QuoteStatus::NoRouteFound
587 }
588 };
589
590 let status_label = match status {
592 QuoteStatus::Timeout => "timeout",
593 QuoteStatus::NotReady => "not_ready",
594 _ => "no_route",
595 };
596 counter!("worker_router_orders_total", "status" => status_label).increment(1);
597
598 let label = options
601 .state_label()
602 .cloned()
603 .unwrap_or_else(|| "0".to_string());
604 let mut fallback = OrderQuote::new(
605 responses.order_id.clone(),
606 status,
607 BigUint::ZERO,
608 BigUint::ZERO,
609 BigUint::ZERO,
610 BigUint::ZERO,
611 BlockInfo::new(0, String::new(), 0),
612 String::new(),
613 Bytes::default(),
614 Bytes::default(),
615 label,
616 );
617 fallback.set_no_route_cause(aggregate_no_route_cause(&responses.failed_solvers));
618 fallback
619 };
620 vec![fallback]
621 }
622
623 fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
625 options
626 .timeout_ms()
627 .map(Duration::from_millis)
628 .unwrap_or(self.config.default_timeout())
629 }
630}
631
632fn combine_with_surplus(
677 responses: &OrderResponses,
678 pool_scopes: &HashMap<String, LiquidityScope>,
679 options: &QuoteOptions,
680 public_ranked: Vec<OrderQuote>,
681 exclusivity_policy: Option<&ExclusivityPolicy>,
682) -> Vec<OrderQuote> {
683 let Some(policy) = exclusivity_policy else {
684 return public_ranked;
685 };
686
687 let committed = match public_ranked.first() {
688 Some(q) if q.status() == QuoteStatus::Success => q,
689 _ => return public_ranked,
690 };
691
692 let best_exclusive_access_candidate = responses
694 .quotes
695 .iter()
696 .filter(|(pool, _)| pool_scopes.get(pool) == Some(&LiquidityScope::All))
697 .filter(|(_, q)| q.status() == QuoteStatus::Success)
698 .filter(|(_, q)| {
699 options
700 .max_gas()
701 .map(|max| q.gas_estimate() <= max)
702 .unwrap_or(true)
703 })
704 .filter(|(_, q)| has_valid_exclusive_route(q, policy))
705 .max_by(|(_, a), (_, b)| {
706 a.amount_out_net_gas()
707 .cmp(b.amount_out_net_gas())
708 })
709 .map(|(_, q)| q);
710
711 let Some(exclusive_candidate) = best_exclusive_access_candidate else {
712 return public_ranked;
713 };
714
715 if exclusive_candidate.amount_out_net_gas() <= committed.amount_out_net_gas() {
717 return public_ranked;
718 }
719
720 let exclusive_route_amount_out = exclusive_candidate.amount_out();
723 let public_amount_out = committed.amount_out();
724
725 if exclusive_route_amount_out < public_amount_out {
729 return public_ranked;
730 }
731
732 let exclusive_gas_cost = exclusive_route_amount_out - exclusive_candidate.amount_out_net_gas();
734
735 let committed_amount_out =
740 (committed.amount_out_net_gas() + &exclusive_gas_cost).max(public_amount_out.clone());
741
742 let surplus_amount = exclusive_route_amount_out - &committed_amount_out;
744
745 let mut surplus_quote = exclusive_candidate.clone();
748
749 let path_final_outs: Vec<BigUint> = surplus_quote
753 .route()
754 .map(|route| {
755 let swaps = route.swaps();
756 let mut finals = vec![BigUint::ZERO; swaps.len()];
757 let mut current_final = BigUint::ZERO;
758 for i in (0..swaps.len()).rev() {
759 let is_terminal =
760 i == swaps.len() - 1 || swaps[i + 1].token_in() != swaps[i].token_out();
761 if is_terminal {
762 current_final = swaps[i].amount_out().clone();
763 }
764 finals[i] = current_final.clone();
765 }
766 finals
767 })
768 .unwrap_or_default();
769
770 if let Some(route) = surplus_quote.route_mut() {
771 let mut excess = exclusive_route_amount_out - &committed_amount_out;
774 for (i, swap) in route.swaps_mut().iter_mut().enumerate() {
775 if policy.is_exclusive(swap.protocol_component()) {
776 let Some(path_final_out) = path_final_outs.get(i) else {
777 continue;
778 };
779 let captured = excess
780 .clone()
781 .min(path_final_out.clone());
782 let captured_leg = if *path_final_out == BigUint::ZERO {
789 BigUint::ZERO
790 } else {
791 &captured * swap.amount_out() / path_final_out
792 };
793 debug_assert!(
794 captured_leg <= *swap.amount_out(),
795 "captured amount ({captured_leg}) must not exceed the leg's output ({})",
796 swap.amount_out(),
797 );
798 let committed_leg = swap.amount_out() - &captured_leg;
799 excess -= captured;
800 swap.set_committed_amount_out(committed_leg);
801 }
802 }
803 }
804
805 surplus_quote.set_amount_out(committed_amount_out.clone());
806
807 surplus_quote.set_amount_out_net_gas(&committed_amount_out - &exclusive_gas_cost);
811
812 let surplus_info = SurplusInfo::new(surplus_amount, committed_amount_out);
813 surplus_quote = surplus_quote.with_surplus(surplus_info);
814
815 let mut result = Vec::with_capacity(public_ranked.len() + 1);
816 result.push(surplus_quote);
817 result.extend(public_ranked);
818 result
819}
820
821fn has_valid_exclusive_route(quote: &OrderQuote, policy: &ExclusivityPolicy) -> bool {
834 let Some(route) = quote.route() else {
835 return false;
836 };
837
838 let swaps = route.swaps();
839 if swaps.is_empty() {
840 return false;
841 }
842
843 let mut exclusive_count = 0;
844
845 for (i, swap) in swaps.iter().enumerate() {
846 if !policy.is_exclusive(swap.protocol_component()) {
847 continue;
848 }
849
850 let is_terminal = i == swaps.len() - 1 || swaps[i + 1].token_in() != swap.token_out();
853
854 if !is_terminal {
855 return false;
856 }
857 exclusive_count += 1;
858 }
859
860 exclusive_count == 1
861}
862
863fn aggregate_no_route_cause(failed_solvers: &[(String, SolveError)]) -> Option<SolveError> {
868 failed_solvers
869 .iter()
870 .map(|(_, error)| error)
871 .min_by_key(|error| cause_tier(error))
872 .cloned()
873}
874
875fn cause_tier(error: &SolveError) -> u8 {
876 use crate::algorithm::NoPathReason;
877 match error {
878 SolveError::NoRouteFound { reason: Some(reason), .. } => match reason {
879 NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => 0,
880 NoPathReason::AmountTooSmall => 1,
881 NoPathReason::NoScorablePaths => 2,
882 NoPathReason::NoGraphPath => 3,
883 },
884 SolveError::InsufficientLiquidity { .. } | SolveError::MaxGasExceeded => 2,
885 SolveError::MissingData(_) |
886 SolveError::MarketDataStale { .. } |
887 SolveError::ComputationFailed(_) |
888 SolveError::NotReady(_) => 3,
889 SolveError::SimulationFailed(_) | SolveError::AlgorithmError(_) => 4,
890 SolveError::Timeout { .. } |
891 SolveError::QueueFull |
892 SolveError::Internal(_) |
893 SolveError::InvalidOrder(_) |
894 SolveError::FailedEncoding(_) |
895 SolveError::EncodingUnavailable(_) |
896 SolveError::PriceCheckFailed { .. } => 5,
897 SolveError::NoRouteFound { reason: None, .. } => 6,
898 }
899}
900
901fn refine_gas_estimates(
902 order_responses: &mut Vec<OrderResponses>,
903 encoding_options: &EncodingOptions,
904) -> Result<(), SolveError> {
905 for responses in order_responses {
906 for (_, quote) in &mut responses.quotes {
907 if quote.status() != QuoteStatus::Success {
908 continue;
909 }
910 let solution = Solution::try_from(&*quote)?
911 .with_user_transfer_type(encoding_options.transfer_type().clone());
912 let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
913 let naive_gas = quote.gas_estimate().clone();
914 if naive_gas > BigUint::ZERO {
915 let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
916 let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
917 let new_net = if new_gas_cost <= *quote.amount_out() {
918 quote.amount_out() - &new_gas_cost
919 } else {
920 BigUint::ZERO
921 };
922 quote.set_amount_out_net_gas(new_net);
923 quote.set_gas_estimate(refined_gas);
924 }
925 }
926 }
927 Ok(())
928}
929
930fn derive_strategy(quote: &OrderQuote) -> Strategy {
931 let Some(route) = quote.route() else { return Strategy::Single };
932 let swaps = route.swaps();
933 if swaps.len() == 1 {
934 Strategy::Single
935 } else if swaps.iter().any(|s| *s.split() > 0.0) {
936 Strategy::Split
937 } else {
938 Strategy::Sequential
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use std::collections::HashMap;
945
946 use rstest::rstest;
947 use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
948 use tycho_simulation::{
949 tycho_common::models::Chain,
950 tycho_core::{
951 models::{token::Token, Address, Chain as SimChain},
952 Bytes,
953 },
954 };
955
956 use super::*;
957 use crate::{
958 algorithm::test_utils::{component, MockProtocolSim},
959 types::internal::SolveTask,
960 EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
961 };
962
963 fn default_encoder() -> Encoder {
964 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
965 .add_default_encoders(None)
966 .expect("default encoders should always succeed");
967 let encoder =
968 Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
969 encoder
971 .router_fees()
972 .set(crate::encoding::router_fees::RouterFees::new(
973 100_000_000,
974 100_000,
975 20_000_000,
976 std::collections::HashMap::new(),
977 ));
978 encoder
979 }
980
981 fn make_address(byte: u8) -> Address {
982 Address::from([byte; 20])
983 }
984
985 fn make_order() -> Order {
986 Order::new(
987 make_address(0x01),
988 make_address(0x02),
989 BigUint::from(1000u64),
990 OrderSide::Sell,
991 make_address(0xAA),
992 )
993 .with_id("test-order".to_string())
994 }
995
996 fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
997 let make_token = |addr: Address| Token {
998 address: addr,
999 symbol: "T".to_string(),
1000 decimals: 18,
1001 tax: Default::default(),
1002 gas: vec![],
1003 chain: SimChain::Ethereum,
1004 quality: 100,
1005 };
1006 let tin = make_address(0x01);
1007 let tout = make_address(0x02);
1008 let tin_token = make_token(tin.clone());
1009 let tout_token = make_token(tout.clone());
1010 let swap = Swap::new(
1011 "pool-1".to_string(),
1012 "uniswap_v2".to_string(),
1013 tin.clone(),
1014 tout.clone(),
1015 BigUint::from(1000u64),
1016 BigUint::from(990u64),
1017 BigUint::from(50_000u64),
1018 component(
1019 "0x0000000000000000000000000000000000000001",
1020 &[tin_token.clone(), tout_token.clone()],
1021 ),
1022 Box::new(MockProtocolSim::default()),
1023 );
1024 let mut tokens = HashMap::new();
1025 tokens.insert(tin, tin_token);
1026 tokens.insert(tout, tout_token);
1027 let quote = OrderQuote::new(
1028 "test-order".to_string(),
1029 QuoteStatus::Success,
1030 BigUint::from(1000u64),
1031 BigUint::from(990u64),
1032 BigUint::from(100_000u64),
1033 BigUint::from(amount_out_net_gas),
1034 BlockInfo::new(1, "0x123".to_string(), 1000),
1035 "test".to_string(),
1036 Bytes::from(make_address(0xAA).as_ref()),
1037 Bytes::from(make_address(0xAA).as_ref()),
1038 "1".to_string(),
1039 )
1040 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1041 SingleOrderQuote::new(quote, 5)
1042 }
1043
1044 fn create_mock_pool(
1046 name: &str,
1047 response: Result<SingleOrderQuote, SolveError>,
1048 delay_ms: u64,
1049 ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
1050 let (tx, rx) = async_channel::bounded::<SolveTask>(10);
1051 let handle = TaskQueueHandle::from_sender(tx);
1052
1053 let worker = tokio::spawn(async move {
1054 while let Ok(task) = rx.recv().await {
1055 if delay_ms > 0 {
1056 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1057 }
1058 task.respond(response.clone());
1059 }
1060 });
1061
1062 (SolverPoolHandle::new(name, handle), worker)
1063 }
1064
1065 #[test]
1066 fn test_config_default() {
1067 let config = WorkerPoolRouterConfig::default();
1068 assert_eq!(config.default_timeout(), Duration::from_secs(1));
1069 assert_eq!(config.min_responses(), 1);
1070 }
1071
1072 #[test]
1073 fn test_config_builder() {
1074 let config = WorkerPoolRouterConfig::default()
1075 .with_timeout(Duration::from_millis(500))
1076 .with_min_responses(2);
1077 assert_eq!(config.default_timeout(), Duration::from_millis(500));
1078 assert_eq!(config.min_responses(), 2);
1079 }
1080
1081 #[tokio::test]
1082 async fn test_router_no_pools() {
1083 let worker_router =
1084 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1085 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1086
1087 let result = worker_router.quote(request).await;
1088 assert!(matches!(result, Err(SolveError::Internal(_))));
1089 }
1090
1091 #[tokio::test]
1092 async fn test_router_single_pool_success() {
1093 let (pool, worker) = create_mock_pool("pool_a", Ok(make_single_quote(900)), 0);
1094
1095 let worker_router =
1096 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1097 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1098 let request = QuoteRequest::new(vec![make_order()], options);
1099
1100 let result = worker_router.quote(request).await;
1101 assert!(result.is_ok());
1102
1103 let quote = result.unwrap();
1104 assert_eq!(quote.orders().len(), 1);
1105 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1106 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
1108 assert!(!quote.orders()[0]
1109 .transaction()
1110 .unwrap()
1111 .data()
1112 .is_empty());
1113
1114 drop(worker_router);
1115 worker.abort();
1116 }
1117
1118 #[tokio::test]
1119 async fn test_router_selects_best_of_two() {
1120 let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
1122 let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
1124
1125 let config = WorkerPoolRouterConfig::default().with_min_responses(2);
1127 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1128 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1129 let request = QuoteRequest::new(vec![make_order()], options);
1130
1131 let result = worker_router.quote(request).await;
1132 assert!(result.is_ok());
1133
1134 let quote = result.unwrap();
1135 assert_eq!(quote.orders().len(), 1);
1136 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
1138 assert!(!quote.orders()[0]
1139 .transaction()
1140 .unwrap()
1141 .data()
1142 .is_empty());
1143
1144 drop(worker_router);
1145 worker_a.abort();
1146 worker_b.abort();
1147 }
1148
1149 #[tokio::test]
1150 async fn test_router_timeout() {
1151 let (pool, worker) = create_mock_pool("slow_pool", Ok(make_single_quote(900)), 500);
1153
1154 let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
1155 let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
1156 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1157
1158 let result = worker_router.quote(request).await;
1159 assert!(result.is_ok());
1160
1161 let quote = result.unwrap();
1162 assert_eq!(quote.orders().len(), 1);
1164 assert!(matches!(
1165 quote.orders()[0].status(),
1166 QuoteStatus::Timeout | QuoteStatus::NoRouteFound
1167 ));
1168
1169 drop(worker_router);
1170 worker.abort();
1171 }
1172
1173 #[tokio::test]
1174 async fn test_router_early_return_on_min_responses() {
1175 let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
1177 let (pool_b, worker_b) = create_mock_pool("slow_pool", Ok(make_single_quote(950)), 500);
1179
1180 let config = WorkerPoolRouterConfig::default()
1181 .with_timeout(Duration::from_millis(1000))
1182 .with_min_responses(1);
1183 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1184
1185 let start = Instant::now();
1186 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1187 let request = QuoteRequest::new(vec![make_order()], options);
1188
1189 let result = worker_router.quote(request).await;
1190 let elapsed = start.elapsed();
1191
1192 assert!(result.is_ok());
1193 assert!(elapsed < Duration::from_millis(200));
1195
1196 let quote = result.unwrap();
1198 assert_eq!(quote.orders().len(), 1);
1199 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1200 assert!(!quote.orders()[0]
1202 .transaction()
1203 .unwrap()
1204 .data()
1205 .is_empty());
1206
1207 drop(worker_router);
1208 worker_a.abort();
1209 worker_b.abort();
1210 }
1211
1212 #[rstest]
1213 #[case::pending_exclusive_pool(0, Some(300), true)]
1214 #[case::pending_public_pool(300, Some(0), true)]
1215 #[case::failed_exclusive_pool(0, None, false)]
1216 #[tokio::test]
1217 async fn test_router_early_return_scope_gating(
1218 #[case] public_delay_ms: u64,
1219 #[case] exclusive_delay_ms: Option<u64>,
1220 #[case] expect_surplus: bool,
1221 ) {
1222 let (public_pool, public_worker) =
1223 create_mock_pool("public_pool", Ok(make_single_quote(800)), public_delay_ms);
1224 let exclusive_response = match exclusive_delay_ms {
1225 Some(_) => Ok(make_exclusive_quote(1100)),
1226 None => {
1227 Err(SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None })
1228 }
1229 };
1230 let (exclusive_pool, exclusive_worker) =
1231 create_mock_pool("exclusive_pool", exclusive_response, exclusive_delay_ms.unwrap_or(0));
1232 let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::All);
1233
1234 let config = WorkerPoolRouterConfig::default()
1235 .with_timeout(Duration::from_millis(2000))
1236 .with_min_responses(1);
1237 let worker_router =
1238 WorkerPoolRouter::new(vec![public_pool, exclusive_pool], config, default_encoder())
1239 .with_exclusivity_policy(exclusive_policy());
1240 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1241
1242 let start = Instant::now();
1243 let result = worker_router
1244 .quote(request)
1245 .await
1246 .expect("quote should succeed");
1247 let elapsed = start.elapsed();
1248
1249 assert!(elapsed < Duration::from_millis(500), "took {elapsed:?}");
1251 let order = &result.orders()[0];
1252 assert_eq!(order.status(), QuoteStatus::Success);
1253 assert_eq!(*order.amount_out(), BigUint::from(990u64));
1254 assert_eq!(order.surplus_amount().is_some(), expect_surplus);
1255
1256 drop(worker_router);
1257 public_worker.abort();
1258 exclusive_worker.abort();
1259 }
1260
1261 #[rstest]
1262 #[case::under_limit(100, Some(200), true)]
1263 #[case::at_limit(200, Some(200), true)]
1264 #[case::over_limit(300, Some(200), false)]
1265 #[case::no_limit(500, None, true)]
1266 fn test_max_gas_constraint(
1267 #[case] gas_estimate: u64,
1268 #[case] max_gas: Option<u64>,
1269 #[case] should_pass: bool,
1270 ) {
1271 let responses = OrderResponses {
1272 order_id: "test".to_string(),
1273 quotes: vec![(
1274 "pool".to_string(),
1275 OrderQuote::new(
1276 "test".to_string(),
1277 QuoteStatus::Success,
1278 BigUint::from(1000u64),
1279 BigUint::from(990u64),
1280 BigUint::from(gas_estimate),
1281 BigUint::from(900u64),
1282 BlockInfo::new(1, "0x123".to_string(), 1000),
1283 "test".to_string(),
1284 Bytes::from(make_address(0xAA).as_ref()),
1285 Bytes::from(make_address(0xAA).as_ref()),
1286 "1".to_string(),
1287 ),
1288 )],
1289 failed_solvers: vec![],
1290 };
1291
1292 let options = match max_gas {
1293 Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
1294 None => QuoteOptions::default(),
1295 };
1296
1297 let worker_router =
1298 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1299 let result = worker_router.rank_quotes(&responses, &options);
1300
1301 if should_pass {
1302 assert_eq!(result[0].status(), QuoteStatus::Success);
1303 } else {
1304 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1305 }
1306 }
1307
1308 #[tokio::test]
1309 async fn test_router_captures_solver_errors() {
1310 let (pool, worker) =
1312 create_mock_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
1313
1314 let worker_router =
1315 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1316 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1317
1318 let result = worker_router.quote(request).await;
1319 assert!(result.is_ok());
1320
1321 let quote = result.unwrap();
1322 assert_eq!(quote.orders().len(), 1);
1323 assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
1325
1326 drop(worker_router);
1327 worker.abort();
1328 }
1329
1330 #[test]
1331 fn test_rank_quotes_all_timeouts_returns_timeout_status() {
1332 let responses = OrderResponses {
1333 order_id: "test".to_string(),
1334 quotes: vec![],
1335 failed_solvers: vec![
1336 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1337 ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1338 ],
1339 };
1340
1341 let worker_router =
1342 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1343 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1344
1345 assert_eq!(result.len(), 1);
1346 assert_eq!(result[0].status(), QuoteStatus::Timeout);
1347 }
1348
1349 #[test]
1350 fn test_rank_quotes_mixed_failures_returns_no_route_found() {
1351 let responses = OrderResponses {
1352 order_id: "test".to_string(),
1353 quotes: vec![],
1354 failed_solvers: vec![
1355 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1356 ("pool_b".to_string(), SolveError::no_route_found("test")),
1357 ],
1358 };
1359
1360 let worker_router =
1361 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1362 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1363
1364 assert_eq!(result.len(), 1);
1365 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1366 }
1367
1368 #[test]
1369 fn test_rank_quotes_no_failures_returns_no_route_found() {
1370 let responses =
1371 OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
1372
1373 let worker_router =
1374 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1375 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1376
1377 assert_eq!(result.len(), 1);
1378 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1379 }
1380
1381 #[test]
1382 fn rank_quotes_attaches_no_route_reason() {
1383 use crate::algorithm::NoPathReason;
1384 let responses = OrderResponses {
1385 order_id: "test".to_string(),
1386 quotes: vec![],
1387 failed_solvers: vec![
1388 (
1389 "pool_a".to_string(),
1390 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1391 ),
1392 (
1393 "pool_b".to_string(),
1394 SolveError::no_route_found_with_reason(
1395 "test",
1396 NoPathReason::DestinationTokenNotInGraph,
1397 ),
1398 ),
1399 ],
1400 };
1401 let worker_router =
1402 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1403 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1404 assert_eq!(result.len(), 1);
1405 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1406 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
1408 }
1409
1410 #[test]
1411 fn test_rank_quotes_no_route_reason_single_failure() {
1412 use crate::algorithm::NoPathReason;
1413 let responses = OrderResponses {
1414 order_id: "test".to_string(),
1415 quotes: vec![],
1416 failed_solvers: vec![(
1417 "pool_a".to_string(),
1418 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1419 )],
1420 };
1421 let worker_router =
1422 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1423 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1424 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
1425 }
1426
1427 #[test]
1428 fn test_aggregate_cause_surfaces_amount_too_small() {
1429 use crate::algorithm::NoPathReason;
1430 let failed = vec![(
1431 "bf".to_string(),
1432 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1433 )];
1434 assert!(matches!(
1435 aggregate_no_route_cause(&failed),
1436 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1437 ));
1438 }
1439
1440 #[test]
1441 fn test_aggregate_no_route_cause_independent_of_pool_order() {
1442 use crate::algorithm::NoPathReason;
1443 let dust = || SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall);
1444 let no_path = || SolveError::no_route_found_with_reason("o1", NoPathReason::NoGraphPath);
1445 let forward = vec![("bf1".to_string(), no_path()), ("bf3".to_string(), dust())];
1446 let reversed = vec![("bf3".to_string(), dust()), ("bf1".to_string(), no_path())];
1447 for failed in [forward, reversed] {
1448 assert!(matches!(
1449 aggregate_no_route_cause(&failed),
1450 Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1451 ));
1452 }
1453 }
1454
1455 #[test]
1456 fn test_aggregate_token_not_in_graph_wins_over_amount_too_small() {
1457 use crate::algorithm::NoPathReason;
1458 let failed = vec![
1459 (
1460 "bf".to_string(),
1461 SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1462 ),
1463 (
1464 "ml".to_string(),
1465 SolveError::no_route_found_with_reason(
1466 "o2",
1467 NoPathReason::DestinationTokenNotInGraph,
1468 ),
1469 ),
1470 ];
1471 assert!(matches!(
1472 aggregate_no_route_cause(&failed),
1473 Some(SolveError::NoRouteFound {
1474 reason: Some(NoPathReason::DestinationTokenNotInGraph),
1475 ..
1476 })
1477 ));
1478 }
1479
1480 #[test]
1481 fn test_aggregate_prefers_liquidity_over_infra() {
1482 let failed = vec![
1483 ("a".to_string(), SolveError::QueueFull),
1484 ("b".to_string(), SolveError::insufficient_liquidity(1u32.into(), 0u32.into())),
1485 ];
1486 assert!(matches!(
1487 aggregate_no_route_cause(&failed),
1488 Some(SolveError::InsufficientLiquidity { .. })
1489 ));
1490 }
1491
1492 #[test]
1493 fn test_rank_quotes_max_gas_filtered_sets_max_gas_exceeded() {
1494 let responses = OrderResponses {
1495 order_id: "o1".to_string(),
1496 quotes: vec![(
1497 "pool".to_string(),
1498 OrderQuote::new(
1499 "o1".to_string(),
1500 QuoteStatus::Success,
1501 BigUint::from(1_000u64),
1502 BigUint::from(990u64),
1503 BigUint::from(100_000u64),
1504 BigUint::from(990u64),
1505 BlockInfo::new(1, "0xabc".to_string(), 0),
1506 "test".to_string(),
1507 Bytes::default(),
1508 Bytes::default(),
1509 "1".to_string(),
1510 ),
1511 )],
1512 failed_solvers: vec![],
1513 };
1514 let options = QuoteOptions::default().with_max_gas(BigUint::from(1u64));
1515 let worker_router =
1516 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1517 let result = worker_router.rank_quotes(&responses, &options);
1518 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1519 assert!(matches!(result[0].no_route_cause(), Some(SolveError::MaxGasExceeded)));
1520 }
1521
1522 #[test]
1523 fn test_rank_quotes_no_cause_when_gas_within_max() {
1524 let responses = OrderResponses {
1525 order_id: "o1".to_string(),
1526 quotes: vec![(
1527 "pool".to_string(),
1528 OrderQuote::new(
1529 "o1".to_string(),
1530 QuoteStatus::NoRouteFound,
1531 BigUint::from(1_000u64),
1532 BigUint::from(990u64),
1533 BigUint::from(100u64),
1534 BigUint::from(990u64),
1535 BlockInfo::new(1, "0xabc".to_string(), 0),
1536 "test".to_string(),
1537 Bytes::default(),
1538 Bytes::default(),
1539 "1".to_string(),
1540 ),
1541 )],
1542 failed_solvers: vec![],
1543 };
1544 let options = QuoteOptions::default().with_max_gas(BigUint::from(1_000u64));
1545 let worker_router =
1546 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1547 let result = worker_router.rank_quotes(&responses, &options);
1548 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1549 assert!(
1550 result[0].no_route_cause().is_none(),
1551 "gas within max_gas must not be labelled MaxGasExceeded: {:?}",
1552 result[0].no_route_cause()
1553 );
1554 }
1555
1556 #[test]
1557 fn test_all_timeout_fallback_carries_timeout_cause() {
1558 let responses = OrderResponses {
1559 order_id: "o1".to_string(),
1560 quotes: vec![],
1561 failed_solvers: vec![("pool".to_string(), SolveError::Timeout { elapsed_ms: 9 })],
1562 };
1563 let worker_router =
1564 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1565 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1566 assert_eq!(result[0].status(), QuoteStatus::Timeout);
1567 assert!(matches!(result[0].no_route_cause(), Some(SolveError::Timeout { .. })));
1568 }
1569
1570 #[test]
1571 fn test_rank_quotes_returns_sorted_candidates() {
1572 let responses = OrderResponses {
1573 order_id: "test".to_string(),
1574 quotes: vec![
1575 (
1576 "pool_a".to_string(),
1577 OrderQuote::new(
1578 "test".to_string(),
1579 QuoteStatus::Success,
1580 BigUint::from(1000u64),
1581 BigUint::from(800u64),
1582 BigUint::from(100_000u64),
1583 BigUint::from(800u64),
1584 BlockInfo::new(1, "0x123".to_string(), 1000),
1585 "test".to_string(),
1586 Bytes::from(make_address(0xAA).as_ref()),
1587 Bytes::from(make_address(0xAA).as_ref()),
1588 "1".to_string(),
1589 ),
1590 ),
1591 (
1592 "pool_b".to_string(),
1593 OrderQuote::new(
1594 "test".to_string(),
1595 QuoteStatus::Success,
1596 BigUint::from(1000u64),
1597 BigUint::from(950u64),
1598 BigUint::from(100_000u64),
1599 BigUint::from(950u64),
1600 BlockInfo::new(1, "0x123".to_string(), 1000),
1601 "test".to_string(),
1602 Bytes::from(make_address(0xAA).as_ref()),
1603 Bytes::from(make_address(0xAA).as_ref()),
1604 "1".to_string(),
1605 ),
1606 ),
1607 ],
1608 failed_solvers: vec![],
1609 };
1610
1611 let worker_router =
1612 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1613 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1614
1615 assert_eq!(result.len(), 2);
1616 assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
1617 assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
1618 }
1619
1620 fn exclusive_policy() -> ExclusivityPolicy {
1621 ExclusivityPolicy::new(|c| c.protocol_system == "vm:exclusive")
1622 }
1623
1624 fn make_exclusive_quote_with_leg(
1628 amount_out: u64,
1629 amount_out_net_gas: u64,
1630 leg_amount_out: u64,
1631 ) -> SingleOrderQuote {
1632 let make_token = |addr: Address| Token {
1633 address: addr,
1634 symbol: "T".to_string(),
1635 decimals: 18,
1636 tax: Default::default(),
1637 gas: vec![],
1638 chain: SimChain::Ethereum,
1639 quality: 100,
1640 };
1641 let tin = make_address(0x01);
1642 let tout = make_address(0x02);
1643 let tin_token = make_token(tin.clone());
1644 let tout_token = make_token(tout.clone());
1645 let mut comp = component(
1646 "0x0000000000000000000000000000000000000002",
1647 &[tin_token.clone(), tout_token.clone()],
1648 );
1649 comp.protocol_system = "vm:exclusive".to_string();
1650 let swap = Swap::new(
1651 "pool-perm".to_string(),
1652 "vm:exclusive".to_string(),
1653 tin.clone(),
1654 tout.clone(),
1655 BigUint::from(1000u64),
1656 BigUint::from(leg_amount_out),
1657 BigUint::from(50_000u64),
1658 comp,
1659 Box::new(MockProtocolSim::default()),
1660 );
1661 let mut tokens = HashMap::new();
1662 tokens.insert(tin, tin_token);
1663 tokens.insert(tout, tout_token);
1664 let quote = OrderQuote::new(
1665 "test-order".to_string(),
1666 QuoteStatus::Success,
1667 BigUint::from(1000u64),
1668 BigUint::from(amount_out),
1669 BigUint::from(100_000u64),
1670 BigUint::from(amount_out_net_gas),
1671 BlockInfo::new(1, "0x123".to_string(), 1000),
1672 "test".to_string(),
1673 Bytes::from(make_address(0xAA).as_ref()),
1674 Bytes::from(make_address(0xAA).as_ref()),
1675 "1".to_string(),
1676 )
1677 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1678 SingleOrderQuote::new(quote, 5)
1679 }
1680
1681 fn make_exclusive_quote(amount_out: u64) -> SingleOrderQuote {
1682 make_exclusive_quote_with_leg(amount_out, amount_out, amount_out)
1683 }
1684
1685 fn make_exclusive_split_quote(public_leg_out: u64, exclusive_leg_out: u64) -> SingleOrderQuote {
1689 let make_token = |addr: Address| Token {
1690 address: addr,
1691 symbol: "T".to_string(),
1692 decimals: 18,
1693 tax: Default::default(),
1694 gas: vec![],
1695 chain: SimChain::Ethereum,
1696 quality: 100,
1697 };
1698 let tin = make_address(0x01);
1699 let tout = make_address(0x02);
1700 let tin_token = make_token(tin.clone());
1701 let tout_token = make_token(tout.clone());
1702 let public_swap = Swap::new(
1703 "pool-pub".to_string(),
1704 "uniswap_v2".to_string(),
1705 tin.clone(),
1706 tout.clone(),
1707 BigUint::from(500u64),
1708 BigUint::from(public_leg_out),
1709 BigUint::from(50_000u64),
1710 component(
1711 "0x0000000000000000000000000000000000000001",
1712 &[tin_token.clone(), tout_token.clone()],
1713 ),
1714 Box::new(MockProtocolSim::default()),
1715 );
1716 let mut exclusive_comp = component(
1717 "0x0000000000000000000000000000000000000002",
1718 &[tin_token.clone(), tout_token.clone()],
1719 );
1720 exclusive_comp.protocol_system = "vm:exclusive".to_string();
1721 let exclusive_swap = Swap::new(
1722 "pool-perm".to_string(),
1723 "vm:exclusive".to_string(),
1724 tin.clone(),
1725 tout.clone(),
1726 BigUint::from(500u64),
1727 BigUint::from(exclusive_leg_out),
1728 BigUint::from(50_000u64),
1729 exclusive_comp,
1730 Box::new(MockProtocolSim::default()),
1731 );
1732 let mut tokens = HashMap::new();
1733 tokens.insert(tin, tin_token);
1734 tokens.insert(tout, tout_token);
1735 let total = public_leg_out + exclusive_leg_out;
1736 let quote = OrderQuote::new(
1737 "test-order".to_string(),
1738 QuoteStatus::Success,
1739 BigUint::from(1000u64),
1740 BigUint::from(total),
1741 BigUint::from(100_000u64),
1742 BigUint::from(total),
1743 BlockInfo::new(1, "0x123".to_string(), 1000),
1744 "test".to_string(),
1745 Bytes::from(make_address(0xAA).as_ref()),
1746 Bytes::from(make_address(0xAA).as_ref()),
1747 "1".to_string(),
1748 )
1749 .with_route(
1750 Route::new(vec![public_swap, exclusive_swap], tokens).expect("non-empty route"),
1751 );
1752 SingleOrderQuote::new(quote, 5)
1753 }
1754
1755 fn make_public_quote_with_net(amount_out: u64, amount_out_net_gas: u64) -> SingleOrderQuote {
1758 let make_token = |addr: Address| Token {
1759 address: addr,
1760 symbol: "T".to_string(),
1761 decimals: 18,
1762 tax: Default::default(),
1763 gas: vec![],
1764 chain: SimChain::Ethereum,
1765 quality: 100,
1766 };
1767 let tin = make_address(0x01);
1768 let tout = make_address(0x02);
1769 let tin_token = make_token(tin.clone());
1770 let tout_token = make_token(tout.clone());
1771 let swap = Swap::new(
1772 "pool-1".to_string(),
1773 "uniswap_v2".to_string(),
1774 tin.clone(),
1775 tout.clone(),
1776 BigUint::from(1000u64),
1777 BigUint::from(amount_out),
1778 BigUint::from(50_000u64),
1779 component(
1780 "0x0000000000000000000000000000000000000001",
1781 &[tin_token.clone(), tout_token.clone()],
1782 ),
1783 Box::new(MockProtocolSim::default()),
1784 );
1785 let mut tokens = HashMap::new();
1786 tokens.insert(tin, tin_token);
1787 tokens.insert(tout, tout_token);
1788 let quote = OrderQuote::new(
1789 "test-order".to_string(),
1790 QuoteStatus::Success,
1791 BigUint::from(1000u64),
1792 BigUint::from(amount_out),
1793 BigUint::from(100_000u64),
1794 BigUint::from(amount_out_net_gas),
1795 BlockInfo::new(1, "0x123".to_string(), 1000),
1796 "test".to_string(),
1797 Bytes::from(make_address(0xAA).as_ref()),
1798 Bytes::from(make_address(0xAA).as_ref()),
1799 "1".to_string(),
1800 )
1801 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1802 SingleOrderQuote::new(quote, 5)
1803 }
1804
1805 fn make_public_quote_zero_gas(amount_out: u64) -> SingleOrderQuote {
1806 make_public_quote_with_net(amount_out, amount_out)
1807 }
1808
1809 fn exclusive_access_responses(public_out: u64, exclusive_out: u64) -> OrderResponses {
1812 let public = make_public_quote_zero_gas(public_out)
1813 .order()
1814 .clone();
1815 let exclusive_access = make_exclusive_quote(exclusive_out)
1816 .order()
1817 .clone();
1818 OrderResponses {
1819 order_id: "test-order".to_string(),
1820 quotes: vec![
1821 ("public_pool".to_string(), public),
1822 ("exclusive_access_pool".to_string(), exclusive_access),
1823 ],
1824 failed_solvers: vec![],
1825 }
1826 }
1827
1828 fn exclusive_access_pool_scopes() -> HashMap<String, LiquidityScope> {
1829 HashMap::from([
1830 ("public_pool".to_string(), LiquidityScope::PublicOnly),
1831 ("exclusive_access_pool".to_string(), LiquidityScope::All),
1832 ])
1833 }
1834
1835 #[rstest]
1839 #[case::exclusive_beats_public((900, 900), (950, 950), (900, 900, Some(50)))]
1840 #[case::exclusive_below_public((950, 950), (900, 900), (950, 950, None))]
1841 #[case::exclusive_ties_public_net((900, 900), (900, 900), (900, 900, None))]
1842 #[case::exclusive_marginally_better((999, 999), (1000, 1000), (999, 999, Some(1)))]
1843 #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), (1000, 950, None))]
1844 #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), (1090, 950, Some(10)))]
1847 #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), (1000, 960, Some(100)))]
1851 fn test_combine_head_selection(
1852 #[case] public: (u64, u64),
1853 #[case] exclusive: (u64, u64),
1854 #[case] expected: (u64, u64, Option<u64>),
1855 ) {
1856 let (public_out, public_net) = public;
1857 let (exclusive_out, exclusive_net) = exclusive;
1858 let (expected_amount_out, expected_net, expected_surplus) = expected;
1859
1860 let responses = responses_with_gas(public_out, public_net, exclusive_out, exclusive_net);
1861 let public_ranked = vec![make_public_quote_with_net(public_out, public_net)
1862 .order()
1863 .clone()];
1864 let policy = exclusive_policy();
1865 let combined = combine_with_surplus(
1866 &responses,
1867 &exclusive_access_pool_scopes(),
1868 &QuoteOptions::default(),
1869 public_ranked,
1870 Some(&policy),
1871 );
1872
1873 let expected_surplus = expected_surplus.map(BigUint::from);
1874 assert_eq!(combined.len(), if expected_surplus.is_some() { 2 } else { 1 });
1875 assert_eq!(*combined[0].amount_out(), BigUint::from(expected_amount_out));
1876 assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(expected_net));
1877 assert_eq!(combined[0].surplus_amount(), expected_surplus.as_ref());
1878 if expected_surplus.is_some() {
1879 assert_eq!(
1880 combined[0].committed_amount_out(),
1881 Some(&BigUint::from(expected_amount_out))
1882 );
1883 }
1884 }
1885
1886 #[rstest]
1887 #[case::over_max_gas(
1888 make_exclusive_quote(1100).order().clone(),
1889 Some(50_000)
1890 )]
1891 #[case::mid_route_exclusive_leg(
1892 make_route_quote(&[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)]),
1893 None
1894 )]
1895 fn test_combine_filters_exclusive_candidate(
1896 #[case] exclusive_quote: OrderQuote,
1897 #[case] max_gas: Option<u64>,
1898 ) {
1899 let mut options = QuoteOptions::default();
1900 if let Some(max) = max_gas {
1901 options = options.with_max_gas(BigUint::from(max));
1902 }
1903 let responses = OrderResponses {
1904 order_id: "test-order".to_string(),
1905 quotes: vec![
1906 (
1907 "public_pool".to_string(),
1908 make_public_quote_zero_gas(900)
1909 .order()
1910 .clone(),
1911 ),
1912 ("exclusive_access_pool".to_string(), exclusive_quote),
1913 ],
1914 failed_solvers: vec![],
1915 };
1916 let public_ranked = vec![make_public_quote_zero_gas(900)
1917 .order()
1918 .clone()];
1919 let policy = exclusive_policy();
1920 let combined = combine_with_surplus(
1921 &responses,
1922 &exclusive_access_pool_scopes(),
1923 &options,
1924 public_ranked,
1925 Some(&policy),
1926 );
1927
1928 assert_eq!(combined.len(), 1);
1929 assert_eq!(*combined[0].amount_out(), BigUint::from(900u64));
1930 assert_eq!(combined[0].surplus_amount(), None);
1931 }
1932
1933 #[test]
1934 fn test_combine_stamps_per_leg_committed_amount_out() {
1935 let responses = exclusive_access_responses(900, 1000);
1936 let public_ranked = vec![make_public_quote_zero_gas(900)
1937 .order()
1938 .clone()];
1939 let policy = exclusive_policy();
1940 let combined = combine_with_surplus(
1941 &responses,
1942 &exclusive_access_pool_scopes(),
1943 &QuoteOptions::default(),
1944 public_ranked,
1945 Some(&policy),
1946 );
1947
1948 let surplus_quote = &combined[0];
1949 let route = surplus_quote
1950 .route()
1951 .expect("surplus quote should have a route");
1952 let perm_swap = route
1953 .swaps()
1954 .iter()
1955 .find(|s| policy.is_exclusive(s.protocol_component()))
1956 .expect("should have an exclusive swap");
1957
1958 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(900u64)),);
1961 }
1962
1963 #[test]
1964 fn test_combine_committed_leg_deduction() {
1965 let responses = OrderResponses {
1968 order_id: "test-order".to_string(),
1969 quotes: vec![
1970 (
1971 "public_pool".to_string(),
1972 make_public_quote_zero_gas(900)
1973 .order()
1974 .clone(),
1975 ),
1976 (
1977 "exclusive_access_pool".to_string(),
1978 make_exclusive_quote_with_leg(1000, 1000, 995)
1979 .order()
1980 .clone(),
1981 ),
1982 ],
1983 failed_solvers: vec![],
1984 };
1985 let public_ranked = vec![make_public_quote_zero_gas(900)
1986 .order()
1987 .clone()];
1988 let policy = exclusive_policy();
1989 let combined = combine_with_surplus(
1990 &responses,
1991 &exclusive_access_pool_scopes(),
1992 &QuoteOptions::default(),
1993 public_ranked,
1994 Some(&policy),
1995 );
1996
1997 let route = combined[0]
1998 .route()
1999 .expect("surplus quote should have a route");
2000 let perm_swap = route
2001 .swaps()
2002 .iter()
2003 .find(|s| policy.is_exclusive(s.protocol_component()))
2004 .expect("should have an exclusive swap");
2005 assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(895u64)));
2006 }
2007
2008 #[test]
2009 fn test_combine_without_exclusivity_policy() {
2010 let responses = exclusive_access_responses(900, 950);
2011 let public_ranked = vec![make_public_quote_zero_gas(900)
2012 .order()
2013 .clone()];
2014 let combined = combine_with_surplus(
2015 &responses,
2016 &exclusive_access_pool_scopes(),
2017 &QuoteOptions::default(),
2018 public_ranked.clone(),
2019 None,
2020 );
2021
2022 assert_eq!(combined.len(), 1);
2023 assert_eq!(combined[0].surplus_amount(), None);
2024 }
2025
2026 #[test]
2027 fn test_combine_split_route_attribution() {
2028 let responses = OrderResponses {
2032 order_id: "test-order".to_string(),
2033 quotes: vec![
2034 (
2035 "public_pool".to_string(),
2036 make_public_quote_zero_gas(1000)
2037 .order()
2038 .clone(),
2039 ),
2040 (
2041 "exclusive_access_pool".to_string(),
2042 make_exclusive_split_quote(600, 500)
2043 .order()
2044 .clone(),
2045 ),
2046 ],
2047 failed_solvers: vec![],
2048 };
2049 let public_ranked = vec![make_public_quote_zero_gas(1000)
2050 .order()
2051 .clone()];
2052 let policy = exclusive_policy();
2053 let combined = combine_with_surplus(
2054 &responses,
2055 &exclusive_access_pool_scopes(),
2056 &QuoteOptions::default(),
2057 public_ranked,
2058 Some(&policy),
2059 );
2060
2061 assert_eq!(*combined[0].amount_out(), BigUint::from(1000u64));
2062 let route = combined[0]
2063 .route()
2064 .expect("surplus quote should have a route");
2065 let public_leg = route
2066 .swaps()
2067 .iter()
2068 .find(|s| !policy.is_exclusive(s.protocol_component()))
2069 .expect("should have a public swap");
2070 assert_eq!(public_leg.committed_amount_out(), None);
2071
2072 let exclusive_leg = route
2077 .swaps()
2078 .iter()
2079 .find(|s| policy.is_exclusive(s.protocol_component()))
2080 .expect("should have an exclusive swap");
2081 assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(400u64)));
2082 }
2083
2084 fn responses_with_gas(
2086 public_out: u64,
2087 public_net: u64,
2088 exclusive_out: u64,
2089 exclusive_net: u64,
2090 ) -> OrderResponses {
2091 OrderResponses {
2092 order_id: "test-order".to_string(),
2093 quotes: vec![
2094 (
2095 "public_pool".to_string(),
2096 make_public_quote_with_net(public_out, public_net)
2097 .order()
2098 .clone(),
2099 ),
2100 (
2101 "exclusive_access_pool".to_string(),
2102 make_exclusive_quote_with_leg(exclusive_out, exclusive_net, exclusive_out)
2103 .order()
2104 .clone(),
2105 ),
2106 ],
2107 failed_solvers: vec![],
2108 }
2109 }
2110
2111 fn make_route_quote(legs: &[(&str, u8, u8)]) -> OrderQuote {
2114 let make_token = |addr: &Address| Token {
2115 address: addr.clone(),
2116 symbol: "T".to_string(),
2117 decimals: 18,
2118 tax: Default::default(),
2119 gas: vec![],
2120 chain: SimChain::Ethereum,
2121 quality: 100,
2122 };
2123 let mut tokens = HashMap::new();
2124 let mut swaps = Vec::new();
2125 for (protocol_system, tin_byte, tout_byte) in legs {
2126 let tin = make_address(*tin_byte);
2127 let tout = make_address(*tout_byte);
2128 let tin_token = make_token(&tin);
2129 let tout_token = make_token(&tout);
2130 let mut comp = component(
2131 "0x0000000000000000000000000000000000000002",
2132 &[tin_token.clone(), tout_token.clone()],
2133 );
2134 comp.protocol_system = protocol_system.to_string();
2135 swaps.push(Swap::new(
2136 format!("pool-{tin_byte}-{tout_byte}"),
2137 protocol_system.to_string(),
2138 tin.clone(),
2139 tout.clone(),
2140 BigUint::from(1000u64),
2141 BigUint::from(1000u64),
2142 BigUint::from(50_000u64),
2143 comp,
2144 Box::new(MockProtocolSim::default()),
2145 ));
2146 tokens.insert(tin, tin_token);
2147 tokens.insert(tout, tout_token);
2148 }
2149 OrderQuote::new(
2150 "test-order".to_string(),
2151 QuoteStatus::Success,
2152 BigUint::from(1000u64),
2153 BigUint::from(1000u64),
2154 BigUint::from(100_000u64),
2155 BigUint::from(1000u64),
2156 BlockInfo::new(1, "0x123".to_string(), 1000),
2157 "test".to_string(),
2158 Bytes::from(make_address(0xAA).as_ref()),
2159 Bytes::from(make_address(0xAA).as_ref()),
2160 "1".to_string(),
2161 )
2162 .with_route(Route::new(swaps, tokens).expect("non-empty route"))
2163 }
2164
2165 #[rstest]
2167 #[case::terminal_exclusive_leg(
2168 &[("uniswap_v2", 0x01, 0x02), ("vm:exclusive", 0x02, 0x03)], true)]
2169 #[case::mid_route_exclusive_leg(
2170 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2171 #[case::exclusive_leg_ending_its_path(
2175 &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x01, 0x03), ("uniswap_v2", 0x03, 0x02)],
2176 true)]
2177 #[case::no_exclusive_leg(
2178 &[("uniswap_v2", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2179 #[case::two_exclusive_legs(
2181 &[("vm:exclusive", 0x01, 0x02), ("vm:exclusive", 0x01, 0x02)], false)]
2182 fn test_exclusive_route_validation(#[case] legs: &[(&str, u8, u8)], #[case] expected: bool) {
2183 let quote = make_route_quote(legs);
2184 assert_eq!(has_valid_exclusive_route("e, &exclusive_policy()), expected);
2185 }
2186}