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 ranking across worker pools, replace each candidate's naive
15//!    `route.total_gas()` estimate (used internally by algorithms for ranking within a worker pool)
16//!    with the more accurate `estimate_gas_usage` from tycho-execution, which accounts for token
17//!    transfer costs and router overhead. The `amount_out_net_gas` values are rescaled
18//!    proportionally so the 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
24mod allocation;
25mod comparison_log;
26pub mod config;
27
28use std::{
29    sync::LazyLock,
30    time::{Duration, Instant},
31};
32
33pub use allocation::ExclusiveAccess;
34use allocation::{allocate, Allocation, OrderClass};
35use comparison_log::{log_quote_comparison, solver_error_label};
36use config::WorkerPoolRouterConfig;
37use futures::stream::{FuturesUnordered, StreamExt};
38use metrics::{counter, gauge, histogram};
39use num_bigint::BigUint;
40use num_traits::{CheckedSub, ToPrimitive};
41use rustc_hash::{FxHashMap, FxHashSet};
42use serde::{Deserialize, Serialize};
43use tracing::{debug, warn};
44use tycho_execution::encoding::{
45    evm::gas_estimator::estimate_gas_usage,
46    models::{Solution, Strategy},
47};
48use tycho_simulation::tycho_common::{models::Chain, Bytes};
49
50use crate::{
51    encoding::encoder::Encoder, feed::exclusivity::is_exclusive, price_guard::guard::PriceGuard,
52    worker_pool::task_queue::TaskQueueHandle, BlockInfo, EncodingOptions, Order, OrderQuote,
53    OrderSide, Quote, QuoteOptions, QuoteRequest, QuoteStatus, SolveError, SolveParams,
54    SurplusInfo, Swap,
55};
56
57/// Environment variable overriding [`DEFAULT_USER_IMPROVEMENT_SHARE_BPS`]. Read once, on the
58/// first quote that overlays an exclusive-access candidate.
59const ENV_USER_IMPROVEMENT_SHARE_BPS: &str = "EXCLUSIVE_ROUTE_USER_SHARE_BPS";
60
61/// Share of an exclusive route's improvement over the public market that is handed to the user,
62/// in basis points of that improvement: `1_000` gives the user a tenth of it.
63const DEFAULT_USER_IMPROVEMENT_SHARE_BPS: u32 = 1_000;
64
65/// Basis-point denominator: `10_000` bps is the whole improvement.
66const BPS_DENOMINATOR: u32 = 10_000;
67
68/// Wei per whole gas token. The exclusive metrics report whole gas tokens, not wei.
69const WEI_PER_GAS_TOKEN: f64 = 1e18;
70
71/// The configured user share, parsed from [`ENV_USER_IMPROVEMENT_SHARE_BPS`].
72static USER_IMPROVEMENT_SHARE_BPS: LazyLock<u32> = LazyLock::new(user_improvement_share_bps_env);
73
74/// Reads the user share from the environment, falling back to
75/// [`DEFAULT_USER_IMPROVEMENT_SHARE_BPS`] when the variable is unset or unusable.
76fn user_improvement_share_bps_env() -> u32 {
77    let Ok(raw) = std::env::var(ENV_USER_IMPROVEMENT_SHARE_BPS) else {
78        return DEFAULT_USER_IMPROVEMENT_SHARE_BPS;
79    };
80    match parse_user_improvement_share_bps(&raw) {
81        Some(bps) => bps,
82        None => {
83            warn!(
84                value = %raw,
85                default_bps = DEFAULT_USER_IMPROVEMENT_SHARE_BPS,
86                "{ENV_USER_IMPROVEMENT_SHARE_BPS} must be an integer from 0 to \
87                 {BPS_DENOMINATOR} basis points; using the default",
88            );
89            DEFAULT_USER_IMPROVEMENT_SHARE_BPS
90        }
91    }
92}
93
94/// Parses a user share in basis points, rejecting anything above the whole improvement — a share
95/// over `10_000` bps would commit more output than the route produces.
96fn parse_user_improvement_share_bps(raw: &str) -> Option<u32> {
97    raw.trim()
98        .parse::<u32>()
99        .ok()
100        .filter(|bps| *bps <= BPS_DENOMINATOR)
101}
102
103/// Returns the part of `improvement` handed to the user, `user_share_bps` of it.
104///
105/// Rounded up, so a share that would otherwise round away still moves the price in the user's
106/// favour. Never exceeds `improvement`, so the commitment it feeds stays within what the route
107/// produces.
108fn user_margin(improvement: &BigUint, user_share_bps: u32) -> BigUint {
109    let denominator = BigUint::from(BPS_DENOMINATOR);
110    (improvement * BigUint::from(user_share_bps) + (&denominator - 1u32)) / denominator
111}
112
113/// The fee in basis points that the protocol takes from an exclusive leg. It applies when the
114/// public worker pools find no route in the solve timeout.
115///
116/// Without a public quote there is no reference price. The protocol takes this fee and the user
117/// gets the rest. The value is the fee of Ekubo's most used ETH/USDC pool. This keeps the quote
118/// competitive with the other solvers.
119const NO_PUBLIC_ROUTE_FEE_BPS: u32 = 5;
120
121/// Which liquidity a solver pool (a group of workers) routes through, and therefore what its
122/// candidates mean in a quote.
123///
124/// A `PublicOnly` worker pool routes only through public liquidity and provides the committed
125/// (quoted) reference output; its workers filter exclusive components out of their graphs. An
126/// `IncludeExclusive` worker pool applies no filtering — its workers ingest whatever the
127/// deployment's stream delivers. In a deployment opted into exclusive components at the stream
128/// filter, an `IncludeExclusive` worker pool may beat the public reference — in which case the
129/// protocol captures the surplus. Without that opt-in, no exclusive components ever arrive and
130/// the two scopes behave identically.
131///
132/// Serialized in snake_case (`"public_only"` / `"include_exclusive"`) in `worker_pools.toml` via
133/// [`PoolConfig`].
134///
135/// [`PoolConfig`]: crate::PoolConfig
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum LiquidityScope {
139    /// Routes through public liquidity only, establishing the committed reference output.
140    #[default]
141    PublicOnly,
142    /// No filtering: routes through whatever the stream delivers, exclusive components
143    /// included if the deployment opted into them. Candidates from this scope may capture
144    /// surplus above the public reference.
145    IncludeExclusive,
146}
147
148/// Handle to a solver pool for dispatching orders.
149#[derive(Clone)]
150pub struct SolverPoolHandle {
151    /// Human-readable name for this worker pool (used in logging & metrics).
152    name: String,
153    /// Queue handle for this worker pool.
154    queue: TaskQueueHandle,
155    /// Which liquidity this worker pool routes through. Decides whether an order is dispatched
156    /// to it ([`SolverPoolHandle::serves`]).
157    liquidity_scope: LiquidityScope,
158}
159
160impl SolverPoolHandle {
161    /// Creates a new solver pool handle with the default [`LiquidityScope`].
162    pub fn new(name: impl Into<String>, queue: TaskQueueHandle) -> Self {
163        Self { name: name.into(), queue, liquidity_scope: LiquidityScope::default() }
164    }
165
166    /// Sets the worker pool's liquidity scope.
167    pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
168        self.liquidity_scope = scope;
169        self
170    }
171
172    /// Returns the worker pool name.
173    pub fn name(&self) -> &str {
174        &self.name
175    }
176
177    /// Returns the task queue handle.
178    pub fn queue(&self) -> &TaskQueueHandle {
179        &self.queue
180    }
181
182    /// Returns the worker pool's liquidity scope.
183    pub fn liquidity_scope(&self) -> LiquidityScope {
184        self.liquidity_scope
185    }
186}
187
188/// One worker pool's answer for an order, with the time that pool took to produce it.
189///
190/// "Worker pool" throughout is the solver thread group named in `worker_pools.toml`, never a
191/// liquidity pool.
192#[derive(Debug, Clone)]
193pub(crate) struct WorkerPoolQuote {
194    /// Name of the worker pool that produced this quote.
195    worker_pool: String,
196    /// What that worker pool solved: route, amounts and gas.
197    quote: OrderQuote,
198    /// Wall time the worker spent on this order, in milliseconds.
199    solve_time_ms: u64,
200}
201
202/// Collected responses for a single order from multiple solvers.
203#[derive(Debug)]
204pub(crate) struct OrderResponses {
205    /// ID of the order these responses correspond to.
206    order_id: String,
207    /// Quotes received from each worker pool.
208    quotes: Vec<WorkerPoolQuote>,
209    /// Worker pools that failed with their respective errors (worker_pool_name, error).
210    /// This captures all error types: timeouts, no routes, algorithm errors, etc.
211    failed_solvers: Vec<(String, SolveError)>,
212}
213
214impl OrderResponses {
215    /// Returns a copy keeping only candidates from public-scoped worker pools.
216    ///
217    /// These form the committed reference and the ranked fallback chain (ranked by `rank_quotes`,
218    /// consumed by the price guard); exclusive-access candidates are overlaid separately by
219    /// `combine_with_surplus`. `failed_solvers` is retained so placeholder construction is
220    /// unchanged.
221    fn public_only(&self, pool_scopes: &FxHashMap<String, LiquidityScope>) -> OrderResponses {
222        let quotes = self
223            .quotes
224            .iter()
225            .filter(|wq| {
226                pool_scopes.get(&wq.worker_pool) != Some(&LiquidityScope::IncludeExclusive)
227            })
228            .cloned()
229            .collect();
230        OrderResponses {
231            order_id: self.order_id.clone(),
232            quotes,
233            failed_solvers: self.failed_solvers.clone(),
234        }
235    }
236}
237
238/// Orchestrates multiple solver pools to find the best quote.
239pub struct WorkerPoolRouter {
240    /// All registered solver pools.
241    solver_pools: Vec<SolverPoolHandle>,
242    /// Configuration for the worker router.
243    config: WorkerPoolRouterConfig,
244    /// Encoder for encoding solutions into on-chain transactions.
245    encoder: Encoder,
246    /// Validates solution outputs against external price sources.
247    /// Present when the server has price guard enabled; `None` when disabled.
248    price_guard: Option<PriceGuard>,
249}
250
251impl WorkerPoolRouter {
252    /// Creates a new WorkerPoolRouter with the given solver pools, config, and encoder.
253    pub fn new(
254        solver_pools: Vec<SolverPoolHandle>,
255        config: WorkerPoolRouterConfig,
256        encoder: Encoder,
257    ) -> Self {
258        Self { solver_pools, config, encoder, price_guard: None }
259    }
260
261    /// Makes price guard validation available for this router.
262    ///
263    /// Providers are started and caches stay warm. Validation only runs for
264    /// requests where the client sets `enabled: true` in `PriceGuardConfig`.
265    pub fn with_price_guard(mut self, price_guard: PriceGuard) -> Self {
266        self.price_guard = Some(price_guard);
267        self
268    }
269
270    /// Returns the number of registered solver pools.
271    pub fn num_pools(&self) -> usize {
272        self.solver_pools.len()
273    }
274
275    /// Returns a quote by fanning out to the worker pools that serve the request.
276    ///
277    /// For each order in the request:
278    /// 1. Allocates the worker pools that serve it (see the `allocation` module)
279    /// 2. Sends the order to those worker pools in parallel
280    /// 3. Waits for responses with timeout
281    /// 4. Selects the best quote based on `amount_out_net_gas`
282    /// 5. If `encoding_options` are set on the request, encodes winning solutions into on-chain
283    ///    transactions
284    ///
285    /// `access` is the caller's access to exclusive liquidity, resolved at the trust
286    /// boundary. With [`ExclusiveAccess::Denied`] no exclusive-access worker pool is allocated, so
287    /// such worker pools do no work for the request and the quote is built from public liquidity
288    /// alone.
289    pub async fn quote(
290        &self,
291        request: QuoteRequest,
292        access: ExclusiveAccess,
293    ) -> Result<Quote, SolveError> {
294        let start = Instant::now();
295        let deadline = start + self.effective_timeout(request.options());
296        let min_responses = request
297            .options()
298            .min_responses()
299            .unwrap_or(self.config.min_responses());
300
301        if self.solver_pools.is_empty() {
302            return Err(SolveError::Internal("no solver pools configured".to_string()));
303        }
304
305        let params = match request.options().state_label().cloned() {
306            Some(label) => SolveParams::default().with_state_label(label),
307            None => SolveParams::default(),
308        };
309
310        counter!(
311            "worker_router_exclusive_access_total",
312            "access" => match access {
313                ExclusiveAccess::Granted => "granted",
314                ExclusiveAccess::Denied => "denied",
315            }
316        )
317        .increment(1);
318
319        // One allocation per order: which worker pools serve an order is a property of the order,
320        // not of the request. Today every order in a request classifies identically; once
321        // trade size joins `OrderClass` they will differ.
322        let class = OrderClass::new(access);
323        let allocations: Vec<Allocation<'_>> = request
324            .orders()
325            .iter()
326            .map(|_| allocate(&self.solver_pools, class))
327            .collect();
328
329        if allocations
330            .iter()
331            .any(Allocation::is_empty)
332        {
333            return Err(SolveError::Internal(format!(
334                "no solver pool serves this request: {class:?}"
335            )));
336        }
337
338        // Process each order independently in parallel
339        let order_futures: Vec<_> = request
340            .orders()
341            .iter()
342            .zip(&allocations)
343            .map(|(order, allocation)| {
344                self.solve_order(order.clone(), params.clone(), deadline, min_responses, allocation)
345            })
346            .collect();
347
348        let mut order_responses = futures::future::join_all(order_futures).await;
349
350        // Refine gas estimates for all candidates using estimate_gas_usage before ranking,
351        // so ranking uses accurate gas costs rather than naive route.total_gas().
352        if let Some(encoding_options) = request.options().encoding_options() {
353            refine_gas_estimates(&mut order_responses, encoding_options)?;
354        }
355
356        // Rank quotes for each order (sorted by refined amount_out_net_gas descending).
357        // `rank_quotes` produces the public ranking — the committed reference AND the price-guard
358        // fallback chain. When the allocation holds an exclusive-scope worker pool, the winning
359        // exclusive-access candidate is overlaid onto that ranked list (prepended) by
360        // `combine_with_surplus`, so the fallbacks are preserved. If the public worker pools find
361        // nothing, that ranking is the `NoRouteFound` placeholder. The exclusive candidate then
362        // uses a default fee.
363        let ranked_quotes: Vec<Vec<OrderQuote>> = order_responses
364            .iter()
365            .zip(&allocations)
366            .map(|(responses, allocation)| {
367                if allocation.exclusive_routing_active() {
368                    let public_ranked = self.rank_quotes(
369                        &responses.public_only(allocation.scopes()),
370                        request.options(),
371                    );
372                    combine_with_surplus(
373                        responses,
374                        allocation.scopes(),
375                        request.options(),
376                        public_ranked,
377                        *USER_IMPROVEMENT_SHARE_BPS,
378                        self.encoder.chain(),
379                    )
380                } else {
381                    self.rank_quotes(responses, request.options())
382                }
383            })
384            .collect();
385
386        // `join_all` preserves input order, so orders and responses line up one to one
387        for (order, responses) in request
388            .orders()
389            .iter()
390            .zip(&order_responses)
391        {
392            log_quote_comparison(order, responses, request.options());
393        }
394
395        // Validate against external prices when the client explicitly enables it.
396        let price_guard_config = request
397            .options()
398            .encoding_options()
399            .map(|e| e.price_guard())
400            .filter(|c| c.enabled());
401
402        let mut order_quotes: Vec<OrderQuote> = match (&self.price_guard, price_guard_config) {
403            (Some(guard), Some(config)) => guard
404                .validate(ranked_quotes, config)
405                .map_err(|e| {
406                    warn!(error = %e, "price guard validation error");
407                    SolveError::Internal(e.to_string())
408                })?,
409            (None, Some(_)) => {
410                return Err(SolveError::Internal(
411                    "price guard config provided but price guard is not enabled on this server"
412                        .to_string(),
413                ));
414            }
415            _ => ranked_quotes
416                .into_iter()
417                .filter_map(|candidates| candidates.into_iter().next())
418                .collect(),
419        };
420
421        // Encode solutions if encoding_options is set
422        if let Some(encoding_options) = request.options().encoding_options() {
423            let encode_start = Instant::now();
424            let encoded = self
425                .encoder
426                .encode(order_quotes, encoding_options.clone())
427                .await;
428            histogram!("encoding_duration_seconds").record(encode_start.elapsed().as_secs_f64());
429            order_quotes = match encoded {
430                Ok(quotes) => quotes,
431                Err(e) => {
432                    counter!("encoding_failures_total").increment(1);
433                    return Err(e);
434                }
435            };
436        }
437
438        // Calculate totals
439        let total_gas_estimate = order_quotes
440            .iter()
441            .map(|o| o.gas_estimate())
442            .fold(BigUint::ZERO, |acc, g| acc + g);
443
444        let solve_time_ms = start.elapsed().as_millis() as u64;
445
446        Ok(Quote::new(order_quotes, total_gas_estimate, solve_time_ms))
447    }
448
449    /// Solves a single order by fanning out to the worker pools allocated to it.
450    async fn solve_order(
451        &self,
452        order: Order,
453        params: SolveParams,
454        deadline: Instant,
455        min_responses: usize,
456        allocation: &Allocation<'_>,
457    ) -> OrderResponses {
458        let start_time = Instant::now();
459        let order_id = order.id().to_string();
460
461        let allocated: Vec<(&str, LiquidityScope)> = allocation
462            .worker_pools()
463            .iter()
464            .map(|worker_pool| (worker_pool.name(), worker_pool.liquidity_scope()))
465            .collect();
466        debug!(
467            order_id = %order_id,
468            worker_pools = ?allocated,
469            "dispatching order to allocated worker pools"
470        );
471
472        // Fan-out: send order to the allocated worker pools. Worker pools that do not serve this
473        // order were already dropped by `allocate`, so nothing here filters by access.
474        let mut pending: FuturesUnordered<_> = allocation
475            .worker_pools()
476            .iter()
477            .map(|worker_pool| {
478                let order_clone = order.clone();
479                let worker_pool_name = worker_pool.name().to_string();
480                let queue = worker_pool.queue().clone();
481                let task_params = params.clone();
482
483                async move {
484                    let result = queue
485                        .enqueue(order_clone, task_params)
486                        .await;
487                    (worker_pool_name, result)
488                }
489            })
490            .collect();
491
492        let mut quotes = Vec::new();
493        let mut failed_solvers: Vec<(String, SolveError)> = Vec::new();
494        let mut remaining_worker_pools: FxHashSet<String> = allocation
495            .worker_pools()
496            .iter()
497            .map(|p| p.name().to_string())
498            .collect();
499        let mut has_public_response = false;
500        let mut has_exclusive_access_response = false;
501
502        // Collect responses with timeout
503        loop {
504            let deadline_instant = tokio::time::Instant::from_std(deadline);
505
506            tokio::select! {
507                // Always checks timeout first, ensuring we respect the deadline
508                biased;
509
510                // Timeout reached
511                _ = tokio::time::sleep_until(deadline_instant) => {
512                    // Mark all remaining worker pools as timed out
513                    let elapsed_ms = deadline.saturating_duration_since(Instant::now())
514                        .as_millis() as u64;
515                    for worker_pool_name in remaining_worker_pools.drain() {
516                        failed_solvers.push((
517                            worker_pool_name,
518                            SolveError::Timeout { elapsed_ms },
519                        ));
520                    }
521                    break;
522                }
523
524                // Response received
525                result = pending.next() => {
526                    match result {
527                        Some((worker_pool_name, Ok(single_quote))) => {
528                            // Remove from remaining
529                            remaining_worker_pools.remove(&worker_pool_name);
530
531                            if allocation.is_exclusive(&worker_pool_name) {
532                                has_exclusive_access_response = true;
533                            } else {
534                                has_public_response = true;
535                            }
536
537                            quotes.push(WorkerPoolQuote {
538                                worker_pool: worker_pool_name.clone(),
539                                quote: single_quote.order().clone(),
540                                solve_time_ms: single_quote.solve_time_ms(),
541                            });
542
543                            // Scope-aware early return: when the allocation routes through
544                            // exclusive liquidity, only fire once we have ≥1 public AND ≥1
545                            // exclusive-access response (so the surplus overlay has both
546                            // inputs). Otherwise, use pure count-based gating (original
547                            // behaviour).
548                            let scope_ready = if allocation.exclusive_routing_active() {
549                                has_public_response && has_exclusive_access_response
550                            } else {
551                                true
552                            };
553                            if min_responses > 0
554                                && quotes.len() >= min_responses
555                                && scope_ready
556                            {
557                                debug!(
558                                    order_id = %order_id,
559                                    responses = quotes.len(),
560                                    min_responses,
561                                    "early return: min_responses reached"
562                                );
563                                counter!("worker_router_early_returns_total").increment(1);
564                                break;
565                            }
566                        }
567                        Some((worker_pool_name, Err(e))) => {
568                            remaining_worker_pools.remove(&worker_pool_name);
569                            // A failed exclusive-access worker pool still counts as "responded"
570                            // for gating — we know it won't produce a surplus quote, so the
571                            // public worker pools can early-return without waiting for a result
572                            // that will never come.
573                            if allocation.is_exclusive(&worker_pool_name) {
574                                has_exclusive_access_response = true;
575                            }
576                            debug!(
577                                pool = %worker_pool_name,
578                                order_id = %order_id,
579                                error = %e,
580                                "solver pool failed"
581                            );
582                            failed_solvers.push((worker_pool_name, e));
583                        }
584                        None => {
585                            // All futures completed
586                            break;
587                        }
588                    }
589                }
590            }
591        }
592
593        // Record metrics
594        let duration = start_time.elapsed().as_secs_f64();
595        histogram!("worker_router_solve_duration_seconds").record(duration);
596        histogram!("worker_router_solver_responses").record(quotes.len() as f64);
597
598        // Record failures by worker pool and error type
599        for (worker_pool_name, error) in &failed_solvers {
600            let error_type = solver_error_label(error);
601            counter!("worker_router_solver_failures_total", "pool" => worker_pool_name.clone(), "error_type" => error_type).increment(1);
602        }
603
604        if !failed_solvers.is_empty() {
605            let timeout_count = failed_solvers
606                .iter()
607                .filter(|(_, e)| matches!(e, SolveError::Timeout { .. }))
608                .count();
609            let other_count = failed_solvers.len() - timeout_count;
610            warn!(
611                order_id = %order_id,
612                timeout_count,
613                other_failures = other_count,
614                "some solver pools failed"
615            );
616        }
617
618        OrderResponses { order_id, quotes, failed_solvers }
619    }
620
621    /// Returns all valid quotes for an order, ranked by `amount_out_net_gas` descending.
622    ///
623    /// If no valid quotes exist, returns a single-element vec with a placeholder
624    /// (`NoRouteFound` or `Timeout`) so that downstream always has at least one
625    /// candidate per order.
626    fn rank_quotes(&self, responses: &OrderResponses, options: &QuoteOptions) -> Vec<OrderQuote> {
627        let mut valid_quotes: Vec<_> = responses
628            .quotes
629            .iter()
630            .filter(|wq| is_rankable(&wq.quote, options))
631            .collect();
632
633        // Sort descending by amount_out_net_gas
634        valid_quotes.sort_by(|a, b| {
635            b.quote
636                .amount_out_net_gas()
637                .cmp(a.quote.amount_out_net_gas())
638        });
639
640        if !valid_quotes.is_empty() {
641            counter!("worker_router_orders_total", "status" => "success").increment(1);
642            let best = valid_quotes[0];
643            counter!("worker_router_best_quote_pool", "pool" => best.worker_pool.clone())
644                .increment(1);
645            debug!(
646                order_id = %best.quote.order_id(),
647                number_of_candidates = valid_quotes.len(),
648                "ranked quotes"
649            );
650            return valid_quotes
651                .into_iter()
652                .map(|pq| pq.quote.clone())
653                .collect();
654        }
655
656        // No valid quote found - return a NoRouteFound response
657        // Try to get any response to extract block info, or create a placeholder
658        let fallback = if let Some(WorkerPoolQuote { quote: any_q, .. }) = responses.quotes.first()
659        {
660            counter!("worker_router_orders_total", "status" => "no_route").increment(1);
661            let mut fallback = OrderQuote::new(
662                responses.order_id.clone(),
663                QuoteStatus::NoRouteFound,
664                any_q.amount_in().clone(),
665                BigUint::ZERO,
666                BigUint::ZERO,
667                BigUint::ZERO,
668                any_q.block().clone(),
669                String::new(),
670                any_q.sender().clone(),
671                any_q.receiver().clone(),
672                any_q.solved_against().clone(),
673            );
674            // Only label the cause when a quote is actually over the request's
675            // max_gas, so a future filter or non-Success status cannot silently
676            // get attributed to the gas cap.
677            let over_max_gas = responses.quotes.iter().any(|pq| {
678                options
679                    .max_gas()
680                    .is_some_and(|max| pq.quote.gas_estimate() > max)
681            });
682            fallback.set_no_route_cause(over_max_gas.then_some(SolveError::MaxGasExceeded));
683            fallback
684        } else {
685            // No responses at all - determine status from failure types
686            let status = if responses.failed_solvers.is_empty() {
687                QuoteStatus::NoRouteFound
688            } else {
689                // If all failures are timeouts, report as Timeout
690                // Otherwise report as NoRouteFound (more general failure)
691                let all_timeouts = responses
692                    .failed_solvers
693                    .iter()
694                    .all(|(_, e)| matches!(e, SolveError::Timeout { .. }));
695                let all_not_ready = responses
696                    .failed_solvers
697                    .iter()
698                    .all(|(_, e)| matches!(e, SolveError::NotReady(_)));
699                if all_timeouts {
700                    QuoteStatus::Timeout
701                } else if all_not_ready {
702                    QuoteStatus::NotReady
703                } else {
704                    QuoteStatus::NoRouteFound
705                }
706            };
707
708            // Record status metric
709            let status_label = match status {
710                QuoteStatus::Timeout => "timeout",
711                QuoteStatus::NotReady => "not_ready",
712                _ => "no_route",
713            };
714            counter!("worker_router_orders_total", "status" => status_label).increment(1);
715
716            // No worker responded — use the requested label if set, otherwise "0"
717            // (we have no block context here since no worker completed).
718            let label = options
719                .state_label()
720                .cloned()
721                .unwrap_or_else(|| "0".to_string());
722            let mut fallback = OrderQuote::new(
723                responses.order_id.clone(),
724                status,
725                BigUint::ZERO,
726                BigUint::ZERO,
727                BigUint::ZERO,
728                BigUint::ZERO,
729                BlockInfo::new(0, String::new(), 0),
730                String::new(),
731                Bytes::default(),
732                Bytes::default(),
733                label,
734            );
735            fallback.set_no_route_cause(aggregate_no_route_cause(&responses.failed_solvers));
736            fallback
737        };
738        vec![fallback]
739    }
740
741    /// Returns the effective timeout for a request.
742    fn effective_timeout(&self, options: &QuoteOptions) -> Duration {
743        options
744            .timeout_ms()
745            .map(Duration::from_millis)
746            .unwrap_or(self.config.default_timeout())
747    }
748}
749
750/// Builds the final ranked quote list for one order by deciding whether an exclusive-access
751/// route should execute instead of the best public route.
752///
753/// Inputs: `public_ranked` is the ranking of public-worker-pool quotes from `rank_quotes`; its
754/// head is the public reference from which the committed amount is derived. `responses`
755/// additionally holds the candidates from `ExclusiveAccess`-scoped worker pools (routes that may
756/// use exclusive components).
757///
758/// There are two ways to set the commitment:
759/// - **Matched**: `public_ranked` starts with a successful public quote, and the commitment follows
760///   that quote, as shown below.
761/// - **Default fee**: no public quote succeeded in the solve timeout, so `public_ranked` holds only
762///   the `NoRouteFound` placeholder. The commitment is the route output minus
763///   `NO_PUBLIC_ROUTE_FEE_BPS` of the exclusive leg. The candidate is skipped if the fee leaves the
764///   user with nothing after gas.
765///
766/// In matched mode a candidate must at least match the public reference net of gas; what it
767/// produces on top, `improvement = exclusive_net − public_net`, is then split. The user's net
768/// target is `required_net = public_net + margin`, where `margin` is `user_share_bps` of
769/// `improvement` — so the mark-up is only as large as the route can afford, and a route with more
770/// to give quotes a better user price. Splitting the improvement rather than marking up the trade
771/// also puts the mark-up below a basis point of the trade whenever the improvement is small.
772///
773/// The committed amount is the larger of two lower bounds:
774/// `max(public_amount_out, required_net + exclusive_gas)`. The first guarantees the quoted
775/// `amount_out` is never below the public market's; the second guarantees the user — who pays
776/// the exclusive route's gas — nets at least `required_net`.
777///
778/// Which bound is larger depends on the gas comparison:
779/// - `exclusive_gas > public_gas`: committed = `required_net + exclusive_gas`, which exceeds
780///   `public_amount_out`. The user receives more tokens than the public quote and nets exactly
781///   `required_net`.
782/// - `exclusive_gas <= public_gas`: committed = `public_amount_out`. The user nets
783///   `public_amount_out − exclusive_gas`, which exceeds `public_net` by the gas difference. A
784///   commitment of `required_net + exclusive_gas` would still leave the user whole and capture
785///   more, but it is below `public_amount_out` — quoting less than the public market is ruled out,
786///   so the gas difference stays with the user.
787///
788/// If there is a commitment, this returns a new list. The head is the pinned surplus quote, and the
789/// entries of `public_ranked` follow it as price-guard fallbacks. The pinned quote is the winning
790/// candidate with:
791/// - `amount_out` set to the committed amount,
792/// - `Swap::committed_amount_out` set on each exclusive leg, and
793/// - an order-level `SurplusInfo`.
794///
795/// If there is no commitment, this returns `public_ranked` unchanged. In default-fee mode the
796/// placeholder is the only fallback, so the order answers "no route" if there is no commitment or
797/// the price guard rejects the exclusive quote.
798///
799/// In matched mode the user is never worse off than the public market, in quoted `amount_out` and
800/// net of gas. A candidate that equals the public reference is quoted with zero surplus, and
801/// executes on exclusive liquidity at the public price.
802///
803/// Per-leg attribution: the route's surplus over the committed amount (`realized − committed`)
804/// is deducted from the exclusive legs — each leg absorbs what it can, capped at its own output,
805/// in route order. Only exclusive legs can withhold output, so the whole
806/// surplus must come out of them; public branches pay out in full. If the exclusive legs cannot
807/// absorb all of it, the remainder is left with the user (who then receives more than the
808/// committed amount).
809///
810/// Exact-in orders only: the commitment, both gates, and the surplus are all denominated in
811/// `amount_out`. Exact-out support would invert the logic — fixed output, commitment and surplus
812/// on the input side — and needs its own treatment here.
813fn combine_with_surplus(
814    responses: &OrderResponses,
815    pool_scopes: &FxHashMap<String, LiquidityScope>,
816    options: &QuoteOptions,
817    public_ranked: Vec<OrderQuote>,
818    user_share_bps: u32,
819    chain: Chain,
820) -> Vec<OrderQuote> {
821    let Some(exclusive_candidate) =
822        best_exclusive_candidate(responses, pool_scopes, options, chain)
823    else {
824        return public_ranked;
825    };
826
827    let public_reference = public_ranked
828        .first()
829        .filter(|q| q.status() == QuoteStatus::Success);
830
831    let commitment = match public_reference {
832        Some(public_reference) => {
833            matched_commitment(public_reference, exclusive_candidate, user_share_bps)
834        }
835        None => default_fee_commitment(exclusive_candidate),
836    };
837    let Some(committed_amount_out) = commitment else {
838        return public_ranked;
839    };
840
841    // Only meaningful against a public reference: the user's improvement over what a plain
842    // public quote would have paid. The no-public-route fallback (default_fee_commitment) has
843    // no public amount_out to diff against.
844    if let Some(public_reference) = public_reference {
845        let user_savings = &committed_amount_out - public_reference.amount_out();
846        record_gas_token_amount(
847            "exclusive_user_savings_amount",
848            exclusive_candidate,
849            &user_savings,
850        );
851    }
852
853    let mut result = Vec::with_capacity(public_ranked.len() + 1);
854    result.push(pin_commitment(exclusive_candidate, committed_amount_out));
855    result.extend(public_ranked);
856    result
857}
858
859/// Restates an output-token `amount` of `quote` in wei.
860///
861/// The quote states its gas cost in both units, so that pair is the rate. `None` when either side
862/// is zero, which is how an unpriced output token shows up.
863fn to_gas_token_amount(quote: &OrderQuote, amount: &BigUint) -> Option<BigUint> {
864    let gas_cost_out = quote
865        .amount_out()
866        .checked_sub(quote.amount_out_net_gas())?;
867    let gas_cost_wei = quote.gas_estimate() * quote.gas_price()?;
868    if gas_cost_out == BigUint::ZERO || gas_cost_wei == BigUint::ZERO {
869        return None;
870    }
871    Some(amount * gas_cost_wei / gas_cost_out)
872}
873
874/// Records an output-token `amount` under `metric`, in whole gas tokens.
875///
876/// Output-token base units are not summable across tokens, so a total over them means nothing. An
877/// amount with no rate increments `exclusive_unpriced_output_total`; a zero would read as "captured
878/// nothing".
879fn record_gas_token_amount(metric: &'static str, quote: &OrderQuote, amount: &BigUint) {
880    let Some(gas_token_amount) = to_gas_token_amount(quote, amount) else {
881        counter!("exclusive_unpriced_output_total").increment(1);
882        return;
883    };
884    gauge!(metric).increment(gas_token_amount.to_f64().unwrap_or(0.0) / WEI_PER_GAS_TOKEN);
885}
886
887/// Whether a quote is eligible to be ranked against the others for this request.
888fn is_rankable(quote: &OrderQuote, options: &QuoteOptions) -> bool {
889    quote.status() == QuoteStatus::Success &&
890        options
891            .max_gas()
892            .map(|max| quote.gas_estimate() <= max)
893            .unwrap_or(true)
894}
895
896/// Returns the exclusive-access candidate with the highest output net of gas. The candidate must
897/// obey the request `max_gas` and the route shape rules of `has_valid_exclusive_route`.
898fn best_exclusive_candidate<'a>(
899    responses: &'a OrderResponses,
900    pool_scopes: &FxHashMap<String, LiquidityScope>,
901    options: &QuoteOptions,
902    chain: Chain,
903) -> Option<&'a OrderQuote> {
904    responses
905        .quotes
906        .iter()
907        .filter(|wq| pool_scopes.get(&wq.worker_pool) == Some(&LiquidityScope::IncludeExclusive))
908        .filter(|wq| wq.quote.status() == QuoteStatus::Success)
909        .filter(|wq| {
910            options
911                .max_gas()
912                .map(|max| wq.quote.gas_estimate() <= max)
913                .unwrap_or(true)
914        })
915        .filter(|wq| has_valid_exclusive_route(&wq.quote, chain))
916        .max_by(|a, b| {
917            a.quote
918                .amount_out_net_gas()
919                .cmp(b.quote.amount_out_net_gas())
920        })
921        .map(|wq| &wq.quote)
922}
923
924/// Returns the amount to commit against a successful public quote:
925/// `max(public_amount_out, required_net + gas)`.
926///
927/// Returns `None` if the candidate fails a gate. The candidate must match the public output net of
928/// gas, and it must produce at least the public output. `combine_with_surplus` gives the reason for
929/// each bound.
930fn matched_commitment(
931    public_reference: &OrderQuote,
932    exclusive_candidate: &OrderQuote,
933    user_share_bps: u32,
934) -> Option<BigUint> {
935    // The candidate route must match the public reference net-of-gas; anything it produces on
936    // top is the improvement, of which the user keeps a share (the margin).
937    let public_net_amount_out = public_reference.amount_out_net_gas();
938    if exclusive_candidate.amount_out_net_gas() < public_net_amount_out {
939        return None;
940    }
941    let improvement = exclusive_candidate.amount_out_net_gas() - public_net_amount_out;
942    let required_net_amount_out = public_net_amount_out + user_margin(&improvement, user_share_bps);
943
944    // Exact-in assumption: everything below compares and commits output amounts. For exact-out
945    // orders this comparison would have to run on amount_in instead (see function docs).
946    // We promise the user at least the public route's output. A private route that produces
947    // less can't keep that promise, so we skip it — even when its gas savings make it better net.
948    let public_amount_out = public_reference.amount_out();
949    if exclusive_candidate.amount_out() < public_amount_out {
950        return None;
951    }
952
953    // Gas the user pays to execute the exclusive route, in output-token terms.
954    let gas_cost = exclusive_candidate.amount_out() - exclusive_candidate.amount_out_net_gas();
955    Some((required_net_amount_out + gas_cost).max(public_amount_out.clone()))
956}
957
958/// Returns the amount to commit if the public worker pools find no route. The amount is the
959/// candidate output minus `NO_PUBLIC_ROUTE_FEE_BPS` of the exclusive leg output.
960///
961/// The fee applies to the exclusive leg only, not to the whole route. The public market prices the
962/// public branches of a split route, so the protocol takes nothing from them.
963///
964/// Returns `None` if the commitment does not cover the route gas, or if the route has no exclusive
965/// leg.
966fn default_fee_commitment(exclusive_candidate: &OrderQuote) -> Option<BigUint> {
967    let exclusive_leg_amount_out = exclusive_candidate
968        .route()?
969        .swaps()
970        .iter()
971        .find(|swap| is_exclusive(swap.protocol_component()))?
972        .amount_out();
973
974    let realized_amount_out = exclusive_candidate.amount_out();
975    let fee = exclusive_leg_amount_out * BigUint::from(NO_PUBLIC_ROUTE_FEE_BPS) /
976        BigUint::from(BPS_DENOMINATOR);
977    let committed_amount_out = realized_amount_out - fee;
978
979    let gas_cost = realized_amount_out - exclusive_candidate.amount_out_net_gas();
980    if committed_amount_out <= gas_cost {
981        return None;
982    }
983    Some(committed_amount_out)
984}
985
986/// Sets `exclusive_candidate` to `committed_amount_out`. This sets the committed amount on each
987/// leg, sets `amount_out` and `amount_out_net_gas`, and attaches the `SurplusInfo`.
988///
989/// The exclusive components capture all output above the commitment.
990fn pin_commitment(exclusive_candidate: &OrderQuote, committed_amount_out: BigUint) -> OrderQuote {
991    let exclusive_route_amount_out = exclusive_candidate.amount_out();
992    let exclusive_gas_cost = exclusive_route_amount_out - exclusive_candidate.amount_out_net_gas();
993    let surplus_amount = exclusive_route_amount_out - &committed_amount_out;
994
995    record_gas_token_amount("exclusive_fee_amount", exclusive_candidate, &surplus_amount);
996
997    let mut surplus_quote = exclusive_candidate.clone();
998
999    // Final output of each swap's path, walked backwards (a path's terminal output propagates
1000    // to its chained predecessors). Converting captured surplus into a leg's token needs the
1001    // realized downstream price `path_final_out / leg_out`; for terminal legs the ratio is 1.
1002    let path_final_outs: Vec<BigUint> = surplus_quote
1003        .route()
1004        .map(|route| {
1005            let swaps = route.swaps();
1006            let mut finals = vec![BigUint::ZERO; swaps.len()];
1007            let mut current_final = BigUint::ZERO;
1008            for i in (0..swaps.len()).rev() {
1009                let is_terminal =
1010                    i == swaps.len() - 1 || swaps[i + 1].token_in() != swaps[i].token_out();
1011                if is_terminal {
1012                    current_final = swaps[i].amount_out().clone();
1013                }
1014                finals[i] = current_final.clone();
1015            }
1016            finals
1017        })
1018        .unwrap_or_default();
1019
1020    if let Some(route) = surplus_quote.route_mut() {
1021        // Capture the route's surplus at the exclusive legs.
1022        // Each leg absorbs up to its path's capacity; any remainder is left with the user.
1023        let mut surplus = exclusive_route_amount_out - &committed_amount_out;
1024        for (i, swap) in route.swaps_mut().iter_mut().enumerate() {
1025            if is_exclusive(swap.protocol_component()) {
1026                let Some(path_final_out) = path_final_outs.get(i) else {
1027                    continue;
1028                };
1029                let captured = surplus
1030                    .clone()
1031                    .min(path_final_out.clone());
1032                // Convert into the leg's own token, rounding down so any error is taken
1033                // from the protocol, not the user. Today the leg is always the last hop of
1034                // its path (validator), so path_final_out equals the leg's output and this
1035                // divides by itself — a plain subtraction. Once mid-path legs are allowed,
1036                // the "user gets at least the committed amount" guarantee also requires the
1037                // components after the leg to have diminishing returns.
1038                let captured_leg = if *path_final_out == BigUint::ZERO {
1039                    BigUint::ZERO
1040                } else {
1041                    &captured * swap.amount_out() / path_final_out
1042                };
1043                debug_assert!(
1044                    captured_leg <= *swap.amount_out(),
1045                    "captured amount ({captured_leg}) must not exceed the leg's output ({})",
1046                    swap.amount_out(),
1047                );
1048                let committed_leg = swap.amount_out() - &captured_leg;
1049                surplus -= captured;
1050                swap.set_committed_amount_out(committed_leg);
1051            }
1052        }
1053    }
1054
1055    surplus_quote.set_amount_out(committed_amount_out.clone());
1056
1057    // The user nets the committed amount minus the exclusive route's gas; in matched mode the
1058    // commitment is built so this is >= the public head's net, keeping the candidate list ranked
1059    // descending.
1060    surplus_quote.set_amount_out_net_gas(&committed_amount_out - &exclusive_gas_cost);
1061
1062    let surplus_info = SurplusInfo::new(surplus_amount, committed_amount_out);
1063    surplus_quote.with_surplus(surplus_info)
1064}
1065
1066/// Returns `true` only for routes carrying exactly one exclusive leg that produces the route's
1067/// output token, either itself or through a single native wrap leg.
1068///
1069/// Returns `false` for routes with no exclusive leg, more than one exclusive leg, an empty route,
1070/// no route at all, or an exclusive leg whose output reaches the route's output token any other
1071/// way.
1072///
1073/// The single-leg constraint is a v1 restriction that keeps per-leg surplus attribution
1074/// unambiguous: multiple exclusive legs make the per-component attribution non-unique.
1075///
1076/// A trailing wrap leg is allowed because it converts 1:1, so it needs no inverse simulation.
1077/// `pin_commitment` converts captured surplus into a leg's token with the ratio
1078/// `path_final_out / leg_out`, which a wrap leaves at exactly 1 — the same arithmetic a terminal
1079/// leg gets. Without this a pool quoting the native token could never serve a request for the
1080/// wrapped token, even though tycho streams the wrap as an ordinary component.
1081fn has_valid_exclusive_route(quote: &OrderQuote, chain: Chain) -> bool {
1082    let Some(route) = quote.route() else {
1083        return false;
1084    };
1085
1086    let swaps = route.swaps();
1087    if swaps.is_empty() {
1088        return false;
1089    }
1090
1091    let Some(output_token) = route.output_token() else {
1092        return false;
1093    };
1094
1095    let mut exclusive_count = 0;
1096
1097    for (index, swap) in swaps.iter().enumerate() {
1098        if !is_exclusive(swap.protocol_component()) {
1099            continue;
1100        }
1101
1102        // Only the immediately following leg is considered: a wrap run that ends at the output
1103        // token is always a single leg, and requiring adjacency keeps this validator in step with
1104        // the chaining rule `pin_commitment` uses to find a path's final output.
1105        let reaches_output = *swap.token_out() == output_token ||
1106            swaps
1107                .get(index + 1)
1108                .is_some_and(|next| {
1109                    next.token_in() == swap.token_out() &&
1110                        next.token_out() == &output_token &&
1111                        is_native_wrap(next, chain)
1112                });
1113        if !reaches_output {
1114            counter!("exclusive_route_invalid_shape_total").increment(1);
1115            return false;
1116        }
1117        exclusive_count += 1;
1118    }
1119
1120    exclusive_count == 1
1121}
1122
1123/// Returns `true` when the leg converts between the native token and its wrapped form, which tycho
1124/// streams as a 1:1 component.
1125///
1126/// An unregistered custom chain resolves no wrap pair, so no leg qualifies and the exclusive leg
1127/// must be terminal.
1128fn is_native_wrap(swap: &Swap, chain: Chain) -> bool {
1129    let (Ok(native), Ok(wrapped)) = (chain.try_native_token(), chain.try_wrapped_native_token())
1130    else {
1131        return false;
1132    };
1133
1134    let (token_in, token_out) = (swap.token_in(), swap.token_out());
1135    (*token_in == native.address && *token_out == wrapped.address) ||
1136        (*token_in == wrapped.address && *token_out == native.address)
1137}
1138
1139/// Shared-graph facts beat path-specific reasons (most specific first:
1140/// amount-too-small, no-scorable-paths, no-graph-path), which beat liquidity,
1141/// data, algorithm, and infrastructure errors; the first error wins within a
1142/// tier.
1143fn aggregate_no_route_cause(failed_solvers: &[(String, SolveError)]) -> Option<SolveError> {
1144    failed_solvers
1145        .iter()
1146        .map(|(_, error)| error)
1147        .min_by_key(|error| cause_tier(error))
1148        .cloned()
1149}
1150
1151fn cause_tier(error: &SolveError) -> u8 {
1152    use crate::algorithm::NoPathReason;
1153    match error {
1154        SolveError::NoRouteFound { reason: Some(reason), .. } => match reason {
1155            NoPathReason::SourceTokenNotInGraph | NoPathReason::DestinationTokenNotInGraph => 0,
1156            NoPathReason::AmountTooSmall => 1,
1157            NoPathReason::NoScorablePaths => 2,
1158            NoPathReason::NoGraphPath => 3,
1159        },
1160        SolveError::InsufficientLiquidity { .. } | SolveError::MaxGasExceeded => 2,
1161        SolveError::MissingData(_) |
1162        SolveError::MarketDataStale { .. } |
1163        SolveError::ComputationFailed(_) |
1164        SolveError::NotReady(_) => 3,
1165        SolveError::SimulationFailed(_) | SolveError::AlgorithmError(_) => 4,
1166        SolveError::Timeout { .. } |
1167        SolveError::QueueFull |
1168        SolveError::Internal(_) |
1169        SolveError::InvalidOrder(_) |
1170        SolveError::FailedEncoding(_) |
1171        SolveError::EncodingUnavailable(_) |
1172        SolveError::PriceCheckFailed { .. } => 5,
1173        SolveError::NoRouteFound { reason: None, .. } => 6,
1174    }
1175}
1176
1177fn refine_gas_estimates(
1178    order_responses: &mut Vec<OrderResponses>,
1179    encoding_options: &EncodingOptions,
1180) -> Result<(), SolveError> {
1181    for responses in order_responses {
1182        for WorkerPoolQuote { quote, .. } in &mut responses.quotes {
1183            if quote.status() != QuoteStatus::Success {
1184                continue;
1185            }
1186            let solution = Solution::try_from(&*quote)?
1187                .with_user_transfer_type(encoding_options.transfer_type().clone());
1188            let refined_gas = estimate_gas_usage(&solution, derive_strategy(quote));
1189            let naive_gas = quote.gas_estimate().clone();
1190            if naive_gas > BigUint::ZERO {
1191                let gas_cost_in_token_out = quote.amount_out() - quote.amount_out_net_gas();
1192                let new_gas_cost = &gas_cost_in_token_out * &refined_gas / &naive_gas;
1193                let new_net = if new_gas_cost <= *quote.amount_out() {
1194                    quote.amount_out() - &new_gas_cost
1195                } else {
1196                    BigUint::ZERO
1197                };
1198                quote.set_amount_out_net_gas(new_net);
1199                quote.set_gas_estimate(refined_gas);
1200            }
1201        }
1202    }
1203    Ok(())
1204}
1205
1206fn derive_strategy(quote: &OrderQuote) -> Strategy {
1207    let Some(route) = quote.route() else { return Strategy::Single };
1208    let swaps = route.swaps();
1209    if swaps.len() == 1 {
1210        Strategy::Single
1211    } else if swaps.iter().any(|s| *s.split() > 0.0) {
1212        Strategy::Split
1213    } else {
1214        Strategy::Sequential
1215    }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use std::sync::{
1221        atomic::{AtomicUsize, Ordering},
1222        Arc,
1223    };
1224
1225    use rstest::rstest;
1226    use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
1227    use tycho_simulation::{
1228        tycho_common::models::Chain,
1229        tycho_core::{
1230            models::{token::Token, Address, Chain as SimChain},
1231            Bytes,
1232        },
1233    };
1234
1235    use super::*;
1236    use crate::{
1237        algorithm::test_utils::{component, MockProtocolSim},
1238        feed::exclusivity::mark_exclusive,
1239        types::internal::SolveTask,
1240        EncodingOptions, OrderSide, Route, SingleOrderQuote, Swap,
1241    };
1242
1243    fn default_encoder() -> Encoder {
1244        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
1245            .add_default_encoders(None)
1246            .expect("default encoders should always succeed");
1247        let encoder =
1248            Encoder::new(Chain::Ethereum, registry).expect("encoder creation should succeed");
1249        // Load fees so encoding can run; the fetcher supplies on-chain values in production.
1250        encoder
1251            .router_fees()
1252            .set(crate::encoding::router_fees::RouterFees::new(
1253                100_000_000,
1254                100_000,
1255                20_000_000,
1256                rustc_hash::FxHashMap::default(),
1257            ));
1258        encoder
1259    }
1260
1261    /// Builds a worker pool response with no recorded solve time, for the ranking tests that do
1262    /// not exercise timing. Use [`timed_worker_quote`] where the solve time is the point.
1263    fn worker_quote((worker_pool, quote): (String, OrderQuote)) -> WorkerPoolQuote {
1264        timed_worker_quote(&worker_pool, quote, 0)
1265    }
1266
1267    fn timed_worker_quote(
1268        worker_pool: &str,
1269        quote: OrderQuote,
1270        solve_time_ms: u64,
1271    ) -> WorkerPoolQuote {
1272        WorkerPoolQuote { worker_pool: worker_pool.to_string(), quote, solve_time_ms }
1273    }
1274
1275    /// A minimal successful quote for tests that only care about the net-of-gas amount.
1276    fn success_quote(net: u64) -> OrderQuote {
1277        OrderQuote::new(
1278            "o1".to_string(),
1279            QuoteStatus::Success,
1280            BigUint::from(1_000u64),
1281            BigUint::from(net + 10),
1282            BigUint::from(10u64),
1283            BigUint::from(net),
1284            BlockInfo::new(42, "0xabc".to_string(), 0),
1285            "algo".to_string(),
1286            Bytes::default(),
1287            Bytes::default(),
1288            "1".to_string(),
1289        )
1290    }
1291
1292    /// `public_only` must carry the order identity across, or the surplus path logs and ranks
1293    /// against a response set that has lost it.
1294    #[test]
1295    fn test_public_only_keeps_order_id_and_failures() {
1296        let responses = OrderResponses {
1297            order_id: "o1".to_string(),
1298            quotes: vec![
1299                timed_worker_quote("public", success_quote(1_000), 3),
1300                timed_worker_quote("excl", success_quote(900), 4),
1301            ],
1302            failed_solvers: vec![("c".to_string(), SolveError::QueueFull)],
1303        };
1304        let scopes = FxHashMap::from_iter([
1305            ("public".to_string(), LiquidityScope::PublicOnly),
1306            ("excl".to_string(), LiquidityScope::IncludeExclusive),
1307        ]);
1308        let public = responses.public_only(&scopes);
1309
1310        assert_eq!(public.order_id, "o1");
1311        assert_eq!(public.quotes.len(), 1);
1312        assert_eq!(public.quotes[0].worker_pool, "public");
1313        assert_eq!(public.quotes[0].solve_time_ms, 3);
1314        assert_eq!(public.failed_solvers.len(), 1);
1315    }
1316
1317    fn make_address(byte: u8) -> tycho_simulation::tycho_common::models::Address {
1318        Address::from([byte; 20])
1319    }
1320
1321    fn make_order() -> Order {
1322        Order::new(
1323            make_address(0x01),
1324            make_address(0x02),
1325            BigUint::from(1000u64),
1326            OrderSide::Sell,
1327            make_address(0xAA),
1328        )
1329        .with_id("test-order".to_string())
1330    }
1331
1332    fn make_single_quote(amount_out_net_gas: u64) -> SingleOrderQuote {
1333        let make_token = |addr: Address| Token {
1334            address: addr,
1335            symbol: "T".to_string(),
1336            decimals: 18,
1337            tax: Default::default(),
1338            gas: vec![],
1339            chain: SimChain::Ethereum,
1340            quality: 100,
1341        };
1342        let tin = make_address(0x01);
1343        let tout = make_address(0x02);
1344        let tin_token = make_token(tin.clone());
1345        let tout_token = make_token(tout.clone());
1346        let swap = Swap::new(
1347            "pool-1".to_string(),
1348            "uniswap_v2".to_string(),
1349            tin.clone(),
1350            tout.clone(),
1351            BigUint::from(1000u64),
1352            BigUint::from(990u64),
1353            BigUint::from(50_000u64),
1354            component(
1355                "0x0000000000000000000000000000000000000001",
1356                &[tin_token.clone(), tout_token.clone()],
1357            ),
1358            Box::new(MockProtocolSim::default()),
1359        );
1360        let mut tokens = FxHashMap::default();
1361        tokens.insert(tin, tin_token);
1362        tokens.insert(tout, tout_token);
1363        let quote = OrderQuote::new(
1364            "test-order".to_string(),
1365            QuoteStatus::Success,
1366            BigUint::from(1000u64),
1367            BigUint::from(990u64),
1368            BigUint::from(100_000u64),
1369            BigUint::from(amount_out_net_gas),
1370            BlockInfo::new(1, "0x123".to_string(), 1000),
1371            "test".to_string(),
1372            Bytes::from(make_address(0xAA).as_ref()),
1373            Bytes::from(make_address(0xAA).as_ref()),
1374            "1".to_string(),
1375        )
1376        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
1377        SingleOrderQuote::new(quote, 5)
1378    }
1379
1380    // Helper to create a mock worker pool that responds with a given solution
1381    fn create_mock_worker_pool(
1382        name: &str,
1383        response: Result<SingleOrderQuote, SolveError>,
1384        delay_ms: u64,
1385    ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>) {
1386        let (pool, worker, _) = create_counting_mock_worker_pool(name, response, delay_ms);
1387        (pool, worker)
1388    }
1389
1390    /// Mock worker pool that also counts the tasks it received, so a test can assert a worker pool
1391    /// was never dispatched to rather than only that its quote did not win.
1392    fn create_counting_mock_worker_pool(
1393        name: &str,
1394        response: Result<SingleOrderQuote, SolveError>,
1395        delay_ms: u64,
1396    ) -> (SolverPoolHandle, tokio::task::JoinHandle<()>, Arc<AtomicUsize>) {
1397        let (tx, rx) = async_channel::bounded::<SolveTask>(10);
1398        let handle = TaskQueueHandle::from_sender(tx);
1399        let received = Arc::new(AtomicUsize::new(0));
1400
1401        let worker = {
1402            let received = Arc::clone(&received);
1403            tokio::spawn(async move {
1404                while let Ok(task) = rx.recv().await {
1405                    received.fetch_add(1, Ordering::SeqCst);
1406                    if delay_ms > 0 {
1407                        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1408                    }
1409                    task.respond(response.clone());
1410                }
1411            })
1412        };
1413
1414        (SolverPoolHandle::new(name, handle), worker, received)
1415    }
1416
1417    #[test]
1418    fn test_config_default() {
1419        let config = WorkerPoolRouterConfig::default();
1420        assert_eq!(config.default_timeout(), Duration::from_secs(1));
1421        assert_eq!(config.min_responses(), 1);
1422    }
1423
1424    #[test]
1425    fn test_config_builder() {
1426        let config = WorkerPoolRouterConfig::default()
1427            .with_timeout(Duration::from_millis(500))
1428            .with_min_responses(2);
1429        assert_eq!(config.default_timeout(), Duration::from_millis(500));
1430        assert_eq!(config.min_responses(), 2);
1431    }
1432
1433    #[tokio::test]
1434    async fn test_router_no_pools() {
1435        let worker_router =
1436            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1437        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1438
1439        let result = worker_router
1440            .quote(request, ExclusiveAccess::Denied)
1441            .await;
1442        assert!(matches!(result, Err(SolveError::Internal(_))));
1443    }
1444
1445    #[tokio::test]
1446    async fn test_router_single_pool_success() {
1447        let (pool, worker) = create_mock_worker_pool("pool_a", Ok(make_single_quote(900)), 0);
1448
1449        let worker_router =
1450            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1451        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1452        let request = QuoteRequest::new(vec![make_order()], options);
1453
1454        let result = worker_router
1455            .quote(request, ExclusiveAccess::Denied)
1456            .await;
1457        assert!(result.is_ok());
1458
1459        let quote = result.unwrap();
1460        assert_eq!(quote.orders().len(), 1);
1461        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1462        // amount_out_net_gas is refined using estimate_gas_usage before ranking
1463        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(873u64));
1464        assert!(!quote.orders()[0]
1465            .transaction()
1466            .unwrap()
1467            .data()
1468            .is_empty());
1469
1470        drop(worker_router);
1471        worker.abort();
1472    }
1473
1474    #[tokio::test]
1475    async fn test_router_selects_best_of_two() {
1476        // Pool A: worse quote (net gas = 800)
1477        let (pool_a, worker_a) = create_mock_worker_pool("pool_a", Ok(make_single_quote(800)), 0);
1478        // Pool B: better quote (net gas = 950)
1479        let (pool_b, worker_b) = create_mock_worker_pool("pool_b", Ok(make_single_quote(950)), 0);
1480
1481        // Wait for both responses to test best selection logic
1482        let config = WorkerPoolRouterConfig::default().with_min_responses(2);
1483        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1484        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1485        let request = QuoteRequest::new(vec![make_order()], options);
1486
1487        let result = worker_router
1488            .quote(request, ExclusiveAccess::Denied)
1489            .await;
1490        assert!(result.is_ok());
1491
1492        let quote = result.unwrap();
1493        assert_eq!(quote.orders().len(), 1);
1494        // Pool B wins (higher refined amount_out_net_gas after estimate_gas_usage)
1495        assert_eq!(*quote.orders()[0].amount_out_net_gas(), BigUint::from(938u64));
1496        assert!(!quote.orders()[0]
1497            .transaction()
1498            .unwrap()
1499            .data()
1500            .is_empty());
1501
1502        drop(worker_router);
1503        worker_a.abort();
1504        worker_b.abort();
1505    }
1506
1507    #[tokio::test]
1508    async fn test_router_timeout() {
1509        // Pool that takes too long
1510        let (pool, worker) = create_mock_worker_pool("slow_pool", Ok(make_single_quote(900)), 500);
1511
1512        let config = WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(50));
1513        let worker_router = WorkerPoolRouter::new(vec![pool], config, default_encoder());
1514        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1515
1516        let result = worker_router
1517            .quote(request, ExclusiveAccess::Denied)
1518            .await;
1519        assert!(result.is_ok());
1520
1521        let quote = result.unwrap();
1522        // Should timeout and return NoRouteFound or Timeout status
1523        assert_eq!(quote.orders().len(), 1);
1524        assert!(matches!(
1525            quote.orders()[0].status(),
1526            QuoteStatus::Timeout | QuoteStatus::NoRouteFound
1527        ));
1528
1529        drop(worker_router);
1530        worker.abort();
1531    }
1532
1533    #[tokio::test]
1534    async fn test_router_early_return_on_min_responses() {
1535        // Pool A: fast
1536        let (pool_a, worker_a) =
1537            create_mock_worker_pool("fast_pool", Ok(make_single_quote(800)), 0);
1538        // Pool B: slow (but we won't wait for it)
1539        let (pool_b, worker_b) =
1540            create_mock_worker_pool("slow_pool", Ok(make_single_quote(950)), 500);
1541
1542        let config = WorkerPoolRouterConfig::default()
1543            .with_timeout(Duration::from_millis(1000))
1544            .with_min_responses(1);
1545        let worker_router = WorkerPoolRouter::new(vec![pool_a, pool_b], config, default_encoder());
1546
1547        let start = Instant::now();
1548        let options = QuoteOptions::default().with_encoding_options(EncodingOptions::new(0.01));
1549        let request = QuoteRequest::new(vec![make_order()], options);
1550
1551        let result = worker_router
1552            .quote(request, ExclusiveAccess::Denied)
1553            .await;
1554        let elapsed = start.elapsed();
1555
1556        assert!(result.is_ok());
1557        // Should return quickly (not waiting for pool_b)
1558        assert!(elapsed < Duration::from_millis(200));
1559
1560        // Should have pool_a's quote
1561        let quote = result.unwrap();
1562        assert_eq!(quote.orders().len(), 1);
1563        assert_eq!(quote.orders()[0].status(), QuoteStatus::Success);
1564        // Should have encoding
1565        assert!(!quote.orders()[0]
1566            .transaction()
1567            .unwrap()
1568            .data()
1569            .is_empty());
1570
1571        drop(worker_router);
1572        worker_a.abort();
1573        worker_b.abort();
1574    }
1575
1576    /// The `denied_access` case sets `min_responses(2)`, which would hold a request with access
1577    /// until both pools answer; a denied request's allocation holds only the public pool, so it
1578    /// must return without waiting on the slow exclusive pool it will never use.
1579    #[rstest]
1580    #[case::pending_exclusive_pool(ExclusiveAccess::Granted, 0, Some(300), 1, true)]
1581    #[case::pending_public_pool(ExclusiveAccess::Granted, 300, Some(0), 1, true)]
1582    #[case::failed_exclusive_pool(ExclusiveAccess::Granted, 0, None, 1, false)]
1583    #[case::denied_access(ExclusiveAccess::Denied, 0, Some(500), 2, false)]
1584    #[tokio::test]
1585    async fn test_router_early_return_scope_gating(
1586        #[case] access: ExclusiveAccess,
1587        #[case] public_delay_ms: u64,
1588        #[case] exclusive_delay_ms: Option<u64>,
1589        #[case] min_responses: usize,
1590        #[case] expect_surplus: bool,
1591    ) {
1592        let (public_pool, public_worker) =
1593            create_mock_worker_pool("public_pool", Ok(make_single_quote(800)), public_delay_ms);
1594        let exclusive_response = match exclusive_delay_ms {
1595            Some(_) => Ok(make_exclusive_quote(1100)),
1596            None => {
1597                Err(SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None })
1598            }
1599        };
1600        let public_pool = public_pool.with_liquidity_scope(LiquidityScope::PublicOnly);
1601        let (exclusive_pool, exclusive_worker) = create_mock_worker_pool(
1602            "exclusive_pool",
1603            exclusive_response,
1604            exclusive_delay_ms.unwrap_or(0),
1605        );
1606        let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::IncludeExclusive);
1607
1608        let config = WorkerPoolRouterConfig::default()
1609            .with_timeout(Duration::from_millis(2000))
1610            .with_min_responses(min_responses);
1611        let worker_router =
1612            WorkerPoolRouter::new(vec![public_pool, exclusive_pool], config, default_encoder());
1613        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1614
1615        let start = Instant::now();
1616        let result = worker_router
1617            .quote(request, access)
1618            .await
1619            .expect("quote should succeed");
1620        let elapsed = start.elapsed();
1621
1622        // Well under the 2s timeout: the gate releases as soon as every allocated scope has
1623        // responded.
1624        assert!(elapsed < Duration::from_millis(500), "took {elapsed:?}");
1625        let order = &result.orders()[0];
1626        assert_eq!(order.status(), QuoteStatus::Success);
1627        assert_eq!(*order.amount_out(), BigUint::from(990u64));
1628        assert_eq!(order.surplus_amount().is_some(), expect_surplus);
1629
1630        drop(worker_router);
1631        public_worker.abort();
1632        exclusive_worker.abort();
1633    }
1634
1635    /// The exclusive pool offers a strictly better route (net 1100 vs 800), so it wins whenever it
1636    /// is allocated. A request without access must never reach it: not merely lose to the public
1637    /// leg in ranking, but cost the exclusive pool no work at all.
1638    #[rstest]
1639    #[case::denied(ExclusiveAccess::Denied, false)]
1640    #[case::granted(ExclusiveAccess::Granted, true)]
1641    #[tokio::test]
1642    async fn test_router_exclusive_access_allocation(
1643        #[case] access: ExclusiveAccess,
1644        #[case] expect_exclusive_leg: bool,
1645    ) {
1646        let (public_pool, public_worker) =
1647            create_mock_worker_pool("public_pool", Ok(make_single_quote(800)), 0);
1648        let (exclusive_pool, exclusive_worker, exclusive_tasks) =
1649            create_counting_mock_worker_pool("exclusive_pool", Ok(make_exclusive_quote(1100)), 0);
1650        let exclusive_pool = exclusive_pool.with_liquidity_scope(LiquidityScope::IncludeExclusive);
1651
1652        let worker_router = WorkerPoolRouter::new(
1653            vec![public_pool, exclusive_pool],
1654            WorkerPoolRouterConfig::default().with_timeout(Duration::from_millis(2000)),
1655            default_encoder(),
1656        );
1657        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1658
1659        let result = worker_router
1660            .quote(request, access)
1661            .await
1662            .expect("quote should succeed");
1663
1664        // The gate is the dispatch, not the ranking: a denied request costs the exclusive pool no
1665        // CPU and no latency.
1666        assert_eq!(
1667            exclusive_tasks.load(Ordering::SeqCst) > 0,
1668            expect_exclusive_leg,
1669            "exclusive pool dispatch"
1670        );
1671
1672        let order = &result.orders()[0];
1673        assert_eq!(order.status(), QuoteStatus::Success);
1674
1675        let routes_through_exclusive = order
1676            .route()
1677            .expect("successful quote has a route")
1678            .swaps()
1679            .iter()
1680            .any(|swap| is_exclusive(swap.protocol_component()));
1681        assert_eq!(routes_through_exclusive, expect_exclusive_leg);
1682        assert_eq!(order.surplus_amount().is_some(), expect_exclusive_leg);
1683
1684        // Either way the quoted output is the public reference, so denied access costs the
1685        // caller nothing they were promised.
1686        assert_eq!(*order.amount_out(), BigUint::from(990u64));
1687
1688        drop(worker_router);
1689        public_worker.abort();
1690        exclusive_worker.abort();
1691    }
1692
1693    #[rstest]
1694    #[case::under_limit(100, Some(200), true)]
1695    #[case::at_limit(200, Some(200), true)]
1696    #[case::over_limit(300, Some(200), false)]
1697    #[case::no_limit(500, None, true)]
1698    fn test_max_gas_constraint(
1699        #[case] gas_estimate: u64,
1700        #[case] max_gas: Option<u64>,
1701        #[case] should_pass: bool,
1702    ) {
1703        let responses = OrderResponses {
1704            order_id: "test".to_string(),
1705            quotes: vec![worker_quote((
1706                "pool".to_string(),
1707                OrderQuote::new(
1708                    "test".to_string(),
1709                    QuoteStatus::Success,
1710                    BigUint::from(1000u64),
1711                    BigUint::from(990u64),
1712                    BigUint::from(gas_estimate),
1713                    BigUint::from(900u64),
1714                    BlockInfo::new(1, "0x123".to_string(), 1000),
1715                    "test".to_string(),
1716                    Bytes::from(make_address(0xAA).as_ref()),
1717                    Bytes::from(make_address(0xAA).as_ref()),
1718                    "1".to_string(),
1719                ),
1720            ))],
1721            failed_solvers: vec![],
1722        };
1723
1724        let options = match max_gas {
1725            Some(gas) => QuoteOptions::default().with_max_gas(BigUint::from(gas)),
1726            None => QuoteOptions::default(),
1727        };
1728
1729        let worker_router =
1730            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1731        let result = worker_router.rank_quotes(&responses, &options);
1732
1733        if should_pass {
1734            assert_eq!(result[0].status(), QuoteStatus::Success);
1735        } else {
1736            assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1737        }
1738    }
1739
1740    #[tokio::test]
1741    async fn test_router_captures_solver_errors() {
1742        // Pool that returns an error
1743        let (pool, worker) =
1744            create_mock_worker_pool("error_pool", Err(SolveError::no_route_found("test-order")), 0);
1745
1746        let worker_router =
1747            WorkerPoolRouter::new(vec![pool], WorkerPoolRouterConfig::default(), default_encoder());
1748        let request = QuoteRequest::new(vec![make_order()], QuoteOptions::default());
1749
1750        let result = worker_router
1751            .quote(request, ExclusiveAccess::Denied)
1752            .await;
1753        assert!(result.is_ok());
1754
1755        let quote = result.unwrap();
1756        assert_eq!(quote.orders().len(), 1);
1757        // Should be NoRouteFound since the only solver returned an error
1758        assert_eq!(quote.orders()[0].status(), QuoteStatus::NoRouteFound);
1759
1760        drop(worker_router);
1761        worker.abort();
1762    }
1763
1764    #[test]
1765    fn test_rank_quotes_all_timeouts_returns_timeout_status() {
1766        let responses = OrderResponses {
1767            order_id: "test".to_string(),
1768            quotes: vec![],
1769            failed_solvers: vec![
1770                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1771                ("pool_b".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1772            ],
1773        };
1774
1775        let worker_router =
1776            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1777        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1778
1779        assert_eq!(result.len(), 1);
1780        assert_eq!(result[0].status(), QuoteStatus::Timeout);
1781    }
1782
1783    #[test]
1784    fn test_rank_quotes_mixed_failures_returns_no_route_found() {
1785        let responses = OrderResponses {
1786            order_id: "test".to_string(),
1787            quotes: vec![],
1788            failed_solvers: vec![
1789                ("pool_a".to_string(), SolveError::Timeout { elapsed_ms: 100 }),
1790                ("pool_b".to_string(), SolveError::no_route_found("test")),
1791            ],
1792        };
1793
1794        let worker_router =
1795            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1796        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1797
1798        assert_eq!(result.len(), 1);
1799        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1800    }
1801
1802    #[test]
1803    fn test_rank_quotes_no_failures_returns_no_route_found() {
1804        let responses =
1805            OrderResponses { order_id: "test".to_string(), quotes: vec![], failed_solvers: vec![] };
1806
1807        let worker_router =
1808            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1809        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1810
1811        assert_eq!(result.len(), 1);
1812        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1813    }
1814
1815    #[test]
1816    fn rank_quotes_attaches_no_route_reason() {
1817        use crate::algorithm::NoPathReason;
1818        let responses = OrderResponses {
1819            order_id: "test".to_string(),
1820            quotes: vec![],
1821            failed_solvers: vec![
1822                (
1823                    "pool_a".to_string(),
1824                    SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1825                ),
1826                (
1827                    "pool_b".to_string(),
1828                    SolveError::no_route_found_with_reason(
1829                        "test",
1830                        NoPathReason::DestinationTokenNotInGraph,
1831                    ),
1832                ),
1833            ],
1834        };
1835        let worker_router =
1836            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1837        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1838        assert_eq!(result.len(), 1);
1839        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1840        // Token-not-in-graph wins over no_graph_path regardless of pool order.
1841        assert_eq!(result[0].no_route_reason(), Some(NoPathReason::DestinationTokenNotInGraph));
1842    }
1843
1844    #[test]
1845    fn test_rank_quotes_no_route_reason_single_failure() {
1846        use crate::algorithm::NoPathReason;
1847        let responses = OrderResponses {
1848            order_id: "test".to_string(),
1849            quotes: vec![],
1850            failed_solvers: vec![(
1851                "pool_a".to_string(),
1852                SolveError::no_route_found_with_reason("test", NoPathReason::NoGraphPath),
1853            )],
1854        };
1855        let worker_router =
1856            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1857        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
1858        assert_eq!(result[0].no_route_reason(), Some(NoPathReason::NoGraphPath));
1859    }
1860
1861    #[test]
1862    fn test_aggregate_cause_surfaces_amount_too_small() {
1863        use crate::algorithm::NoPathReason;
1864        let failed = vec![(
1865            "bf".to_string(),
1866            SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1867        )];
1868        assert!(matches!(
1869            aggregate_no_route_cause(&failed),
1870            Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1871        ));
1872    }
1873
1874    #[test]
1875    fn test_aggregate_no_route_cause_independent_of_pool_order() {
1876        use crate::algorithm::NoPathReason;
1877        let dust = || SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall);
1878        let no_path = || SolveError::no_route_found_with_reason("o1", NoPathReason::NoGraphPath);
1879        let forward = vec![("bf1".to_string(), no_path()), ("bf3".to_string(), dust())];
1880        let reversed = vec![("bf3".to_string(), dust()), ("bf1".to_string(), no_path())];
1881        for failed in [forward, reversed] {
1882            assert!(matches!(
1883                aggregate_no_route_cause(&failed),
1884                Some(SolveError::NoRouteFound { reason: Some(NoPathReason::AmountTooSmall), .. })
1885            ));
1886        }
1887    }
1888
1889    #[test]
1890    fn test_aggregate_token_not_in_graph_wins_over_amount_too_small() {
1891        use crate::algorithm::NoPathReason;
1892        let failed = vec![
1893            (
1894                "bf".to_string(),
1895                SolveError::no_route_found_with_reason("o1", NoPathReason::AmountTooSmall),
1896            ),
1897            (
1898                "ml".to_string(),
1899                SolveError::no_route_found_with_reason(
1900                    "o2",
1901                    NoPathReason::DestinationTokenNotInGraph,
1902                ),
1903            ),
1904        ];
1905        assert!(matches!(
1906            aggregate_no_route_cause(&failed),
1907            Some(SolveError::NoRouteFound {
1908                reason: Some(NoPathReason::DestinationTokenNotInGraph),
1909                ..
1910            })
1911        ));
1912    }
1913
1914    #[test]
1915    fn test_aggregate_prefers_liquidity_over_infra() {
1916        let failed = vec![
1917            ("a".to_string(), SolveError::QueueFull),
1918            ("b".to_string(), SolveError::insufficient_liquidity(1u32.into(), 0u32.into())),
1919        ];
1920        assert!(matches!(
1921            aggregate_no_route_cause(&failed),
1922            Some(SolveError::InsufficientLiquidity { .. })
1923        ));
1924    }
1925
1926    #[test]
1927    fn test_rank_quotes_max_gas_filtered_sets_max_gas_exceeded() {
1928        let responses = OrderResponses {
1929            order_id: "o1".to_string(),
1930            quotes: vec![worker_quote((
1931                "pool".to_string(),
1932                OrderQuote::new(
1933                    "o1".to_string(),
1934                    QuoteStatus::Success,
1935                    BigUint::from(1_000u64),
1936                    BigUint::from(990u64),
1937                    BigUint::from(100_000u64),
1938                    BigUint::from(990u64),
1939                    BlockInfo::new(1, "0xabc".to_string(), 0),
1940                    "test".to_string(),
1941                    Bytes::default(),
1942                    Bytes::default(),
1943                    "1".to_string(),
1944                ),
1945            ))],
1946            failed_solvers: vec![],
1947        };
1948        let options = QuoteOptions::default().with_max_gas(BigUint::from(1u64));
1949        let worker_router =
1950            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1951        let result = worker_router.rank_quotes(&responses, &options);
1952        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1953        assert!(matches!(result[0].no_route_cause(), Some(SolveError::MaxGasExceeded)));
1954    }
1955
1956    #[test]
1957    fn test_rank_quotes_no_cause_when_gas_within_max() {
1958        let responses = OrderResponses {
1959            order_id: "o1".to_string(),
1960            quotes: vec![worker_quote((
1961                "pool".to_string(),
1962                OrderQuote::new(
1963                    "o1".to_string(),
1964                    QuoteStatus::NoRouteFound,
1965                    BigUint::from(1_000u64),
1966                    BigUint::from(990u64),
1967                    BigUint::from(100u64),
1968                    BigUint::from(990u64),
1969                    BlockInfo::new(1, "0xabc".to_string(), 0),
1970                    "test".to_string(),
1971                    Bytes::default(),
1972                    Bytes::default(),
1973                    "1".to_string(),
1974                ),
1975            ))],
1976            failed_solvers: vec![],
1977        };
1978        let options = QuoteOptions::default().with_max_gas(BigUint::from(1_000u64));
1979        let worker_router =
1980            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1981        let result = worker_router.rank_quotes(&responses, &options);
1982        assert_eq!(result[0].status(), QuoteStatus::NoRouteFound);
1983        assert!(
1984            result[0].no_route_cause().is_none(),
1985            "gas within max_gas must not be labelled MaxGasExceeded: {:?}",
1986            result[0].no_route_cause()
1987        );
1988    }
1989
1990    #[test]
1991    fn test_all_timeout_fallback_carries_timeout_cause() {
1992        let responses = OrderResponses {
1993            order_id: "o1".to_string(),
1994            quotes: vec![],
1995            failed_solvers: vec![("pool".to_string(), SolveError::Timeout { elapsed_ms: 9 })],
1996        };
1997        let worker_router =
1998            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
1999        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
2000        assert_eq!(result[0].status(), QuoteStatus::Timeout);
2001        assert!(matches!(result[0].no_route_cause(), Some(SolveError::Timeout { .. })));
2002    }
2003
2004    #[test]
2005    fn test_rank_quotes_returns_sorted_candidates() {
2006        let responses = OrderResponses {
2007            order_id: "test".to_string(),
2008            quotes: vec![
2009                worker_quote((
2010                    "pool_a".to_string(),
2011                    OrderQuote::new(
2012                        "test".to_string(),
2013                        QuoteStatus::Success,
2014                        BigUint::from(1000u64),
2015                        BigUint::from(800u64),
2016                        BigUint::from(100_000u64),
2017                        BigUint::from(800u64),
2018                        BlockInfo::new(1, "0x123".to_string(), 1000),
2019                        "test".to_string(),
2020                        Bytes::from(make_address(0xAA).as_ref()),
2021                        Bytes::from(make_address(0xAA).as_ref()),
2022                        "1".to_string(),
2023                    ),
2024                )),
2025                worker_quote((
2026                    "pool_b".to_string(),
2027                    OrderQuote::new(
2028                        "test".to_string(),
2029                        QuoteStatus::Success,
2030                        BigUint::from(1000u64),
2031                        BigUint::from(950u64),
2032                        BigUint::from(100_000u64),
2033                        BigUint::from(950u64),
2034                        BlockInfo::new(1, "0x123".to_string(), 1000),
2035                        "test".to_string(),
2036                        Bytes::from(make_address(0xAA).as_ref()),
2037                        Bytes::from(make_address(0xAA).as_ref()),
2038                        "1".to_string(),
2039                    ),
2040                )),
2041            ],
2042            failed_solvers: vec![],
2043        };
2044
2045        let worker_router =
2046            WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), default_encoder());
2047        let result = worker_router.rank_quotes(&responses, &QuoteOptions::default());
2048
2049        assert_eq!(result.len(), 2);
2050        assert_eq!(*result[0].amount_out_net_gas(), BigUint::from(950u64));
2051        assert_eq!(*result[1].amount_out_net_gas(), BigUint::from(800u64));
2052    }
2053
2054    /// Like `make_single_quote` but the swap uses an exclusive protocol component. The swap's own
2055    /// output is `leg_amount_out`, letting tests exercise per-leg attribution where the leg
2056    /// differs from the route total
2057    fn make_exclusive_quote_with_leg(
2058        amount_out: u64,
2059        amount_out_net_gas: u64,
2060        leg_amount_out: u64,
2061    ) -> SingleOrderQuote {
2062        let make_token = |addr: Address| Token {
2063            address: addr,
2064            symbol: "T".to_string(),
2065            decimals: 18,
2066            tax: Default::default(),
2067            gas: vec![],
2068            chain: SimChain::Ethereum,
2069            quality: 100,
2070        };
2071        let tin = make_address(0x01);
2072        let tout = make_address(0x02);
2073        let tin_token = make_token(tin.clone());
2074        let tout_token = make_token(tout.clone());
2075        let mut comp = component(
2076            "0x0000000000000000000000000000000000000002",
2077            &[tin_token.clone(), tout_token.clone()],
2078        );
2079        mark_exclusive(&mut comp);
2080        let swap = Swap::new(
2081            "pool-perm".to_string(),
2082            "vm:exclusive".to_string(),
2083            tin.clone(),
2084            tout.clone(),
2085            BigUint::from(1000u64),
2086            BigUint::from(leg_amount_out),
2087            BigUint::from(50_000u64),
2088            comp,
2089            Box::new(MockProtocolSim::default()),
2090        );
2091        let mut tokens = FxHashMap::default();
2092        tokens.insert(tin, tin_token);
2093        tokens.insert(tout, tout_token);
2094        let quote = OrderQuote::new(
2095            "test-order".to_string(),
2096            QuoteStatus::Success,
2097            BigUint::from(1000u64),
2098            BigUint::from(amount_out),
2099            BigUint::from(100_000u64),
2100            BigUint::from(amount_out_net_gas),
2101            BlockInfo::new(1, "0x123".to_string(), 1000),
2102            "test".to_string(),
2103            Bytes::from(make_address(0xAA).as_ref()),
2104            Bytes::from(make_address(0xAA).as_ref()),
2105            "1".to_string(),
2106        )
2107        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
2108        SingleOrderQuote::new(quote, 5)
2109    }
2110
2111    fn make_exclusive_quote(amount_out: u64) -> SingleOrderQuote {
2112        make_exclusive_quote_with_leg(amount_out, amount_out, amount_out)
2113    }
2114
2115    /// Split route with two parallel branches (same token pair): a public component producing
2116    /// `public_leg_out` and an exclusive component producing `exclusive_leg_out`. Route output is
2117    /// the sum of the branches; zero gas cost.
2118    fn make_exclusive_split_quote(public_leg_out: u64, exclusive_leg_out: u64) -> SingleOrderQuote {
2119        let make_token = |addr: Address| Token {
2120            address: addr,
2121            symbol: "T".to_string(),
2122            decimals: 18,
2123            tax: Default::default(),
2124            gas: vec![],
2125            chain: SimChain::Ethereum,
2126            quality: 100,
2127        };
2128        let tin = make_address(0x01);
2129        let tout = make_address(0x02);
2130        let tin_token = make_token(tin.clone());
2131        let tout_token = make_token(tout.clone());
2132        let public_swap = Swap::new(
2133            "pool-pub".to_string(),
2134            "uniswap_v2".to_string(),
2135            tin.clone(),
2136            tout.clone(),
2137            BigUint::from(500u64),
2138            BigUint::from(public_leg_out),
2139            BigUint::from(50_000u64),
2140            component(
2141                "0x0000000000000000000000000000000000000001",
2142                &[tin_token.clone(), tout_token.clone()],
2143            ),
2144            Box::new(MockProtocolSim::default()),
2145        );
2146        let mut exclusive_comp = component(
2147            "0x0000000000000000000000000000000000000002",
2148            &[tin_token.clone(), tout_token.clone()],
2149        );
2150        mark_exclusive(&mut exclusive_comp);
2151        let exclusive_swap = Swap::new(
2152            "pool-perm".to_string(),
2153            "vm:exclusive".to_string(),
2154            tin.clone(),
2155            tout.clone(),
2156            BigUint::from(500u64),
2157            BigUint::from(exclusive_leg_out),
2158            BigUint::from(50_000u64),
2159            exclusive_comp,
2160            Box::new(MockProtocolSim::default()),
2161        );
2162        let mut tokens = FxHashMap::default();
2163        tokens.insert(tin, tin_token);
2164        tokens.insert(tout, tout_token);
2165        let total = public_leg_out + exclusive_leg_out;
2166        let quote = OrderQuote::new(
2167            "test-order".to_string(),
2168            QuoteStatus::Success,
2169            BigUint::from(1000u64),
2170            BigUint::from(total),
2171            BigUint::from(100_000u64),
2172            BigUint::from(total),
2173            BlockInfo::new(1, "0x123".to_string(), 1000),
2174            "test".to_string(),
2175            Bytes::from(make_address(0xAA).as_ref()),
2176            Bytes::from(make_address(0xAA).as_ref()),
2177            "1".to_string(),
2178        )
2179        .with_route(
2180            Route::new(vec![public_swap, exclusive_swap], tokens).expect("non-empty route"),
2181        );
2182        SingleOrderQuote::new(quote, 5)
2183    }
2184
2185    /// Like `make_single_quote` but with a configurable `amount_out_net_gas` so tests can
2186    /// express non-zero gas cost.
2187    fn make_public_quote_with_net(amount_out: u64, amount_out_net_gas: u64) -> SingleOrderQuote {
2188        let make_token = |addr: Address| Token {
2189            address: addr,
2190            symbol: "T".to_string(),
2191            decimals: 18,
2192            tax: Default::default(),
2193            gas: vec![],
2194            chain: SimChain::Ethereum,
2195            quality: 100,
2196        };
2197        let tin = make_address(0x01);
2198        let tout = make_address(0x02);
2199        let tin_token = make_token(tin.clone());
2200        let tout_token = make_token(tout.clone());
2201        let swap = Swap::new(
2202            "pool-1".to_string(),
2203            "uniswap_v2".to_string(),
2204            tin.clone(),
2205            tout.clone(),
2206            BigUint::from(1000u64),
2207            BigUint::from(amount_out),
2208            BigUint::from(50_000u64),
2209            component(
2210                "0x0000000000000000000000000000000000000001",
2211                &[tin_token.clone(), tout_token.clone()],
2212            ),
2213            Box::new(MockProtocolSim::default()),
2214        );
2215        let mut tokens = FxHashMap::default();
2216        tokens.insert(tin, tin_token);
2217        tokens.insert(tout, tout_token);
2218        let quote = OrderQuote::new(
2219            "test-order".to_string(),
2220            QuoteStatus::Success,
2221            BigUint::from(1000u64),
2222            BigUint::from(amount_out),
2223            BigUint::from(100_000u64),
2224            BigUint::from(amount_out_net_gas),
2225            BlockInfo::new(1, "0x123".to_string(), 1000),
2226            "test".to_string(),
2227            Bytes::from(make_address(0xAA).as_ref()),
2228            Bytes::from(make_address(0xAA).as_ref()),
2229            "1".to_string(),
2230        )
2231        .with_route(Route::new(vec![swap], tokens).expect("non-empty route"));
2232        SingleOrderQuote::new(quote, 5)
2233    }
2234
2235    fn make_public_quote_zero_gas(amount_out: u64) -> SingleOrderQuote {
2236        make_public_quote_with_net(amount_out, amount_out)
2237    }
2238
2239    /// Builds an `OrderResponses` with a public quote and an exclusive-access quote (zero gas
2240    /// cost).
2241    fn exclusive_access_responses(public_out: u64, exclusive_out: u64) -> OrderResponses {
2242        let public = make_public_quote_zero_gas(public_out)
2243            .order()
2244            .clone();
2245        let exclusive_access = make_exclusive_quote(exclusive_out)
2246            .order()
2247            .clone();
2248        OrderResponses {
2249            order_id: "test-order".to_string(),
2250            quotes: vec![
2251                worker_quote(("public_pool".to_string(), public)),
2252                worker_quote(("exclusive_access_pool".to_string(), exclusive_access)),
2253            ],
2254            failed_solvers: vec![],
2255        }
2256    }
2257
2258    fn exclusive_access_pool_scopes() -> FxHashMap<String, LiquidityScope> {
2259        FxHashMap::from_iter([
2260            ("public_pool".to_string(), LiquidityScope::PublicOnly),
2261            ("exclusive_access_pool".to_string(), LiquidityScope::IncludeExclusive),
2262        ])
2263    }
2264
2265    /// Head selection across the gate case matrix. Each route is given as `(gross, net)`, followed
2266    /// by the user's share of the improvement in bps; expected is
2267    /// `(head amount_out, head net, captured surplus)`. A surplus win prepends the pinned quote to
2268    /// the public fallbacks; otherwise the public ranking is returned unchanged.
2269    #[rstest]
2270    // A tenth of the 50 improvement goes to the user, the rest is captured.
2271    #[case::exclusive_beats_public((900, 900), (950, 950), 1_000, (905, 905, Some(45)))]
2272    #[case::exclusive_below_public((950, 950), (900, 900), 1_000, (950, 950, None))]
2273    // A tie is quoted at the public price, with nothing to capture.
2274    #[case::exclusive_ties_public_net((900, 900), (900, 900), 1_000, (900, 900, Some(0)))]
2275    // An improvement of 1 rounds the user's tenth up to the whole unit, leaving zero surplus.
2276    #[case::exclusive_marginally_better((999, 999), (1000, 1000), 1_000, (1000, 1000, Some(0)))]
2277    #[case::exclusive_cannot_cover_public_gross((1000, 950), (990, 980), 1_000, (1000, 950, None))]
2278    // Gas-heavier exclusive route: the committed amount rises to max(1000, 951 + 140) = 1091 so
2279    // the user nets the public 950 plus a tenth of the 10 improvement; the protocol captures 9.
2280    #[case::exclusive_with_higher_gas((1000, 950), (1100, 960), 1_000, (1091, 951, Some(9)))]
2281    // Gas-cheaper exclusive route: the improvement (110) includes the gas saving, so the user
2282    // nets 950 + 11 = 961 and the protocol captures 1100 - (961 + 40) = 99.
2283    #[case::exclusive_with_lower_gas((1000, 950), (1100, 1060), 1_000, (1001, 961, Some(99)))]
2284    // A 50 improvement on a 1_000_000 trade: the user's 5 is 0.05 bps of the trade, below what a
2285    // mark-up on the trade itself could express.
2286    #[case::sub_bps_markup((1_000_000, 1_000_000), (1_000_050, 1_000_050), 1_000,
2287        (1_000_005, 1_000_005, Some(45)))]
2288    // Zero share: the user is left at the public net and the protocol captures the improvement.
2289    #[case::zero_share((900, 900), (950, 950), 0, (900, 900, Some(50)))]
2290    // Full share: the whole improvement goes to the user and there is nothing left to capture.
2291    #[case::full_share((900, 900), (950, 950), 10_000, (950, 950, Some(0)))]
2292    fn test_combine_head_selection(
2293        #[case] public: (u64, u64),
2294        #[case] exclusive: (u64, u64),
2295        #[case] user_share_bps: u32,
2296        #[case] expected: (u64, u64, Option<u64>),
2297    ) {
2298        let (public_out, public_net) = public;
2299        let (exclusive_out, exclusive_net) = exclusive;
2300        let (expected_amount_out, expected_net, expected_surplus) = expected;
2301
2302        let responses = responses_with_gas(public_out, public_net, exclusive_out, exclusive_net);
2303        let public_ranked = vec![make_public_quote_with_net(public_out, public_net)
2304            .order()
2305            .clone()];
2306        let combined = combine_with_surplus(
2307            &responses,
2308            &exclusive_access_pool_scopes(),
2309            &QuoteOptions::default(),
2310            public_ranked,
2311            user_share_bps,
2312            SimChain::Ethereum,
2313        );
2314
2315        let expected_surplus = expected_surplus.map(BigUint::from);
2316        assert_eq!(combined.len(), if expected_surplus.is_some() { 2 } else { 1 });
2317        assert_eq!(*combined[0].amount_out(), BigUint::from(expected_amount_out));
2318        assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(expected_net));
2319        assert_eq!(combined[0].surplus_amount(), expected_surplus.as_ref());
2320        if expected_surplus.is_some() {
2321            assert_eq!(
2322                combined[0].committed_amount_out(),
2323                Some(&BigUint::from(expected_amount_out))
2324            );
2325        }
2326    }
2327
2328    #[rstest]
2329    #[case::over_max_gas(
2330        make_exclusive_quote(1100).order().clone(),
2331        Some(50_000)
2332    )]
2333    #[case::mid_route_exclusive_leg(
2334        make_route_quote(&[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)]),
2335        None
2336    )]
2337    fn test_combine_filters_exclusive_candidate(
2338        #[case] exclusive_quote: OrderQuote,
2339        #[case] max_gas: Option<u64>,
2340    ) {
2341        let mut options = QuoteOptions::default();
2342        if let Some(max) = max_gas {
2343            options = options.with_max_gas(BigUint::from(max));
2344        }
2345        let responses = OrderResponses {
2346            order_id: "test-order".to_string(),
2347            quotes: vec![
2348                worker_quote((
2349                    "public_pool".to_string(),
2350                    make_public_quote_zero_gas(900)
2351                        .order()
2352                        .clone(),
2353                )),
2354                worker_quote(("exclusive_access_pool".to_string(), exclusive_quote)),
2355            ],
2356            failed_solvers: vec![],
2357        };
2358        let public_ranked = vec![make_public_quote_zero_gas(900)
2359            .order()
2360            .clone()];
2361        let combined = combine_with_surplus(
2362            &responses,
2363            &exclusive_access_pool_scopes(),
2364            &options,
2365            public_ranked,
2366            1_000,
2367            SimChain::Ethereum,
2368        );
2369
2370        assert_eq!(combined.len(), 1);
2371        assert_eq!(*combined[0].amount_out(), BigUint::from(900u64));
2372        assert_eq!(combined[0].surplus_amount(), None);
2373    }
2374
2375    /// The `NoRouteFound` placeholder `rank_quotes` returns when no public candidate succeeded.
2376    fn no_route_quote() -> OrderQuote {
2377        OrderQuote::new(
2378            "test-order".to_string(),
2379            QuoteStatus::NoRouteFound,
2380            BigUint::from(1000u64),
2381            BigUint::ZERO,
2382            BigUint::ZERO,
2383            BigUint::ZERO,
2384            BlockInfo::new(1, "0x123".to_string(), 1000),
2385            String::new(),
2386            Bytes::from(make_address(0xAA).as_ref()),
2387            Bytes::from(make_address(0xAA).as_ref()),
2388            "1".to_string(),
2389        )
2390    }
2391
2392    /// Builds an `OrderResponses` where the public worker pool failed and only the exclusive-access
2393    /// pool returned a quote.
2394    fn no_public_route_responses(exclusive: OrderQuote) -> OrderResponses {
2395        OrderResponses {
2396            order_id: "test-order".to_string(),
2397            quotes: vec![worker_quote(("exclusive_access_pool".to_string(), exclusive))],
2398            failed_solvers: vec![(
2399                "public_pool".to_string(),
2400                SolveError::NoRouteFound { order_id: "test-order".to_string(), reason: None },
2401            )],
2402        }
2403    }
2404
2405    #[test]
2406    fn test_combine_no_public_route_applies_default_fee() {
2407        // No public reference to match: 5 bps of the exclusive leg's 1_000_000 output is
2408        // withheld, so the user is committed 999_500 and the exclusive component captures 500.
2409        let responses = no_public_route_responses(
2410            make_exclusive_quote(1_000_000)
2411                .order()
2412                .clone(),
2413        );
2414        let combined = combine_with_surplus(
2415            &responses,
2416            &exclusive_access_pool_scopes(),
2417            &QuoteOptions::default(),
2418            vec![no_route_quote()],
2419            1_000,
2420            SimChain::Ethereum,
2421        );
2422
2423        assert_eq!(combined.len(), 2);
2424        assert_eq!(combined[0].status(), QuoteStatus::Success);
2425        assert_eq!(*combined[0].amount_out(), BigUint::from(999_500u64));
2426        assert_eq!(*combined[0].amount_out_net_gas(), BigUint::from(999_500u64));
2427        assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(500u64)));
2428
2429        let exclusive_leg = combined[0]
2430            .route()
2431            .expect("surplus quote should have a route")
2432            .swaps()
2433            .iter()
2434            .find(|s| is_exclusive(s.protocol_component()))
2435            .expect("should have an exclusive swap")
2436            .committed_amount_out()
2437            .cloned();
2438        assert_eq!(exclusive_leg, Some(BigUint::from(999_500u64)));
2439
2440        // The placeholder stays on as the price-guard fallback, so a rejected exclusive quote
2441        // still answers "no route".
2442        assert_eq!(combined[1].status(), QuoteStatus::NoRouteFound);
2443    }
2444
2445    #[test]
2446    fn test_combine_no_public_route_split_route_fee_on_exclusive_leg() {
2447        // Split route with no public reference: the 5 bps fee is charged on the exclusive leg's
2448        // 500_000 output only (250), not on the route's 1_100_000. The public branch pays out in
2449        // full, so the whole fee comes out of the exclusive leg: 500_000 − 250 = 499_750.
2450        let responses = no_public_route_responses(
2451            make_exclusive_split_quote(600_000, 500_000)
2452                .order()
2453                .clone(),
2454        );
2455        let combined = combine_with_surplus(
2456            &responses,
2457            &exclusive_access_pool_scopes(),
2458            &QuoteOptions::default(),
2459            vec![no_route_quote()],
2460            1_000,
2461            SimChain::Ethereum,
2462        );
2463
2464        assert_eq!(*combined[0].amount_out(), BigUint::from(1_099_750u64));
2465        assert_eq!(combined[0].surplus_amount(), Some(&BigUint::from(250u64)));
2466
2467        let route = combined[0]
2468            .route()
2469            .expect("surplus quote should have a route");
2470        let public_leg = route
2471            .swaps()
2472            .iter()
2473            .find(|s| !is_exclusive(s.protocol_component()))
2474            .expect("should have a public swap");
2475        assert_eq!(public_leg.committed_amount_out(), None);
2476
2477        let exclusive_leg = route
2478            .swaps()
2479            .iter()
2480            .find(|s| is_exclusive(s.protocol_component()))
2481            .expect("should have an exclusive swap");
2482        assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(499_750u64)));
2483    }
2484
2485    #[test]
2486    fn test_combine_no_public_route_fee_below_gas() {
2487        // Gas leaves 3 of the 10_000 output, so the 5 fee would put the user under water: the
2488        // candidate is dropped and the order still reports no route.
2489        let responses = no_public_route_responses(
2490            make_exclusive_quote_with_leg(10_000, 3, 10_000)
2491                .order()
2492                .clone(),
2493        );
2494        let combined = combine_with_surplus(
2495            &responses,
2496            &exclusive_access_pool_scopes(),
2497            &QuoteOptions::default(),
2498            vec![no_route_quote()],
2499            1_000,
2500            SimChain::Ethereum,
2501        );
2502
2503        assert_eq!(combined.len(), 1);
2504        assert_eq!(combined[0].status(), QuoteStatus::NoRouteFound);
2505    }
2506
2507    #[test]
2508    fn test_combine_stamps_per_leg_committed_amount_out() {
2509        let responses = exclusive_access_responses(900, 1000);
2510        let public_ranked = vec![make_public_quote_zero_gas(900)
2511            .order()
2512            .clone()];
2513        let combined = combine_with_surplus(
2514            &responses,
2515            &exclusive_access_pool_scopes(),
2516            &QuoteOptions::default(),
2517            public_ranked,
2518            1_000,
2519            SimChain::Ethereum,
2520        );
2521
2522        let surplus_quote = &combined[0];
2523        let route = surplus_quote
2524            .route()
2525            .expect("surplus quote should have a route");
2526        let perm_swap = route
2527            .swaps()
2528            .iter()
2529            .find(|s| is_exclusive(s.protocol_component()))
2530            .expect("should have an exclusive swap");
2531
2532        // committed_leg = leg.amount_out * committed_route_out / realized_route_out
2533        // = 1000 * 910 / 1000 = 910
2534        assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(910u64)),);
2535    }
2536
2537    #[test]
2538    fn test_combine_committed_leg_deduction() {
2539        // leg = 995, committed = 910, realized = 1000: the route's surplus (90) is deducted from
2540        // the exclusive leg in full — committed_leg = 995 − 90 = 905, exactly, no rounding.
2541        let responses = OrderResponses {
2542            order_id: "test-order".to_string(),
2543            quotes: vec![
2544                worker_quote((
2545                    "public_pool".to_string(),
2546                    make_public_quote_zero_gas(900)
2547                        .order()
2548                        .clone(),
2549                )),
2550                worker_quote((
2551                    "exclusive_access_pool".to_string(),
2552                    make_exclusive_quote_with_leg(1000, 1000, 995)
2553                        .order()
2554                        .clone(),
2555                )),
2556            ],
2557            failed_solvers: vec![],
2558        };
2559        let public_ranked = vec![make_public_quote_zero_gas(900)
2560            .order()
2561            .clone()];
2562        let combined = combine_with_surplus(
2563            &responses,
2564            &exclusive_access_pool_scopes(),
2565            &QuoteOptions::default(),
2566            public_ranked,
2567            1_000,
2568            SimChain::Ethereum,
2569        );
2570
2571        let route = combined[0]
2572            .route()
2573            .expect("surplus quote should have a route");
2574        let perm_swap = route
2575            .swaps()
2576            .iter()
2577            .find(|s| is_exclusive(s.protocol_component()))
2578            .expect("should have an exclusive swap");
2579        assert_eq!(perm_swap.committed_amount_out(), Some(&BigUint::from(905u64)));
2580    }
2581
2582    #[rstest]
2583    #[case::zero("0", Some(0))]
2584    #[case::whole_improvement("10000", Some(10_000))]
2585    #[case::padded(" 2500 ", Some(2_500))]
2586    #[case::above_whole_improvement("10001", None)]
2587    #[case::negative("-1", None)]
2588    #[case::fractional("0.5", None)]
2589    #[case::empty("", None)]
2590    fn test_parse_user_improvement_share_bps(#[case] raw: &str, #[case] expected: Option<u32>) {
2591        assert_eq!(parse_user_improvement_share_bps(raw), expected);
2592    }
2593
2594    #[test]
2595    fn test_combine_split_route_attribution() {
2596        // Split route: public branch 600 + exclusive branch 500 = 1100 realized vs 1010
2597        // committed (zero gas, a tenth of the 100 improvement to the user). Only the exclusive
2598        // leg is stamped; the public branch flows to the user untouched.
2599        let responses = OrderResponses {
2600            order_id: "test-order".to_string(),
2601            quotes: vec![
2602                worker_quote((
2603                    "public_pool".to_string(),
2604                    make_public_quote_zero_gas(1000)
2605                        .order()
2606                        .clone(),
2607                )),
2608                worker_quote((
2609                    "exclusive_access_pool".to_string(),
2610                    make_exclusive_split_quote(600, 500)
2611                        .order()
2612                        .clone(),
2613                )),
2614            ],
2615            failed_solvers: vec![],
2616        };
2617        let public_ranked = vec![make_public_quote_zero_gas(1000)
2618            .order()
2619            .clone()];
2620        let combined = combine_with_surplus(
2621            &responses,
2622            &exclusive_access_pool_scopes(),
2623            &QuoteOptions::default(),
2624            public_ranked,
2625            1_000,
2626            SimChain::Ethereum,
2627        );
2628
2629        assert_eq!(*combined[0].amount_out(), BigUint::from(1010u64));
2630        let route = combined[0]
2631            .route()
2632            .expect("surplus quote should have a route");
2633        let public_leg = route
2634            .swaps()
2635            .iter()
2636            .find(|s| !is_exclusive(s.protocol_component()))
2637            .expect("should have a public swap");
2638        assert_eq!(public_leg.committed_amount_out(), None);
2639
2640        // The public branch (600) pays out in full, so the entire surplus
2641        // (1100 − 1010 = 90) is deducted from the exclusive leg: committed_leg = 500 − 90 =
2642        // 410. The user receives 600 + 410 = 1010 (exactly the committed amount) and the
2643        // exclusive component captures all 90.
2644        let exclusive_leg = route
2645            .swaps()
2646            .iter()
2647            .find(|s| is_exclusive(s.protocol_component()))
2648            .expect("should have an exclusive swap");
2649        assert_eq!(exclusive_leg.committed_amount_out(), Some(&BigUint::from(410u64)));
2650    }
2651
2652    /// Builds an `OrderResponses` where both quotes carry explicit `amount_out_net_gas`.
2653    fn responses_with_gas(
2654        public_out: u64,
2655        public_net: u64,
2656        exclusive_out: u64,
2657        exclusive_net: u64,
2658    ) -> OrderResponses {
2659        OrderResponses {
2660            order_id: "test-order".to_string(),
2661            quotes: vec![
2662                worker_quote((
2663                    "public_pool".to_string(),
2664                    make_public_quote_with_net(public_out, public_net)
2665                        .order()
2666                        .clone(),
2667                )),
2668                worker_quote((
2669                    "exclusive_access_pool".to_string(),
2670                    make_exclusive_quote_with_leg(exclusive_out, exclusive_net, exclusive_out)
2671                        .order()
2672                        .clone(),
2673                )),
2674            ],
2675            failed_solvers: vec![],
2676        }
2677    }
2678
2679    /// Builds a Success quote whose route has one swap per `(protocol_system, token_in, token_out)`
2680    /// leg, for exercising `has_valid_exclusive_route` on multi-leg and multi-path route shapes.
2681    fn make_route_quote(legs: &[(&str, u8, u8)]) -> OrderQuote {
2682        let legs: Vec<(&str, Address, Address)> = legs
2683            .iter()
2684            .map(|(protocol_system, token_in, token_out)| {
2685                (*protocol_system, make_address(*token_in), make_address(*token_out))
2686            })
2687            .collect();
2688        make_route_quote_for_tokens(&legs)
2689    }
2690
2691    fn make_route_quote_for_tokens(legs: &[(&str, Address, Address)]) -> OrderQuote {
2692        let make_token = |addr: &Address| Token {
2693            address: addr.clone(),
2694            symbol: "T".to_string(),
2695            decimals: 18,
2696            tax: Default::default(),
2697            gas: vec![],
2698            chain: SimChain::Ethereum,
2699            quality: 100,
2700        };
2701        let mut tokens = FxHashMap::default();
2702        let mut swaps = Vec::new();
2703        for (protocol_system, tin, tout) in legs {
2704            let (tin, tout) = (tin.clone(), tout.clone());
2705            let tin_token = make_token(&tin);
2706            let tout_token = make_token(&tout);
2707            let mut comp = component(
2708                "0x0000000000000000000000000000000000000002",
2709                &[tin_token.clone(), tout_token.clone()],
2710            );
2711            comp.protocol_system = protocol_system.to_string();
2712            if *protocol_system == "vm:exclusive" {
2713                mark_exclusive(&mut comp);
2714            }
2715            swaps.push(Swap::new(
2716                format!("pool-{tin}-{tout}"),
2717                protocol_system.to_string(),
2718                tin.clone(),
2719                tout.clone(),
2720                BigUint::from(1000u64),
2721                BigUint::from(1000u64),
2722                BigUint::from(50_000u64),
2723                comp,
2724                Box::new(MockProtocolSim::default()),
2725            ));
2726            tokens.insert(tin, tin_token);
2727            tokens.insert(tout, tout_token);
2728        }
2729        OrderQuote::new(
2730            "test-order".to_string(),
2731            QuoteStatus::Success,
2732            BigUint::from(1000u64),
2733            BigUint::from(1000u64),
2734            BigUint::from(100_000u64),
2735            BigUint::from(1000u64),
2736            BlockInfo::new(1, "0x123".to_string(), 1000),
2737            "test".to_string(),
2738            Bytes::from(make_address(0xAA).as_ref()),
2739            Bytes::from(make_address(0xAA).as_ref()),
2740            "1".to_string(),
2741        )
2742        .with_route(Route::new(swaps, tokens).expect("non-empty route"))
2743    }
2744
2745    /// Route-shape validation: exactly one exclusive leg, terminal in its path.
2746    #[rstest]
2747    #[case::terminal_exclusive_leg(
2748        &[("uniswap_v2", 0x01, 0x02), ("vm:exclusive", 0x02, 0x03)], true)]
2749    #[case::mid_route_exclusive_leg(
2750        &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2751    // Split route in sequential representation: path 1 is a single exclusive hop 0x01→0x02;
2752    // path 2 is 0x01→0x03→0x02. The exclusive leg is terminal for its path because the next
2753    // swap starts over from 0x01.
2754    #[case::exclusive_leg_ending_its_path(
2755        &[("vm:exclusive", 0x01, 0x02), ("uniswap_v2", 0x01, 0x03), ("uniswap_v2", 0x03, 0x02)],
2756        true)]
2757    #[case::no_exclusive_leg(
2758        &[("uniswap_v2", 0x01, 0x02), ("uniswap_v2", 0x02, 0x03)], false)]
2759    // Two exclusive legs: out of scope for v1 (ambiguous per-component attribution).
2760    #[case::two_exclusive_legs(
2761        &[("vm:exclusive", 0x01, 0x02), ("vm:exclusive", 0x01, 0x02)], false)]
2762    // Diamond split: 0x01 splits into 0x01->0x02 (exclusive) and 0x01->0x03, both merging into
2763    // 0x02->0x04 and 0x03->0x04. The exclusive leg feeds the merge point, not the route's output
2764    // (0x04), so it's mid-path even though the next serialized swap starts a sibling branch.
2765    #[case::exclusive_leg_feeding_diamond_merge(
2766        &[
2767            ("vm:exclusive", 0x01, 0x02),
2768            ("uniswap_v2", 0x01, 0x03),
2769            ("uniswap_v2", 0x02, 0x04),
2770            ("uniswap_v2", 0x03, 0x04),
2771        ],
2772        false)]
2773    // Same diamond shape, reordered so the exclusive leg's real continuation is adjacent to it —
2774    // regression case for a prior bug where terminal-ness was inferred from adjacency alone.
2775    #[case::exclusive_leg_feeding_diamond_merge_reordered(
2776        &[
2777            ("vm:exclusive", 0x01, 0x02),
2778            ("uniswap_v2", 0x02, 0x04),
2779            ("uniswap_v2", 0x01, 0x03),
2780            ("uniswap_v2", 0x03, 0x04),
2781        ],
2782        false)]
2783    // Sibling branches sharing a prefix: 0x01->0x02->0x03->0x04 alongside
2784    // 0x01->0x02->0x05->0x04(exclusive). The exclusive leg is the terminal hop of its own branch
2785    // and produces the route's output token, so it's valid despite sharing 0x01->0x02 with the
2786    // other branch.
2787    #[case::exclusive_leg_on_sibling_branch(
2788        &[
2789            ("uniswap_v2", 0x01, 0x02),
2790            ("uniswap_v2", 0x02, 0x03),
2791            ("uniswap_v2", 0x03, 0x04),
2792            ("uniswap_v2", 0x02, 0x05),
2793            ("vm:exclusive", 0x05, 0x04),
2794        ],
2795        true)]
2796    fn test_exclusive_route_validation(#[case] legs: &[(&str, u8, u8)], #[case] expected: bool) {
2797        let quote = make_route_quote(legs);
2798        assert_eq!(has_valid_exclusive_route(&quote, SimChain::Ethereum), expected);
2799    }
2800
2801    /// Native token, its wrapped form, and an unrelated token. Read from the same chain config the
2802    /// validator uses, so the pair cannot drift from tycho's.
2803    fn wrap_tokens() -> (Address, Address, Address) {
2804        (
2805            SimChain::Ethereum
2806                .native_token()
2807                .address,
2808            SimChain::Ethereum
2809                .wrapped_native_token()
2810                .address,
2811            make_address(0x07),
2812        )
2813    }
2814
2815    #[test]
2816    fn test_exclusive_leg_wrapped_into_output() {
2817        let (native, wrapped, other) = wrap_tokens();
2818        let quote = make_route_quote_for_tokens(&[
2819            ("vm:exclusive", other, native.clone()),
2820            ("uniswap_v2", native, wrapped),
2821        ]);
2822
2823        assert!(has_valid_exclusive_route(&quote, SimChain::Ethereum));
2824    }
2825
2826    #[test]
2827    fn test_exclusive_leg_unwrapped_into_output() {
2828        let (native, wrapped, other) = wrap_tokens();
2829        let quote = make_route_quote_for_tokens(&[
2830            ("vm:exclusive", other, wrapped.clone()),
2831            ("uniswap_v2", wrapped, native),
2832        ]);
2833
2834        assert!(has_valid_exclusive_route(&quote, SimChain::Ethereum));
2835    }
2836
2837    #[test]
2838    fn test_exclusive_leg_followed_by_non_wrap() {
2839        let (native, _, other) = wrap_tokens();
2840        let quote = make_route_quote_for_tokens(&[
2841            ("vm:exclusive", other, native.clone()),
2842            ("uniswap_v2", native, make_address(0x08)),
2843        ]);
2844
2845        assert!(!has_valid_exclusive_route(&quote, SimChain::Ethereum));
2846    }
2847
2848    #[test]
2849    fn test_exclusive_leg_wrap_not_producing_output() {
2850        let (native, wrapped, other) = wrap_tokens();
2851        let quote = make_route_quote_for_tokens(&[
2852            ("vm:exclusive", other, native.clone()),
2853            ("uniswap_v2", native, wrapped.clone()),
2854            ("uniswap_v2", wrapped, make_address(0x08)),
2855        ]);
2856
2857        assert!(!has_valid_exclusive_route(&quote, SimChain::Ethereum));
2858    }
2859
2860    #[test]
2861    fn test_exclusive_leg_wrap_of_another_path() {
2862        let (native, wrapped, other) = wrap_tokens();
2863        let quote = make_route_quote_for_tokens(&[
2864            ("vm:exclusive", other, make_address(0x08)),
2865            ("uniswap_v2", native, wrapped),
2866        ]);
2867
2868        assert!(!has_valid_exclusive_route(&quote, SimChain::Ethereum));
2869    }
2870
2871    /// A quote stating its gas cost twice, in output-token units and in wei.
2872    fn make_rate_quote(amount_out: u64, amount_out_net_gas: u64, gas_estimate: u64) -> OrderQuote {
2873        OrderQuote::new(
2874            "rate-order".to_string(),
2875            QuoteStatus::Success,
2876            BigUint::from(1_000u64),
2877            BigUint::from(amount_out),
2878            BigUint::from(gas_estimate),
2879            BigUint::from(amount_out_net_gas),
2880            BlockInfo::new(1, "0x123".to_string(), 1000),
2881            "test".to_string(),
2882            Bytes::from(make_address(0xAA).as_ref()),
2883            Bytes::from(make_address(0xAA).as_ref()),
2884            "1".to_string(),
2885        )
2886    }
2887
2888    /// 300k gas at 5 gwei is 0.0015 ETH, charged as 3 USDC: 2000 USDC per ETH.
2889    fn usdc_quote() -> OrderQuote {
2890        make_rate_quote(100_000_000, 97_000_000, 300_000)
2891            .with_gas_price(BigUint::from(5_000_000_000u64))
2892    }
2893
2894    #[test]
2895    fn test_to_gas_token_amount() {
2896        // 100 USDC at 2000 USDC per ETH is 0.05 ETH.
2897        let wei = to_gas_token_amount(&usdc_quote(), &BigUint::from(100_000_000u64));
2898
2899        assert_eq!(wei, Some(BigUint::from(50_000_000_000_000_000u64)));
2900    }
2901
2902    #[test]
2903    fn test_to_gas_token_amount_of_zero() {
2904        assert_eq!(to_gas_token_amount(&usdc_quote(), &BigUint::ZERO), Some(BigUint::ZERO));
2905    }
2906
2907    #[test]
2908    fn test_to_gas_token_amount_unpriced_output() {
2909        // Derived data has not priced the output token, so the algorithm netted no gas off it.
2910        let quote = make_rate_quote(100_000_000, 100_000_000, 300_000)
2911            .with_gas_price(BigUint::from(5_000_000_000u64));
2912
2913        assert_eq!(to_gas_token_amount(&quote, &BigUint::from(100_000_000u64)), None);
2914    }
2915
2916    #[test]
2917    fn test_to_gas_token_amount_without_gas_price() {
2918        let quote = make_rate_quote(100_000_000, 97_000_000, 300_000);
2919
2920        assert_eq!(to_gas_token_amount(&quote, &BigUint::from(100_000_000u64)), None);
2921    }
2922
2923    #[test]
2924    fn test_to_gas_token_amount_without_gas_estimate() {
2925        let quote = make_rate_quote(100_000_000, 97_000_000, 0).with_gas_price(BigUint::from(5u64));
2926
2927        assert_eq!(to_gas_token_amount(&quote, &BigUint::from(100_000_000u64)), None);
2928    }
2929
2930    #[test]
2931    fn test_to_gas_token_amount_net_gas_above_output() {
2932        // Gas refinement can overwrite the net output. Subtracting it must not panic.
2933        let quote = make_rate_quote(97_000_000, 100_000_000, 300_000)
2934            .with_gas_price(BigUint::from(5_000_000_000u64));
2935
2936        assert_eq!(to_gas_token_amount(&quote, &BigUint::from(100_000_000u64)), None);
2937    }
2938}