1pub mod config;
25
26use std::{
27 collections::HashSet,
28 time::{Duration, Instant},
29};
30
31use config::WorkerPoolRouterConfig;
32use futures::stream::{FuturesUnordered, StreamExt};
33use metrics::{counter, histogram};
34use num_bigint::BigUint;
35use tracing::{debug, warn};
36use tycho_execution::encoding::{
37 evm::gas_estimator::estimate_gas_usage,
38 models::{Solution, Strategy},
39};
40use tycho_simulation::tycho_common::Bytes;
41
42use crate::{
43 encoding::encoder::Encoder, price_guard::guard::PriceGuard,
44 worker_pool::task_queue::TaskQueueHandle, BlockInfo, EncodingOptions, Order, OrderQuote, Quote,
45 QuoteOptions, QuoteRequest, QuoteStatus, SolveError, SolveParams,
46};
47
48#[derive(Clone)]
50pub struct SolverPoolHandle {
51 name: String,
53 queue: TaskQueueHandle,
55}
56
57impl SolverPoolHandle {
58 pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
60 Self { name: name.into(), queue }
61 }
62
63 pub fn name(&self) -> &str {
65 &self.name
66 }
67
68 pub fn queue(&self) -> &TaskQueueHandle {
70 &self.queue
71 }
72}
73
74#[derive(Debug)]
76pub(crate) struct OrderResponses {
77 order_id: String,
79 quotes: Vec<(String, OrderQuote)>,
81 failed_solvers: Vec<(String, SolveError)>,
84}
85
86pub struct WorkerPoolRouter {
88 solver_pools: Vec<SolverPoolHandle>,
90 config: WorkerPoolRouterConfig,
92 encoder: Encoder,
94 price_guard: Option<PriceGuard>,
97}
98
99impl WorkerPoolRouter {
100 pub fn new(
102 solver_pools: Vec<SolverPoolHandle>,
103 config: WorkerPoolRouterConfig,
104 encoder: Encoder,
105 ) -> Self {
106 Self { solver_pools, config, encoder, price_guard: None }
107 }
108
109 pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
114 self.price_guard = Some(price_guard);
115 self
116 }
117
118 pub fn num_pools(&self) -> usize {
120 self.solver_pools.len()
121 }
122
123 pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
132 let start = Instant::now();
133 let deadline = start + self.effective_timeout(request.options());
134 let min_responses = request
135 .options()
136 .min_responses()
137 .unwrap_or(self.config.min_responses());
138
139 if self.solver_pools.is_empty() {
140 return Err(SolveError::Internal("no solver pools configured".to_string()));
141 }
142
143 let params = match request.options().state_label().cloned() {
144 Some(label) => SolveParams::default().with_state_label(label),
145 None => SolveParams::default(),
146 };
147
148 let order_futures: Vec<_> = request
150 .orders()
151 .iter()
152 .map(|order| self.solve_order(order.clone(), params.clone(), deadline, min_responses))
153 .collect();
154
155 let mut order_responses = futures::future::join_all(order_futures).await;
156
157 if let Some(encoding_options) = request.options().encoding_options() {
160 refine_gas_estimates(&mut order_responses, encoding_options)?;
161 }
162
163 let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
165 .into_iter()
166 .map(|responses| self.rank_quotes(&responses, request.options()))
167 .collect();
168
169 let price_guard_config = request
171 .options()
172 .encoding_options()
173 .map(|e| e.price_guard())
174 .filter(|c| c.enabled());
175
176 let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
177 (Some(guard), Some(config)) => guard
178 .validate(ranked_quotes, config)
179 .map_err(|e| {
180 warn!(error = %e, "price guard validation error");
181 SolveError::Internal(e.to_string())
182 })?,
183 (None, Some(_)) => {
184 return Err(SolveError::Internal(
185 "price guard config provided but price guard is not enabled on this server"
186 .to_string(),
187 ));
188 }
189 _ => ranked_quotes
190 .into_iter()
191 .filter_map(|candidates| candidates.into_iter().next())
192 .collect(),
193 };
194
195 if let Some(encoding_options) = request.options().encoding_options() {
197 let encode_start = Instant::now();
198 let encoded = self
199 .encoder
200 .encode(order_quotes, encoding_options.clone())
201 .await;
202 histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
203 order_quotes = match encoded {
204 Ok(quotes) => quotes,
205 Err(e) => {
206 counter!("encoding_failures_total").increment(1);
207 return Err(e);
208 }
209 };
210 }
211
212 let total_gas_estimate = order_quotes
214 .iter()
215 .map(|o| o.gas_estimate())
216 .fold(BigUint::ZERO, |acc, g| acc + g);
217
218 let solve_time_ms = start.elapsed().as_millis() as u64;
219
220 Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
221 }
222
223 async fn solve_order(
225 &self,
226 order: Order,
227 params: SolveParams,
228 deadline: Instant,
229 min_responses: usize,
230 ) -> OrderResponses {
231 let start_time = Instant::now();
232 let order_id = order.id().to_string();
233
234 let mut pending: FuturesUnordered<_> = self
238 .solver_pools
239 .iter()
240 .map(|pool| {
241 let order_clone = order.clone();
242 let pool_name = pool.name().to_string();
243 let queue = pool.queue().clone();
244 let task_params = params.clone();
245
246 async move {
247 let result = queue
248 .enqueue(order_clone, task_params)
249 .await;
250 (pool_name, result)
251 }
252 })
253 .collect();
254
255 let mut quotes = Vec::new();
256 let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
257 let mut remaining_pools: HashSet<String> = self
258 .solver_pools
259 .iter()
260 .map(|p| p.name().to_string())
261 .collect();
262
263 loop {
265 let deadline_instant = tokio::time::Instant::from_std(deadline);
266
267 tokio::select! {
268 biased;
270
271 _ = tokio::time::sleep_until(deadline_instant) => {
273 let elapsed_ms = deadline.saturating_duration_since(Instant::now())
275 .as_millis() as u64;
276 for pool_name in remaining_pools.drain() {
277 failed_solvers.push((
278 pool_name,
279 SolveError::Timeout { elapsed_ms },
280 ));
281 }
282 break;
283 }
284
285 result = pending.next() => {
287 match result {
288 Some((pool_name, Ok(single_quote))) => {
289 remaining_pools.remove(&pool_name);
291
292 quotes.push((pool_name.clone(), single_quote.order().clone()));
294
295 if min_responses > 0 && quotes.len() >= min_responses {
297 debug!(
298 order_id = %order_id,
299 responses = quotes.len(),
300 min_responses,
301 "early return: min_responses reached"
302 );
303 counter!("worker_router_early_returns_total").increment(1);
304 break;
305 }
306 }
307 Some((pool_name, Err(e))) => {
308 remaining_pools.remove(&pool_name);
309 debug!(
310 pool = %pool_name,
311 order_id = %order_id,
312 error = %e,
313 "solver pool failed"
314 );
315 failed_solvers.push((pool_name, e));
316 }
317 None => {
318 break;
320 }
321 }
322 }
323 }
324 }
325
326 let duration = start_time.elapsed().as_secs_f64();
328 histogram!("worker_router_solve_duration_seconds").record(duration);
329 histogram!("worker_router_solver_responses").record(quotes.len() as f64);
330
331 for (pool_name, error) in &failed_solvers {
333 let error_type = match error {
334 SolveError::Timeout { .. } => "timeout",
335 SolveError::NoRouteFound { .. } => "no_route",
336 SolveError::QueueFull => "queue_full",
337 SolveError::Internal(_) => "internal",
338 SolveError::PriceCheckFailed { .. } => "price_check_failed",
339 _ => "other",
340 };
341 counter!("worker_router_solver_failures_total", "pool" => pool_name.clone(), "error_type" => error_type).increment(1);
342 }
343
344 if !failed_solvers.is_empty() {
345 let timeout_count = failed_solvers
346 .iter()
347 .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
348 .count();
349 let other_count = failed_solvers.len() - timeout_count;
350 warn!(
351 order_id = %order_id,
352 timeout_count,
353 other_failures = other_count,
354 "some solver pools failed"
355 );
356 }
357
358 OrderResponses { order_id, quotes, failed_solvers }
359 }
360
361 fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
367 let mut valid_quotes: Vec<_> = responses
368 .quotes
369 .iter()
370 .filter(|(_, q)| q.status() == QuoteStatus::Success)
371 .filter(|(_, q)| {
372 options
373 .max_gas()
374 .map(|max| q.gas_estimate() <= max)
375 .unwrap_or(true)
376 })
377 .collect();
378
379 valid_quotes.sort_by(|(_, a), (_, b)| {
381 b.amount_out_net_gas()
382 .cmp(a.amount_out_net_gas())
383 });
384
385 if !valid_quotes.is_empty() {
386 counter!("worker_router_orders_total", "status" => "success").increment(1);
387 let (pool_name, best) = valid_quotes[0];
388 counter!("worker_router_best_quote_pool", "pool" => pool_name.clone()).increment(1);
389 debug!(
390 order_id = %best.order_id(),
391 number_of_candidates = valid_quotes.len(),
392 "ranked quotes"
393 );
394 return valid_quotes
395 .into_iter()
396 .map(|(_, q)| q.clone())
397 .collect();
398 }
399
400 let mut fallback = if let Some((_, any_q)) = responses.quotes.first() {
403 counter!("worker_router_orders_total", "status" => "no_route").increment(1);
404 OrderQuote::new(
405 responses.order_id.clone(),
406 QuoteStatus::NoRouteFound,
407 any_q.amount_in().clone(),
408 BigUint::ZERO,
409 BigUint::ZERO,
410 BigUint::ZERO,
411 any_q.block().clone(),
412 String::new(),
413 any_q.sender().clone(),
414 any_q.receiver().clone(),
415 any_q.solved_against().clone(),
416 )
417 } else {
418 let status = if responses.failed_solvers.is_empty() {
420 QuoteStatus::NoRouteFound
421 } else {
422 let all_timeouts = responses
425 .failed_solvers
426 .iter()
427 .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
428 let all_not_ready = responses
429 .failed_solvers
430 .iter()
431 .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
432 if all_timeouts {
433 QuoteStatus::Timeout
434 } else if all_not_ready {
435 QuoteStatus::NotReady
436 } else {
437 QuoteStatus::NoRouteFound
438 }
439 };
440
441 let status_label = match status {
443 QuoteStatus::Timeout => "timeout",
444 QuoteStatus::NotReady => "not_ready",
445 _ => "no_route",
446 };
447 counter!("worker_router_orders_total", "status" => status_label).increment(1);
448
449 let label = options
452 .state_label()
453 .cloned()
454 .unwrap_or_else(|| "0".to_string());
455 OrderQuote::new(
456 responses.order_id.clone(),
457 status,
458 BigUint::ZERO,
459 BigUint::ZERO,
460 BigUint::ZERO,
461 BigUint::ZERO,
462 BlockInfo::new(0, String::new(), 0),
463 String::new(),
464 Bytes::default(),
465 Bytes::default(),
466 label,
467 )
468 };
469 if fallback.status() == QuoteStatus::NoRouteFound {
470 fallback.set_no_route_reason(aggregate_no_route_reason(&responses.failed_solvers));
471 }
472 vec![fallback]
473 }
474
475 fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
477 options
478 .timeout_ms()
479 .map(Duration::from_millis)
480 .unwrap_or(self.config.default_timeout())
481 }
482}
483
484fn aggregate_no_route_reason(
489 failed_solvers: &[(String, SolveError)],
490) -> Option<crate::algorithm::NoPathReason> {
491 use crate::algorithm::NoPathReason;
492 let mut first = None;
493 for (_, error) in failed_solvers {
494 if let SolveError::NoRouteFound { reason: Some(reason), .. } = error {
495 match reason {
496 NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => {
497 return Some(*reason)
498 }
499 NoPathReason::NoGraphPath | NoPathReason::NoScorablePaths => {
500 if first.is_none() {
501 first = Some(*reason);
502 }
503 }
504 }
505 }
506 }
507 first
508}
509
510fn refine_gas_estimates(
511 order_responses: &mut Vec<OrderResponses>,
512 encoding_options: &EncodingOptions,
513) -> Result<(), SolveError> {
514 for responses in order_responses {
515 for (_, quote) in &mut responses.quotes {
516 if quote.status() != QuoteStatus::Success {
517 continue;
518 }
519 let solution = Solution::try_from(&*quote)?
520 .with_user_transfer_type(encoding_options.transfer_type().clone());
521 let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
522 let naive_gas = quote.gas_estimate().clone();
523 if naive_gas > BigUint::ZERO {
524 let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
525 let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
526 let new_net = if new_gas_cost <= *quote.amount_out() {
527 quote.amount_out() - &new_gas_cost
528 } else {
529 BigUint::ZERO
530 };
531 quote.set_amount_out_net_gas(new_net);
532 quote.set_gas_estimate(refined_gas);
533 }
534 }
535 }
536 Ok(())
537}
538
539fn derive_strategy(quote: &OrderQuote) -> Strategy {
540 let Some(route) = quote.route() else { return Strategy::Single };
541 let swaps = route.swaps();
542 if swaps.len() == 1 {
543 Strategy::Single
544 } else if swaps.iter().any(|s| *s.split() > 0.0) {
545 Strategy::Split
546 } else {
547 Strategy::Sequential
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use std::collections::HashMap;
554
555 use rstest::rstest;
556 use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
557 use tycho_simulation::{
558 tycho_common::models::Chain,
559 tycho_core::{
560 models::{token::Token, Address, Chain as SimChain},
561 Bytes,
562 },
563 };
564
565 use super::*;
566 use crate::{
567 algorithm::test_utils::{component, MockProtocolSim},
568 types::internal::SolveTask,
569 EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
570 };
571
572 fn default_encoder() -> Encoder {
573 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
574 .add_default_encoders(None)
575 .expect("default encoders should always succeed");
576 let encoder =
577 Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
578 encoder
580 .router_fees()
581 .set(crate::encoding::router_fees::RouterFees::new(
582 100_000_000,
583 100_000,
584 20_000_000,
585 std::collections::HashMap::new(),
586 ));
587 encoder
588 }
589
590 fn make_address(byte: u8) -> Address {
591 Address::from([byte; 20])
592 }
593
594 fn make_order() -> Order {
595 Order::new(
596 make_address(0x01),
597 make_address(0x02),
598 BigUint::from(1000u64),
599 OrderSide::Sell,
600 make_address(0xAA),
601 )
602 .with_id("test-order".to_string())
603 }
604
605 fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
606 let make_token = |addr: Address| Token {
607 address: addr,
608 symbol: "T".to_string(),
609 decimals: 18,
610 tax: Default::default(),
611 gas: vec![],
612 chain: SimChain::Ethereum,
613 quality: 100,
614 };
615 let tin = make_address(0x01);
616 let tout = make_address(0x02);
617 let tin_token = make_token(tin.clone());
618 let tout_token = make_token(tout.clone());
619 let swap = Swap::new(
620 "pool-1".to_string(),
621 "uniswap_v2".to_string(),
622 tin.clone(),
623 tout.clone(),
624 BigUint::from(1000u64),
625 BigUint::from(990u64),
626 BigUint::from(50_000u64),
627 component(
628 "0x0000000000000000000000000000000000000001",
629 &[tin_token.clone(), tout_token.clone()],
630 ),
631 Box::new(MockProtocolSim::default()),
632 );
633 let mut tokens = HashMap::new();
634 tokens.insert(tin, tin_token);
635 tokens.insert(tout, tout_token);
636 let quote = OrderQuote::new(
637 "test-order".to_string(),
638 QuoteStatus::Success,
639 BigUint::from(1000u64),
640 BigUint::from(990u64),
641 BigUint::from(100_000u64),
642 BigUint::from(amount_out_net_gas),
643 BlockInfo::new(1, "0x123".to_string(), 1000),
644 "test".to_string(),
645 Bytes::from(make_address(0xAA).as_ref()),
646 Bytes::from(make_address(0xAA).as_ref()),
647 "1".to_string(),
648 )
649 .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
650 SingleOrderQuote::new(quote, 5)
651 }
652
653 fn create_mock_pool(
655 name: &str,
656 response: Result<SingleOrderQuote, SolveError>,
657 delay_ms: u64,
658 ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
659 let (tx, rx) = async_channel::bounded::<SolveTask>(10);
660 let handle = TaskQueueHandle::from_sender(tx);
661
662 let worker = tokio::spawn(async move {
663 while let Ok(task) = rx.recv().await {
664 if delay_ms > 0 {
665 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
666 }
667 task.respond(response.clone());
668 }
669 });
670
671 (SolverPoolHandle::new(name, handle), worker)
672 }
673
674 #[test]
675 fn test_config_default() {
676 let config = WorkerPoolRouterConfig::default();
677 assert_eq!(config.default_timeout(), Duration::from_secs(1));
678 assert_eq!(config.min_responses(), 1);
679 }
680
681 #[test]
682 fn test_config_builder() {
683 let config = WorkerPoolRouterConfig::default()
684 .with_timeout(Duration::from_millis(500))
685 .with_min_responses(2);
686 assert_eq!(config.default_timeout(), Duration::from_millis(500));
687 assert_eq!(config.min_responses(), 2);
688 }
689
690 #[tokio::test]
691 async fn test_router_no_pools() {
692 let worker_router =
693 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
694 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
695
696 let result = worker_router.quote(request).await;
697 assert!(matches!(result, Err(SolveError::Internal(_))));
698 }
699
700 #[tokio::test]
701 async fn test_router_single_pool_success() {
702 let (pool, worker) = create_mock_pool("pool_a", Ok(make_single_quote(900)), 0);
703
704 let worker_router =
705 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
706 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
707 let request = QuoteRequest::new(vec![make_order()], options);
708
709 let result = worker_router.quote(request).await;
710 assert!(result.is_ok());
711
712 let quote = result.unwrap();
713 assert_eq!(quote.orders().len(), 1);
714 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
715 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
717 assert!(!quote.orders()[0]
718 .transaction()
719 .unwrap()
720 .data()
721 .is_empty());
722
723 drop(worker_router);
724 worker.abort();
725 }
726
727 #[tokio::test]
728 async fn test_router_selects_best_of_two() {
729 let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
731 let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
733
734 let config = WorkerPoolRouterConfig::default().with_min_responses(2);
736 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
737 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
738 let request = QuoteRequest::new(vec![make_order()], options);
739
740 let result = worker_router.quote(request).await;
741 assert!(result.is_ok());
742
743 let quote = result.unwrap();
744 assert_eq!(quote.orders().len(), 1);
745 assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
747 assert!(!quote.orders()[0]
748 .transaction()
749 .unwrap()
750 .data()
751 .is_empty());
752
753 drop(worker_router);
754 worker_a.abort();
755 worker_b.abort();
756 }
757
758 #[tokio::test]
759 async fn test_router_timeout() {
760 let (pool, worker) = create_mock_pool("slow_pool", Ok(make_single_quote(900)), 500);
762
763 let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
764 let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
765 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
766
767 let result = worker_router.quote(request).await;
768 assert!(result.is_ok());
769
770 let quote = result.unwrap();
771 assert_eq!(quote.orders().len(), 1);
773 assert!(matches!(
774 quote.orders()[0].status(),
775 QuoteStatus::Timeout | QuoteStatus::NoRouteFound
776 ));
777
778 drop(worker_router);
779 worker.abort();
780 }
781
782 #[tokio::test]
783 async fn test_router_early_return_on_min_responses() {
784 let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
786 let (pool_b, worker_b) = create_mock_pool("slow_pool", Ok(make_single_quote(950)), 500);
788
789 let config = WorkerPoolRouterConfig::default()
790 .with_timeout(Duration::from_millis(1000))
791 .with_min_responses(1);
792 let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
793
794 let start = Instant::now();
795 let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
796 let request = QuoteRequest::new(vec![make_order()], options);
797
798 let result = worker_router.quote(request).await;
799 let elapsed = start.elapsed();
800
801 assert!(result.is_ok());
802 assert!(elapsed < Duration::from_millis(200));
804
805 let quote = result.unwrap();
807 assert_eq!(quote.orders().len(), 1);
808 assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
809 assert!(!quote.orders()[0]
811 .transaction()
812 .unwrap()
813 .data()
814 .is_empty());
815
816 drop(worker_router);
817 worker_a.abort();
818 worker_b.abort();
819 }
820
821 #[rstest]
822 #[case::under_limit(100, Some(200), true)]
823 #[case::at_limit(200, Some(200), true)]
824 #[case::over_limit(300, Some(200), false)]
825 #[case::no_limit(500, None, true)]
826 fn test_max_gas_constraint(
827 #[case] gas_estimate: u64,
828 #[case] max_gas: Option<u64>,
829 #[case] should_pass: bool,
830 ) {
831 let responses = OrderResponses {
832 order_id: "test".to_string(),
833 quotes: vec![(
834 "pool".to_string(),
835 OrderQuote::new(
836 "test".to_string(),
837 QuoteStatus::Success,
838 BigUint::from(1000u64),
839 BigUint::from(990u64),
840 BigUint::from(gas_estimate),
841 BigUint::from(900u64),
842 BlockInfo::new(1, "0x123".to_string(), 1000),
843 "test".to_string(),
844 Bytes::from(make_address(0xAA).as_ref()),
845 Bytes::from(make_address(0xAA).as_ref()),
846 "1".to_string(),
847 ),
848 )],
849 failed_solvers: vec![],
850 };
851
852 let options = match max_gas {
853 Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
854 None => QuoteOptions::default(),
855 };
856
857 let worker_router =
858 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
859 let result = worker_router.rank_quotes(&responses, &options);
860
861 if should_pass {
862 assert_eq!(result[0].status(), QuoteStatus::Success);
863 } else {
864 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
865 }
866 }
867
868 #[tokio::test]
869 async fn test_router_captures_solver_errors() {
870 let (pool, worker) =
872 create_mock_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
873
874 let worker_router =
875 WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
876 let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
877
878 let result = worker_router.quote(request).await;
879 assert!(result.is_ok());
880
881 let quote = result.unwrap();
882 assert_eq!(quote.orders().len(), 1);
883 assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
885
886 drop(worker_router);
887 worker.abort();
888 }
889
890 #[test]
891 fn test_rank_quotes_all_timeouts_returns_timeout_status() {
892 let responses = OrderResponses {
893 order_id: "test".to_string(),
894 quotes: vec![],
895 failed_solvers: vec![
896 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
897 ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
898 ],
899 };
900
901 let worker_router =
902 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
903 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
904
905 assert_eq!(result.len(), 1);
906 assert_eq!(result[0].status(), QuoteStatus::Timeout);
907 }
908
909 #[test]
910 fn test_rank_quotes_mixed_failures_returns_no_route_found() {
911 let responses = OrderResponses {
912 order_id: "test".to_string(),
913 quotes: vec![],
914 failed_solvers: vec![
915 ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
916 ("pool_b".to_string(), SolveError::no_route_found("test")),
917 ],
918 };
919
920 let worker_router =
921 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
922 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
923
924 assert_eq!(result.len(), 1);
925 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
926 }
927
928 #[test]
929 fn test_rank_quotes_no_failures_returns_no_route_found() {
930 let responses =
931 OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
932
933 let worker_router =
934 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
935 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
936
937 assert_eq!(result.len(), 1);
938 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
939 }
940
941 #[test]
942 fn rank_quotes_attaches_no_route_reason() {
943 use crate::algorithm::NoPathReason;
944 let responses = OrderResponses {
945 order_id: "test".to_string(),
946 quotes: vec![],
947 failed_solvers: vec![
948 (
949 "pool_a".to_string(),
950 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
951 ),
952 (
953 "pool_b".to_string(),
954 SolveError::no_route_found_with_reason(
955 "test",
956 NoPathReason::DestinationTokenNotInGraph,
957 ),
958 ),
959 ],
960 };
961 let worker_router =
962 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
963 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
964 assert_eq!(result.len(), 1);
965 assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
966 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
968 }
969
970 #[test]
971 fn rank_quotes_no_route_reason_falls_back_to_first() {
972 use crate::algorithm::NoPathReason;
973 let responses = OrderResponses {
974 order_id: "test".to_string(),
975 quotes: vec![],
976 failed_solvers: vec![(
977 "pool_a".to_string(),
978 SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
979 )],
980 };
981 let worker_router =
982 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
983 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
984 assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
985 }
986
987 #[test]
988 fn test_rank_quotes_returns_sorted_candidates() {
989 let responses = OrderResponses {
990 order_id: "test".to_string(),
991 quotes: vec![
992 (
993 "pool_a".to_string(),
994 OrderQuote::new(
995 "test".to_string(),
996 QuoteStatus::Success,
997 BigUint::from(1000u64),
998 BigUint::from(800u64),
999 BigUint::from(100_000u64),
1000 BigUint::from(800u64),
1001 BlockInfo::new(1, "0x123".to_string(), 1000),
1002 "test".to_string(),
1003 Bytes::from(make_address(0xAA).as_ref()),
1004 Bytes::from(make_address(0xAA).as_ref()),
1005 "1".to_string(),
1006 ),
1007 ),
1008 (
1009 "pool_b".to_string(),
1010 OrderQuote::new(
1011 "test".to_string(),
1012 QuoteStatus::Success,
1013 BigUint::from(1000u64),
1014 BigUint::from(950u64),
1015 BigUint::from(100_000u64),
1016 BigUint::from(950u64),
1017 BlockInfo::new(1, "0x123".to_string(), 1000),
1018 "test".to_string(),
1019 Bytes::from(make_address(0xAA).as_ref()),
1020 Bytes::from(make_address(0xAA).as_ref()),
1021 "1".to_string(),
1022 ),
1023 ),
1024 ],
1025 failed_solvers: vec![],
1026 };
1027
1028 let worker_router =
1029 WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1030 let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1031
1032 assert_eq!(result.len(), 2);
1033 assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
1034 assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
1035 }
1036}