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