Skip to main content

fynd_core/
solver.rs

1//! High-level solver setup via [`FyndBuilder`].
2//!
3//! [`FyndBuilder`] assembles the full Tycho feed + gas fetcher + computation
4//! manager + one or more worker pools + encoder + router pipeline with sensible
5//! defaults. For simple cases a single call chain is all that's needed:
6//!
7//! ```ignore
8//! let solver = FyndBuilder::new(chain, tycho_url, rpc_url, protocols, min_tvl)
9//!     .tycho_api_key(key)
10//!     .algorithm("most_liquid")
11//!     .build()?;
12//! ```
13use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
14
15use num_cpus;
16use serde::{Deserialize, Serialize};
17use tokio::{sync::broadcast, task::JoinHandle};
18use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
19#[cfg(feature = "experimental")]
20use tycho_simulation::evm::stream::BlockStepController;
21#[cfg(feature = "test-utils")]
22use tycho_simulation::tycho_ethereum::gas::{BlockGasPrice, GasPrice};
23use tycho_simulation::{
24    evm::pending::PendingBlockProcessor,
25    tycho_common::{models::Chain, traits::TxDeltaIndexer, Bytes},
26    tycho_core::models::Address,
27    tycho_ethereum::rpc::EthereumRpcClient,
28};
29
30use crate::{
31    algorithm::{AlgorithmConfig, AlgorithmError},
32    derived::{ComputationManager, ComputationManagerConfig, SharedDerivedDataRef},
33    encoding::{encoder::Encoder, fee_fetcher::RouterFeeFetcher, router_fees::SharedRouterFees},
34    feed::{
35        events::{MarketEvent, MarketEventHandler},
36        exclusivity::ExclusivityPolicy,
37        gas::GasPriceFetcher,
38        market_data::MarketData,
39        metrics_sampler::MetricsSampler,
40        tycho_feed::TychoFeed,
41        TychoFeedConfig,
42    },
43    graph::EdgeWeightUpdaterWithDerived,
44    price_guard::{
45        guard::PriceGuard, provider::PriceProvider, provider_registry::PriceProviderRegistry,
46    },
47    types::constants::native_token,
48    worker_pool::{
49        pool::{WorkerPool, WorkerPoolBuilder},
50        registry::UnknownAlgorithmError,
51    },
52    worker_pool_router::{
53        config::WorkerPoolRouterConfig, LiquidityScope, SolverPoolHandle, WorkerPoolRouter,
54    },
55    Algorithm, Quote, QuoteRequest, SolveError,
56};
57
58/// Default values for [`FyndBuilder`] configuration and [`PoolConfig`] deserialization.
59///
60/// These are the single source of truth for all tunable defaults. Downstream
61/// crates (e.g. `fynd-rpc`) should re-export or reference these rather than
62/// redeclaring their own copies.
63pub mod defaults {
64    use std::time::Duration;
65
66    /// Minimum token quality score required for a token to be included in routing.
67    pub const MIN_TOKEN_QUALITY: i32 = 100;
68    /// Maximum age (in days) of trading history required for a token to be considered liquid.
69    pub const TRADED_N_DAYS_AGO: u64 = 3;
70    /// Multiplier applied to a pool's TVL when estimating available liquidity.
71    pub const TVL_BUFFER_RATIO: f64 = 1.1;
72    /// How often the gas price is refreshed from the RPC node.
73    pub const GAS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
74    /// How often per-protocol market metrics are sampled and exported.
75    pub const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_secs(10);
76    /// How often router fees are refreshed from the on-chain FeeCalculator contract.
77    pub const ROUTER_FEE_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
78    /// Delay before reconnecting to the Tycho feed after a disconnect.
79    pub const RECONNECT_DELAY: Duration = Duration::from_secs(5);
80    /// Minimum number of solver pool responses required before returning a quote (`0` = wait for
81    /// all).
82    pub const ROUTER_MIN_RESPONSES: usize = 0;
83    /// Capacity of the task queue for each worker pool.
84    pub const POOL_TASK_QUEUE_CAPACITY: usize = 1000;
85    /// Minimum number of hops allowed in a route.
86    pub const POOL_MIN_HOPS: usize = 1;
87    /// Maximum number of hops allowed in a route.
88    pub const POOL_MAX_HOPS: usize = 3;
89    /// Per-pool solve timeout in milliseconds.
90    pub const POOL_TIMEOUT_MS: u64 = 100;
91}
92
93// Internal-only defaults not shared with downstream crates.
94const DEFAULT_TYCHO_USE_TLS: bool = true;
95const DEFAULT_DEPTH_SLIPPAGE_THRESHOLD: f64 = 0.01;
96/// Generous router timeout for standalone (non-server) use. HTTP services should
97/// override this to a tighter value appropriate for their SLA.
98const DEFAULT_ROUTER_TIMEOUT: Duration = Duration::from_secs(10);
99
100// serde requires free functions for `#[serde(default = "...")]` — these delegate to the
101// defaults module so both deserialization and the builder stay in sync.
102fn default_task_queue_capacity() -> usize {
103    defaults::POOL_TASK_QUEUE_CAPACITY
104}
105
106fn default_min_hops() -> usize {
107    defaults::POOL_MIN_HOPS
108}
109
110fn default_max_hops() -> usize {
111    defaults::POOL_MAX_HOPS
112}
113
114fn default_algo_timeout_ms() -> u64 {
115    defaults::POOL_TIMEOUT_MS
116}
117
118fn parse_connector_tokens(
119    raw: Option<&[String]>,
120) -> Result<Option<HashSet<Address>>, SolverBuildError> {
121    let Some(strings) = raw else {
122        return Ok(None);
123    };
124    let mut set = HashSet::with_capacity(strings.len());
125    for s in strings {
126        let addr = Address::from_str(s).map_err(|e| AlgorithmError::InvalidConfiguration {
127            reason: format!("connector_tokens: invalid address {s:?}: {e}"),
128        })?;
129        set.insert(addr);
130    }
131    Ok(Some(set))
132}
133
134/// Per-pool configuration for [`FyndBuilder::add_pool`].
135#[must_use]
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PoolConfig {
138    /// Algorithm name for this pool (e.g., `"most_liquid"`).
139    algorithm: String,
140    /// Number of worker threads for this pool.
141    #[serde(default = "num_cpus::get")]
142    num_workers: usize,
143    /// Task queue capacity for this pool.
144    #[serde(default = "default_task_queue_capacity")]
145    task_queue_capacity: usize,
146    /// Minimum hops to search (must be >= 1).
147    #[serde(default = "default_min_hops")]
148    min_hops: usize,
149    /// Maximum hops to search.
150    #[serde(default = "default_max_hops")]
151    max_hops: usize,
152    /// Timeout for solving in milliseconds.
153    #[serde(default = "default_algo_timeout_ms")]
154    timeout_ms: u64,
155    /// Maximum number of paths to simulate per solve. `None` simulates all scored paths.
156    #[serde(default)]
157    max_routes: Option<usize>,
158    /// Lowercase hex addresses (e.g. `"0xc02aaa…"`) allowed as intermediate routing hops.
159    /// Absent = no restriction. Typically 3–10 entries (e.g. WETH, USDC, USDT, DAI).
160    #[serde(default)]
161    connector_tokens: Option<Vec<String>>,
162    /// Pool liquidity scope: `public_only` or `all` (routes through exclusive liquidity as
163    /// well). Setting this key (either value) declares exclusive routing intent and requires an
164    /// exclusivity policy; absent = plain deployment, no filtering.
165    #[serde(default)]
166    liquidity_scope: Option<LiquidityScope>,
167}
168
169impl PoolConfig {
170    /// Creates a new pool config with the given algorithm name and defaults for all other fields.
171    pub fn new(algorithm: impl Into<String>) -> Self {
172        Self {
173            algorithm: algorithm.into(),
174            num_workers: num_cpus::get(),
175            task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
176            min_hops: defaults::POOL_MIN_HOPS,
177            max_hops: defaults::POOL_MAX_HOPS,
178            timeout_ms: defaults::POOL_TIMEOUT_MS,
179            max_routes: None,
180            connector_tokens: None,
181            liquidity_scope: None,
182        }
183    }
184
185    /// Returns the algorithm name.
186    pub fn algorithm(&self) -> &str {
187        &self.algorithm
188    }
189
190    /// Returns the pool's liquidity scope.
191    pub fn liquidity_scope(&self) -> Option<LiquidityScope> {
192        self.liquidity_scope
193    }
194
195    /// Sets the pool's liquidity scope (public or exclusive-access).
196    pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
197        self.liquidity_scope = Some(scope);
198        self
199    }
200
201    /// Returns the number of worker threads.
202    pub fn num_workers(&self) -> usize {
203        self.num_workers
204    }
205
206    /// Sets the number of worker threads.
207    pub fn with_num_workers(mut self, num_workers: usize) -> Self {
208        self.num_workers = num_workers;
209        self
210    }
211
212    /// Sets the task queue capacity.
213    pub fn with_task_queue_capacity(mut self, task_queue_capacity: usize) -> Self {
214        self.task_queue_capacity = task_queue_capacity;
215        self
216    }
217
218    /// Sets the minimum hops.
219    pub fn with_min_hops(mut self, min_hops: usize) -> Self {
220        self.min_hops = min_hops;
221        self
222    }
223
224    /// Sets the maximum hops.
225    pub fn with_max_hops(mut self, max_hops: usize) -> Self {
226        self.max_hops = max_hops;
227        self
228    }
229
230    /// Sets the timeout in milliseconds.
231    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
232        self.timeout_ms = timeout_ms;
233        self
234    }
235
236    /// Sets the maximum number of routes to simulate.
237    pub fn with_max_routes(mut self, max_routes: Option<usize>) -> Self {
238        self.max_routes = max_routes;
239        self
240    }
241
242    /// Returns the task queue capacity.
243    pub fn task_queue_capacity(&self) -> usize {
244        self.task_queue_capacity
245    }
246
247    /// Returns the minimum hops.
248    pub fn min_hops(&self) -> usize {
249        self.min_hops
250    }
251
252    /// Returns the maximum hops.
253    pub fn max_hops(&self) -> usize {
254        self.max_hops
255    }
256
257    /// Returns the timeout in milliseconds.
258    pub fn timeout_ms(&self) -> u64 {
259        self.timeout_ms
260    }
261
262    /// Returns the maximum number of routes to simulate.
263    pub fn max_routes(&self) -> Option<usize> {
264        self.max_routes
265    }
266
267    /// Restricts intermediate hops to the given token addresses (hex strings with or without `0x`
268    /// prefix). Absent = no restriction.
269    pub fn with_connector_tokens(mut self, tokens: Vec<String>) -> Self {
270        self.connector_tokens = Some(tokens);
271        self
272    }
273
274    /// Returns the raw connector token address strings, or `None` if unrestricted.
275    pub fn connector_tokens(&self) -> Option<&[String]> {
276        self.connector_tokens.as_deref()
277    }
278}
279
280/// Error returned by [`Solver::wait_until_ready`].
281#[derive(Debug, thiserror::Error)]
282#[error("timed out after {timeout_ms}ms waiting for market data and derived computations")]
283pub struct WaitReadyError {
284    timeout_ms: u64,
285}
286
287/// Error returned by [`FyndBuilder::build`] and [`FyndBuilder::build_with_pending`].
288#[non_exhaustive]
289#[derive(Debug, thiserror::Error)]
290pub enum SolverBuildError {
291    /// The Ethereum RPC client could not be created (e.g. malformed URL).
292    #[error("failed to create ethereum RPC client: {0}")]
293    RpcClient(String),
294    /// An invalid algorithm configuration was supplied.
295    #[error(transparent)]
296    AlgorithmConfig(#[from] AlgorithmError),
297    /// The [`ComputationManager`] failed to initialise.
298    #[error("failed to create computation manager: {0}")]
299    ComputationManager(String),
300    /// The swap encoder could not be created for the target chain.
301    #[error("failed to create encoder: {0}")]
302    Encoder(String),
303    /// The router fee fetcher could not be created (e.g. malformed RPC URL).
304    #[error("failed to create router fee fetcher: {0}")]
305    RouterFeeFetcher(String),
306    /// A pool referenced an algorithm name that is not registered.
307    #[error(transparent)]
308    UnknownAlgorithm(#[from] UnknownAlgorithmError),
309    /// No native gas token is defined for the requested chain.
310    #[error("gas token not configured for chain")]
311    GasToken,
312    /// [`FyndBuilder::build`] was called without configuring any worker pools.
313    #[error("no worker pools configured")]
314    NoPools,
315    /// A worker pool sets `liquidity_scope` but no exclusivity policy is configured. The
316    /// policy is what public pools filter by and what the router identifies exclusive legs
317    /// with — without it, nothing distinguishes exclusive protocol components from public ones.
318    #[error(
319        "a worker pool sets liquidity_scope but no exclusivity policy is configured; \
320         set one via FyndBuilder::exclusivity_policy"
321    )]
322    LiquidityScopeWithoutPolicy,
323    /// A recorded update failed to replay through the feed.
324    #[cfg(feature = "test-utils")]
325    #[error("replay failed: {0}")]
326    Replay(String),
327    /// The feed task failed before delivering the [`PendingBlockProcessor`].
328    ///
329    /// The inner string is the `DataFeedError` message from `TychoFeed::run_with_pending`
330    /// (e.g. "failed to load tokens: connection refused").
331    #[error("feed setup failed before delivering pending processor: {0}")]
332    FeedSetup(String),
333    /// The pending-processor oneshot closed without delivering a value, meaning the feed task
334    /// panicked rather than returning an error through the channel.
335    #[error("pending processor channel closed before processor was delivered")]
336    PendingChannelClosed,
337    /// The step-controller oneshot closed without delivering a value, meaning the feed task
338    /// panicked rather than returning an error through the channel.
339    #[cfg(feature = "experimental")]
340    #[error("step controller channel closed before controller was delivered")]
341    StepControllerChannelClosed,
342}
343
344/// Internal pool entry — either a built-in algorithm (by name) or a custom one.
345enum PoolEntry {
346    BuiltIn {
347        name: String,
348        algorithm: String,
349        num_workers: usize,
350        task_queue_capacity: usize,
351        min_hops: usize,
352        max_hops: usize,
353        timeout_ms: u64,
354        max_routes: Option<usize>,
355        connector_tokens: Option<HashSet<Address>>,
356        liquidity_scope: Option<LiquidityScope>,
357    },
358    Custom(CustomPoolEntry),
359}
360
361impl PoolEntry {
362    /// Returns the configured liquidity scope for this worker pool.
363    fn liquidity_scope(&self) -> Option<LiquidityScope> {
364        match self {
365            PoolEntry::BuiltIn { liquidity_scope, .. } => *liquidity_scope,
366            PoolEntry::Custom(custom) => custom.liquidity_scope,
367        }
368    }
369}
370
371/// Pool entry backed by a custom [`Algorithm`] implementation.
372struct CustomPoolEntry {
373    name: String,
374    num_workers: usize,
375    task_queue_capacity: usize,
376    min_hops: usize,
377    max_hops: usize,
378    timeout_ms: u64,
379    max_routes: Option<usize>,
380    /// Pool liquidity scope: public (default) or exclusive-access.
381    liquidity_scope: Option<LiquidityScope>,
382    /// Applies the custom algorithm to a `WorkerPoolBuilder`.
383    configure: Box<dyn FnOnce(WorkerPoolBuilder) -> WorkerPoolBuilder + Send>,
384}
385
386/// All components produced by [`FyndBuilder::assemble_components`], consumed by
387/// [`FyndBuilder::build`] and [`FyndBuilder::build_with_pending`].
388struct BuiltComponents {
389    tycho_feed: TychoFeed,
390    gas_price_fetcher: GasPriceFetcher<EthereumRpcClient>,
391    router_fee_fetcher: Option<RouterFeeFetcher>,
392    computation_manager: ComputationManager,
393    computation_event_rx: broadcast::Receiver<MarketEvent>,
394    computation_shutdown_tx: broadcast::Sender<()>,
395    computation_shutdown_rx: broadcast::Receiver<()>,
396    router: WorkerPoolRouter,
397    worker_pools: Vec<WorkerPool>,
398    market_data: MarketData,
399    derived_data: SharedDerivedDataRef,
400    router_fees: SharedRouterFees,
401    chain: Chain,
402    router_address: Option<Bytes>,
403    pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
404    market_event_tx: broadcast::Sender<MarketEvent>,
405}
406
407/// Builder for assembling the full solver pipeline.
408///
409/// Configures the Tycho market-data feed, gas price fetcher, derived-data
410/// computation manager, one or more worker pools, encoder, and router.
411#[must_use = "a builder does nothing until .build() is called"]
412pub struct FyndBuilder {
413    chain: Chain,
414    tycho_url: String,
415    rpc_url: String,
416    protocols: Vec<String>,
417    min_tvl: f64,
418    tycho_api_key: Option<String>,
419    tycho_use_tls: bool,
420    min_token_quality: i32,
421    traded_n_days_ago: u64,
422    tvl_buffer_ratio: f64,
423    gas_refresh_interval: Duration,
424    reconnect_delay: Duration,
425    blocklisted_components: HashSet<String>,
426    partial_blocks: bool,
427    router_timeout: Duration,
428    router_min_responses: usize,
429    encoder: Option<Encoder>,
430    pools: Vec<PoolEntry>,
431    price_guard_enabled: bool,
432    price_providers: Vec<Box<dyn PriceProvider>>,
433    pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
434    /// Predicate identifying exclusive components. Shared by all pools: public pools exclude
435    /// matching components, exclusive-access pools include them. `None` ⇒ no pool filters
436    /// anything.
437    exclusivity_policy: Option<ExclusivityPolicy>,
438}
439
440impl FyndBuilder {
441    /// Creates a new builder with the required parameters.
442    pub fn new(
443        chain: Chain,
444        tycho_url: impl Into<String>,
445        rpc_url: impl Into<String>,
446        protocols: Vec<String>,
447        min_tvl: f64,
448    ) -> Self {
449        Self {
450            chain,
451            tycho_url: tycho_url.into(),
452            rpc_url: rpc_url.into(),
453            protocols,
454            min_tvl,
455            tycho_api_key: None,
456            tycho_use_tls: DEFAULT_TYCHO_USE_TLS,
457            min_token_quality: defaults::MIN_TOKEN_QUALITY,
458            traded_n_days_ago: defaults::TRADED_N_DAYS_AGO,
459            tvl_buffer_ratio: defaults::TVL_BUFFER_RATIO,
460            gas_refresh_interval: defaults::GAS_REFRESH_INTERVAL,
461            reconnect_delay: defaults::RECONNECT_DELAY,
462            blocklisted_components: HashSet::new(),
463            partial_blocks: false,
464            router_timeout: DEFAULT_ROUTER_TIMEOUT,
465            router_min_responses: defaults::ROUTER_MIN_RESPONSES,
466            encoder: None,
467            pools: Vec::new(),
468            price_guard_enabled: false,
469            price_providers: Vec::new(),
470            pending_indexers: Vec::new(),
471            exclusivity_policy: None,
472        }
473    }
474
475    /// The blockchain this builder is configured for.
476    pub fn chain(&self) -> Chain {
477        self.chain
478    }
479
480    /// Sets the Tycho API key.
481    pub fn tycho_api_key(mut self, key: impl Into<String>) -> Self {
482        self.tycho_api_key = Some(key.into());
483        self
484    }
485
486    /// Overrides the minimum TVL filter set in [`FyndBuilder::new`].
487    pub fn min_tvl(mut self, min_tvl: f64) -> Self {
488        self.min_tvl = min_tvl;
489        self
490    }
491
492    /// Enables or disables TLS for the Tycho WebSocket connection (default: `true`).
493    pub fn tycho_use_tls(mut self, use_tls: bool) -> Self {
494        self.tycho_use_tls = use_tls;
495        self
496    }
497
498    /// Sets the minimum token quality score; tokens below this threshold are excluded (default:
499    /// 100).
500    pub fn min_token_quality(mut self, quality: i32) -> Self {
501        self.min_token_quality = quality;
502        self
503    }
504
505    /// Filters out pools whose last trade is older than `days` days (default: 3).
506    pub fn traded_n_days_ago(mut self, days: u64) -> Self {
507        self.traded_n_days_ago = days;
508        self
509    }
510
511    /// Multiplies reported TVL by `ratio` before applying the `min_tvl` filter (default: 1.1).
512    pub fn tvl_buffer_ratio(mut self, ratio: f64) -> Self {
513        self.tvl_buffer_ratio = ratio;
514        self
515    }
516
517    /// Sets how often the gas price is refreshed from the RPC node (default: 30 s).
518    pub fn gas_refresh_interval(mut self, interval: Duration) -> Self {
519        self.gas_refresh_interval = interval;
520        self
521    }
522
523    /// Sets the delay before reconnecting to Tycho after a disconnection (default: 5 s).
524    pub fn reconnect_delay(mut self, delay: Duration) -> Self {
525        self.reconnect_delay = delay;
526        self
527    }
528
529    /// Sets component IDs to exclude from the Tycho stream.
530    pub fn blocklisted_components(mut self, components: HashSet<String>) -> Self {
531        self.blocklisted_components = components;
532        self
533    }
534
535    /// Enables partial block (flashblock) updates from the Tycho stream (default: `false`).
536    ///
537    /// When enabled, the stream delivers pool state updates mid-block rather than only at
538    /// finalization, reducing latency. Only supported for on-chain protocols; RFQ streams are
539    /// unaffected.
540    pub fn partial_blocks(mut self, enabled: bool) -> Self {
541        self.partial_blocks = enabled;
542        self
543    }
544
545    /// Sets the worker router timeout (default: 10s).
546    pub fn worker_router_timeout(mut self, timeout: Duration) -> Self {
547        self.router_timeout = timeout;
548        self
549    }
550
551    /// Sets the minimum number of solver responses before early return (default: 0).
552    pub fn worker_router_min_responses(mut self, min: usize) -> Self {
553        self.router_min_responses = min;
554        self
555    }
556
557    /// Overrides the default encoder.
558    pub fn encoder(mut self, encoder: Encoder) -> Self {
559        self.encoder = Some(encoder);
560        self
561    }
562
563    /// Shorthand: adds a single pool named `"default"` using a built-in algorithm by name.
564    pub fn algorithm(mut self, algorithm: impl Into<String>) -> Self {
565        self.pools.push(PoolEntry::BuiltIn {
566            name: "default".to_string(),
567            algorithm: algorithm.into(),
568            num_workers: num_cpus::get(),
569            task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
570            min_hops: defaults::POOL_MIN_HOPS,
571            max_hops: defaults::POOL_MAX_HOPS,
572            timeout_ms: defaults::POOL_TIMEOUT_MS,
573            max_routes: None,
574            connector_tokens: None,
575            liquidity_scope: None,
576        });
577        self
578    }
579
580    /// Shorthand: adds a single pool with a custom [`Algorithm`] implementation.
581    ///
582    /// The `factory` closure is called once per worker thread.
583    pub fn with_algorithm<A, F>(mut self, name: impl Into<String>, factory: F) -> Self
584    where
585        A: Algorithm + 'static,
586        A::GraphManager: MarketEventHandler + EdgeWeightUpdaterWithDerived + 'static,
587        F: Fn(AlgorithmConfig) -> A + Clone + Send + Sync + 'static,
588    {
589        let name = name.into();
590        let algo_name = name.clone();
591        let configure =
592            Box::new(move |builder: WorkerPoolBuilder| builder.with_algorithm(algo_name, factory));
593        self.pools
594            .push(PoolEntry::Custom(CustomPoolEntry {
595                name,
596                num_workers: num_cpus::get(),
597                task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
598                min_hops: defaults::POOL_MIN_HOPS,
599                max_hops: defaults::POOL_MAX_HOPS,
600                timeout_ms: defaults::POOL_TIMEOUT_MS,
601                max_routes: None,
602                liquidity_scope: None,
603                configure,
604            }));
605        self
606    }
607
608    /// Registers the built-in price providers (Hyperliquid + Binance).
609    ///
610    /// Called automatically during [`build`](Self::build) if no providers have been
611    /// registered and the price guard is not disabled. To use only custom
612    /// providers, call [`register_price_provider`](Self::register_price_provider)
613    /// before `build()` and the defaults will be skipped.
614    pub fn add_default_price_providers(self) -> Self {
615        self.register_price_provider(Box::new(
616            crate::price_guard::hyperliquid::HyperliquidProvider::default(),
617        ))
618        .register_price_provider(Box::new(
619            crate::price_guard::binance_ws::BinanceWsProvider::default(),
620        ))
621    }
622
623    /// Registers a custom price provider for the price guard.
624    ///
625    /// The provider's [`start`](PriceProvider::start) method is called during
626    /// [`build`](Self::build) with the shared market data.
627    pub fn register_price_provider(mut self, provider: Box<dyn PriceProvider>) -> Self {
628        self.price_providers.push(provider);
629        self
630    }
631
632    /// Registers a [`TxDeltaIndexer`] for ephemeral pending-block simulation.
633    ///
634    /// `extractor` is the protocol synchronizer name (e.g. `"uniswap_v3"`). Only has effect
635    /// when calling [`build_with_pending`](Self::build_with_pending). VM protocols (prefix
636    /// `"vm:"`) are rejected by the underlying stream builder at build time.
637    pub fn with_pending_indexer(
638        mut self,
639        extractor: impl Into<String>,
640        indexer: Box<dyn TxDeltaIndexer>,
641    ) -> Self {
642        self.pending_indexers
643            .push((extractor.into(), indexer));
644        self
645    }
646
647    /// Enables or disables the price guard.
648    ///
649    /// When enabled, providers are started and caches stay warm. Validation
650    /// only runs for requests where the client sets `enabled: true` in
651    /// `PriceGuardConfig`. When disabled, no providers are started and
652    /// per-request attempts to use the guard return an error.
653    pub fn price_guard_enabled(mut self, enabled: bool) -> Self {
654        self.price_guard_enabled = enabled;
655        self
656    }
657
658    /// Sets the predicate that classifies components as exclusive.
659    ///
660    /// Public pools exclude matching components from their graphs; exclusive-access pools
661    /// include them. Without a policy, no pool filters anything.
662    pub fn exclusivity_policy<F>(mut self, predicate: F) -> Self
663    where
664        F: Fn(&tycho_simulation::tycho_common::models::protocol::ProtocolComponent) -> bool
665            + Send
666            + Sync
667            + 'static,
668    {
669        self.exclusivity_policy = Some(ExclusivityPolicy::new(predicate));
670        self
671    }
672
673    /// Adds a named pool using the given [`PoolConfig`].
674    ///
675    /// # Errors
676    ///
677    /// Returns [`SolverBuildError::AlgorithmConfig`] if any address in `connector_tokens` is not
678    /// valid hex.
679    pub fn add_pool(
680        mut self,
681        name: impl Into<String>,
682        config: &PoolConfig,
683    ) -> Result<Self, SolverBuildError> {
684        let connector_tokens = parse_connector_tokens(config.connector_tokens())?;
685        self.pools.push(PoolEntry::BuiltIn {
686            name: name.into(),
687            algorithm: config.algorithm().to_string(),
688            num_workers: config.num_workers(),
689            task_queue_capacity: config.task_queue_capacity(),
690            min_hops: config.min_hops(),
691            max_hops: config.max_hops(),
692            timeout_ms: config.timeout_ms(),
693            max_routes: config.max_routes(),
694            connector_tokens,
695            liquidity_scope: config.liquidity_scope(),
696        });
697        Ok(self)
698    }
699
700    /// Constructs all components shared between [`build`](Self::build) and
701    /// [`build_with_pending`](Self::build_with_pending).
702    fn assemble_components(mut self) -> Result<BuiltComponents, SolverBuildError> {
703        if self.pools.is_empty() {
704            return Err(SolverBuildError::NoPools);
705        }
706
707        // Setting `liquidity_scope` on any pool (either value) declares exclusive routing
708        // intent, which requires a policy: public pools filter by it and the router identifies
709        // exclusive legs with it. Without one, a `public_only` pool could not honor its label
710        // and an `all` pool could not produce anything distinct. Pools with the key absent are
711        // plain deployments and need no policy.
712        if self.exclusivity_policy.is_none() &&
713            self.pools
714                .iter()
715                .any(|p| p.liquidity_scope().is_some())
716        {
717            return Err(SolverBuildError::LiquidityScopeWithoutPolicy);
718        }
719
720        // Add built-in providers if none were explicitly registered.
721        if self.price_providers.is_empty() {
722            self = self.add_default_price_providers();
723        }
724
725        let market_data = MarketData::new_shared();
726
727        let tycho_feed_config = TychoFeedConfig::new(
728            self.tycho_url,
729            self.chain,
730            self.tycho_api_key,
731            self.tycho_use_tls,
732            self.protocols,
733            self.min_tvl,
734        )
735        .tvl_buffer_ratio(self.tvl_buffer_ratio)
736        .reconnect_delay(self.reconnect_delay)
737        .min_token_quality(self.min_token_quality)
738        .traded_n_days_ago(self.traded_n_days_ago)
739        .blocklisted_components(self.blocklisted_components)
740        .partial_blocks(self.partial_blocks);
741
742        let ethereum_client = EthereumRpcClient::new(self.rpc_url.as_str())
743            .map_err(|e| SolverBuildError::RpcClient(e.to_string()))?;
744
745        let gas_price_fetcher =
746            GasPriceFetcher::new(ethereum_client, market_data.clone(), self.gas_refresh_interval);
747
748        let tycho_feed = TychoFeed::new(tycho_feed_config, market_data.clone());
749        let market_event_tx = tycho_feed.event_sender();
750
751        let gas_token = native_token(&self.chain).map_err(|_| SolverBuildError::GasToken)?;
752        let computation_config = ComputationManagerConfig::new()
753            .with_gas_token(gas_token)
754            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
755        // ComputationManager::new returns a broadcast receiver that we don't need here —
756        // workers subscribe via computation_manager.event_sender() below.
757        let (computation_manager, _) =
758            ComputationManager::new(computation_config, market_data.clone())
759                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
760
761        let derived_data: SharedDerivedDataRef = computation_manager.store();
762        let derived_event_tx = computation_manager.event_sender();
763
764        // Subscribe event channels before spawning (one for computation manager + one per pool)
765        let computation_event_rx = tycho_feed.subscribe();
766        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
767
768        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
769        let mut worker_pools: Vec<WorkerPool> = Vec::new();
770
771        // Shared across all pools: public pools exclude exclusive components, exclusive-access
772        // pools include them. `None` ⇒ no pool filters anything (original behaviour).
773        let exclusivity_policy = self.exclusivity_policy.take();
774        let pools = std::mem::take(&mut self.pools);
775
776        for pool_entry in pools {
777            let pool_event_rx = tycho_feed.subscribe();
778            let derived_rx = derived_event_tx.subscribe();
779
780            // Explicit `public_only` pools (and unscoped pools, harmlessly) get the policy so
781            // their workers filter exclusive components out; `all` pools get `None` and ingest
782            // everything.
783            let pool_scope = pool_entry
784                .liquidity_scope()
785                .unwrap_or_default();
786            let pool_policy = match pool_scope {
787                LiquidityScope::PublicOnly => exclusivity_policy.clone(),
788                LiquidityScope::All => None,
789            };
790
791            let (worker_pool, task_handle) = match pool_entry {
792                PoolEntry::BuiltIn {
793                    name,
794                    algorithm,
795                    num_workers,
796                    task_queue_capacity,
797                    min_hops,
798                    max_hops,
799                    timeout_ms,
800                    max_routes,
801                    connector_tokens,
802                    liquidity_scope: _,
803                } => {
804                    let mut algo_cfg = AlgorithmConfig::new(
805                        min_hops,
806                        max_hops,
807                        Duration::from_millis(timeout_ms),
808                        max_routes,
809                    )?;
810                    if let Some(tokens) = connector_tokens {
811                        algo_cfg = algo_cfg.with_connector_tokens(tokens);
812                    }
813                    let builder = WorkerPoolBuilder::new()
814                        .name(name)
815                        .algorithm(algorithm)
816                        .algorithm_config(algo_cfg)
817                        .num_workers(num_workers)
818                        .task_queue_capacity(task_queue_capacity)
819                        .exclusivity_policy(pool_policy.clone());
820                    builder.build(
821                        market_data.clone(),
822                        Arc::clone(&derived_data),
823                        pool_event_rx,
824                        derived_rx,
825                    )?
826                }
827                PoolEntry::Custom(custom) => {
828                    let algo_cfg = AlgorithmConfig::new(
829                        custom.min_hops,
830                        custom.max_hops,
831                        Duration::from_millis(custom.timeout_ms),
832                        custom.max_routes,
833                    )?;
834                    let builder = WorkerPoolBuilder::new()
835                        .name(custom.name)
836                        .algorithm_config(algo_cfg)
837                        .num_workers(custom.num_workers)
838                        .task_queue_capacity(custom.task_queue_capacity)
839                        .exclusivity_policy(pool_policy.clone());
840                    let builder = (custom.configure)(builder);
841                    builder.build(
842                        market_data.clone(),
843                        Arc::clone(&derived_data),
844                        pool_event_rx,
845                        derived_rx,
846                    )?
847                }
848            };
849
850            solver_pool_handles.push(
851                SolverPoolHandle::new(worker_pool.name(), task_handle)
852                    .with_liquidity_scope(pool_scope),
853            );
854            worker_pools.push(worker_pool);
855        }
856
857        let encoder = match self.encoder {
858            Some(enc) => enc,
859            None => {
860                let registry = SwapEncoderRegistry::new(self.chain)
861                    .add_default_encoders(None)
862                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
863                Encoder::new(self.chain, registry)
864                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?
865            }
866        };
867
868        let chain = self.chain;
869        let router_address = encoder.router_address().cloned();
870        let router_fees = encoder.router_fees();
871
872        let router_fee_fetcher = match &router_address {
873            Some(addr) => Some(
874                RouterFeeFetcher::new(
875                    self.rpc_url.as_str(),
876                    addr,
877                    router_fees.clone(),
878                    defaults::ROUTER_FEE_REFRESH_INTERVAL,
879                )
880                .map_err(|e| SolverBuildError::RouterFeeFetcher(e.to_string()))?,
881            ),
882            None => {
883                tracing::warn!(
884                    %chain,
885                    "no Tycho router for this chain; running quote-only (encoding disabled)"
886                );
887                None
888            }
889        };
890
891        // Only start price providers when the guard is enabled.
892        // When disabled, per-request attempts to enable the guard return an error.
893        let router_config = WorkerPoolRouterConfig::default()
894            .with_timeout(self.router_timeout)
895            .with_min_responses(self.router_min_responses);
896        let mut router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
897
898        if let Some(ref policy) = exclusivity_policy {
899            router = router.with_exclusivity_policy(policy.clone());
900        }
901
902        if self.price_guard_enabled {
903            let mut registry = PriceProviderRegistry::new();
904            let mut worker_handles = Vec::new();
905            for mut provider in self.price_providers {
906                worker_handles.push(provider.start(market_data.clone()));
907                registry = registry.register(provider);
908            }
909            let price_guard = PriceGuard::new(registry, worker_handles);
910            router = router.with_price_guard(price_guard);
911        }
912
913        Ok(BuiltComponents {
914            tycho_feed,
915            gas_price_fetcher,
916            router_fee_fetcher,
917            computation_manager,
918            computation_event_rx,
919            computation_shutdown_tx,
920            computation_shutdown_rx,
921            router,
922            worker_pools,
923            market_data,
924            derived_data,
925            router_fees,
926            chain,
927            router_address,
928            pending_indexers: self.pending_indexers,
929            market_event_tx,
930        })
931    }
932
933    /// Assembles and starts all solver components.
934    ///
935    /// # Errors
936    ///
937    /// Returns [`SolverBuildError`] if any component fails to initialize.
938    pub fn build(self) -> Result<Solver, SolverBuildError> {
939        let mut c = self.assemble_components()?;
940
941        let feed_handle = tokio::spawn(async move {
942            if let Err(e) = c.tycho_feed.run().await {
943                metrics::counter!("tycho_feed_failures_total").increment(1);
944                tracing::error!(error = %e, "tycho feed error");
945            }
946        });
947        let gas_price_handle = tokio::spawn(async move {
948            c.gas_price_fetcher.run().await;
949        });
950        let metrics_sampler =
951            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
952        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
953        let router_fee_handle = match c.router_fee_fetcher {
954            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
955            None => tokio::spawn(async {}),
956        };
957        let computation_handle = tokio::spawn(async move {
958            c.computation_manager
959                .run(c.computation_event_rx, c.computation_shutdown_rx)
960                .await;
961        });
962
963        Ok(Solver {
964            router: c.router,
965            worker_pools: c.worker_pools,
966            market_data: c.market_data,
967            derived_data: c.derived_data,
968            router_fees: c.router_fees,
969            feed_handle,
970            gas_price_handle,
971            metrics_sampler_handle,
972            router_fee_handle,
973            computation_handle,
974            computation_shutdown_tx: c.computation_shutdown_tx,
975            chain: c.chain,
976            router_address: c.router_address,
977            market_event_tx: c.market_event_tx,
978        })
979    }
980
981    /// Assembles and starts all solver components, also returning a [`PendingBlockProcessor`]
982    /// for ephemeral bundle simulation against the live Tycho market state.
983    ///
984    /// Identical to [`build`](Self::build) except the feed task runs via
985    /// `TychoFeed::run_with_pending`. The `PendingBlockProcessor` is delivered after
986    /// token loading, before the first block is processed.
987    ///
988    /// # Errors
989    ///
990    /// Returns [`SolverBuildError`] if any component fails to initialize or the pending
991    /// channel closes before delivering the processor (e.g. token loading failed).
992    pub async fn build_with_pending(
993        self,
994    ) -> Result<(Solver, PendingBlockProcessor), SolverBuildError> {
995        let mut c = self.assemble_components()?;
996
997        let (pending_tx, pending_rx) =
998            tokio::sync::oneshot::channel::<Result<PendingBlockProcessor, String>>();
999
1000        let pending_indexers = c.pending_indexers;
1001        let feed_handle = tokio::spawn(async move {
1002            if let Err(e) = c
1003                .tycho_feed
1004                .run_with_pending(pending_tx, pending_indexers)
1005                .await
1006            {
1007                metrics::counter!("tycho_feed_failures_total").increment(1);
1008                tracing::error!(error = %e, "tycho feed error");
1009            }
1010        });
1011        let gas_price_handle = tokio::spawn(async move {
1012            c.gas_price_fetcher.run().await;
1013        });
1014        let metrics_sampler =
1015            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1016        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1017        let router_fee_handle = match c.router_fee_fetcher {
1018            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1019            None => tokio::spawn(async {}),
1020        };
1021        let computation_handle = tokio::spawn(async move {
1022            c.computation_manager
1023                .run(c.computation_event_rx, c.computation_shutdown_rx)
1024                .await;
1025        });
1026
1027        let pending = pending_rx
1028            .await
1029            .map_err(|_| SolverBuildError::PendingChannelClosed)?
1030            .map_err(SolverBuildError::FeedSetup)?;
1031
1032        Ok((
1033            Solver {
1034                router: c.router,
1035                worker_pools: c.worker_pools,
1036                market_data: c.market_data,
1037                derived_data: c.derived_data,
1038                router_fees: c.router_fees,
1039                feed_handle,
1040                gas_price_handle,
1041                metrics_sampler_handle,
1042                router_fee_handle,
1043                computation_handle,
1044                computation_shutdown_tx: c.computation_shutdown_tx,
1045                chain: c.chain,
1046                router_address: c.router_address,
1047                market_event_tx: c.market_event_tx,
1048            },
1049            pending,
1050        ))
1051    }
1052
1053    /// Assembles and starts all solver components, also returning a [`BlockStepController`]
1054    /// that lets the caller control when each buffered block is released for processing.
1055    ///
1056    /// Intended for deterministic testing: call [`BlockStepController::trigger_next_block`] to
1057    /// step through blocks one at a time, and [`BlockStepController::peek_next_block`] to inspect
1058    /// a block before it is decoded. Dropping the controller ungates the stream so it runs to its
1059    /// natural end.
1060    ///
1061    /// Only valid when at least one non-RFQ protocol is configured.
1062    ///
1063    /// # Errors
1064    ///
1065    /// Returns [`SolverBuildError`] if any component fails to initialize, all protocols are RFQ,
1066    /// or the step-controller channel closes before the controller is delivered.
1067    #[cfg(feature = "experimental")]
1068    pub async fn build_with_step_controller(
1069        self,
1070    ) -> Result<(Solver, BlockStepController), SolverBuildError> {
1071        let mut c = self.assemble_components()?;
1072
1073        let (controller_tx, controller_rx) =
1074            tokio::sync::oneshot::channel::<Result<BlockStepController, String>>();
1075
1076        let feed_handle = tokio::spawn(async move {
1077            if let Err(e) = c
1078                .tycho_feed
1079                .run_with_step_controller(controller_tx)
1080                .await
1081            {
1082                tracing::error!(error = %e, "tycho feed error");
1083            }
1084        });
1085        let gas_price_handle = tokio::spawn(async move {
1086            c.gas_price_fetcher.run().await;
1087        });
1088        let metrics_sampler =
1089            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1090        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1091        let router_fee_handle = match c.router_fee_fetcher {
1092            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1093            None => tokio::spawn(async {}),
1094        };
1095        let computation_handle = tokio::spawn(async move {
1096            c.computation_manager
1097                .run(c.computation_event_rx, c.computation_shutdown_rx)
1098                .await;
1099        });
1100
1101        let controller = controller_rx
1102            .await
1103            .map_err(|_| SolverBuildError::StepControllerChannelClosed)?
1104            .map_err(SolverBuildError::FeedSetup)?;
1105
1106        Ok((
1107            Solver {
1108                router: c.router,
1109                worker_pools: c.worker_pools,
1110                market_data: c.market_data,
1111                derived_data: c.derived_data,
1112                router_fees: c.router_fees,
1113                feed_handle,
1114                gas_price_handle,
1115                metrics_sampler_handle,
1116                router_fee_handle,
1117                computation_handle,
1118                computation_shutdown_tx: c.computation_shutdown_tx,
1119                chain: c.chain,
1120                router_address: c.router_address,
1121                market_event_tx: c.market_event_tx,
1122            },
1123            controller,
1124        ))
1125    }
1126} // impl FyndBuilder
1127
1128/// A running solver assembled by [`FyndBuilder`].
1129pub struct Solver {
1130    router: WorkerPoolRouter,
1131    worker_pools: Vec<WorkerPool>,
1132    market_data: MarketData,
1133    derived_data: SharedDerivedDataRef,
1134    router_fees: SharedRouterFees,
1135    feed_handle: JoinHandle<()>,
1136    gas_price_handle: JoinHandle<()>,
1137    metrics_sampler_handle: JoinHandle<()>,
1138    router_fee_handle: JoinHandle<()>,
1139    computation_handle: JoinHandle<()>,
1140    computation_shutdown_tx: broadcast::Sender<()>,
1141    chain: Chain,
1142    router_address: Option<Bytes>,
1143    market_event_tx: broadcast::Sender<MarketEvent>,
1144}
1145
1146impl Solver {
1147    /// Returns a clone of the shared market data reference.
1148    pub fn market_data(&self) -> MarketData {
1149        self.market_data.clone()
1150    }
1151
1152    /// Returns the Tycho Router contract address, or `None` on a quote-only chain.
1153    pub fn router_address(&self) -> Option<&Bytes> {
1154        self.router_address.as_ref()
1155    }
1156
1157    /// Returns a clone of the shared derived data reference.
1158    pub fn derived_data(&self) -> SharedDerivedDataRef {
1159        Arc::clone(&self.derived_data)
1160    }
1161
1162    /// Returns a new receiver for [`MarketEvent`]s broadcast by the Tycho feed.
1163    ///
1164    /// Each call returns an independent receiver. Events are broadcast on every block update.
1165    /// Receivers created after a block has been processed will miss that block's event.
1166    pub fn subscribe_market_events(&self) -> broadcast::Receiver<crate::feed::events::MarketEvent> {
1167        self.market_event_tx.subscribe()
1168    }
1169
1170    /// Submits a [`QuoteRequest`] to the worker pools and returns the best [`Quote`].
1171    ///
1172    /// # Errors
1173    ///
1174    /// Returns [`SolveError`] if all pools fail or the router timeout elapses.
1175    pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
1176        self.router.quote(request).await
1177    }
1178
1179    /// Waits until the solver is ready to answer quotes.
1180    ///
1181    /// Ready means:
1182    /// - The Tycho feed has delivered at least one market snapshot.
1183    /// - The computation manager has completed at least one derived-data cycle (spot prices, pool
1184    ///   depths, token gas prices).
1185    /// - Router fees have been loaded from the on-chain FeeCalculator at least once.
1186    ///
1187    /// The method polls every 500 ms and returns as soon as all conditions are
1188    /// met, or returns [`WaitReadyError`] if `timeout` elapses first.
1189    ///
1190    /// # Example
1191    ///
1192    /// ```ignore
1193    /// solver.wait_until_ready(Duration::from_secs(180)).await?;
1194    /// ```
1195    pub async fn wait_until_ready(&self, timeout: Duration) -> Result<(), WaitReadyError> {
1196        const POLL_INTERVAL: Duration = Duration::from_millis(500);
1197
1198        let deadline = tokio::time::Instant::now() + timeout;
1199
1200        loop {
1201            let market_ready = self
1202                .market_data
1203                .read()
1204                .await
1205                .last_updated()
1206                .is_some();
1207            let derived_ready = self
1208                .derived_data
1209                .read()
1210                .await
1211                .derived_data_ready();
1212
1213            if market_ready && derived_ready {
1214                return Ok(());
1215            }
1216
1217            if tokio::time::Instant::now() >= deadline {
1218                return Err(WaitReadyError { timeout_ms: timeout.as_millis() as u64 });
1219            }
1220
1221            tokio::time::sleep(POLL_INTERVAL).await;
1222        }
1223    }
1224
1225    /// Build a Solver by replaying recorded market updates.
1226    ///
1227    /// Creates the full pipeline (feed -> derived data -> worker pools -> router)
1228    /// from pre-recorded data instead of a live Tycho connection. The returned
1229    /// Solver behaves identically to a live one — call [`wait_until_ready`](Self::wait_until_ready)
1230    /// then [`quote`](Self::quote).
1231    ///
1232    /// VM-backed protocol states that couldn't be serialized will be absent from
1233    /// the recording. Pools without states will be registered as components but
1234    /// won't contribute to routing.
1235    ///
1236    /// Requires the `test-utils` feature.
1237    #[cfg(feature = "test-utils")]
1238    pub async fn from_recording(
1239        chain: Chain,
1240        updates: Vec<tycho_simulation::protocol::models::Update>,
1241        pools: std::collections::HashMap<String, PoolConfig>,
1242        gas_price_wei: Option<num_bigint::BigUint>,
1243    ) -> Result<Self, SolverBuildError> {
1244        if pools.is_empty() {
1245            return Err(SolverBuildError::NoPools);
1246        }
1247
1248        let market_data = MarketData::new_shared();
1249
1250        // Replay updates through TychoFeed (stays pub(crate))
1251        let feed_config =
1252            TychoFeedConfig::new("ws://replay".to_string(), chain, None, false, vec![], 0.0);
1253        let feed = TychoFeed::new(feed_config, market_data.clone());
1254        let market_event_tx = feed.event_sender();
1255        let _feed_rx = feed.subscribe();
1256
1257        for update in updates {
1258            feed.handle_tycho_message(update)
1259                .await
1260                .map_err(|e| SolverBuildError::Replay(e.to_string()))?;
1261        }
1262
1263        // Inject gas price (recorded value or default 10 gwei)
1264        let gas_price = match gas_price_wei {
1265            Some(price) => price,
1266            None => {
1267                tracing::warn!("no recorded gas price, defaulting to 10 gwei");
1268                num_bigint::BigUint::from(10_000_000_000u64)
1269            }
1270        };
1271        let block_number = match market_data.read().await.last_updated() {
1272            Some(block) => block.number(),
1273            None => {
1274                tracing::warn!("no block number from replayed updates, defaulting to 0");
1275                0
1276            }
1277        };
1278        {
1279            let mut market = market_data.write().await;
1280            market.update_gas_price(BlockGasPrice {
1281                block_number,
1282                block_hash: Default::default(),
1283                block_timestamp: 0,
1284                pricing: GasPrice::Legacy { gas_price },
1285            });
1286        }
1287
1288        // Computation manager
1289        let gas_token = native_token(&chain).map_err(|_| SolverBuildError::GasToken)?;
1290        let computation_config = ComputationManagerConfig::new()
1291            .with_gas_token(gas_token)
1292            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
1293        let (computation_manager, _) =
1294            ComputationManager::new(computation_config, market_data.clone())
1295                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
1296
1297        let derived_data: SharedDerivedDataRef = computation_manager.store();
1298        let derived_event_tx = computation_manager.event_sender();
1299
1300        let computation_event_rx = feed.subscribe();
1301        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
1302
1303        let computation_handle = tokio::spawn(async move {
1304            computation_manager
1305                .run(computation_event_rx, computation_shutdown_rx)
1306                .await;
1307        });
1308
1309        // Build worker pools BEFORE sending MarketUpdated
1310        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
1311        let mut worker_pools: Vec<WorkerPool> = Vec::new();
1312        let mut max_timeout_ms = 0u64;
1313
1314        for (name, pool_cfg) in &pools {
1315            let algo_cfg = AlgorithmConfig::new(
1316                pool_cfg.min_hops(),
1317                pool_cfg.max_hops(),
1318                Duration::from_millis(pool_cfg.timeout_ms()),
1319                pool_cfg.max_routes(),
1320            )?;
1321
1322            let pool_event_rx = feed.subscribe();
1323            let derived_rx = derived_event_tx.subscribe();
1324
1325            let (worker_pool, task_handle) = WorkerPoolBuilder::new()
1326                .name(name.clone())
1327                .algorithm(pool_cfg.algorithm().to_string())
1328                .algorithm_config(algo_cfg)
1329                .num_workers(pool_cfg.num_workers())
1330                .task_queue_capacity(pool_cfg.task_queue_capacity())
1331                .build(market_data.clone(), Arc::clone(&derived_data), pool_event_rx, derived_rx)?;
1332
1333            solver_pool_handles.push(SolverPoolHandle::new(worker_pool.name(), task_handle));
1334            max_timeout_ms = max_timeout_ms.max(pool_cfg.timeout_ms());
1335            worker_pools.push(worker_pool);
1336        }
1337
1338        // Encoder + router
1339        let encoder = {
1340            let registry = SwapEncoderRegistry::new(chain)
1341                .add_default_encoders(None)
1342                .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
1343            Encoder::new(chain, registry).map_err(|e| SolverBuildError::Encoder(e.to_string()))?
1344        };
1345
1346        let router_address = encoder.router_address().cloned();
1347        // Replay mode has no FeeCalculator to read; seed a zero-fee config at the standard
1348        // 8-decimal scale so the recording-based solver reports ready (integration tests do
1349        // not exercise encoding).
1350        let router_fees = encoder.router_fees();
1351        router_fees.set(crate::encoding::router_fees::RouterFees::new(
1352            100_000_000,
1353            0,
1354            0,
1355            std::collections::HashMap::new(),
1356        ));
1357        let router_config = WorkerPoolRouterConfig::default()
1358            .with_timeout(Duration::from_millis(max_timeout_ms.max(5000)))
1359            .with_min_responses(defaults::ROUTER_MIN_RESPONSES);
1360        let router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
1361
1362        // Trigger derived data computation
1363        let market_read = market_data.read().await;
1364        let added = market_read.component_topology();
1365        drop(market_read);
1366
1367        if market_event_tx
1368            .send(MarketEvent::MarketUpdated {
1369                added_components: added,
1370                removed_components: vec![],
1371                updated_components: vec![],
1372            })
1373            .is_err()
1374        {
1375            tracing::warn!("no receivers for initial MarketUpdated broadcast");
1376        }
1377
1378        // Dummy handles for feed/gas/metrics/router-fees (not running in replay mode). The market
1379        // event channel stays alive through the `market_event_tx` field on `Solver`.
1380        let feed_handle = tokio::spawn(futures::future::pending::<()>());
1381        let gas_price_handle = tokio::spawn(async { /* no-op */ });
1382        let metrics_sampler_handle = tokio::spawn(async { /* no-op */ });
1383        let router_fee_handle = tokio::spawn(async { /* no-op */ });
1384
1385        Ok(Solver {
1386            router,
1387            worker_pools,
1388            market_data,
1389            derived_data,
1390            router_fees,
1391            feed_handle,
1392            gas_price_handle,
1393            metrics_sampler_handle,
1394            router_fee_handle,
1395            computation_handle,
1396            computation_shutdown_tx,
1397            chain,
1398            router_address,
1399            market_event_tx,
1400        })
1401    }
1402
1403    /// Signals all worker pools and the computation manager to stop, then aborts background tasks.
1404    pub fn shutdown(self) {
1405        let _ = self.computation_shutdown_tx.send(());
1406        for pool in self.worker_pools {
1407            pool.shutdown();
1408        }
1409        self.feed_handle.abort();
1410        self.gas_price_handle.abort();
1411        self.metrics_sampler_handle.abort();
1412        self.router_fee_handle.abort();
1413    }
1414
1415    /// Consumes the solver into its raw parts for callers that add their own layer.
1416    pub fn into_parts(self) -> SolverParts {
1417        SolverParts {
1418            router: self.router,
1419            worker_pools: self.worker_pools,
1420            market_data: self.market_data,
1421            derived_data: self.derived_data,
1422            router_fees: self.router_fees,
1423            feed_handle: self.feed_handle,
1424            gas_price_handle: self.gas_price_handle,
1425            metrics_sampler_handle: self.metrics_sampler_handle,
1426            router_fee_handle: self.router_fee_handle,
1427            computation_handle: self.computation_handle,
1428            computation_shutdown_tx: self.computation_shutdown_tx,
1429            chain: self.chain,
1430            router_address: self.router_address,
1431        }
1432    }
1433}
1434
1435/// Raw components of a [`Solver`], for callers adding their own layer (e.g., an HTTP server).
1436///
1437/// Obtained via [`Solver::into_parts`].
1438pub struct SolverParts {
1439    /// Routes quote requests across worker pools.
1440    router: WorkerPoolRouter,
1441    /// One [`WorkerPool`] per configured algorithm pool.
1442    worker_pools: Vec<WorkerPool>,
1443    /// Live market snapshot shared across all components.
1444    market_data: MarketData,
1445    /// Derived on-chain data (spot prices, depths, gas costs) shared across all components.
1446    derived_data: SharedDerivedDataRef,
1447    /// Router fee configuration, refreshed from chain by the router-fee fetcher.
1448    router_fees: SharedRouterFees,
1449    /// Background task running the Tycho market-data feed.
1450    feed_handle: JoinHandle<()>,
1451    /// Background task polling the RPC node for gas prices.
1452    gas_price_handle: JoinHandle<()>,
1453    /// Background task exporting per-protocol market metrics.
1454    metrics_sampler_handle: JoinHandle<()>,
1455    /// Background task refreshing router fees from the on-chain FeeCalculator.
1456    router_fee_handle: JoinHandle<()>,
1457    /// Background task running the computation manager.
1458    computation_handle: JoinHandle<()>,
1459    /// Send a unit value on this channel to trigger a graceful computation-manager shutdown.
1460    computation_shutdown_tx: broadcast::Sender<()>,
1461    /// Chain this solver is configured for.
1462    chain: Chain,
1463    /// Address of the Tycho Router contract on this chain, or `None` on a quote-only chain.
1464    router_address: Option<Bytes>,
1465}
1466
1467impl SolverParts {
1468    /// Returns the chain this solver is configured for.
1469    pub fn chain(&self) -> Chain {
1470        self.chain
1471    }
1472
1473    /// Returns the Tycho Router contract address for this chain, or `None` on a quote-only chain.
1474    pub fn router_address(&self) -> Option<&Bytes> {
1475        self.router_address.as_ref()
1476    }
1477
1478    /// Returns a reference to the worker pools.
1479    pub fn worker_pools(&self) -> &[WorkerPool] {
1480        &self.worker_pools
1481    }
1482
1483    /// Returns a reference to the shared market data.
1484    pub fn market_data(&self) -> &MarketData {
1485        &self.market_data
1486    }
1487
1488    /// Returns a reference to the shared derived data.
1489    pub fn derived_data(&self) -> &SharedDerivedDataRef {
1490        &self.derived_data
1491    }
1492
1493    /// Returns a reference to the shared router fee configuration.
1494    pub fn router_fees(&self) -> &SharedRouterFees {
1495        &self.router_fees
1496    }
1497
1498    /// Consumes the parts and returns the router.
1499    pub fn into_router(self) -> WorkerPoolRouter {
1500        self.router
1501    }
1502
1503    /// Consumes the parts, returning all owned components.
1504    #[allow(clippy::type_complexity)]
1505    pub fn into_components(
1506        self,
1507    ) -> (
1508        WorkerPoolRouter,
1509        Vec<WorkerPool>,
1510        MarketData,
1511        SharedDerivedDataRef,
1512        JoinHandle<()>,
1513        JoinHandle<()>,
1514        JoinHandle<()>,
1515        JoinHandle<()>,
1516        JoinHandle<()>,
1517        broadcast::Sender<()>,
1518    ) {
1519        (
1520            self.router,
1521            self.worker_pools,
1522            self.market_data,
1523            self.derived_data,
1524            self.feed_handle,
1525            self.gas_price_handle,
1526            self.metrics_sampler_handle,
1527            self.router_fee_handle,
1528            self.computation_handle,
1529            self.computation_shutdown_tx,
1530        )
1531    }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536    use super::*;
1537
1538    /// Setting `liquidity_scope` (either value) without an exclusivity policy fails the build.
1539    #[rstest::rstest]
1540    #[case::all(LiquidityScope::All)]
1541    #[case::public_only(LiquidityScope::PublicOnly)]
1542    fn test_build_rejects_scoped_pool_without_policy(#[case] scope: LiquidityScope) {
1543        let config = PoolConfig::new("most_liquid").with_liquidity_scope(scope);
1544        let result = FyndBuilder::new(
1545            Chain::Ethereum,
1546            "wss://example.invalid",
1547            "https://example.invalid",
1548            vec!["uniswap_v2".to_string()],
1549            100.0,
1550        )
1551        .add_pool("surplus", &config)
1552        .expect("add_pool should accept the config")
1553        .build();
1554
1555        assert!(matches!(result, Err(SolverBuildError::LiquidityScopeWithoutPolicy)));
1556    }
1557}