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            order_quotes = self
198                .encoder
199                .encode(order_quotes, encoding_options.clone())
200                .await?;
201        }
202
203        // Calculate totals
204        let total_gas_estimate = order_quotes
205            .iter()
206            .map(|o| o.gas_estimate())
207            .fold(BigUint::ZERO, |acc, g| acc + g);
208
209        let solve_time_ms = start.elapsed().as_millis() as u64;
210
211        Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
212    }
213
214    /// Solves a single order by fanning out to all solver pools.
215    async fn solve_order(
216        &self,
217        order: Order,
218        params: SolveParams,
219        deadline: Instant,
220        min_responses: usize,
221    ) -> OrderResponses {
222        let start_time = Instant::now();
223        let order_id = order.id().to_string();
224
225        // Fan-out: send order to all solver pools
226        // perf: In the future, we can add new distribution algorithms, like sending short-timeout
227        // only to fast workers.
228        let mut pending: FuturesUnordered<_> = self
229            .solver_pools
230            .iter()
231            .map(|pool| {
232                let order_clone = order.clone();
233                let pool_name = pool.name().to_string();
234                let queue = pool.queue().clone();
235                let task_params = params.clone();
236
237                async move {
238                    let result = queue
239                        .enqueue(order_clone, task_params)
240                        .await;
241                    (pool_name, result)
242                }
243            })
244            .collect();
245
246        let mut quotes = Vec::new();
247        let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
248        let mut remaining_pools: HashSet<String> = self
249            .solver_pools
250            .iter()
251            .map(|p| p.name().to_string())
252            .collect();
253
254        // Collect responses with timeout
255        loop {
256            let deadline_instant = tokio::time::Instant::from_std(deadline);
257
258            tokio::select! {
259                // Always checks timeout first, ensuring we respect the deadline
260                biased;
261
262                // Timeout reached
263                _ = tokio::time::sleep_until(deadline_instant) => {
264                    // Mark all remaining pools as timed out
265                    let elapsed_ms = deadline.saturating_duration_since(Instant::now())
266                        .as_millis() as u64;
267                    for pool_name in remaining_pools.drain() {
268                        failed_solvers.push((
269                            pool_name,
270                            SolveError::Timeout { elapsed_ms },
271                        ));
272                    }
273                    break;
274                }
275
276                // Response received
277                result = pending.next() => {
278                    match result {
279                        Some((pool_name, Ok(single_quote))) => {
280                            // Remove from remaining
281                            remaining_pools.remove(&pool_name);
282
283                            // Extract the OrderQuote from SingleOrderQuote
284                            quotes.push((pool_name.clone(), single_quote.order().clone()));
285
286                            // Early return if min_responses reached
287                            if min_responses > 0 && quotes.len() >= min_responses {
288                                debug!(
289                                    order_id = %order_id,
290                                    responses = quotes.len(),
291                                    min_responses,
292                                    "early return: min_responses reached"
293                                );
294                                counter!("worker_router_early_returns_total").increment(1);
295                                break;
296                            }
297                        }
298                        Some((pool_name, Err(e))) => {
299                            remaining_pools.remove(&pool_name);
300                            debug!(
301                                pool = %pool_name,
302                                order_id = %order_id,
303                                error = %e,
304                                "solver pool failed"
305                            );
306                            failed_solvers.push((pool_name, e));
307                        }
308                        None => {
309                            // All futures completed
310                            break;
311                        }
312                    }
313                }
314            }
315        }
316
317        // Record metrics
318        let duration = start_time.elapsed().as_secs_f64();
319        histogram!("worker_router_solve_duration_seconds").record(duration);
320        histogram!("worker_router_solver_responses").record(quotes.len() as f64);
321
322        // Record failures by pool and error type
323        for (pool_name, error) in &failed_solvers {
324            let error_type = match error {
325                SolveError::Timeout { .. } => "timeout",
326                SolveError::NoRouteFound { .. } => "no_route",
327                SolveError::QueueFull => "queue_full",
328                SolveError::Internal(_) => "internal",
329                SolveError::PriceCheckFailed { .. } => "price_check_failed",
330                _ => "other",
331            };
332            counter!("worker_router_solver_failures_total", "pool" => pool_name.clone(), "error_type" => error_type).increment(1);
333        }
334
335        if !failed_solvers.is_empty() {
336            let timeout_count = failed_solvers
337                .iter()
338                .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
339                .count();
340            let other_count = failed_solvers.len() - timeout_count;
341            warn!(
342                order_id = %order_id,
343                timeout_count,
344                other_failures = other_count,
345                "some solver pools failed"
346            );
347        }
348
349        OrderResponses { order_id, quotes, failed_solvers }
350    }
351
352    /// Returns all valid quotes for an order, ranked by `amount_out_net_gas` descending.
353    ///
354    /// If no valid quotes exist, returns a single-element vec with a placeholder
355    /// (`NoRouteFound` or `Timeout`) so that downstream always has at least one
356    /// candidate per order.
357    fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
358        let mut valid_quotes: Vec<_> = responses
359            .quotes
360            .iter()
361            .filter(|(_, q)| q.status() == QuoteStatus::Success)
362            .filter(|(_, q)| {
363                options
364                    .max_gas()
365                    .map(|max| q.gas_estimate() <= max)
366                    .unwrap_or(true)
367            })
368            .collect();
369
370        // Sort descending by amount_out_net_gas
371        valid_quotes.sort_by(|(_, a), (_, b)| {
372            b.amount_out_net_gas()
373                .cmp(a.amount_out_net_gas())
374        });
375
376        if !valid_quotes.is_empty() {
377            counter!("worker_router_orders_total", "status" => "success").increment(1);
378            let (pool_name, best) = valid_quotes[0];
379            counter!("worker_router_best_quote_pool", "pool" => pool_name.clone()).increment(1);
380            debug!(
381                order_id = %best.order_id(),
382                number_of_candidates = valid_quotes.len(),
383                "ranked quotes"
384            );
385            return valid_quotes
386                .into_iter()
387                .map(|(_, q)| q.clone())
388                .collect();
389        }
390
391        // No valid quote found - return a NoRouteFound response
392        // Try to get any response to extract block info, or create a placeholder
393        let fallback = if let Some((_, any_q)) = responses.quotes.first() {
394            counter!("worker_router_orders_total", "status" => "no_route").increment(1);
395            OrderQuote::new(
396                responses.order_id.clone(),
397                QuoteStatus::NoRouteFound,
398                any_q.amount_in().clone(),
399                BigUint::ZERO,
400                BigUint::ZERO,
401                BigUint::ZERO,
402                any_q.block().clone(),
403                String::new(),
404                any_q.sender().clone(),
405                any_q.receiver().clone(),
406                any_q.solved_against().clone(),
407            )
408        } else {
409            // No responses at all - determine status from failure types
410            let status = if responses.failed_solvers.is_empty() {
411                QuoteStatus::NoRouteFound
412            } else {
413                // If all failures are timeouts, report as Timeout
414                // Otherwise report as NoRouteFound (more general failure)
415                let all_timeouts = responses
416                    .failed_solvers
417                    .iter()
418                    .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
419                let all_not_ready = responses
420                    .failed_solvers
421                    .iter()
422                    .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
423                if all_timeouts {
424                    QuoteStatus::Timeout
425                } else if all_not_ready {
426                    QuoteStatus::NotReady
427                } else {
428                    QuoteStatus::NoRouteFound
429                }
430            };
431
432            // Record status metric
433            let status_label = match status {
434                QuoteStatus::Timeout => "timeout",
435                QuoteStatus::NotReady => "not_ready",
436                _ => "no_route",
437            };
438            counter!("worker_router_orders_total", "status" => status_label).increment(1);
439
440            // No worker responded — use the requested label if set, otherwise "0"
441            // (we have no block context here since no worker completed).
442            let label = options
443                .state_label()
444                .cloned()
445                .unwrap_or_else(|| "0".to_string());
446            OrderQuote::new(
447                responses.order_id.clone(),
448                status,
449                BigUint::ZERO,
450                BigUint::ZERO,
451                BigUint::ZERO,
452                BigUint::ZERO,
453                BlockInfo::new(0, String::new(), 0),
454                String::new(),
455                Bytes::default(),
456                Bytes::default(),
457                label,
458            )
459        };
460        vec![fallback]
461    }
462
463    /// Returns the effective timeout for a request.
464    fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
465        options
466            .timeout_ms()
467            .map(Duration::from_millis)
468            .unwrap_or(self.config.default_timeout())
469    }
470}
471
472fn refine_gas_estimates(
473    order_responses: &mut Vec<OrderResponses>,
474    encoding_options: &EncodingOptions,
475) -> Result<(), SolveError> {
476    for responses in order_responses {
477        for (_, quote) in &mut responses.quotes {
478            if quote.status() != QuoteStatus::Success {
479                continue;
480            }
481            let solution = Solution::try_from(&*quote)?
482                .with_user_transfer_type(encoding_options.transfer_type().clone());
483            let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
484            let naive_gas = quote.gas_estimate().clone();
485            if naive_gas > BigUint::ZERO {
486                let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
487                let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
488                let new_net = if new_gas_cost <= *quote.amount_out() {
489                    quote.amount_out() - &new_gas_cost
490                } else {
491                    BigUint::ZERO
492                };
493                quote.set_amount_out_net_gas(new_net);
494                quote.set_gas_estimate(refined_gas);
495            }
496        }
497    }
498    Ok(())
499}
500
501fn derive_strategy(quote: &OrderQuote) -> Strategy {
502    let Some(route) = quote.route() else { return Strategy::Single };
503    let swaps = route.swaps();
504    if swaps.len() == 1 {
505        Strategy::Single
506    } else if swaps.iter().any(|s| *s.split() > 0.0) {
507        Strategy::Split
508    } else {
509        Strategy::Sequential
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use std::collections::HashMap;
516
517    use rstest::rstest;
518    use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
519    use tycho_simulation::{
520        tycho_common::models::Chain,
521        tycho_core::{
522            models::{token::Token, Address, Chain as SimChain},
523            Bytes,
524        },
525    };
526
527    use super::*;
528    use crate::{
529        algorithm::test_utils::{component, MockProtocolSim},
530        types::internal::SolveTask,
531        EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
532    };
533
534    fn default_encoder() -> Encoder {
535        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
536            .add_default_encoders(None)
537            .expect("default encoders should always succeed");
538        let encoder =
539            Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
540        // Load fees so encoding can run; the fetcher supplies on-chain values in production.
541        encoder
542            .router_fees()
543            .set(crate::encoding::router_fees::RouterFees::new(
544                100_000_000,
545                100_000,
546                20_000_000,
547                std::collections::HashMap::new(),
548            ));
549        encoder
550    }
551
552    fn make_address(byte: u8) -> Address {
553        Address::from([byte; 20])
554    }
555
556    fn make_order() -> Order {
557        Order::new(
558            make_address(0x01),
559            make_address(0x02),
560            BigUint::from(1000u64),
561            OrderSide::Sell,
562            make_address(0xAA),
563        )
564        .with_id("test-order".to_string())
565    }
566
567    fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
568        let make_token = |addr: Address| Token {
569            address: addr,
570            symbol: "T".to_string(),
571            decimals: 18,
572            tax: Default::default(),
573            gas: vec![],
574            chain: SimChain::Ethereum,
575            quality: 100,
576        };
577        let tin = make_address(0x01);
578        let tout = make_address(0x02);
579        let tin_token = make_token(tin.clone());
580        let tout_token = make_token(tout.clone());
581        let swap = Swap::new(
582            "pool-1".to_string(),
583            "uniswap_v2".to_string(),
584            tin.clone(),
585            tout.clone(),
586            BigUint::from(1000u64),
587            BigUint::from(990u64),
588            BigUint::from(50_000u64),
589            component(
590                "0x0000000000000000000000000000000000000001",
591                &[tin_token.clone(), tout_token.clone()],
592            ),
593            Box::new(MockProtocolSim::default()),
594        );
595        let mut tokens = HashMap::new();
596        tokens.insert(tin, tin_token);
597        tokens.insert(tout, tout_token);
598        let quote = OrderQuote::new(
599            "test-order".to_string(),
600            QuoteStatus::Success,
601            BigUint::from(1000u64),
602            BigUint::from(990u64),
603            BigUint::from(100_000u64),
604            BigUint::from(amount_out_net_gas),
605            BlockInfo::new(1, "0x123".to_string(), 1000),
606            "test".to_string(),
607            Bytes::from(make_address(0xAA).as_ref()),
608            Bytes::from(make_address(0xAA).as_ref()),
609            "1".to_string(),
610        )
611        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
612        SingleOrderQuote::new(quote, 5)
613    }
614
615    // Helper to create a mock solver pool that responds with a given solution
616    fn create_mock_pool(
617        name: &str,
618        response: Result<SingleOrderQuote, SolveError>,
619        delay_ms: u64,
620    ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
621        let (tx, rx) = async_channel::bounded::<SolveTask>(10);
622        let handle = TaskQueueHandle::from_sender(tx);
623
624        let worker = tokio::spawn(async move {
625            while let Ok(task) = rx.recv().await {
626                if delay_ms > 0 {
627                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
628                }
629                task.respond(response.clone());
630            }
631        });
632
633        (SolverPoolHandle::new(name, handle), worker)
634    }
635
636    #[test]
637    fn test_config_default() {
638        let config = WorkerPoolRouterConfig::default();
639        assert_eq!(config.default_timeout(), Duration::from_secs(1));
640        assert_eq!(config.min_responses(), 1);
641    }
642
643    #[test]
644    fn test_config_builder() {
645        let config = WorkerPoolRouterConfig::default()
646            .with_timeout(Duration::from_millis(500))
647            .with_min_responses(2);
648        assert_eq!(config.default_timeout(), Duration::from_millis(500));
649        assert_eq!(config.min_responses(), 2);
650    }
651
652    #[tokio::test]
653    async fn test_router_no_pools() {
654        let worker_router =
655            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
656        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
657
658        let result = worker_router.quote(request).await;
659        assert!(matches!(result, Err(SolveError::Internal(_))));
660    }
661
662    #[tokio::test]
663    async fn test_router_single_pool_success() {
664        let (pool, worker) = create_mock_pool("pool_a", Ok(make_single_quote(900)), 0);
665
666        let worker_router =
667            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
668        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
669        let request = QuoteRequest::new(vec![make_order()], options);
670
671        let result = worker_router.quote(request).await;
672        assert!(result.is_ok());
673
674        let quote = result.unwrap();
675        assert_eq!(quote.orders().len(), 1);
676        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
677        // amount_out_net_gas is refined using estimate_gas_usage before ranking
678        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
679        assert!(!quote.orders()[0]
680            .transaction()
681            .unwrap()
682            .data()
683            .is_empty());
684
685        drop(worker_router);
686        worker.abort();
687    }
688
689    #[tokio::test]
690    async fn test_router_selects_best_of_two() {
691        // Pool A: worse quote (net gas = 800)
692        let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
693        // Pool B: better quote (net gas = 950)
694        let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
695
696        // Wait for both responses to test best selection logic
697        let config = WorkerPoolRouterConfig::default().with_min_responses(2);
698        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
699        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
700        let request = QuoteRequest::new(vec![make_order()], options);
701
702        let result = worker_router.quote(request).await;
703        assert!(result.is_ok());
704
705        let quote = result.unwrap();
706        assert_eq!(quote.orders().len(), 1);
707        // Pool B wins (higher refined amount_out_net_gas after estimate_gas_usage)
708        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
709        assert!(!quote.orders()[0]
710            .transaction()
711            .unwrap()
712            .data()
713            .is_empty());
714
715        drop(worker_router);
716        worker_a.abort();
717        worker_b.abort();
718    }
719
720    #[tokio::test]
721    async fn test_router_timeout() {
722        // Pool that takes too long
723        let (pool, worker) = create_mock_pool("slow_pool", Ok(make_single_quote(900)), 500);
724
725        let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
726        let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
727        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
728
729        let result = worker_router.quote(request).await;
730        assert!(result.is_ok());
731
732        let quote = result.unwrap();
733        // Should timeout and return NoRouteFound or Timeout status
734        assert_eq!(quote.orders().len(), 1);
735        assert!(matches!(
736            quote.orders()[0].status(),
737            QuoteStatus::Timeout | QuoteStatus::NoRouteFound
738        ));
739
740        drop(worker_router);
741        worker.abort();
742    }
743
744    #[tokio::test]
745    async fn test_router_early_return_on_min_responses() {
746        // Pool A: fast
747        let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
748        // Pool B: slow (but we won't wait for it)
749        let (pool_b, worker_b) = create_mock_pool("slow_pool", Ok(make_single_quote(950)), 500);
750
751        let config = WorkerPoolRouterConfig::default()
752            .with_timeout(Duration::from_millis(1000))
753            .with_min_responses(1);
754        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
755
756        let start = Instant::now();
757        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
758        let request = QuoteRequest::new(vec![make_order()], options);
759
760        let result = worker_router.quote(request).await;
761        let elapsed = start.elapsed();
762
763        assert!(result.is_ok());
764        // Should return quickly (not waiting for pool_b)
765        assert!(elapsed < Duration::from_millis(200));
766
767        // Should have pool_a's quote
768        let quote = result.unwrap();
769        assert_eq!(quote.orders().len(), 1);
770        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
771        // Should have encoding
772        assert!(!quote.orders()[0]
773            .transaction()
774            .unwrap()
775            .data()
776            .is_empty());
777
778        drop(worker_router);
779        worker_a.abort();
780        worker_b.abort();
781    }
782
783    #[rstest]
784    #[case::under_limit(100, Some(200), true)]
785    #[case::at_limit(200, Some(200), true)]
786    #[case::over_limit(300, Some(200), false)]
787    #[case::no_limit(500, None, true)]
788    fn test_max_gas_constraint(
789        #[case] gas_estimate: u64,
790        #[case] max_gas: Option<u64>,
791        #[case] should_pass: bool,
792    ) {
793        let responses = OrderResponses {
794            order_id: "test".to_string(),
795            quotes: vec![(
796                "pool".to_string(),
797                OrderQuote::new(
798                    "test".to_string(),
799                    QuoteStatus::Success,
800                    BigUint::from(1000u64),
801                    BigUint::from(990u64),
802                    BigUint::from(gas_estimate),
803                    BigUint::from(900u64),
804                    BlockInfo::new(1, "0x123".to_string(), 1000),
805                    "test".to_string(),
806                    Bytes::from(make_address(0xAA).as_ref()),
807                    Bytes::from(make_address(0xAA).as_ref()),
808                    "1".to_string(),
809                ),
810            )],
811            failed_solvers: vec![],
812        };
813
814        let options = match max_gas {
815            Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
816            None => QuoteOptions::default(),
817        };
818
819        let worker_router =
820            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
821        let result = worker_router.rank_quotes(&responses, &options);
822
823        if should_pass {
824            assert_eq!(result[0].status(), QuoteStatus::Success);
825        } else {
826            assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
827        }
828    }
829
830    #[tokio::test]
831    async fn test_router_captures_solver_errors() {
832        // Pool that returns an error
833        let (pool, worker) = create_mock_pool(
834            "error_pool",
835            Err(SolveError::NoRouteFound { order_id: "test-order".to_string() }),
836            0,
837        );
838
839        let worker_router =
840            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
841        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
842
843        let result = worker_router.quote(request).await;
844        assert!(result.is_ok());
845
846        let quote = result.unwrap();
847        assert_eq!(quote.orders().len(), 1);
848        // Should be NoRouteFound since the only solver returned an error
849        assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
850
851        drop(worker_router);
852        worker.abort();
853    }
854
855    #[test]
856    fn test_rank_quotes_all_timeouts_returns_timeout_status() {
857        let responses = OrderResponses {
858            order_id: "test".to_string(),
859            quotes: vec![],
860            failed_solvers: vec![
861                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
862                ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
863            ],
864        };
865
866        let worker_router =
867            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
868        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
869
870        assert_eq!(result.len(), 1);
871        assert_eq!(result[0].status(), QuoteStatus::Timeout);
872    }
873
874    #[test]
875    fn test_rank_quotes_mixed_failures_returns_no_route_found() {
876        let responses = OrderResponses {
877            order_id: "test".to_string(),
878            quotes: vec![],
879            failed_solvers: vec![
880                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
881                ("pool_b".to_string(), SolveError::NoRouteFound { order_id: "test".to_string() }),
882            ],
883        };
884
885        let worker_router =
886            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
887        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
888
889        assert_eq!(result.len(), 1);
890        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
891    }
892
893    #[test]
894    fn test_rank_quotes_no_failures_returns_no_route_found() {
895        let responses =
896            OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
897
898        let worker_router =
899            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
900        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
901
902        assert_eq!(result.len(), 1);
903        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
904    }
905
906    #[test]
907    fn test_rank_quotes_returns_sorted_candidates() {
908        let responses = OrderResponses {
909            order_id: "test".to_string(),
910            quotes: vec![
911                (
912                    "pool_a".to_string(),
913                    OrderQuote::new(
914                        "test".to_string(),
915                        QuoteStatus::Success,
916                        BigUint::from(1000u64),
917                        BigUint::from(800u64),
918                        BigUint::from(100_000u64),
919                        BigUint::from(800u64),
920                        BlockInfo::new(1, "0x123".to_string(), 1000),
921                        "test".to_string(),
922                        Bytes::from(make_address(0xAA).as_ref()),
923                        Bytes::from(make_address(0xAA).as_ref()),
924                        "1".to_string(),
925                    ),
926                ),
927                (
928                    "pool_b".to_string(),
929                    OrderQuote::new(
930                        "test".to_string(),
931                        QuoteStatus::Success,
932                        BigUint::from(1000u64),
933                        BigUint::from(950u64),
934                        BigUint::from(100_000u64),
935                        BigUint::from(950u64),
936                        BlockInfo::new(1, "0x123".to_string(), 1000),
937                        "test".to_string(),
938                        Bytes::from(make_address(0xAA).as_ref()),
939                        Bytes::from(make_address(0xAA).as_ref()),
940                        "1".to_string(),
941                    ),
942                ),
943            ],
944            failed_solvers: vec![],
945        };
946
947        let worker_router =
948            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
949        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
950
951        assert_eq!(result.len(), 2);
952        assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
953        assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
954    }
955}