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