Skip to main content

fynd_core/worker_pool_router/
mod.rs

1//! Orchestrates multiple solver pools to find the best quote per request.
2//!
3//! The WorkerPoolRouter sits between the API layer and multiple solver pools.
4//! It fans out each order to all configured solvers, manages timeouts,
5//! selects the best quote based on `amount_out_net_gas`, and optionally
6//! encodes the winning solution into an on-chain transaction.
7
8//! # Responsibilities
9//!
10//! 1. **Fan-out**: Distribute each order to solver pools. Its distribution algorithm can be
11//!    customized, but initially it's set to relay to all solvers.
12//! 2. **Timeout**: Cancel if solver response takes too long
13//! 3. **Collection**: Wait for N responses OR timeout per order
14//! 4. **Gas refinement**: Before cross-pool ranking, replace each candidate's naive
15//!    `route.total_gas()` estimate (used internally by algorithms for intra-pool ranking) with the
16//!    more accurate `estimate_gas_usage` from tycho-execution, which accounts for token transfer
17//!    costs and router overhead. The `amount_out_net_gas` values are rescaled proportionally so the
18//!    final ranking reflects realistic execution cost.
19//! 5. **Selection**: Choose best quote (max refined `amount_out_net_gas`)
20//! 6. **Encoding**: If [`EncodingOptions`](crate::EncodingOptions) are provided in the request,
21//!    encode winning solutions into executable on-chain transactions via the
22//!    [`encoding::encoder::Encoder`](crate::encoding::encoder::Encoder)
23
24pub 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/// Handle to a solver pool for dispatching orders.
49#[derive(Clone)]
50pub struct SolverPoolHandle {
51    /// Human-readable name for this pool (used in logging & metrics).
52    name: String,
53    /// Queue handle for this pool.
54    queue: TaskQueueHandle,
55}
56
57impl SolverPoolHandle {
58    /// Creates a new solver pool handle.
59    pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
60        Self { name: name.into(), queue }
61    }
62
63    /// Returns the pool name.
64    pub fn name(&self) -> &str {
65        &self.name
66    }
67
68    /// Returns the task queue handle.
69    pub fn queue(&self) -> &TaskQueueHandle {
70        &self.queue
71    }
72}
73
74/// Collected responses for a single order from multiple solvers.
75#[derive(Debug)]
76pub(crate) struct OrderResponses {
77    /// ID of the order these responses correspond to.
78    order_id: String,
79    /// Quotes received from each solver pool (pool_name, quote).
80    quotes: Vec<(String, OrderQuote)>,
81    /// Solver pools that failed with their respective errors (pool_name, error).
82    /// This captures all error types: timeouts, no routes, algorithm errors, etc.
83    failed_solvers: Vec<(String, SolveError)>,
84}
85
86/// Orchestrates multiple solver pools to find the best quote.
87pub struct WorkerPoolRouter {
88    /// All registered solver pools.
89    solver_pools: Vec<SolverPoolHandle>,
90    /// Configuration for the worker router.
91    config: WorkerPoolRouterConfig,
92    /// Encoder for encoding solutions into on-chain transactions.
93    encoder: Encoder,
94    /// Validates solution outputs against external price sources.
95    /// Present when the server has price guard enabled; `None` when disabled.
96    price_guard: Option<PriceGuard>,
97}
98
99impl WorkerPoolRouter {
100    /// Creates a new WorkerPoolRouter with the given solver pools, config, and encoder.
101    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    /// Makes price guard validation available for this router.
110    ///
111    /// Providers are started and caches stay warm. Validation only runs for
112    /// requests where the client sets `enabled: true` in `PriceGuardConfig`.
113    pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
114        self.price_guard = Some(price_guard);
115        self
116    }
117
118    /// Returns the number of registered solver pools.
119    pub fn num_pools(&self) -> usize {
120        self.solver_pools.len()
121    }
122
123    /// Returns a quote by fanning out to all solver pools.
124    ///
125    /// For each order in the request:
126    /// 1. Sends the order to all solver pools in parallel
127    /// 2. Waits for responses with timeout
128    /// 3. Selects the best quote based on `amount_out_net_gas`
129    /// 4. If `encoding_options` are set on the request, encodes winning solutions into on-chain
130    ///    transactions
131    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        // Process each order independently in parallel
149        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        // Refine gas estimates for all candidates using estimate_gas_usage before ranking,
158        // so ranking uses accurate gas costs rather than naive route.total_gas().
159        if let Some(encoding_options) = request.options().encoding_options() {
160            refine_gas_estimates(&mut order_responses, encoding_options)?;
161        }
162
163        // Rank quotes for each order (sorted by refined amount_out_net_gas descending)
164        let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
165            .into_iter()
166            .map(|responses| self.rank_quotes(&responses, request.options()))
167            .collect();
168
169        // Validate against external prices when the client explicitly enables it.
170        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        // Encode solutions if encoding_options is set
196        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        // Calculate totals
213        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    /// Solves a single order by fanning out to all solver pools.
224    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        // Fan-out: send order to all solver pools
235        // perf: In the future, we can add new distribution algorithms, like sending short-timeout
236        // only to fast workers.
237        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        // Collect responses with timeout
264        loop {
265            let deadline_instant = tokio::time::Instant::from_std(deadline);
266
267            tokio::select! {
268                // Always checks timeout first, ensuring we respect the deadline
269                biased;
270
271                // Timeout reached
272                _ = tokio::time::sleep_until(deadline_instant) => {
273                    // Mark all remaining pools as timed out
274                    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                // Response received
286                result = pending.next() => {
287                    match result {
288                        Some((pool_name, Ok(single_quote))) => {
289                            // Remove from remaining
290                            remaining_pools.remove(&pool_name);
291
292                            // Extract the OrderQuote from SingleOrderQuote
293                            quotes.push((pool_name.clone(), single_quote.order().clone()));
294
295                            // Early return if min_responses reached
296                            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                            // All futures completed
319                            break;
320                        }
321                    }
322                }
323            }
324        }
325
326        // Record metrics
327        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        // Record failures by pool and error type
332        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    /// Returns all valid quotes for an order, ranked by `amount_out_net_gas` descending.
362    ///
363    /// If no valid quotes exist, returns a single-element vec with a placeholder
364    /// (`NoRouteFound` or `Timeout`) so that downstream always has at least one
365    /// candidate per order.
366    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        // Sort descending by amount_out_net_gas
380        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        // No valid quote found - return a NoRouteFound response
401        // Try to get any response to extract block info, or create a placeholder
402        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            // No responses at all - determine status from failure types
419            let status = if responses.failed_solvers.is_empty() {
420                QuoteStatus::NoRouteFound
421            } else {
422                // If all failures are timeouts, report as Timeout
423                // Otherwise report as NoRouteFound (more general failure)
424                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            // Record status metric
442            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            // No worker responded — use the requested label if set, otherwise "0"
450            // (we have no block context here since no worker completed).
451            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    /// Returns the effective timeout for a request.
476    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
484/// Picks the most informative no-route reason across the failed pools.
485///
486/// A token-not-in-graph reason is a shared-graph fact and wins over any
487/// path-specific reason; otherwise the first reason present is used.
488fn 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        // Load fees so encoding can run; the fetcher supplies on-chain values in production.
579        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    // Helper to create a mock solver pool that responds with a given solution
654    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        // amount_out_net_gas is refined using estimate_gas_usage before ranking
716        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        // Pool A: worse quote (net gas = 800)
730        let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
731        // Pool B: better quote (net gas = 950)
732        let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
733
734        // Wait for both responses to test best selection logic
735        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        // Pool B wins (higher refined amount_out_net_gas after estimate_gas_usage)
746        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        // Pool that takes too long
761        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        // Should timeout and return NoRouteFound or Timeout status
772        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        // Pool A: fast
785        let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
786        // Pool B: slow (but we won't wait for it)
787        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        // Should return quickly (not waiting for pool_b)
803        assert!(elapsed < Duration::from_millis(200));
804
805        // Should have pool_a's quote
806        let quote = result.unwrap();
807        assert_eq!(quote.orders().len(), 1);
808        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
809        // Should have encoding
810        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        // Pool that returns an error
871        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        // Should be NoRouteFound since the only solver returned an error
884        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        // Token-not-in-graph wins over no_graph_path regardless of pool order.
967        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}