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::{HashMap, HashSet},
28    time::{Duration, Instant},
29};
30
31use config::WorkerPoolRouterConfig;
32use futures::stream::{FuturesUnordered, StreamExt};
33use metrics::{counter, histogram};
34use num_bigint::BigUint;
35use serde::{Deserialize, Serialize};
36use tracing::{debug, warn};
37use tycho_execution::encoding::{
38    evm::gas_estimator::estimate_gas_usage,
39    models::{Solution, Strategy},
40};
41use tycho_simulation::tycho_common::Bytes;
42
43use crate::{
44    encoding::encoder::Encoder, feed::exclusivity::ExclusivityPolicy,
45    price_guard::guard::PriceGuard, worker_pool::task_queue::TaskQueueHandle, BlockInfo,
46    EncodingOptions, Order, OrderQuote, Quote, QuoteOptions, QuoteRequest, QuoteStatus, SolveError,
47    SolveParams, SurplusInfo,
48};
49
50/// Which liquidity a solver pool (a group of workers) routes through, and therefore what its
51/// candidates mean in a quote.
52///
53/// A `Public` pool routes only through public liquidity and provides the committed (quoted)
54/// reference output; its workers are given the [`ExclusivityPolicy`] so exclusive components
55/// never enter their graphs. An `ExclusiveAccess` pool routes through all liquidity, public and
56/// exclusive components alike, and may beat that reference — in which case the protocol
57/// captures the surplus.
58///
59/// Serialized in snake_case (`"public_only"` / `"all"`) in `worker_pools.toml` via
60/// [`PoolConfig`].
61///
62/// [`PoolConfig`]: crate::PoolConfig
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum LiquidityScope {
66    /// Routes through public liquidity only. Establishes the committed reference output. Default.
67    #[default]
68    PublicOnly,
69    /// Routes through all liquidity, public and exclusive components alike; candidates from
70    /// this scope may capture surplus above the public reference.
71    All,
72}
73
74/// Handle to a solver pool for dispatching orders.
75#[derive(Clone)]
76pub struct SolverPoolHandle {
77    /// Human-readable name for this pool (used in logging & metrics).
78    name: String,
79    /// Queue handle for this pool.
80    queue: TaskQueueHandle,
81    /// Whether this pool routes public-only or all liquidity.
82    liquidity_scope: LiquidityScope,
83}
84
85impl SolverPoolHandle {
86    /// Creates a new solver pool handle with the default [`LiquidityScope::PublicOnly`] scope.
87    pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
88        Self { name: name.into(), queue, liquidity_scope: LiquidityScope::PublicOnly }
89    }
90
91    /// Sets the pool's liquidity scope (e.g. [`LiquidityScope::All`] for a pool
92    /// that routes
93    /// through exclusive liquidity as well).
94    pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
95        self.liquidity_scope = scope;
96        self
97    }
98
99    /// Returns the pool name.
100    pub fn name(&self) -> &str {
101        &self.name
102    }
103
104    /// Returns the task queue handle.
105    pub fn queue(&self) -> &TaskQueueHandle {
106        &self.queue
107    }
108
109    /// Returns the pool's liquidity scope.
110    pub fn liquidity_scope(&self) -> LiquidityScope {
111        self.liquidity_scope
112    }
113}
114
115/// Collected responses for a single order from multiple solvers.
116#[derive(Debug)]
117pub(crate) struct OrderResponses {
118    /// ID of the order these responses correspond to.
119    order_id: String,
120    /// Quotes received from each solver pool (pool_name, quote).
121    quotes: Vec<(String, OrderQuote)>,
122    /// Solver pools that failed with their respective errors (pool_name, error).
123    /// This captures all error types: timeouts, no routes, algorithm errors, etc.
124    failed_solvers: Vec<(String, SolveError)>,
125}
126
127impl OrderResponses {
128    /// Returns a copy keeping only candidates from public-scoped pools.
129    ///
130    /// These form the committed reference and the ranked fallback chain (ranked by `rank_quotes`,
131    /// consumed by the price guard); exclusive-access candidates are overlaid separately by
132    /// `combine_with_surplus`. `failed_solvers` is retained so placeholder construction is
133    /// unchanged.
134    fn public_only(&self, pool_scopes: &HashMap<String, LiquidityScope>) -> OrderResponses {
135        let quotes = self
136            .quotes
137            .iter()
138            .filter(|(pool, _)| pool_scopes.get(pool) != Some(&LiquidityScope::All))
139            .cloned()
140            .collect();
141        OrderResponses {
142            order_id: self.order_id.clone(),
143            quotes,
144            failed_solvers: self.failed_solvers.clone(),
145        }
146    }
147}
148
149/// Orchestrates multiple solver pools to find the best quote.
150pub struct WorkerPoolRouter {
151    /// All registered solver pools.
152    solver_pools: Vec<SolverPoolHandle>,
153    /// Configuration for the worker router.
154    config: WorkerPoolRouterConfig,
155    /// Encoder for encoding solutions into on-chain transactions.
156    encoder: Encoder,
157    /// Validates solution outputs against external price sources.
158    /// Present when the server has price guard enabled; `None` when disabled.
159    price_guard: Option<PriceGuard>,
160    /// Predicate identifying the exclusive legs in exclusive-access routes. `None` when no
161    /// exclusive components are configured.
162    exclusivity_policy: Option<ExclusivityPolicy>,
163}
164
165impl WorkerPoolRouter {
166    /// Creates a new WorkerPoolRouter with the given solver pools, config, and encoder.
167    pub fn new(
168        solver_pools: Vec<SolverPoolHandle>,
169        config: WorkerPoolRouterConfig,
170        encoder: Encoder,
171    ) -> Self {
172        Self { solver_pools, config, encoder, price_guard: None, exclusivity_policy: None }
173    }
174
175    /// Attaches the policy used to identify exclusive legs in exclusive-access routes.
176    pub fn with_exclusivity_policy(mut self, policy: ExclusivityPolicy) -> Self {
177        self.exclusivity_policy = Some(policy);
178        self
179    }
180
181    /// Makes price guard validation available for this router.
182    ///
183    /// Providers are started and caches stay warm. Validation only runs for
184    /// requests where the client sets `enabled: true` in `PriceGuardConfig`.
185    pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
186        self.price_guard = Some(price_guard);
187        self
188    }
189
190    /// Returns the number of registered solver pools.
191    pub fn num_pools(&self) -> usize {
192        self.solver_pools.len()
193    }
194
195    /// Returns a quote by fanning out to all solver pools.
196    ///
197    /// For each order in the request:
198    /// 1. Sends the order to all solver pools in parallel
199    /// 2. Waits for responses with timeout
200    /// 3. Selects the best quote based on `amount_out_net_gas`
201    /// 4. If `encoding_options` are set on the request, encodes winning solutions into on-chain
202    ///    transactions
203    pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
204        let start = Instant::now();
205        let deadline = start + self.effective_timeout(request.options());
206        let min_responses = request
207            .options()
208            .min_responses()
209            .unwrap_or(self.config.min_responses());
210
211        if self.solver_pools.is_empty() {
212            return Err(SolveError::Internal("no solver pools configured".to_string()));
213        }
214
215        let params = match request.options().state_label().cloned() {
216            Some(label) => SolveParams::default().with_state_label(label),
217            None => SolveParams::default(),
218        };
219
220        // Process each order independently in parallel
221        let order_futures: Vec<_> = request
222            .orders()
223            .iter()
224            .map(|order| self.solve_order(order.clone(), params.clone(), deadline, min_responses))
225            .collect();
226
227        let mut order_responses = futures::future::join_all(order_futures).await;
228
229        // Refine gas estimates for all candidates using estimate_gas_usage before ranking,
230        // so ranking uses accurate gas costs rather than naive route.total_gas().
231        if let Some(encoding_options) = request.options().encoding_options() {
232            refine_gas_estimates(&mut order_responses, encoding_options)?;
233        }
234
235        // Map each pool name to its liquidity scope so candidate quotes can be split into public vs
236        // exclusive-access.
237        let pool_scopes: HashMap<String, LiquidityScope> = self
238            .solver_pools
239            .iter()
240            .map(|p| (p.name().to_string(), p.liquidity_scope()))
241            .collect();
242        let has_exclusive_access_pool = pool_scopes
243            .values()
244            .any(|r| *r == LiquidityScope::All);
245
246        // Rank quotes for each order (sorted by refined amount_out_net_gas descending).
247        // `rank_quotes` produces the public ranking — the committed reference AND the price-guard
248        // fallback chain. When an `ExclusiveAccess`-scoped pool is configured, the winning
249        // exclusive-access candidate is overlaid
250        // onto that ranked list (prepended) by `combine_with_surplus`, so the fallbacks are
251        // preserved.
252        let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
253            .into_iter()
254            .map(|responses| {
255                if has_exclusive_access_pool {
256                    let public_ranked =
257                        self.rank_quotes(&responses.public_only(&pool_scopes), request.options());
258                    combine_with_surplus(
259                        &responses,
260                        &pool_scopes,
261                        request.options(),
262                        public_ranked,
263                        self.exclusivity_policy.as_ref(),
264                    )
265                } else {
266                    self.rank_quotes(&responses, request.options())
267                }
268            })
269            .collect();
270
271        // Validate against external prices when the client explicitly enables it.
272        let price_guard_config = request
273            .options()
274            .encoding_options()
275            .map(|e| e.price_guard())
276            .filter(|c| c.enabled());
277
278        let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
279            (Some(guard), Some(config)) => guard
280                .validate(ranked_quotes, config)
281                .map_err(|e| {
282                    warn!(error = %e, "price guard validation error");
283                    SolveError::Internal(e.to_string())
284                })?,
285            (None, Some(_)) => {
286                return Err(SolveError::Internal(
287                    "price guard config provided but price guard is not enabled on this server"
288                        .to_string(),
289                ));
290            }
291            _ => ranked_quotes
292                .into_iter()
293                .filter_map(|candidates| candidates.into_iter().next())
294                .collect(),
295        };
296
297        // Encode solutions if encoding_options is set
298        if let Some(encoding_options) = request.options().encoding_options() {
299            let encode_start = Instant::now();
300            let encoded = self
301                .encoder
302                .encode(order_quotes, encoding_options.clone())
303                .await;
304            histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
305            order_quotes = match encoded {
306                Ok(quotes) => quotes,
307                Err(e) => {
308                    counter!("encoding_failures_total").increment(1);
309                    return Err(e);
310                }
311            };
312        }
313
314        // Calculate totals
315        let total_gas_estimate = order_quotes
316            .iter()
317            .map(|o| o.gas_estimate())
318            .fold(BigUint::ZERO, |acc, g| acc + g);
319
320        let solve_time_ms = start.elapsed().as_millis() as u64;
321
322        Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
323    }
324
325    /// Solves a single order by fanning out to all solver pools.
326    async fn solve_order(
327        &self,
328        order: Order,
329        params: SolveParams,
330        deadline: Instant,
331        min_responses: usize,
332    ) -> OrderResponses {
333        let start_time = Instant::now();
334        let order_id = order.id().to_string();
335
336        // Fan-out: send order to all solver pools
337        // perf: In the future, we can add new distribution algorithms, like sending short-timeout
338        // only to fast workers.
339        let mut pending: FuturesUnordered<_> = self
340            .solver_pools
341            .iter()
342            .map(|pool| {
343                let order_clone = order.clone();
344                let pool_name = pool.name().to_string();
345                let queue = pool.queue().clone();
346                let task_params = params.clone();
347
348                async move {
349                    let result = queue
350                        .enqueue(order_clone, task_params)
351                        .await;
352                    (pool_name, result)
353                }
354            })
355            .collect();
356
357        // Pre-compute which pool names have the ExclusiveAccess scope, for scope-aware early
358        // return gating.
359        let exclusive_access_pool_names: HashSet<String> = self
360            .solver_pools
361            .iter()
362            .filter(|p| p.liquidity_scope() == LiquidityScope::All)
363            .map(|p| p.name().to_string())
364            .collect();
365        let has_exclusive_access_pool = !exclusive_access_pool_names.is_empty();
366
367        let mut quotes = Vec::new();
368        let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
369        let mut remaining_pools: HashSet<String> = self
370            .solver_pools
371            .iter()
372            .map(|p| p.name().to_string())
373            .collect();
374        let mut has_public_response = false;
375        let mut has_exclusive_access_response = false;
376
377        // Collect responses with timeout
378        loop {
379            let deadline_instant = tokio::time::Instant::from_std(deadline);
380
381            tokio::select! {
382                // Always checks timeout first, ensuring we respect the deadline
383                biased;
384
385                // Timeout reached
386                _ = tokio::time::sleep_until(deadline_instant) => {
387                    // Mark all remaining pools as timed out
388                    let elapsed_ms = deadline.saturating_duration_since(Instant::now())
389                        .as_millis() as u64;
390                    for pool_name in remaining_pools.drain() {
391                        failed_solvers.push((
392                            pool_name,
393                            SolveError::Timeout { elapsed_ms },
394                        ));
395                    }
396                    break;
397                }
398
399                // Response received
400                result = pending.next() => {
401                    match result {
402                        Some((pool_name, Ok(single_quote))) => {
403                            // Remove from remaining
404                            remaining_pools.remove(&pool_name);
405
406                            if exclusive_access_pool_names.contains(&pool_name) {
407                                has_exclusive_access_response = true;
408                            } else {
409                                has_public_response = true;
410                            }
411
412                            // Extract the OrderQuote from SingleOrderQuote
413                            quotes.push((pool_name.clone(), single_quote.order().clone()));
414
415                            // Scope-aware early return: when an exclusive-access pool is
416                            // configured, only fire once we have ≥1 public AND the
417                            // exclusive-access pool (so the surplus overlay has both inputs).
418                            // Without an exclusive-access pool, use pure
419                            // count-based gating (original behaviour).
420                            let scope_ready = if has_exclusive_access_pool {
421                                has_public_response && has_exclusive_access_response
422                            } else {
423                                true
424                            };
425                            if min_responses > 0
426                                && quotes.len() >= min_responses
427                                && scope_ready
428                            {
429                                debug!(
430                                    order_id = %order_id,
431                                    responses = quotes.len(),
432                                    min_responses,
433                                    "early return: min_responses reached"
434                                );
435                                counter!("worker_router_early_returns_total").increment(1);
436                                break;
437                            }
438                        }
439                        Some((pool_name, Err(e))) => {
440                            remaining_pools.remove(&pool_name);
441                            // A failed exclusive-access pool still counts as "responded" for
442                            // gating — we know it won't produce a surplus quote, so the public
443                            // pool can
444                            // early-return without waiting for a result that will never come.
445                            if exclusive_access_pool_names.contains(&pool_name) {
446                                has_exclusive_access_response = true;
447                            }
448                            debug!(
449                                pool = %pool_name,
450                                order_id = %order_id,
451                                error = %e,
452                                "solver pool failed"
453                            );
454                            failed_solvers.push((pool_name, e));
455                        }
456                        None => {
457                            // All futures completed
458                            break;
459                        }
460                    }
461                }
462            }
463        }
464
465        // Record metrics
466        let duration = start_time.elapsed().as_secs_f64();
467        histogram!("worker_router_solve_duration_seconds").record(duration);
468        histogram!("worker_router_solver_responses").record(quotes.len() as f64);
469
470        // Record failures by pool and error type
471        for (pool_name, error) in &failed_solvers {
472            let error_type = match error {
473                SolveError::Timeout { .. } => "timeout",
474                SolveError::NoRouteFound { .. } => "no_route",
475                SolveError::QueueFull => "queue_full",
476                SolveError::Internal(_) => "internal",
477                SolveError::PriceCheckFailed { .. } => "price_check_failed",
478                _ => "other",
479            };
480            counter!("worker_router_solver_failures_total", "pool" => pool_name.clone(), "error_type" => error_type).increment(1);
481        }
482
483        if !failed_solvers.is_empty() {
484            let timeout_count = failed_solvers
485                .iter()
486                .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
487                .count();
488            let other_count = failed_solvers.len() - timeout_count;
489            warn!(
490                order_id = %order_id,
491                timeout_count,
492                other_failures = other_count,
493                "some solver pools failed"
494            );
495        }
496
497        OrderResponses { order_id, quotes, failed_solvers }
498    }
499
500    /// Returns all valid quotes for an order, ranked by `amount_out_net_gas` descending.
501    ///
502    /// If no valid quotes exist, returns a single-element vec with a placeholder
503    /// (`NoRouteFound` or `Timeout`) so that downstream always has at least one
504    /// candidate per order.
505    fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
506        let mut valid_quotes: Vec<_> = responses
507            .quotes
508            .iter()
509            .filter(|(_, q)| q.status() == QuoteStatus::Success)
510            .filter(|(_, q)| {
511                options
512                    .max_gas()
513                    .map(|max| q.gas_estimate() <= max)
514                    .unwrap_or(true)
515            })
516            .collect();
517
518        // Sort descending by amount_out_net_gas
519        valid_quotes.sort_by(|(_, a), (_, b)| {
520            b.amount_out_net_gas()
521                .cmp(a.amount_out_net_gas())
522        });
523
524        if !valid_quotes.is_empty() {
525            counter!("worker_router_orders_total", "status" => "success").increment(1);
526            let (pool_name, best) = valid_quotes[0];
527            counter!("worker_router_best_quote_pool", "pool" => pool_name.clone()).increment(1);
528            debug!(
529                order_id = %best.order_id(),
530                number_of_candidates = valid_quotes.len(),
531                "ranked quotes"
532            );
533            return valid_quotes
534                .into_iter()
535                .map(|(_, q)| q.clone())
536                .collect();
537        }
538
539        // No valid quote found - return a NoRouteFound response
540        // Try to get any response to extract block info, or create a placeholder
541        let fallback = if let Some((_, any_q)) = responses.quotes.first() {
542            counter!("worker_router_orders_total", "status" => "no_route").increment(1);
543            let mut fallback = OrderQuote::new(
544                responses.order_id.clone(),
545                QuoteStatus::NoRouteFound,
546                any_q.amount_in().clone(),
547                BigUint::ZERO,
548                BigUint::ZERO,
549                BigUint::ZERO,
550                any_q.block().clone(),
551                String::new(),
552                any_q.sender().clone(),
553                any_q.receiver().clone(),
554                any_q.solved_against().clone(),
555            );
556            // Only label the cause when a quote is actually over the request's
557            // max_gas, so a future filter or non-Success status cannot silently
558            // get attributed to the gas cap.
559            let over_max_gas = responses.quotes.iter().any(|(_, q)| {
560                options
561                    .max_gas()
562                    .is_some_and(|max| q.gas_estimate() > max)
563            });
564            fallback.set_no_route_cause(over_max_gas.then_some(SolveError::MaxGasExceeded));
565            fallback
566        } else {
567            // No responses at all - determine status from failure types
568            let status = if responses.failed_solvers.is_empty() {
569                QuoteStatus::NoRouteFound
570            } else {
571                // If all failures are timeouts, report as Timeout
572                // Otherwise report as NoRouteFound (more general failure)
573                let all_timeouts = responses
574                    .failed_solvers
575                    .iter()
576                    .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
577                let all_not_ready = responses
578                    .failed_solvers
579                    .iter()
580                    .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
581                if all_timeouts {
582                    QuoteStatus::Timeout
583                } else if all_not_ready {
584                    QuoteStatus::NotReady
585                } else {
586                    QuoteStatus::NoRouteFound
587                }
588            };
589
590            // Record status metric
591            let status_label = match status {
592                QuoteStatus::Timeout => "timeout",
593                QuoteStatus::NotReady => "not_ready",
594                _ => "no_route",
595            };
596            counter!("worker_router_orders_total", "status" => status_label).increment(1);
597
598            // No worker responded — use the requested label if set, otherwise "0"
599            // (we have no block context here since no worker completed).
600            let label = options
601                .state_label()
602                .cloned()
603                .unwrap_or_else(|| "0".to_string());
604            let mut fallback = OrderQuote::new(
605                responses.order_id.clone(),
606                status,
607                BigUint::ZERO,
608                BigUint::ZERO,
609                BigUint::ZERO,
610                BigUint::ZERO,
611                BlockInfo::new(0, String::new(), 0),
612                String::new(),
613                Bytes::default(),
614                Bytes::default(),
615                label,
616            );
617            fallback.set_no_route_cause(aggregate_no_route_cause(&responses.failed_solvers));
618            fallback
619        };
620        vec![fallback]
621    }
622
623    /// Returns the effective timeout for a request.
624    fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
625        options
626            .timeout_ms()
627            .map(Duration::from_millis)
628            .unwrap_or(self.config.default_timeout())
629    }
630}
631
632/// Builds the final ranked quote list for one order by deciding whether an exclusive-access
633/// route should execute instead of the best public route.
634///
635/// Inputs: `public_ranked` is the ranking of public-pool quotes from `rank_quotes`; its head is
636/// the public reference from which the committed amount is derived. `responses` additionally
637/// holds the candidates from `ExclusiveAccess`-scoped pools (routes that may use exclusive
638/// components).
639///
640/// The committed amount is the larger of two lower bounds:
641/// `max(public_amount_out, public_net + exclusive_gas)`. The first guarantees the quoted
642/// `amount_out` is never below the public market's; the second guarantees the user — who pays
643/// the exclusive route's gas — nets at least what the public route would leave them.
644///
645/// Which bound is larger depends on the gas comparison:
646/// - `exclusive_gas > public_gas`: committed = `public_net + exclusive_gas`, which exceeds
647///   `public_amount_out`. The user receives more tokens than the public quote and nets exactly
648///   `public_net`.
649/// - `exclusive_gas <= public_gas`: committed = `public_amount_out`. The user nets
650///   `public_amount_out − exclusive_gas`, which exceeds `public_net` by the gas difference. A
651///   commitment of `public_net + exclusive_gas` would still leave the user whole and capture more,
652///   but it is below `public_amount_out` — quoting less than the public market is ruled out, so the
653///   gas difference stays with the user.
654///
655/// If the best exclusive-access candidate beats the public reference net-of-gas and produces at
656/// least the committed amount, this returns a new list whose head is the pinned surplus quote,
657/// followed by every public candidate as price-guard fallbacks. The pinned quote is the winning
658/// candidate with:
659/// - `amount_out` pinned to the committed amount,
660/// - `Swap::committed_amount_out` set on each exclusive leg (consumed by the encoder), and
661/// - an order-level [`SurplusInfo`] attached (observability).
662///
663/// Otherwise `public_ranked` is returned unchanged. Either way the user is never worse off than
664/// the public market — neither in quoted `amount_out` nor net of gas.
665///
666/// Per-leg attribution: the route's excess over the committed amount (`realized − committed`)
667/// is deducted from the exclusive legs — each leg absorbs what it can, capped at its own output,
668/// in route order. Only exclusive legs can withhold output, so the whole
669/// excess must come out of them; public branches pay out in full. If the exclusive legs cannot
670/// absorb all of it, the remainder is left with the user (who then receives more than the
671/// committed amount).
672///
673/// Exact-in orders only: the commitment, both gates, and the surplus are all denominated in
674/// `amount_out`. Exact-out support would invert the logic — fixed output, commitment and surplus
675/// on the input side — and needs its own treatment here.
676fn combine_with_surplus(
677    responses: &OrderResponses,
678    pool_scopes: &HashMap<String, LiquidityScope>,
679    options: &QuoteOptions,
680    public_ranked: Vec<OrderQuote>,
681    exclusivity_policy: Option<&ExclusivityPolicy>,
682) -> Vec<OrderQuote> {
683    let Some(policy) = exclusivity_policy else {
684        return public_ranked;
685    };
686
687    let committed = match public_ranked.first() {
688        Some(q) if q.status() == QuoteStatus::Success => q,
689        _ => return public_ranked,
690    };
691
692    // Find the best exclusive-access candidate, respecting max_gas and route shape constraints.
693    let best_exclusive_access_candidate = responses
694        .quotes
695        .iter()
696        .filter(|(pool, _)| pool_scopes.get(pool) == Some(&LiquidityScope::All))
697        .filter(|(_, q)| q.status() == QuoteStatus::Success)
698        .filter(|(_, q)| {
699            options
700                .max_gas()
701                .map(|max| q.gas_estimate() <= max)
702                .unwrap_or(true)
703        })
704        .filter(|(_, q)| has_valid_exclusive_route(q, policy))
705        .max_by(|(_, a), (_, b)| {
706            a.amount_out_net_gas()
707                .cmp(b.amount_out_net_gas())
708        })
709        .map(|(_, q)| q);
710
711    let Some(exclusive_candidate) = best_exclusive_access_candidate else {
712        return public_ranked;
713    };
714
715    // The candidate route must beat the committed reference net-of-gas.
716    if exclusive_candidate.amount_out_net_gas() <= committed.amount_out_net_gas() {
717        return public_ranked;
718    }
719
720    // Exact-in assumption: everything below compares and commits output amounts. For exact-out
721    // orders this comparison would have to run on amount_in instead (see function docs).
722    let exclusive_route_amount_out = exclusive_candidate.amount_out();
723    let public_amount_out = committed.amount_out();
724
725    // We promise the user at least the public route's output. A private route that produces
726    // less can't keep that promise, so we skip it — even when its gas savings make it better
727    // net.
728    if exclusive_route_amount_out < public_amount_out {
729        return public_ranked;
730    }
731
732    // Gas the user pays to execute the exclusive route, in output-token terms.
733    let exclusive_gas_cost = exclusive_route_amount_out - exclusive_candidate.amount_out_net_gas();
734
735    // The commitment is the larger of two lower bounds: the quoted amount_out is never below
736    // the public market's, and the user — who pays the exclusive route's gas — never nets less
737    // than the public route would leave them (public_net + gas). Together with the strict net
738    // check above, these gates guarantee the route covers the committed amount.
739    let committed_amount_out =
740        (committed.amount_out_net_gas() + &exclusive_gas_cost).max(public_amount_out.clone());
741
742    // What the exclusive components capture: everything the route produces above the commitment.
743    let surplus_amount = exclusive_route_amount_out - &committed_amount_out;
744
745    // Pin the winning candidate: stamp per-leg committed amounts, pin amount_out to committed,
746    // attach SurplusInfo.
747    let mut surplus_quote = exclusive_candidate.clone();
748
749    // Final output of each swap's path, walked backwards (a path's terminal output propagates
750    // to its chained predecessors). Converting captured excess into a leg's token needs the
751    // realized downstream price `path_final_out / leg_out`; for terminal legs the ratio is 1.
752    let path_final_outs: Vec<BigUint> = surplus_quote
753        .route()
754        .map(|route| {
755            let swaps = route.swaps();
756            let mut finals = vec![BigUint::ZERO; swaps.len()];
757            let mut current_final = BigUint::ZERO;
758            for i in (0..swaps.len()).rev() {
759                let is_terminal =
760                    i == swaps.len() - 1 || swaps[i + 1].token_in() != swaps[i].token_out();
761                if is_terminal {
762                    current_final = swaps[i].amount_out().clone();
763                }
764                finals[i] = current_final.clone();
765            }
766            finals
767        })
768        .unwrap_or_default();
769
770    if let Some(route) = surplus_quote.route_mut() {
771        // Capture the route's excess at the exclusive legs.
772        // Each leg absorbs up to its path's capacity; any remainder is left with the user.
773        let mut excess = exclusive_route_amount_out - &committed_amount_out;
774        for (i, swap) in route.swaps_mut().iter_mut().enumerate() {
775            if policy.is_exclusive(swap.protocol_component()) {
776                let Some(path_final_out) = path_final_outs.get(i) else {
777                    continue;
778                };
779                let captured = excess
780                    .clone()
781                    .min(path_final_out.clone());
782                // Convert into the leg's own token, rounding down so any error is taken
783                // from the protocol, not the user. Today the leg is always the last hop of
784                // its path (validator), so path_final_out equals the leg's output and this
785                // divides by itself — a plain subtraction. Once mid-path legs are allowed,
786                // the "user gets at least the committed amount" guarantee also requires the
787                // pools after the leg to have diminishing returns.
788                let captured_leg = if *path_final_out == BigUint::ZERO {
789                    BigUint::ZERO
790                } else {
791                    &captured * swap.amount_out() / path_final_out
792                };
793                debug_assert!(
794                    captured_leg <= *swap.amount_out(),
795                    "captured amount ({captured_leg}) must not exceed the leg's output ({})",
796                    swap.amount_out(),
797                );
798                let committed_leg = swap.amount_out() - &captured_leg;
799                excess -= captured;
800                swap.set_committed_amount_out(committed_leg);
801            }
802        }
803    }
804
805    surplus_quote.set_amount_out(committed_amount_out.clone());
806
807    // The user nets the committed amount minus the exclusive route's gas; by construction of
808    // the committed amount this is >= the public head's net, so the returned list stays ranked
809    // descending.
810    surplus_quote.set_amount_out_net_gas(&committed_amount_out - &exclusive_gas_cost);
811
812    let surplus_info = SurplusInfo::new(surplus_amount, committed_amount_out);
813    surplus_quote = surplus_quote.with_surplus(surplus_info);
814
815    let mut result = Vec::with_capacity(public_ranked.len() + 1);
816    result.push(surplus_quote);
817    result.extend(public_ranked);
818    result
819}
820
821/// Returns `true` only for routes carrying exactly one exclusive leg, positioned as the terminal
822/// leg of its path.
823///
824/// Returns `false` for routes with no exclusive leg, more than one exclusive leg, an empty
825/// route, no route at all, or an exclusive leg that sits mid-path.
826///
827/// Both constraints are v1 restrictions that keep per-leg surplus attribution exact and
828/// unambiguous: a mid-route leg would need inverse simulation to convert the excess into its
829/// token, and multiple exclusive legs make the per-pool attribution non-unique. Both are
830/// deferred to a future version. Path boundaries are detected by checking whether the next
831/// swap's `token_in` differs from the current swap's `token_out` — correct for Fynd's sequential
832/// route representation, but would need revisiting if routes gain explicit path-boundary markers.
833fn has_valid_exclusive_route(quote: &OrderQuote, policy: &ExclusivityPolicy) -> bool {
834    let Some(route) = quote.route() else {
835        return false;
836    };
837
838    let swaps = route.swaps();
839    if swaps.is_empty() {
840        return false;
841    }
842
843    let mut exclusive_count = 0;
844
845    for (i, swap) in swaps.iter().enumerate() {
846        if !policy.is_exclusive(swap.protocol_component()) {
847            continue;
848        }
849
850        // A swap is terminal if it's the last swap or the next swap starts a new path
851        // (its token_in doesn't match this swap's token_out).
852        let is_terminal = i == swaps.len() - 1 || swaps[i + 1].token_in() != swap.token_out();
853
854        if !is_terminal {
855            return false;
856        }
857        exclusive_count += 1;
858    }
859
860    exclusive_count == 1
861}
862
863/// Shared-graph facts beat path-specific reasons (most specific first:
864/// amount-too-small, no-scorable-paths, no-graph-path), which beat liquidity,
865/// data, algorithm, and infrastructure errors; the first error wins within a
866/// tier.
867fn aggregate_no_route_cause(failed_solvers: &[(String, SolveError)]) -> Option<SolveError> {
868    failed_solvers
869        .iter()
870        .map(|(_, error)| error)
871        .min_by_key(|error| cause_tier(error))
872        .cloned()
873}
874
875fn cause_tier(error: &SolveError) -> u8 {
876    use crate::algorithm::NoPathReason;
877    match error {
878        SolveError::NoRouteFound { reason: Some(reason), .. } => match reason {
879            NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => 0,
880            NoPathReason::AmountTooSmall => 1,
881            NoPathReason::NoScorablePaths => 2,
882            NoPathReason::NoGraphPath => 3,
883        },
884        SolveError::InsufficientLiquidity { .. } | SolveError::MaxGasExceeded => 2,
885        SolveError::MissingData(_) |
886        SolveError::MarketDataStale { .. } |
887        SolveError::ComputationFailed(_) |
888        SolveError::NotReady(_) => 3,
889        SolveError::SimulationFailed(_) | SolveError::AlgorithmError(_) => 4,
890        SolveError::Timeout { .. } |
891        SolveError::QueueFull |
892        SolveError::Internal(_) |
893        SolveError::InvalidOrder(_) |
894        SolveError::FailedEncoding(_) |
895        SolveError::EncodingUnavailable(_) |
896        SolveError::PriceCheckFailed { .. } => 5,
897        SolveError::NoRouteFound { reason: None, .. } => 6,
898    }
899}
900
901fn refine_gas_estimates(
902    order_responses: &mut Vec<OrderResponses>,
903    encoding_options: &EncodingOptions,
904) -> Result<(), SolveError> {
905    for responses in order_responses {
906        for (_, quote) in &mut responses.quotes {
907            if quote.status() != QuoteStatus::Success {
908                continue;
909            }
910            let solution = Solution::try_from(&*quote)?
911                .with_user_transfer_type(encoding_options.transfer_type().clone());
912            let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
913            let naive_gas = quote.gas_estimate().clone();
914            if naive_gas > BigUint::ZERO {
915                let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
916                let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
917                let new_net = if new_gas_cost <= *quote.amount_out() {
918                    quote.amount_out() - &new_gas_cost
919                } else {
920                    BigUint::ZERO
921                };
922                quote.set_amount_out_net_gas(new_net);
923                quote.set_gas_estimate(refined_gas);
924            }
925        }
926    }
927    Ok(())
928}
929
930fn derive_strategy(quote: &OrderQuote) -> Strategy {
931    let Some(route) = quote.route() else { return Strategy::Single };
932    let swaps = route.swaps();
933    if swaps.len() == 1 {
934        Strategy::Single
935    } else if swaps.iter().any(|s| *s.split() > 0.0) {
936        Strategy::Split
937    } else {
938        Strategy::Sequential
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use std::collections::HashMap;
945
946    use rstest::rstest;
947    use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
948    use tycho_simulation::{
949        tycho_common::models::Chain,
950        tycho_core::{
951            models::{token::Token, Address, Chain as SimChain},
952            Bytes,
953        },
954    };
955
956    use super::*;
957    use crate::{
958        algorithm::test_utils::{component, MockProtocolSim},
959        types::internal::SolveTask,
960        EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
961    };
962
963    fn default_encoder() -> Encoder {
964        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
965            .add_default_encoders(None)
966            .expect("default encoders should always succeed");
967        let encoder =
968            Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
969        // Load fees so encoding can run; the fetcher supplies on-chain values in production.
970        encoder
971            .router_fees()
972            .set(crate::encoding::router_fees::RouterFees::new(
973                100_000_000,
974                100_000,
975                20_000_000,
976                std::collections::HashMap::new(),
977            ));
978        encoder
979    }
980
981    fn make_address(byte: u8) -> Address {
982        Address::from([byte; 20])
983    }
984
985    fn make_order() -> Order {
986        Order::new(
987            make_address(0x01),
988            make_address(0x02),
989            BigUint::from(1000u64),
990            OrderSide::Sell,
991            make_address(0xAA),
992        )
993        .with_id("test-order".to_string())
994    }
995
996    fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
997        let make_token = |addr: Address| Token {
998            address: addr,
999            symbol: "T".to_string(),
1000            decimals: 18,
1001            tax: Default::default(),
1002            gas: vec![],
1003            chain: SimChain::Ethereum,
1004            quality: 100,
1005        };
1006        let tin = make_address(0x01);
1007        let tout = make_address(0x02);
1008        let tin_token = make_token(tin.clone());
1009        let tout_token = make_token(tout.clone());
1010        let swap = Swap::new(
1011            "pool-1".to_string(),
1012            "uniswap_v2".to_string(),
1013            tin.clone(),
1014            tout.clone(),
1015            BigUint::from(1000u64),
1016            BigUint::from(990u64),
1017            BigUint::from(50_000u64),
1018            component(
1019                "0x0000000000000000000000000000000000000001",
1020                &[tin_token.clone(), tout_token.clone()],
1021            ),
1022            Box::new(MockProtocolSim::default()),
1023        );
1024        let mut tokens = HashMap::new();
1025        tokens.insert(tin, tin_token);
1026        tokens.insert(tout, tout_token);
1027        let quote = OrderQuote::new(
1028            "test-order".to_string(),
1029            QuoteStatus::Success,
1030            BigUint::from(1000u64),
1031            BigUint::from(990u64),
1032            BigUint::from(100_000u64),
1033            BigUint::from(amount_out_net_gas),
1034            BlockInfo::new(1, "0x123".to_string(), 1000),
1035            "test".to_string(),
1036            Bytes::from(make_address(0xAA).as_ref()),
1037            Bytes::from(make_address(0xAA).as_ref()),
1038            "1".to_string(),
1039        )
1040        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1041        SingleOrderQuote::new(quote, 5)
1042    }
1043
1044    // Helper to create a mock solver pool that responds with a given solution
1045    fn create_mock_pool(
1046        name: &str,
1047        response: Result<SingleOrderQuote, SolveError>,
1048        delay_ms: u64,
1049    ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
1050        let (tx, rx) = async_channel::bounded::<SolveTask>(10);
1051        let handle = TaskQueueHandle::from_sender(tx);
1052
1053        let worker = tokio::spawn(async move {
1054            while let Ok(task) = rx.recv().await {
1055                if delay_ms > 0 {
1056                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1057                }
1058                task.respond(response.clone());
1059            }
1060        });
1061
1062        (SolverPoolHandle::new(name, handle), worker)
1063    }
1064
1065    #[test]
1066    fn test_config_default() {
1067        let config = WorkerPoolRouterConfig::default();
1068        assert_eq!(config.default_timeout(), Duration::from_secs(1));
1069        assert_eq!(config.min_responses(), 1);
1070    }
1071
1072    #[test]
1073    fn test_config_builder() {
1074        let config = WorkerPoolRouterConfig::default()
1075            .with_timeout(Duration::from_millis(500))
1076            .with_min_responses(2);
1077        assert_eq!(config.default_timeout(), Duration::from_millis(500));
1078        assert_eq!(config.min_responses(), 2);
1079    }
1080
1081    #[tokio::test]
1082    async fn test_router_no_pools() {
1083        let worker_router =
1084            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1085        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1086
1087        let result = worker_router.quote(request).await;
1088        assert!(matches!(result, Err(SolveError::Internal(_))));
1089    }
1090
1091    #[tokio::test]
1092    async fn test_router_single_pool_success() {
1093        let (pool, worker) = create_mock_pool("pool_a", Ok(make_single_quote(900)), 0);
1094
1095        let worker_router =
1096            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1097        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1098        let request = QuoteRequest::new(vec![make_order()], options);
1099
1100        let result = worker_router.quote(request).await;
1101        assert!(result.is_ok());
1102
1103        let quote = result.unwrap();
1104        assert_eq!(quote.orders().len(), 1);
1105        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1106        // amount_out_net_gas is refined using estimate_gas_usage before ranking
1107        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
1108        assert!(!quote.orders()[0]
1109            .transaction()
1110            .unwrap()
1111            .data()
1112            .is_empty());
1113
1114        drop(worker_router);
1115        worker.abort();
1116    }
1117
1118    #[tokio::test]
1119    async fn test_router_selects_best_of_two() {
1120        // Pool A: worse quote (net gas = 800)
1121        let (pool_a, worker_a) = create_mock_pool("pool_a", Ok(make_single_quote(800)), 0);
1122        // Pool B: better quote (net gas = 950)
1123        let (pool_b, worker_b) = create_mock_pool("pool_b", Ok(make_single_quote(950)), 0);
1124
1125        // Wait for both responses to test best selection logic
1126        let config = WorkerPoolRouterConfig::default().with_min_responses(2);
1127        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1128        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1129        let request = QuoteRequest::new(vec![make_order()], options);
1130
1131        let result = worker_router.quote(request).await;
1132        assert!(result.is_ok());
1133
1134        let quote = result.unwrap();
1135        assert_eq!(quote.orders().len(), 1);
1136        // Pool B wins (higher refined amount_out_net_gas after estimate_gas_usage)
1137        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
1138        assert!(!quote.orders()[0]
1139            .transaction()
1140            .unwrap()
1141            .data()
1142            .is_empty());
1143
1144        drop(worker_router);
1145        worker_a.abort();
1146        worker_b.abort();
1147    }
1148
1149    #[tokio::test]
1150    async fn test_router_timeout() {
1151        // Pool that takes too long
1152        let (pool, worker) = create_mock_pool("slow_pool", Ok(make_single_quote(900)), 500);
1153
1154        let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
1155        let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
1156        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1157
1158        let result = worker_router.quote(request).await;
1159        assert!(result.is_ok());
1160
1161        let quote = result.unwrap();
1162        // Should timeout and return NoRouteFound or Timeout status
1163        assert_eq!(quote.orders().len(), 1);
1164        assert!(matches!(
1165            quote.orders()[0].status(),
1166            QuoteStatus::Timeout | QuoteStatus::NoRouteFound
1167        ));
1168
1169        drop(worker_router);
1170        worker.abort();
1171    }
1172
1173    #[tokio::test]
1174    async fn test_router_early_return_on_min_responses() {
1175        // Pool A: fast
1176        let (pool_a, worker_a) = create_mock_pool("fast_pool", Ok(make_single_quote(800)), 0);
1177        // Pool B: slow (but we won't wait for it)
1178        let (pool_b, worker_b) = create_mock_pool("slow_pool", Ok(make_single_quote(950)), 500);
1179
1180        let config = WorkerPoolRouterConfig::default()
1181            .with_timeout(Duration::from_millis(1000))
1182            .with_min_responses(1);
1183        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1184
1185        let start = Instant::now();
1186        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1187        let request = QuoteRequest::new(vec![make_order()], options);
1188
1189        let result = worker_router.quote(request).await;
1190        let elapsed = start.elapsed();
1191
1192        assert!(result.is_ok());
1193        // Should return quickly (not waiting for pool_b)
1194        assert!(elapsed < Duration::from_millis(200));
1195
1196        // Should have pool_a's quote
1197        let quote = result.unwrap();
1198        assert_eq!(quote.orders().len(), 1);
1199        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1200        // Should have encoding
1201        assert!(!quote.orders()[0]
1202            .transaction()
1203            .unwrap()
1204            .data()
1205            .is_empty());
1206
1207        drop(worker_router);
1208        worker_a.abort();
1209        worker_b.abort();
1210    }
1211
1212    #[rstest]
1213    #[case::pending_exclusive_pool(0, Some(300), true)]
1214    #[case::pending_public_pool(300, Some(0), true)]
1215    #[case::failed_exclusive_pool(0, None, false)]
1216    #[tokio::test]
1217    async fn test_router_early_return_scope_gating(
1218        #[case] public_delay_ms: u64,
1219        #[case] exclusive_delay_ms: Option<u64>,
1220        #[case] expect_surplus: bool,
1221    ) {
1222        let (public_pool, public_worker) =
1223            create_mock_pool("public_pool", Ok(make_single_quote(800)), public_delay_ms);
1224        let exclusive_response = match exclusive_delay_ms {
1225            Some(_) => Ok(make_exclusive_quote(1100)),
1226            None => {
1227                Err(SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None })
1228            }
1229        };
1230        let (exclusive_pool, exclusive_worker) =
1231            create_mock_pool("exclusive_pool", exclusive_response, exclusive_delay_ms.unwrap_or(0));
1232        let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::All);
1233
1234        let config = WorkerPoolRouterConfig::default()
1235            .with_timeout(Duration::from_millis(2000))
1236            .with_min_responses(1);
1237        let worker_router =
1238            WorkerPoolRouter::new(vec![public_pool, exclusive_pool], config, default_encoder())
1239                .with_exclusivity_policy(exclusive_policy());
1240        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1241
1242        let start = Instant::now();
1243        let result = worker_router
1244            .quote(request)
1245            .await
1246            .expect("quote should succeed");
1247        let elapsed = start.elapsed();
1248
1249        // Well under the 2s timeout: the gate releases as soon as both scopes have responded.
1250        assert!(elapsed < Duration::from_millis(500), "took {elapsed:?}");
1251        let order = &result.orders()[0];
1252        assert_eq!(order.status(), QuoteStatus::Success);
1253        assert_eq!(*order.amount_out(), BigUint::from(990u64));
1254        assert_eq!(order.surplus_amount().is_some(), expect_surplus);
1255
1256        drop(worker_router);
1257        public_worker.abort();
1258        exclusive_worker.abort();
1259    }
1260
1261    #[rstest]
1262    #[case::under_limit(100, Some(200), true)]
1263    #[case::at_limit(200, Some(200), true)]
1264    #[case::over_limit(300, Some(200), false)]
1265    #[case::no_limit(500, None, true)]
1266    fn test_max_gas_constraint(
1267        #[case] gas_estimate: u64,
1268        #[case] max_gas: Option<u64>,
1269        #[case] should_pass: bool,
1270    ) {
1271        let responses = OrderResponses {
1272            order_id: "test".to_string(),
1273            quotes: vec![(
1274                "pool".to_string(),
1275                OrderQuote::new(
1276                    "test".to_string(),
1277                    QuoteStatus::Success,
1278                    BigUint::from(1000u64),
1279                    BigUint::from(990u64),
1280                    BigUint::from(gas_estimate),
1281                    BigUint::from(900u64),
1282                    BlockInfo::new(1, "0x123".to_string(), 1000),
1283                    "test".to_string(),
1284                    Bytes::from(make_address(0xAA).as_ref()),
1285                    Bytes::from(make_address(0xAA).as_ref()),
1286                    "1".to_string(),
1287                ),
1288            )],
1289            failed_solvers: vec![],
1290        };
1291
1292        let options = match max_gas {
1293            Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
1294            None => QuoteOptions::default(),
1295        };
1296
1297        let worker_router =
1298            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1299        let result = worker_router.rank_quotes(&responses, &options);
1300
1301        if should_pass {
1302            assert_eq!(result[0].status(), QuoteStatus::Success);
1303        } else {
1304            assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1305        }
1306    }
1307
1308    #[tokio::test]
1309    async fn test_router_captures_solver_errors() {
1310        // Pool that returns an error
1311        let (pool, worker) =
1312            create_mock_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
1313
1314        let worker_router =
1315            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1316        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1317
1318        let result = worker_router.quote(request).await;
1319        assert!(result.is_ok());
1320
1321        let quote = result.unwrap();
1322        assert_eq!(quote.orders().len(), 1);
1323        // Should be NoRouteFound since the only solver returned an error
1324        assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
1325
1326        drop(worker_router);
1327        worker.abort();
1328    }
1329
1330    #[test]
1331    fn test_rank_quotes_all_timeouts_returns_timeout_status() {
1332        let responses = OrderResponses {
1333            order_id: "test".to_string(),
1334            quotes: vec![],
1335            failed_solvers: vec![
1336                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1337                ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1338            ],
1339        };
1340
1341        let worker_router =
1342            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1343        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1344
1345        assert_eq!(result.len(), 1);
1346        assert_eq!(result[0].status(), QuoteStatus::Timeout);
1347    }
1348
1349    #[test]
1350    fn test_rank_quotes_mixed_failures_returns_no_route_found() {
1351        let responses = OrderResponses {
1352            order_id: "test".to_string(),
1353            quotes: vec![],
1354            failed_solvers: vec![
1355                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1356                ("pool_b".to_string(), SolveError::no_route_found("test")),
1357            ],
1358        };
1359
1360        let worker_router =
1361            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1362        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1363
1364        assert_eq!(result.len(), 1);
1365        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1366    }
1367
1368    #[test]
1369    fn test_rank_quotes_no_failures_returns_no_route_found() {
1370        let responses =
1371            OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
1372
1373        let worker_router =
1374            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1375        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1376
1377        assert_eq!(result.len(), 1);
1378        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1379    }
1380
1381    #[test]
1382    fn rank_quotes_attaches_no_route_reason() {
1383        use crate::algorithm::NoPathReason;
1384        let responses = OrderResponses {
1385            order_id: "test".to_string(),
1386            quotes: vec![],
1387            failed_solvers: vec![
1388                (
1389                    "pool_a".to_string(),
1390                    SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1391                ),
1392                (
1393                    "pool_b".to_string(),
1394                    SolveError::no_route_found_with_reason(
1395                        "test",
1396                        NoPathReason::DestinationTokenNotInGraph,
1397                    ),
1398                ),
1399            ],
1400        };
1401        let worker_router =
1402            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1403        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1404        assert_eq!(result.len(), 1);
1405        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1406        // Token-not-in-graph wins over no_graph_path regardless of pool order.
1407        assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
1408    }
1409
1410    #[test]
1411    fn test_rank_quotes_no_route_reason_single_failure() {
1412        use crate::algorithm::NoPathReason;
1413        let responses = OrderResponses {
1414            order_id: "test".to_string(),
1415            quotes: vec![],
1416            failed_solvers: vec![(
1417                "pool_a".to_string(),
1418                SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1419            )],
1420        };
1421        let worker_router =
1422            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1423        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1424        assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
1425    }
1426
1427    #[test]
1428    fn test_aggregate_cause_surfaces_amount_too_small() {
1429        use crate::algorithm::NoPathReason;
1430        let failed = vec![(
1431            "bf".to_string(),
1432            SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1433        )];
1434        assert!(matches!(
1435            aggregate_no_route_cause(&failed),
1436            Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1437        ));
1438    }
1439
1440    #[test]
1441    fn test_aggregate_no_route_cause_independent_of_pool_order() {
1442        use crate::algorithm::NoPathReason;
1443        let dust = || SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall);
1444        let no_path = || SolveError::no_route_found_with_reason("o1", NoPathReason::NoGraphPath);
1445        let forward = vec![("bf1".to_string(), no_path()), ("bf3".to_string(), dust())];
1446        let reversed = vec![("bf3".to_string(), dust()), ("bf1".to_string(), no_path())];
1447        for failed in [forward, reversed] {
1448            assert!(matches!(
1449                aggregate_no_route_cause(&failed),
1450                Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1451            ));
1452        }
1453    }
1454
1455    #[test]
1456    fn test_aggregate_token_not_in_graph_wins_over_amount_too_small() {
1457        use crate::algorithm::NoPathReason;
1458        let failed = vec![
1459            (
1460                "bf".to_string(),
1461                SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1462            ),
1463            (
1464                "ml".to_string(),
1465                SolveError::no_route_found_with_reason(
1466                    "o2",
1467                    NoPathReason::DestinationTokenNotInGraph,
1468                ),
1469            ),
1470        ];
1471        assert!(matches!(
1472            aggregate_no_route_cause(&failed),
1473            Some(SolveError::NoRouteFound {
1474                reason: Some(NoPathReason::DestinationTokenNotInGraph),
1475                ..
1476            })
1477        ));
1478    }
1479
1480    #[test]
1481    fn test_aggregate_prefers_liquidity_over_infra() {
1482        let failed = vec![
1483            ("a".to_string(), SolveError::QueueFull),
1484            ("b".to_string(), SolveError::insufficient_liquidity(1u32.into(), 0u32.into())),
1485        ];
1486        assert!(matches!(
1487            aggregate_no_route_cause(&failed),
1488            Some(SolveError::InsufficientLiquidity { .. })
1489        ));
1490    }
1491
1492    #[test]
1493    fn test_rank_quotes_max_gas_filtered_sets_max_gas_exceeded() {
1494        let responses = OrderResponses {
1495            order_id: "o1".to_string(),
1496            quotes: vec![(
1497                "pool".to_string(),
1498                OrderQuote::new(
1499                    "o1".to_string(),
1500                    QuoteStatus::Success,
1501                    BigUint::from(1_000u64),
1502                    BigUint::from(990u64),
1503                    BigUint::from(100_000u64),
1504                    BigUint::from(990u64),
1505                    BlockInfo::new(1, "0xabc".to_string(), 0),
1506                    "test".to_string(),
1507                    Bytes::default(),
1508                    Bytes::default(),
1509                    "1".to_string(),
1510                ),
1511            )],
1512            failed_solvers: vec![],
1513        };
1514        let options = QuoteOptions::default().with_max_gas(BigUint::from(1u64));
1515        let worker_router =
1516            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1517        let result = worker_router.rank_quotes(&responses, &options);
1518        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1519        assert!(matches!(result[0].no_route_cause(), Some(SolveError::MaxGasExceeded)));
1520    }
1521
1522    #[test]
1523    fn test_rank_quotes_no_cause_when_gas_within_max() {
1524        let responses = OrderResponses {
1525            order_id: "o1".to_string(),
1526            quotes: vec![(
1527                "pool".to_string(),
1528                OrderQuote::new(
1529                    "o1".to_string(),
1530                    QuoteStatus::NoRouteFound,
1531                    BigUint::from(1_000u64),
1532                    BigUint::from(990u64),
1533                    BigUint::from(100u64),
1534                    BigUint::from(990u64),
1535                    BlockInfo::new(1, "0xabc".to_string(), 0),
1536                    "test".to_string(),
1537                    Bytes::default(),
1538                    Bytes::default(),
1539                    "1".to_string(),
1540                ),
1541            )],
1542            failed_solvers: vec![],
1543        };
1544        let options = QuoteOptions::default().with_max_gas(BigUint::from(1_000u64));
1545        let worker_router =
1546            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1547        let result = worker_router.rank_quotes(&responses, &options);
1548        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1549        assert!(
1550            result[0].no_route_cause().is_none(),
1551            "gas within max_gas must not be labelled MaxGasExceeded: {:?}",
1552            result[0].no_route_cause()
1553        );
1554    }
1555
1556    #[test]
1557    fn test_all_timeout_fallback_carries_timeout_cause() {
1558        let responses = OrderResponses {
1559            order_id: "o1".to_string(),
1560            quotes: vec![],
1561            failed_solvers: vec![("pool".to_string(), SolveError::Timeout { elapsed_ms: 9 })],
1562        };
1563        let worker_router =
1564            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1565        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1566        assert_eq!(result[0].status(), QuoteStatus::Timeout);
1567        assert!(matches!(result[0].no_route_cause(), Some(SolveError::Timeout { .. })));
1568    }
1569
1570    #[test]
1571    fn test_rank_quotes_returns_sorted_candidates() {
1572        let responses = OrderResponses {
1573            order_id: "test".to_string(),
1574            quotes: vec![
1575                (
1576                    "pool_a".to_string(),
1577                    OrderQuote::new(
1578                        "test".to_string(),
1579                        QuoteStatus::Success,
1580                        BigUint::from(1000u64),
1581                        BigUint::from(800u64),
1582                        BigUint::from(100_000u64),
1583                        BigUint::from(800u64),
1584                        BlockInfo::new(1, "0x123".to_string(), 1000),
1585                        "test".to_string(),
1586                        Bytes::from(make_address(0xAA).as_ref()),
1587                        Bytes::from(make_address(0xAA).as_ref()),
1588                        "1".to_string(),
1589                    ),
1590                ),
1591                (
1592                    "pool_b".to_string(),
1593                    OrderQuote::new(
1594                        "test".to_string(),
1595                        QuoteStatus::Success,
1596                        BigUint::from(1000u64),
1597                        BigUint::from(950u64),
1598                        BigUint::from(100_000u64),
1599                        BigUint::from(950u64),
1600                        BlockInfo::new(1, "0x123".to_string(), 1000),
1601                        "test".to_string(),
1602                        Bytes::from(make_address(0xAA).as_ref()),
1603                        Bytes::from(make_address(0xAA).as_ref()),
1604                        "1".to_string(),
1605                    ),
1606                ),
1607            ],
1608            failed_solvers: vec![],
1609        };
1610
1611        let worker_router =
1612            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1613        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1614
1615        assert_eq!(result.len(), 2);
1616        assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
1617        assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
1618    }
1619
1620    fn exclusive_policy() -> ExclusivityPolicy {
1621        ExclusivityPolicy::new(|c| c.protocol_system == "vm:exclusive")
1622    }
1623
1624    /// Like `make_single_quote` but the swap uses an exclusive protocol component. The swap's own
1625    /// output is `leg_amount_out`, letting tests exercise per-leg attribution where the leg
1626    /// differs from the route total
1627    fn make_exclusive_quote_with_leg(
1628        amount_out: u64,
1629        amount_out_net_gas: u64,
1630        leg_amount_out: u64,
1631    ) -> SingleOrderQuote {
1632        let make_token = |addr: Address| Token {
1633            address: addr,
1634            symbol: "T".to_string(),
1635            decimals: 18,
1636            tax: Default::default(),
1637            gas: vec![],
1638            chain: SimChain::Ethereum,
1639            quality: 100,
1640        };
1641        let tin = make_address(0x01);
1642        let tout = make_address(0x02);
1643        let tin_token = make_token(tin.clone());
1644        let tout_token = make_token(tout.clone());
1645        let mut comp = component(
1646            "0x0000000000000000000000000000000000000002",
1647            &[tin_token.clone(), tout_token.clone()],
1648        );
1649        comp.protocol_system = "vm:exclusive".to_string();
1650        let swap = Swap::new(
1651            "pool-perm".to_string(),
1652            "vm:exclusive".to_string(),
1653            tin.clone(),
1654            tout.clone(),
1655            BigUint::from(1000u64),
1656            BigUint::from(leg_amount_out),
1657            BigUint::from(50_000u64),
1658            comp,
1659            Box::new(MockProtocolSim::default()),
1660        );
1661        let mut tokens = HashMap::new();
1662        tokens.insert(tin, tin_token);
1663        tokens.insert(tout, tout_token);
1664        let quote = OrderQuote::new(
1665            "test-order".to_string(),
1666            QuoteStatus::Success,
1667            BigUint::from(1000u64),
1668            BigUint::from(amount_out),
1669            BigUint::from(100_000u64),
1670            BigUint::from(amount_out_net_gas),
1671            BlockInfo::new(1, "0x123".to_string(), 1000),
1672            "test".to_string(),
1673            Bytes::from(make_address(0xAA).as_ref()),
1674            Bytes::from(make_address(0xAA).as_ref()),
1675            "1".to_string(),
1676        )
1677        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1678        SingleOrderQuote::new(quote, 5)
1679    }
1680
1681    fn make_exclusive_quote(amount_out: u64) -> SingleOrderQuote {
1682        make_exclusive_quote_with_leg(amount_out, amount_out, amount_out)
1683    }
1684
1685    /// Split route with two parallel branches (same token pair): a public pool producing
1686    /// `public_leg_out` and an exclusive pool producing `exclusive_leg_out`. Route output is the
1687    /// sum of the branches; zero gas cost.
1688    fn make_exclusive_split_quote(public_leg_out: u64, exclusive_leg_out: u64) -> SingleOrderQuote {
1689        let make_token = |addr: Address| Token {
1690            address: addr,
1691            symbol: "T".to_string(),
1692            decimals: 18,
1693            tax: Default::default(),
1694            gas: vec![],
1695            chain: SimChain::Ethereum,
1696            quality: 100,
1697        };
1698        let tin = make_address(0x01);
1699        let tout = make_address(0x02);
1700        let tin_token = make_token(tin.clone());
1701        let tout_token = make_token(tout.clone());
1702        let public_swap = Swap::new(
1703            "pool-pub".to_string(),
1704            "uniswap_v2".to_string(),
1705            tin.clone(),
1706            tout.clone(),
1707            BigUint::from(500u64),
1708            BigUint::from(public_leg_out),
1709            BigUint::from(50_000u64),
1710            component(
1711                "0x0000000000000000000000000000000000000001",
1712                &[tin_token.clone(), tout_token.clone()],
1713            ),
1714            Box::new(MockProtocolSim::default()),
1715        );
1716        let mut exclusive_comp = component(
1717            "0x0000000000000000000000000000000000000002",
1718            &[tin_token.clone(), tout_token.clone()],
1719        );
1720        exclusive_comp.protocol_system = "vm:exclusive".to_string();
1721        let exclusive_swap = Swap::new(
1722            "pool-perm".to_string(),
1723            "vm:exclusive".to_string(),
1724            tin.clone(),
1725            tout.clone(),
1726            BigUint::from(500u64),
1727            BigUint::from(exclusive_leg_out),
1728            BigUint::from(50_000u64),
1729            exclusive_comp,
1730            Box::new(MockProtocolSim::default()),
1731        );
1732        let mut tokens = HashMap::new();
1733        tokens.insert(tin, tin_token);
1734        tokens.insert(tout, tout_token);
1735        let total = public_leg_out + exclusive_leg_out;
1736        let quote = OrderQuote::new(
1737            "test-order".to_string(),
1738            QuoteStatus::Success,
1739            BigUint::from(1000u64),
1740            BigUint::from(total),
1741            BigUint::from(100_000u64),
1742            BigUint::from(total),
1743            BlockInfo::new(1, "0x123".to_string(), 1000),
1744            "test".to_string(),
1745            Bytes::from(make_address(0xAA).as_ref()),
1746            Bytes::from(make_address(0xAA).as_ref()),
1747            "1".to_string(),
1748        )
1749        .with_route(
1750            Route::new(vec![public_swap, exclusive_swap], tokens).expect("non-empty route"),
1751        );
1752        SingleOrderQuote::new(quote, 5)
1753    }
1754
1755    /// Like `make_single_quote` but with a configurable `amount_out_net_gas` so tests can
1756    /// express non-zero gas cost.
1757    fn make_public_quote_with_net(amount_out: u64, amount_out_net_gas: u64) -> SingleOrderQuote {
1758        let make_token = |addr: Address| Token {
1759            address: addr,
1760            symbol: "T".to_string(),
1761            decimals: 18,
1762            tax: Default::default(),
1763            gas: vec![],
1764            chain: SimChain::Ethereum,
1765            quality: 100,
1766        };
1767        let tin = make_address(0x01);
1768        let tout = make_address(0x02);
1769        let tin_token = make_token(tin.clone());
1770        let tout_token = make_token(tout.clone());
1771        let swap = Swap::new(
1772            "pool-1".to_string(),
1773            "uniswap_v2".to_string(),
1774            tin.clone(),
1775            tout.clone(),
1776            BigUint::from(1000u64),
1777            BigUint::from(amount_out),
1778            BigUint::from(50_000u64),
1779            component(
1780                "0x0000000000000000000000000000000000000001",
1781                &[tin_token.clone(), tout_token.clone()],
1782            ),
1783            Box::new(MockProtocolSim::default()),
1784        );
1785        let mut tokens = HashMap::new();
1786        tokens.insert(tin, tin_token);
1787        tokens.insert(tout, tout_token);
1788        let quote = OrderQuote::new(
1789            "test-order".to_string(),
1790            QuoteStatus::Success,
1791            BigUint::from(1000u64),
1792            BigUint::from(amount_out),
1793            BigUint::from(100_000u64),
1794            BigUint::from(amount_out_net_gas),
1795            BlockInfo::new(1, "0x123".to_string(), 1000),
1796            "test".to_string(),
1797            Bytes::from(make_address(0xAA).as_ref()),
1798            Bytes::from(make_address(0xAA).as_ref()),
1799            "1".to_string(),
1800        )
1801        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1802        SingleOrderQuote::new(quote, 5)
1803    }
1804
1805    fn make_public_quote_zero_gas(amount_out: u64) -> SingleOrderQuote {
1806        make_public_quote_with_net(amount_out, amount_out)
1807    }
1808
1809    /// Builds an `OrderResponses` with a public quote and an exclusive-access quote (zero gas
1810    /// cost).
1811    fn exclusive_access_responses(public_out: u64, exclusive_out: u64) -> OrderResponses {
1812        let public = make_public_quote_zero_gas(public_out)
1813            .order()
1814            .clone();
1815        let exclusive_access = make_exclusive_quote(exclusive_out)
1816            .order()
1817            .clone();
1818        OrderResponses {
1819            order_id: "test-order".to_string(),
1820            quotes: vec![
1821                ("public_pool".to_string(), public),
1822                ("exclusive_access_pool".to_string(), exclusive_access),
1823            ],
1824            failed_solvers: vec![],
1825        }
1826    }
1827
1828    fn exclusive_access_pool_scopes() -> HashMap<String, LiquidityScope> {
1829        HashMap::from([
1830            ("public_pool".to_string(), LiquidityScope::PublicOnly),
1831            ("exclusive_access_pool".to_string(), LiquidityScope::All),
1832        ])
1833    }
1834
1835    /// Head selection across the gate case matrix. Each route is given as `(gross, net)`;
1836    /// expected is `(head amount_out, captured surplus)`. A surplus win prepends the pinned
1837    /// quote to the public fallbacks; otherwise the public ranking is returned unchanged.
1838    #[rstest]
1839    #[case::exclusive_beats_public((900, 900), (950, 950), (900, 900, Some(50)))]
1840    #[case::exclusive_below_public((950, 950), (900, 900), (950, 950, None))]
1841    #[case::exclusive_ties_public_net((900, 900), (900, 900), (900, 900, None))]
1842    #[case::exclusive_marginally_better((999, 999), (1000, 1000), (999, 999, Some(1)))]
1843    #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), (1000, 950, None))]
1844    // Gas-heavier exclusive route: the committed amount rises to max(1000, 950 + 140) = 1090 so
1845    // the user still nets the public 950; the protocol captures 1100 - 1090 = 10.
1846    #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), (1090, 950, Some(10)))]
1847    // Gas-cheaper exclusive route: the public amount is the larger bound (committed = 1000);
1848    // the user keeps the
1849    // gas saving (nets 960) and the protocol captures 100.
1850    #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), (1000, 960, Some(100)))]
1851    fn test_combine_head_selection(
1852        #[case] public: (u64, u64),
1853        #[case] exclusive: (u64, u64),
1854        #[case] expected: (u64, u64, Option<u64>),
1855    ) {
1856        let (public_out, public_net) = public;
1857        let (exclusive_out, exclusive_net) = exclusive;
1858        let (expected_amount_out, expected_net, expected_surplus) = expected;
1859
1860        let responses = responses_with_gas(public_out, public_net, exclusive_out, exclusive_net);
1861        let public_ranked = vec![make_public_quote_with_net(public_out, public_net)
1862            .order()
1863            .clone()];
1864        let policy = exclusive_policy();
1865        let combined = combine_with_surplus(
1866            &responses,
1867            &exclusive_access_pool_scopes(),
1868            &QuoteOptions::default(),
1869            public_ranked,
1870            Some(&policy),
1871        );
1872
1873        let expected_surplus = expected_surplus.map(BigUint::from);
1874        assert_eq!(combined.len(), if expected_surplus.is_some() { 2 } else { 1 });
1875        assert_eq!(*combined[0].amount_out(), BigUint::from(expected_amount_out));
1876        assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(expected_net));
1877        assert_eq!(combined[0].surplus_amount(), expected_surplus.as_ref());
1878        if expected_surplus.is_some() {
1879            assert_eq!(
1880                combined[0].committed_amount_out(),
1881                Some(&BigUint::from(expected_amount_out))
1882            );
1883        }
1884    }
1885
1886    #[rstest]
1887    #[case::over_max_gas(
1888        make_exclusive_quote(1100).order().clone(),
1889        Some(50_000)
1890    )]
1891    #[case::mid_route_exclusive_leg(
1892        make_route_quote(&[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)]),
1893        None
1894    )]
1895    fn test_combine_filters_exclusive_candidate(
1896        #[case] exclusive_quote: OrderQuote,
1897        #[case] max_gas: Option<u64>,
1898    ) {
1899        let mut options = QuoteOptions::default();
1900        if let Some(max) = max_gas {
1901            options = options.with_max_gas(BigUint::from(max));
1902        }
1903        let responses = OrderResponses {
1904            order_id: "test-order".to_string(),
1905            quotes: vec![
1906                (
1907                    "public_pool".to_string(),
1908                    make_public_quote_zero_gas(900)
1909                        .order()
1910                        .clone(),
1911                ),
1912                ("exclusive_access_pool".to_string(), exclusive_quote),
1913            ],
1914            failed_solvers: vec![],
1915        };
1916        let public_ranked = vec![make_public_quote_zero_gas(900)
1917            .order()
1918            .clone()];
1919        let policy = exclusive_policy();
1920        let combined = combine_with_surplus(
1921            &responses,
1922            &exclusive_access_pool_scopes(),
1923            &options,
1924            public_ranked,
1925            Some(&policy),
1926        );
1927
1928        assert_eq!(combined.len(), 1);
1929        assert_eq!(*combined[0].amount_out(), BigUint::from(900u64));
1930        assert_eq!(combined[0].surplus_amount(), None);
1931    }
1932
1933    #[test]
1934    fn test_combine_stamps_per_leg_committed_amount_out() {
1935        let responses = exclusive_access_responses(900, 1000);
1936        let public_ranked = vec![make_public_quote_zero_gas(900)
1937            .order()
1938            .clone()];
1939        let policy = exclusive_policy();
1940        let combined = combine_with_surplus(
1941            &responses,
1942            &exclusive_access_pool_scopes(),
1943            &QuoteOptions::default(),
1944            public_ranked,
1945            Some(&policy),
1946        );
1947
1948        let surplus_quote = &combined[0];
1949        let route = surplus_quote
1950            .route()
1951            .expect("surplus quote should have a route");
1952        let perm_swap = route
1953            .swaps()
1954            .iter()
1955            .find(|s| policy.is_exclusive(s.protocol_component()))
1956            .expect("should have an exclusive swap");
1957
1958        // committed_leg = leg.amount_out * committed_route_out / realized_route_out
1959        // = 1000 * 900 / 1000 = 900
1960        assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(900u64)),);
1961    }
1962
1963    #[test]
1964    fn test_combine_committed_leg_deduction() {
1965        // leg = 995, committed = 900, realized = 1000: the route's excess (100) is deducted from
1966        // the exclusive leg in full — committed_leg = 995 − 100 = 895, exactly, no rounding.
1967        let responses = OrderResponses {
1968            order_id: "test-order".to_string(),
1969            quotes: vec![
1970                (
1971                    "public_pool".to_string(),
1972                    make_public_quote_zero_gas(900)
1973                        .order()
1974                        .clone(),
1975                ),
1976                (
1977                    "exclusive_access_pool".to_string(),
1978                    make_exclusive_quote_with_leg(1000, 1000, 995)
1979                        .order()
1980                        .clone(),
1981                ),
1982            ],
1983            failed_solvers: vec![],
1984        };
1985        let public_ranked = vec![make_public_quote_zero_gas(900)
1986            .order()
1987            .clone()];
1988        let policy = exclusive_policy();
1989        let combined = combine_with_surplus(
1990            &responses,
1991            &exclusive_access_pool_scopes(),
1992            &QuoteOptions::default(),
1993            public_ranked,
1994            Some(&policy),
1995        );
1996
1997        let route = combined[0]
1998            .route()
1999            .expect("surplus quote should have a route");
2000        let perm_swap = route
2001            .swaps()
2002            .iter()
2003            .find(|s| policy.is_exclusive(s.protocol_component()))
2004            .expect("should have an exclusive swap");
2005        assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(895u64)));
2006    }
2007
2008    #[test]
2009    fn test_combine_without_exclusivity_policy() {
2010        let responses = exclusive_access_responses(900, 950);
2011        let public_ranked = vec![make_public_quote_zero_gas(900)
2012            .order()
2013            .clone()];
2014        let combined = combine_with_surplus(
2015            &responses,
2016            &exclusive_access_pool_scopes(),
2017            &QuoteOptions::default(),
2018            public_ranked.clone(),
2019            None,
2020        );
2021
2022        assert_eq!(combined.len(), 1);
2023        assert_eq!(combined[0].surplus_amount(), None);
2024    }
2025
2026    #[test]
2027    fn test_combine_split_route_attribution() {
2028        // Split route: public branch 600 + exclusive branch 500 = 1100 realized vs 1000
2029        // committed (zero gas). Only the exclusive leg is stamped; the public branch flows to
2030        // the user untouched.
2031        let responses = OrderResponses {
2032            order_id: "test-order".to_string(),
2033            quotes: vec![
2034                (
2035                    "public_pool".to_string(),
2036                    make_public_quote_zero_gas(1000)
2037                        .order()
2038                        .clone(),
2039                ),
2040                (
2041                    "exclusive_access_pool".to_string(),
2042                    make_exclusive_split_quote(600, 500)
2043                        .order()
2044                        .clone(),
2045                ),
2046            ],
2047            failed_solvers: vec![],
2048        };
2049        let public_ranked = vec![make_public_quote_zero_gas(1000)
2050            .order()
2051            .clone()];
2052        let policy = exclusive_policy();
2053        let combined = combine_with_surplus(
2054            &responses,
2055            &exclusive_access_pool_scopes(),
2056            &QuoteOptions::default(),
2057            public_ranked,
2058            Some(&policy),
2059        );
2060
2061        assert_eq!(*combined[0].amount_out(), BigUint::from(1000u64));
2062        let route = combined[0]
2063            .route()
2064            .expect("surplus quote should have a route");
2065        let public_leg = route
2066            .swaps()
2067            .iter()
2068            .find(|s| !policy.is_exclusive(s.protocol_component()))
2069            .expect("should have a public swap");
2070        assert_eq!(public_leg.committed_amount_out(), None);
2071
2072        // The public branch (600) pays out in full, so the entire excess
2073        // (1100 − 1000 = 100) is deducted from the exclusive leg: committed_leg = 500 − 100 =
2074        // 400. The user receives 600 + 400 = 1000 (exactly the committed amount) and the
2075        // exclusive component captures all 100.
2076        let exclusive_leg = route
2077            .swaps()
2078            .iter()
2079            .find(|s| policy.is_exclusive(s.protocol_component()))
2080            .expect("should have an exclusive swap");
2081        assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(400u64)));
2082    }
2083
2084    /// Builds an `OrderResponses` where both quotes carry explicit `amount_out_net_gas`.
2085    fn responses_with_gas(
2086        public_out: u64,
2087        public_net: u64,
2088        exclusive_out: u64,
2089        exclusive_net: u64,
2090    ) -> OrderResponses {
2091        OrderResponses {
2092            order_id: "test-order".to_string(),
2093            quotes: vec![
2094                (
2095                    "public_pool".to_string(),
2096                    make_public_quote_with_net(public_out, public_net)
2097                        .order()
2098                        .clone(),
2099                ),
2100                (
2101                    "exclusive_access_pool".to_string(),
2102                    make_exclusive_quote_with_leg(exclusive_out, exclusive_net, exclusive_out)
2103                        .order()
2104                        .clone(),
2105                ),
2106            ],
2107            failed_solvers: vec![],
2108        }
2109    }
2110
2111    /// Builds a Success quote whose route has one swap per `(protocol_system, token_in, token_out)`
2112    /// leg, for exercising `has_valid_exclusive_route` on multi-leg and multi-path route shapes.
2113    fn make_route_quote(legs: &[(&str, u8, u8)]) -> OrderQuote {
2114        let make_token = |addr: &Address| Token {
2115            address: addr.clone(),
2116            symbol: "T".to_string(),
2117            decimals: 18,
2118            tax: Default::default(),
2119            gas: vec![],
2120            chain: SimChain::Ethereum,
2121            quality: 100,
2122        };
2123        let mut tokens = HashMap::new();
2124        let mut swaps = Vec::new();
2125        for (protocol_system, tin_byte, tout_byte) in legs {
2126            let tin = make_address(*tin_byte);
2127            let tout = make_address(*tout_byte);
2128            let tin_token = make_token(&tin);
2129            let tout_token = make_token(&tout);
2130            let mut comp = component(
2131                "0x0000000000000000000000000000000000000002",
2132                &[tin_token.clone(), tout_token.clone()],
2133            );
2134            comp.protocol_system = protocol_system.to_string();
2135            swaps.push(Swap::new(
2136                format!("pool-{tin_byte}-{tout_byte}"),
2137                protocol_system.to_string(),
2138                tin.clone(),
2139                tout.clone(),
2140                BigUint::from(1000u64),
2141                BigUint::from(1000u64),
2142                BigUint::from(50_000u64),
2143                comp,
2144                Box::new(MockProtocolSim::default()),
2145            ));
2146            tokens.insert(tin, tin_token);
2147            tokens.insert(tout, tout_token);
2148        }
2149        OrderQuote::new(
2150            "test-order".to_string(),
2151            QuoteStatus::Success,
2152            BigUint::from(1000u64),
2153            BigUint::from(1000u64),
2154            BigUint::from(100_000u64),
2155            BigUint::from(1000u64),
2156            BlockInfo::new(1, "0x123".to_string(), 1000),
2157            "test".to_string(),
2158            Bytes::from(make_address(0xAA).as_ref()),
2159            Bytes::from(make_address(0xAA).as_ref()),
2160            "1".to_string(),
2161        )
2162        .with_route(Route::new(swaps, tokens).expect("non-empty route"))
2163    }
2164
2165    /// Route-shape validation: exactly one exclusive leg, terminal in its path.
2166    #[rstest]
2167    #[case::terminal_exclusive_leg(
2168        &[("uniswap_v2", 0x01, 0x02), ("vm:exclusive", 0x02, 0x03)], true)]
2169    #[case::mid_route_exclusive_leg(
2170        &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2171    // Split route in sequential representation: path 1 is a single exclusive hop 0x01→0x02;
2172    // path 2 is 0x01→0x03→0x02. The exclusive leg is terminal for its path because the next
2173    // swap starts over from 0x01.
2174    #[case::exclusive_leg_ending_its_path(
2175        &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x01, 0x03), ("uniswap_v2", 0x03, 0x02)],
2176        true)]
2177    #[case::no_exclusive_leg(
2178        &[("uniswap_v2", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2179    // Two exclusive legs: out of scope for v1 (ambiguous per-pool attribution).
2180    #[case::two_exclusive_legs(
2181        &[("vm:exclusive", 0x01, 0x02), ("vm:exclusive", 0x01, 0x02)], false)]
2182    fn test_exclusive_route_validation(#[case] legs: &[(&str, u8, u8)], #[case] expected: bool) {
2183        let quote = make_route_quote(legs);
2184        assert_eq!(has_valid_exclusive_route(&quote, &exclusive_policy()), expected);
2185    }
2186}