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