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