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