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    router_timeout: Duration,
532    router_min_responses: usize,
533    encoder: Option<Encoder>,
534    calldata_watermark: Option<Vec<u8>>,
535    pools: Vec<PoolEntry>,
536    price_guard_enabled: bool,
537    simulation_enabled: bool,
538    price_providers: Vec<Box<dyn PriceProvider>>,
539    pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
540}
541
542impl FyndBuilder {
543    /// Creates a new builder with the required parameters.
544    pub fn new(
545        chain: Chain,
546        tycho_url: impl Into<String>,
547        rpc_url: impl Into<String>,
548        protocols: Vec<String>,
549        min_tvl: f64,
550    ) -> Self {
551        Self {
552            algorithms: AlgorithmRegistry::new(),
553            chain,
554            tycho_url: tycho_url.into(),
555            rpc_url: rpc_url.into(),
556            protocols,
557            min_tvl,
558            tycho_api_key: None,
559            tycho_use_tls: DEFAULT_TYCHO_USE_TLS,
560            min_token_quality: defaults::MIN_TOKEN_QUALITY,
561            traded_n_days_ago: defaults::TRADED_N_DAYS_AGO,
562            tvl_buffer_ratio: defaults::TVL_BUFFER_RATIO,
563            gas_refresh_interval: defaults::GAS_REFRESH_INTERVAL,
564            reconnect_delay: defaults::RECONNECT_DELAY,
565            blocklisted_components: FxHashSet::default(),
566            partial_blocks: false,
567            router_timeout: DEFAULT_ROUTER_TIMEOUT,
568            router_min_responses: defaults::ROUTER_MIN_RESPONSES,
569            encoder: None,
570            calldata_watermark: None,
571            pools: Vec::new(),
572            price_guard_enabled: false,
573            simulation_enabled: false,
574            price_providers: Vec::new(),
575            pending_indexers: Vec::new(),
576        }
577    }
578
579    /// The blockchain this builder is configured for.
580    pub fn chain(&self) -> Chain {
581        self.chain
582    }
583
584    /// Sets the Tycho API key.
585    pub fn tycho_api_key(mut self, key: impl Into<String>) -> Self {
586        self.tycho_api_key = Some(key.into());
587        self
588    }
589
590    /// Overrides the minimum TVL filter set in [`FyndBuilder::new`].
591    pub fn min_tvl(mut self, min_tvl: f64) -> Self {
592        self.min_tvl = min_tvl;
593        self
594    }
595
596    /// Enables or disables TLS for the Tycho WebSocket connection (default: `true`).
597    pub fn tycho_use_tls(mut self, use_tls: bool) -> Self {
598        self.tycho_use_tls = use_tls;
599        self
600    }
601
602    /// Sets the minimum token quality score; tokens below this threshold are excluded (default:
603    /// 100).
604    pub fn min_token_quality(mut self, quality: i32) -> Self {
605        self.min_token_quality = quality;
606        self
607    }
608
609    /// Filters out components whose last trade is older than `days` days (default: 3).
610    pub fn traded_n_days_ago(mut self, days: u64) -> Self {
611        self.traded_n_days_ago = days;
612        self
613    }
614
615    /// Multiplies reported TVL by `ratio` before applying the `min_tvl` filter (default: 1.1).
616    pub fn tvl_buffer_ratio(mut self, ratio: f64) -> Self {
617        self.tvl_buffer_ratio = ratio;
618        self
619    }
620
621    /// Sets how often the gas price is refreshed from the RPC node (default: 30 s).
622    pub fn gas_refresh_interval(mut self, interval: Duration) -> Self {
623        self.gas_refresh_interval = interval;
624        self
625    }
626
627    /// Sets the delay before reconnecting to Tycho after a disconnection (default: 5 s).
628    pub fn reconnect_delay(mut self, delay: Duration) -> Self {
629        self.reconnect_delay = delay;
630        self
631    }
632
633    /// Sets component IDs to exclude from the Tycho stream.
634    pub fn blocklisted_components(mut self, components: impl IntoIterator<Item = String>) -> Self {
635        self.blocklisted_components = components.into_iter().collect();
636        self
637    }
638
639    /// Enables partial block (flashblock) updates from the Tycho stream (default: `false`).
640    ///
641    /// When enabled, the stream delivers component state updates mid-block rather than only at
642    /// finalization, reducing latency. Only supported for on-chain protocols; RFQ streams are
643    /// unaffected.
644    pub fn partial_blocks(mut self, enabled: bool) -> Self {
645        self.partial_blocks = enabled;
646        self
647    }
648
649    /// Sets the worker router timeout (default: 10s).
650    pub fn worker_router_timeout(mut self, timeout: Duration) -> Self {
651        self.router_timeout = timeout;
652        self
653    }
654
655    /// Sets the minimum number of solver responses before early return (default: 0).
656    pub fn worker_router_min_responses(mut self, min: usize) -> Self {
657        self.router_min_responses = min;
658        self
659    }
660
661    /// Overrides the default encoder.
662    pub fn encoder(mut self, encoder: Encoder) -> Self {
663        self.encoder = Some(encoder);
664        self
665    }
666
667    /// Sets a watermark appended to every encoded transaction's calldata (e.g. `"fynd"`), so
668    /// on-chain observers can attribute router calls to this deployment. Applied to the encoder
669    /// at build time, whether default or overridden. Default: no watermark.
670    pub fn calldata_watermark(mut self, watermark: impl Into<Vec<u8>>) -> Self {
671        self.calldata_watermark = Some(watermark.into());
672        self
673    }
674
675    /// Shorthand: adds a single worker pool named `"default"` using a built-in algorithm by name.
676    pub fn algorithm(mut self, algorithm: impl Into<String>) -> Self {
677        self.pools.push(PoolEntry::BuiltIn {
678            name: "default".to_string(),
679            algorithm: algorithm.into(),
680            num_workers: num_cpus::get(),
681            task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
682            min_hops: defaults::POOL_MIN_HOPS,
683            max_hops: defaults::POOL_MAX_HOPS,
684            timeout_ms: defaults::POOL_TIMEOUT_MS,
685            max_routes: None,
686            connector_tokens: None,
687            liquidity_scope: None,
688            exclude_protocols: Vec::new(),
689        });
690        self
691    }
692
693    /// Shorthand: adds a single worker pool with a custom [`Algorithm`] implementation.
694    ///
695    /// The `factory` closure is called once per worker thread.
696    #[deprecated(
697        since = "0.99.23",
698        note = "register the algorithm in an `AlgorithmRegistry` and pass it to \
699                `with_algorithms`, which also serves pools that name it in a configuration \
700                file; this shorthand only ever added one pool"
701    )]
702    pub fn with_algorithm<A, F>(mut self, name: impl Into<String>, factory: F) -> Self
703    where
704        A: Algorithm + 'static,
705        A::GraphManager: MarketEventHandler + EdgeWeightUpdaterWithDerived + 'static,
706        F: Fn(AlgorithmConfig) -> A + Clone + Send + Sync + 'static,
707    {
708        let name = name.into();
709        let algo_name = name.clone();
710        let configure =
711            Box::new(move |builder: WorkerPoolBuilder| builder.with_algorithm(algo_name, factory));
712        self.pools
713            .push(PoolEntry::Custom(CustomPoolEntry {
714                name,
715                num_workers: num_cpus::get(),
716                task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
717                min_hops: defaults::POOL_MIN_HOPS,
718                max_hops: defaults::POOL_MAX_HOPS,
719                timeout_ms: defaults::POOL_TIMEOUT_MS,
720                max_routes: None,
721                liquidity_scope: None,
722                configure,
723            }));
724        self
725    }
726
727    /// Serves any pool whose configured algorithm name `algorithms` holds.
728    ///
729    /// A pool configuration names an algorithm; only the built-ins are known by name here. This
730    /// hands the builder the ones the caller brought, so a deployment can run an algorithm that
731    /// lives outside this crate without changing how its pools are configured.
732    pub fn with_algorithms(mut self, algorithms: AlgorithmRegistry) -> Self {
733        self.algorithms = algorithms;
734        self
735    }
736
737    /// Registers the built-in price providers (Hyperliquid + Binance).
738    ///
739    /// Called automatically during [`build`](Self::build) if no providers have been
740    /// registered and the price guard is not disabled. To use only custom
741    /// providers, call [`register_price_provider`](Self::register_price_provider)
742    /// before `build()` and the defaults will be skipped.
743    pub fn add_default_price_providers(self) -> Self {
744        self.register_price_provider(Box::new(
745            crate::price_guard::hyperliquid::HyperliquidProvider::default(),
746        ))
747        .register_price_provider(Box::new(
748            crate::price_guard::binance_ws::BinanceWsProvider::default(),
749        ))
750    }
751
752    /// Registers a custom price provider for the price guard.
753    ///
754    /// The provider's [`start`](PriceProvider::start) method is called during
755    /// [`build`](Self::build) with the shared market data.
756    pub fn register_price_provider(mut self, provider: Box<dyn PriceProvider>) -> Self {
757        self.price_providers.push(provider);
758        self
759    }
760
761    /// Registers a [`TxDeltaIndexer`] for ephemeral pending-block simulation.
762    ///
763    /// `extractor` is the protocol synchronizer name (e.g. `"uniswap_v3"`). Only has effect
764    /// when calling [`build_with_pending`](Self::build_with_pending). VM protocols (prefix
765    /// `"vm:"`) are rejected by the underlying stream builder at build time.
766    pub fn with_pending_indexer(
767        mut self,
768        extractor: impl Into<String>,
769        indexer: Box<dyn TxDeltaIndexer>,
770    ) -> Self {
771        self.pending_indexers
772            .push((extractor.into(), indexer));
773        self
774    }
775
776    /// Enables or disables the price guard.
777    ///
778    /// When enabled, providers are started and caches stay warm. Validation
779    /// only runs for requests where the client sets `enabled: true` in
780    /// `PriceGuardConfig`. When disabled, no providers are started and
781    /// per-request attempts to use the guard return an error.
782    pub fn price_guard_enabled(mut self, enabled: bool) -> Self {
783        self.price_guard_enabled = enabled;
784        self
785    }
786
787    /// Enables or disables on-chain simulation of encoded quotes.
788    ///
789    /// When disabled, requests that ask for simulation return an error without making RPC calls.
790    pub fn simulation_enabled(mut self, enabled: bool) -> Self {
791        self.simulation_enabled = enabled;
792        self
793    }
794
795    /// Adds a named worker pool using the given [`PoolConfig`].
796    ///
797    /// # Errors
798    ///
799    /// Returns [`SolverBuildError::AlgorithmConfig`] if any address in `connector_tokens` is not
800    /// valid hex.
801    pub fn add_pool(
802        mut self,
803        name: impl Into<String>,
804        config: &PoolConfig,
805    ) -> Result<Self, SolverBuildError> {
806        let connector_tokens = parse_connector_tokens(config.connector_tokens())?;
807        self.pools.push(PoolEntry::BuiltIn {
808            name: name.into(),
809            algorithm: config.algorithm().to_string(),
810            num_workers: config.num_workers(),
811            task_queue_capacity: config.task_queue_capacity(),
812            min_hops: config.min_hops(),
813            max_hops: config.max_hops(),
814            timeout_ms: config.timeout_ms(),
815            max_routes: config.max_routes(),
816            connector_tokens,
817            liquidity_scope: config.liquidity_scope(),
818            exclude_protocols: config
819                .exclude_protocols()
820                .map(<[String]>::to_vec)
821                .unwrap_or_default(),
822        });
823        Ok(self)
824    }
825
826    /// Constructs all components shared between [`build`](Self::build) and
827    /// [`build_with_pending`](Self::build_with_pending).
828    fn assemble_components(mut self) -> Result<BuiltComponents, SolverBuildError> {
829        if self.pools.is_empty() {
830            return Err(SolverBuildError::NoPools);
831        }
832
833        // Exclusive-access worker pools only serve requests granted access, so a deployment made
834        // entirely of them would allocate no worker pool at all to everyone else. Caught here
835        // rather than per request: it is a configuration mistake, not a runtime condition.
836        if self
837            .pools
838            .iter()
839            .all(|p| p.liquidity_scope() == Some(LiquidityScope::IncludeExclusive))
840        {
841            return Err(SolverBuildError::NoPublicPool);
842        }
843
844        // Add built-in providers if none were explicitly registered.
845        if self.price_providers.is_empty() {
846            self = self.add_default_price_providers();
847        }
848
849        let market_data = MarketData::new_shared();
850
851        let tycho_feed_config = TychoFeedConfig::new(
852            self.tycho_url,
853            self.chain,
854            self.tycho_api_key,
855            self.tycho_use_tls,
856            self.protocols,
857            self.min_tvl,
858        )
859        .tvl_buffer_ratio(self.tvl_buffer_ratio)
860        .reconnect_delay(self.reconnect_delay)
861        .min_token_quality(self.min_token_quality)
862        .traded_n_days_ago(self.traded_n_days_ago)
863        .blocklisted_components(self.blocklisted_components)
864        .partial_blocks(self.partial_blocks);
865
866        let ethereum_client = EthereumRpcClient::new(self.rpc_url.as_str())
867            .map_err(|e| SolverBuildError::RpcClient(e.to_string()))?;
868
869        let gas_price_fetcher =
870            GasPriceFetcher::new(ethereum_client, market_data.clone(), self.gas_refresh_interval);
871
872        let tycho_feed = TychoFeed::new(tycho_feed_config, market_data.clone());
873        let market_event_tx = tycho_feed.event_sender();
874
875        let gas_token = native_token(&self.chain).map_err(|_| SolverBuildError::GasToken)?;
876        let pricing_max_hops = pricing_max_hops(
877            self.pools
878                .iter()
879                .map(PoolEntry::max_hops),
880        );
881        let computation_config = ComputationManagerConfig::new()
882            .with_gas_token(gas_token)
883            .with_max_hop(pricing_max_hops)
884            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
885        // ComputationManager::new returns a broadcast receiver that we don't need here —
886        // workers subscribe via computation_manager.event_sender() below.
887        let (computation_manager, _) =
888            ComputationManager::new(computation_config, market_data.clone())
889                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
890
891        let derived_data: SharedDerivedDataRef = computation_manager.store();
892        let derived_event_tx = computation_manager.event_sender();
893
894        // Subscribe event channels before spawning (one for the computation manager + one per
895        // worker pool)
896        let computation_event_rx = tycho_feed.subscribe();
897        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
898
899        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
900        let mut worker_pools: Vec<WorkerPool> = Vec::new();
901        // Created before the pools so every worker reads the same tiers the fetcher refreshes.
902        let fallback_fee_tiers = SharedFeeTiers::default();
903
904        let pools = std::mem::take(&mut self.pools);
905
906        for pool_entry in pools {
907            let pool_event_rx = tycho_feed.subscribe();
908            let derived_rx = derived_event_tx.subscribe();
909
910            let pool_scope = pool_entry
911                .liquidity_scope()
912                .unwrap_or_default();
913
914            let (worker_pool, task_handle) = match pool_entry {
915                PoolEntry::BuiltIn {
916                    name,
917                    algorithm,
918                    num_workers,
919                    task_queue_capacity,
920                    min_hops,
921                    max_hops,
922                    timeout_ms,
923                    max_routes,
924                    connector_tokens,
925                    liquidity_scope: _,
926                    exclude_protocols,
927                } => {
928                    let mut algo_cfg = AlgorithmConfig::new(
929                        min_hops,
930                        max_hops,
931                        Duration::from_millis(timeout_ms),
932                        max_routes,
933                    )?;
934                    if let Some(tokens) = connector_tokens {
935                        algo_cfg = algo_cfg.with_connector_tokens(tokens);
936                    }
937                    let named = WorkerPoolBuilder::new()
938                        .name(name)
939                        .algorithm_config(algo_cfg)
940                        .num_workers(num_workers)
941                        .task_queue_capacity(task_queue_capacity)
942                        .liquidity_scope(pool_scope)
943                        .exclude_protocols(exclude_protocols)
944                        .fallback_fee_tiers(fallback_fee_tiers.clone());
945                    let builder = self
946                        .algorithms
947                        .configure(&algorithm, named)?;
948                    builder.build(
949                        market_data.clone(),
950                        Arc::clone(&derived_data),
951                        pool_event_rx,
952                        derived_rx,
953                    )?
954                }
955                PoolEntry::Custom(custom) => {
956                    let algo_cfg = AlgorithmConfig::new(
957                        custom.min_hops,
958                        custom.max_hops,
959                        Duration::from_millis(custom.timeout_ms),
960                        custom.max_routes,
961                    )?;
962                    let builder = WorkerPoolBuilder::new()
963                        .name(custom.name)
964                        .algorithm_config(algo_cfg)
965                        .num_workers(custom.num_workers)
966                        .task_queue_capacity(custom.task_queue_capacity)
967                        .liquidity_scope(pool_scope)
968                        .fallback_fee_tiers(fallback_fee_tiers.clone());
969                    let builder = (custom.configure)(builder);
970                    builder.build(
971                        market_data.clone(),
972                        Arc::clone(&derived_data),
973                        pool_event_rx,
974                        derived_rx,
975                    )?
976                }
977            };
978
979            solver_pool_handles.push(
980                SolverPoolHandle::new(worker_pool.name(), task_handle)
981                    .with_liquidity_scope(pool_scope),
982            );
983            worker_pools.push(worker_pool);
984        }
985
986        let encoder = match self.encoder {
987            Some(enc) => enc,
988            None => {
989                let registry = SwapEncoderRegistry::new(self.chain)
990                    .add_default_encoders(None)
991                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
992                Encoder::new(self.chain, registry)
993                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?
994            }
995        };
996        let encoder = match self.calldata_watermark {
997            Some(watermark) => encoder.with_calldata_watermark(watermark),
998            None => encoder,
999        };
1000
1001        let chain = self.chain;
1002        let router_address = encoder.router_address().cloned();
1003        let router_fees = encoder.router_fees();
1004
1005        let router_fee_fetcher = match &router_address {
1006            Some(addr) => Some(
1007                RouterFeeFetcher::new(
1008                    self.rpc_url.as_str(),
1009                    addr,
1010                    router_fees.clone(),
1011                    defaults::ROUTER_FEE_REFRESH_INTERVAL,
1012                )
1013                .map_err(|e| SolverBuildError::RouterFeeFetcher(e.to_string()))?,
1014            ),
1015            None => {
1016                tracing::warn!(
1017                    %chain,
1018                    "no Tycho router for this chain; running quote-only (encoding disabled)"
1019                );
1020                None
1021            }
1022        };
1023
1024        let quote_simulator = if self.simulation_enabled {
1025            Some(
1026                QuoteSimulator::new(
1027                    self.rpc_url.as_str(),
1028                    chain,
1029                    defaults::SIMULATION_REQUEST_TIMEOUT,
1030                )
1031                .map_err(|error| SolverBuildError::QuoteSimulator(error.to_string()))?,
1032            )
1033        } else {
1034            None
1035        };
1036
1037        // The PropAMMRouter is an Ethereum mainnet deployment, so no other chain has fee tiers to
1038        // read. Without the fetcher the tiers stay empty and every pAMM route is dropped.
1039        //
1040        // The router and venue addresses are compile-time constants, so a malformed one is a typo
1041        // that would silently lose that venue's fee tiers. Fail the build instead.
1042        let fee_tier_fetcher = if chain == Chain::Ethereum {
1043            Some(propamm_fee_tier_fetcher(self.rpc_url.as_str(), fallback_fee_tiers.clone())?)
1044        } else {
1045            None
1046        };
1047
1048        // Only start price providers when the guard is enabled.
1049        // When disabled, per-request attempts to enable the guard return an error.
1050        let router_config = WorkerPoolRouterConfig::default()
1051            .with_timeout(self.router_timeout)
1052            .with_min_responses(self.router_min_responses);
1053        let mut router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
1054        if let Some(simulator) = quote_simulator {
1055            router = router.with_simulator(simulator);
1056        }
1057
1058        if self.price_guard_enabled {
1059            let mut registry = PriceProviderRegistry::new();
1060            let mut worker_handles = Vec::new();
1061            for mut provider in self.price_providers {
1062                worker_handles.push(provider.start(market_data.clone()));
1063                registry = registry.register(provider);
1064            }
1065            let price_guard = PriceGuard::new(registry, worker_handles);
1066            router = router.with_price_guard(price_guard);
1067        }
1068
1069        Ok(BuiltComponents {
1070            tycho_feed,
1071            gas_price_fetcher,
1072            router_fee_fetcher,
1073            fee_tier_fetcher,
1074            computation_manager,
1075            computation_event_rx,
1076            computation_shutdown_tx,
1077            computation_shutdown_rx,
1078            router,
1079            worker_pools,
1080            market_data,
1081            derived_data,
1082            router_fees,
1083            chain,
1084            router_address,
1085            pending_indexers: self.pending_indexers,
1086            market_event_tx,
1087        })
1088    }
1089
1090    /// Assembles and starts all solver components.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns [`SolverBuildError`] if any component fails to initialize.
1095    pub fn build(self) -> Result<Solver, SolverBuildError> {
1096        let mut c = self.assemble_components()?;
1097
1098        let feed_handle = tokio::spawn(async move {
1099            if let Err(e) = c.tycho_feed.run().await {
1100                metrics::counter!("tycho_feed_failures_total").increment(1);
1101                tracing::error!(error = %e, "tycho feed error");
1102            }
1103        });
1104        let gas_price_handle = tokio::spawn(async move {
1105            c.gas_price_fetcher.run().await;
1106        });
1107        let metrics_sampler =
1108            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1109        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1110        let router_fee_handle = match c.router_fee_fetcher {
1111            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1112            None => tokio::spawn(async {}),
1113        };
1114        let fee_tier_handle = match c.fee_tier_fetcher {
1115            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1116            None => tokio::spawn(async {}),
1117        };
1118        let computation_handle = tokio::spawn(async move {
1119            c.computation_manager
1120                .run(c.computation_event_rx, c.computation_shutdown_rx)
1121                .await;
1122        });
1123
1124        Ok(Solver {
1125            router: c.router,
1126            worker_pools: c.worker_pools,
1127            market_data: c.market_data,
1128            derived_data: c.derived_data,
1129            router_fees: c.router_fees,
1130            feed_handle,
1131            gas_price_handle,
1132            metrics_sampler_handle,
1133            router_fee_handle,
1134            fee_tier_handle,
1135            computation_handle,
1136            computation_shutdown_tx: c.computation_shutdown_tx,
1137            chain: c.chain,
1138            router_address: c.router_address,
1139            market_event_tx: c.market_event_tx,
1140        })
1141    }
1142
1143    /// Assembles and starts all solver components, also returning a [`PendingBlockProcessor`]
1144    /// for ephemeral bundle simulation against the live Tycho market state.
1145    ///
1146    /// Identical to [`build`](Self::build) except the feed task runs via
1147    /// `TychoFeed::run_with_pending`. The `PendingBlockProcessor` is delivered after
1148    /// token loading, before the first block is processed.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns [`SolverBuildError`] if any component fails to initialize or the pending
1153    /// channel closes before delivering the processor (e.g. token loading failed).
1154    pub async fn build_with_pending(
1155        self,
1156    ) -> Result<(Solver, PendingBlockProcessor), SolverBuildError> {
1157        let mut c = self.assemble_components()?;
1158
1159        let (pending_tx, pending_rx) =
1160            tokio::sync::oneshot::channel::<Result<PendingBlockProcessor, String>>();
1161
1162        let pending_indexers = c.pending_indexers;
1163        let feed_handle = tokio::spawn(async move {
1164            if let Err(e) = c
1165                .tycho_feed
1166                .run_with_pending(pending_tx, pending_indexers)
1167                .await
1168            {
1169                metrics::counter!("tycho_feed_failures_total").increment(1);
1170                tracing::error!(error = %e, "tycho feed error");
1171            }
1172        });
1173        let gas_price_handle = tokio::spawn(async move {
1174            c.gas_price_fetcher.run().await;
1175        });
1176        let metrics_sampler =
1177            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1178        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1179        let router_fee_handle = match c.router_fee_fetcher {
1180            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1181            None => tokio::spawn(async {}),
1182        };
1183        let fee_tier_handle = match c.fee_tier_fetcher {
1184            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1185            None => tokio::spawn(async {}),
1186        };
1187        let computation_handle = tokio::spawn(async move {
1188            c.computation_manager
1189                .run(c.computation_event_rx, c.computation_shutdown_rx)
1190                .await;
1191        });
1192
1193        let pending = pending_rx
1194            .await
1195            .map_err(|_| SolverBuildError::PendingChannelClosed)?
1196            .map_err(SolverBuildError::FeedSetup)?;
1197
1198        Ok((
1199            Solver {
1200                router: c.router,
1201                worker_pools: c.worker_pools,
1202                market_data: c.market_data,
1203                derived_data: c.derived_data,
1204                router_fees: c.router_fees,
1205                feed_handle,
1206                gas_price_handle,
1207                metrics_sampler_handle,
1208                router_fee_handle,
1209                fee_tier_handle,
1210                computation_handle,
1211                computation_shutdown_tx: c.computation_shutdown_tx,
1212                chain: c.chain,
1213                router_address: c.router_address,
1214                market_event_tx: c.market_event_tx,
1215            },
1216            pending,
1217        ))
1218    }
1219
1220    /// Assembles and starts all solver components, also returning a [`BlockStepController`]
1221    /// that lets the caller control when each buffered block is released for processing.
1222    ///
1223    /// Intended for deterministic testing: call [`BlockStepController::trigger_next_block`] to
1224    /// step through blocks one at a time, and [`BlockStepController::peek_next_block`] to inspect
1225    /// a block before it is decoded. Dropping the controller ungates the stream so it runs to its
1226    /// natural end.
1227    ///
1228    /// Only valid when at least one non-RFQ protocol is configured.
1229    ///
1230    /// # Errors
1231    ///
1232    /// Returns [`SolverBuildError`] if any component fails to initialize, all protocols are RFQ,
1233    /// or the step-controller channel closes before the controller is delivered.
1234    #[cfg(feature = "experimental")]
1235    pub async fn build_with_step_controller(
1236        self,
1237    ) -> Result<(Solver, BlockStepController), SolverBuildError> {
1238        let mut c = self.assemble_components()?;
1239
1240        let (controller_tx, controller_rx) =
1241            tokio::sync::oneshot::channel::<Result<BlockStepController, String>>();
1242
1243        let feed_handle = tokio::spawn(async move {
1244            if let Err(e) = c
1245                .tycho_feed
1246                .run_with_step_controller(controller_tx)
1247                .await
1248            {
1249                tracing::error!(error = %e, "tycho feed error");
1250            }
1251        });
1252        let gas_price_handle = tokio::spawn(async move {
1253            c.gas_price_fetcher.run().await;
1254        });
1255        let metrics_sampler =
1256            MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1257        let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1258        let router_fee_handle = match c.router_fee_fetcher {
1259            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1260            None => tokio::spawn(async {}),
1261        };
1262        let fee_tier_handle = match c.fee_tier_fetcher {
1263            Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1264            None => tokio::spawn(async {}),
1265        };
1266        let computation_handle = tokio::spawn(async move {
1267            c.computation_manager
1268                .run(c.computation_event_rx, c.computation_shutdown_rx)
1269                .await;
1270        });
1271
1272        let controller = controller_rx
1273            .await
1274            .map_err(|_| SolverBuildError::StepControllerChannelClosed)?
1275            .map_err(SolverBuildError::FeedSetup)?;
1276
1277        Ok((
1278            Solver {
1279                router: c.router,
1280                worker_pools: c.worker_pools,
1281                market_data: c.market_data,
1282                derived_data: c.derived_data,
1283                router_fees: c.router_fees,
1284                feed_handle,
1285                gas_price_handle,
1286                metrics_sampler_handle,
1287                router_fee_handle,
1288                fee_tier_handle,
1289                computation_handle,
1290                computation_shutdown_tx: c.computation_shutdown_tx,
1291                chain: c.chain,
1292                router_address: c.router_address,
1293                market_event_tx: c.market_event_tx,
1294            },
1295            controller,
1296        ))
1297    }
1298} // impl FyndBuilder
1299
1300/// A running solver assembled by [`FyndBuilder`].
1301pub struct Solver {
1302    router: WorkerPoolRouter,
1303    worker_pools: Vec<WorkerPool>,
1304    market_data: MarketData,
1305    derived_data: SharedDerivedDataRef,
1306    router_fees: SharedRouterFees,
1307    feed_handle: JoinHandle<()>,
1308    gas_price_handle: JoinHandle<()>,
1309    metrics_sampler_handle: JoinHandle<()>,
1310    router_fee_handle: JoinHandle<()>,
1311    fee_tier_handle: JoinHandle<()>,
1312    computation_handle: JoinHandle<()>,
1313    computation_shutdown_tx: broadcast::Sender<()>,
1314    chain: Chain,
1315    router_address: Option<Bytes>,
1316    market_event_tx: broadcast::Sender<MarketEvent>,
1317}
1318
1319impl Solver {
1320    /// Returns a clone of the shared market data reference.
1321    pub fn market_data(&self) -> MarketData {
1322        self.market_data.clone()
1323    }
1324
1325    /// Returns the Tycho Router contract address, or `None` on a quote-only chain.
1326    pub fn router_address(&self) -> Option<&Bytes> {
1327        self.router_address.as_ref()
1328    }
1329
1330    /// Returns a clone of the shared derived data reference.
1331    pub fn derived_data(&self) -> SharedDerivedDataRef {
1332        Arc::clone(&self.derived_data)
1333    }
1334
1335    /// Returns a new receiver for [`MarketEvent`]s broadcast by the Tycho feed.
1336    ///
1337    /// Each call returns an independent receiver. Events are broadcast on every block update.
1338    /// Receivers created after a block has been processed will miss that block's event.
1339    pub fn subscribe_market_events(&self) -> broadcast::Receiver<crate::feed::events::MarketEvent> {
1340        self.market_event_tx.subscribe()
1341    }
1342
1343    /// Submits a [`QuoteRequest`] to the worker pools and returns the best [`Quote`].
1344    ///
1345    /// Grants `ExclusiveAccess::Granted`: a library embedder configures its own pools, so there
1346    /// is no untrusted caller to gate here. Access is decided at the HTTP boundary, where
1347    /// requests do come from untrusted callers.
1348    ///
1349    /// # Errors
1350    ///
1351    /// Returns [`SolveError`] if all worker pools fail or the router timeout elapses.
1352    pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
1353        self.router
1354            .quote(request, ExclusiveAccess::Granted)
1355            .await
1356    }
1357
1358    /// Waits until the solver is ready to answer quotes.
1359    ///
1360    /// Ready means:
1361    /// - The Tycho feed has delivered at least one market snapshot.
1362    /// - The computation manager has completed at least one derived-data cycle (spot prices,
1363    ///   component depths, token gas prices).
1364    /// - Router fees have been loaded from the on-chain FeeCalculator at least once.
1365    ///
1366    /// The method polls every 500 ms and returns as soon as all conditions are
1367    /// met, or returns [`WaitReadyError`] if `timeout` elapses first.
1368    ///
1369    /// # Example
1370    ///
1371    /// ```ignore
1372    /// solver.wait_until_ready(Duration::from_secs(180)).await?;
1373    /// ```
1374    pub async fn wait_until_ready(&self, timeout: Duration) -> Result<(), WaitReadyError> {
1375        const POLL_INTERVAL: Duration = Duration::from_millis(500);
1376
1377        let deadline = tokio::time::Instant::now() + timeout;
1378
1379        loop {
1380            let market_ready = self
1381                .market_data
1382                .read()
1383                .await
1384                .last_updated()
1385                .is_some();
1386            let derived_ready = self
1387                .derived_data
1388                .read()
1389                .await
1390                .derived_data_ready();
1391
1392            if market_ready && derived_ready {
1393                return Ok(());
1394            }
1395
1396            if tokio::time::Instant::now() >= deadline {
1397                return Err(WaitReadyError { timeout_ms: timeout.as_millis() as u64 });
1398            }
1399
1400            tokio::time::sleep(POLL_INTERVAL).await;
1401        }
1402    }
1403
1404    /// Build a Solver by replaying recorded market updates.
1405    ///
1406    /// Creates the full pipeline (feed -> derived data -> worker pools -> router)
1407    /// from pre-recorded data instead of a live Tycho connection. The returned
1408    /// Solver behaves identically to a live one — call [`wait_until_ready`](Self::wait_until_ready)
1409    /// then [`quote`](Self::quote).
1410    ///
1411    /// VM-backed protocol states that couldn't be serialized will be absent from
1412    /// the recording. Components without states will still be registered but
1413    /// won't contribute to routing.
1414    ///
1415    /// `rpc_url` reads the PropAMMRouter's fee tiers once, which a recording holding
1416    /// `propammfallback:` components needs: without them every route through one is dropped. It is
1417    /// read once and never refreshed, because a recording is solved against a single block. `None`
1418    /// skips the read, and suits a recording with no such component.
1419    ///
1420    /// Requires the `test-utils` feature.
1421    #[cfg(feature = "test-utils")]
1422    pub async fn from_recording(
1423        chain: Chain,
1424        updates: Vec<tycho_simulation::protocol::models::Update>,
1425        pools: std::collections::HashMap<String, PoolConfig>,
1426        gas_price_wei: Option<num_bigint::BigUint>,
1427        rpc_url: Option<&str>,
1428    ) -> Result<Self, SolverBuildError> {
1429        Self::from_recording_with(
1430            chain,
1431            updates,
1432            pools,
1433            gas_price_wei,
1434            rpc_url,
1435            &AlgorithmRegistry::new(),
1436        )
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        rpc_url: Option<&str>,
1458        algorithms: &AlgorithmRegistry,
1459    ) -> Result<Self, SolverBuildError> {
1460        if pools.is_empty() {
1461            return Err(SolverBuildError::NoPools);
1462        }
1463        if pools
1464            .values()
1465            .all(|pool| pool.liquidity_scope() == Some(LiquidityScope::IncludeExclusive))
1466        {
1467            return Err(SolverBuildError::NoPublicPool);
1468        }
1469
1470        let market_data = MarketData::new_shared();
1471
1472        // Replay updates through TychoFeed (stays pub(crate))
1473        let feed_config =
1474            TychoFeedConfig::new("ws://replay".to_string(), chain, None, false, vec![], 0.0);
1475        let feed = TychoFeed::new(feed_config, market_data.clone());
1476        let market_event_tx = feed.event_sender();
1477        let _feed_rx = feed.subscribe();
1478
1479        for update in updates {
1480            feed.handle_tycho_message(update)
1481                .await
1482                .map_err(|e| SolverBuildError::Replay(e.to_string()))?;
1483        }
1484
1485        // Inject gas price (recorded value or default 10 gwei)
1486        let gas_price = match gas_price_wei {
1487            Some(price) => price,
1488            None => {
1489                tracing::warn!("no recorded gas price, defaulting to 10 gwei");
1490                num_bigint::BigUint::from(10_000_000_000u64)
1491            }
1492        };
1493        let block_number = match market_data.read().await.last_updated() {
1494            Some(block) => block.number(),
1495            None => {
1496                tracing::warn!("no block number from replayed updates, defaulting to 0");
1497                0
1498            }
1499        };
1500        {
1501            let mut market = market_data.write().await;
1502            market.update_gas_price(BlockGasPrice {
1503                block_number,
1504                block_hash: Default::default(),
1505                block_timestamp: 0,
1506                pricing: GasPrice::Legacy { gas_price },
1507            });
1508        }
1509
1510        let gas_token = native_token(&chain).map_err(|_| SolverBuildError::GasToken)?;
1511        let pricing_max_hops = pricing_max_hops(
1512            pools
1513                .values()
1514                .map(|pool_cfg| pool_cfg.max_hops()),
1515        );
1516        let computation_config = ComputationManagerConfig::new()
1517            .with_gas_token(gas_token)
1518            .with_max_hop(pricing_max_hops)
1519            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD)
1520            // Replay tests assert exact priced-token counts against a deterministic recording;
1521            // an effectively unbounded budget keeps a starved CI machine from cutting the
1522            // pricing pass short and failing the count.
1523            .with_pricing_pass_budget(Duration::from_secs(24 * 60 * 60));
1524        let (computation_manager, _) =
1525            ComputationManager::new(computation_config, market_data.clone())
1526                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
1527
1528        let derived_data: SharedDerivedDataRef = computation_manager.store();
1529        let derived_event_tx = computation_manager.event_sender();
1530
1531        let computation_event_rx = feed.subscribe();
1532        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
1533
1534        let computation_handle = tokio::spawn(async move {
1535            computation_manager
1536                .run(computation_event_rx, computation_shutdown_rx)
1537                .await;
1538        });
1539
1540        // Read once, before any worker can be asked for a route: a recording is one block, so
1541        // there is nothing to refresh and no window in which a pAMM route would be dropped.
1542        let fallback_fee_tiers = SharedFeeTiers::default();
1543        if let Some(rpc_url) = rpc_url.filter(|_| chain == Chain::Ethereum) {
1544            propamm_fee_tier_fetcher(rpc_url, fallback_fee_tiers.clone())?
1545                .refresh_once()
1546                .await
1547                .map_err(|e| SolverBuildError::FeeTierFetcher(e.to_string()))?;
1548        }
1549
1550        // Build worker pools BEFORE sending MarketUpdated
1551        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
1552        let mut worker_pools: Vec<WorkerPool> = Vec::new();
1553        let mut max_timeout_ms = 0u64;
1554
1555        for (name, pool_cfg) in &pools {
1556            let mut algo_cfg = AlgorithmConfig::new(
1557                pool_cfg.min_hops(),
1558                pool_cfg.max_hops(),
1559                Duration::from_millis(pool_cfg.timeout_ms()),
1560                pool_cfg.max_routes(),
1561            )?;
1562            if let Some(tokens) = parse_connector_tokens(pool_cfg.connector_tokens())? {
1563                algo_cfg = algo_cfg.with_connector_tokens(tokens);
1564            }
1565
1566            let pool_event_rx = feed.subscribe();
1567            let derived_rx = derived_event_tx.subscribe();
1568
1569            let named = WorkerPoolBuilder::new()
1570                .name(name.clone())
1571                .algorithm_config(algo_cfg)
1572                .num_workers(pool_cfg.num_workers())
1573                .task_queue_capacity(pool_cfg.task_queue_capacity())
1574                .liquidity_scope(
1575                    pool_cfg
1576                        .liquidity_scope()
1577                        .unwrap_or_default(),
1578                )
1579                .fallback_fee_tiers(fallback_fee_tiers.clone());
1580            let (worker_pool, task_handle) = algorithms
1581                .configure(pool_cfg.algorithm(), named)?
1582                .build(market_data.clone(), Arc::clone(&derived_data), pool_event_rx, derived_rx)?;
1583
1584            solver_pool_handles.push(SolverPoolHandle::new(worker_pool.name(), task_handle));
1585            max_timeout_ms = max_timeout_ms.max(pool_cfg.timeout_ms());
1586            worker_pools.push(worker_pool);
1587        }
1588
1589        // Encoder + router
1590        let encoder = {
1591            let registry = SwapEncoderRegistry::new(chain)
1592                .add_default_encoders(None)
1593                .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
1594            Encoder::new(chain, registry).map_err(|e| SolverBuildError::Encoder(e.to_string()))?
1595        };
1596
1597        let router_address = encoder.router_address().cloned();
1598        // Replay mode has no FeeCalculator to read; seed a zero-fee config at the standard
1599        // 8-decimal scale so the recording-based solver reports ready (integration tests do
1600        // not exercise encoding).
1601        let router_fees = encoder.router_fees();
1602        router_fees.set(crate::encoding::router_fees::RouterFees::new(
1603            100_000_000,
1604            0,
1605            0,
1606            rustc_hash::FxHashMap::default(),
1607        ));
1608        let router_config = WorkerPoolRouterConfig::default()
1609            .with_timeout(Duration::from_millis(max_timeout_ms.max(5000)))
1610            .with_min_responses(defaults::ROUTER_MIN_RESPONSES);
1611        let router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
1612
1613        // Trigger derived data computation
1614        let market_read = market_data.read().await;
1615        let added = market_read.component_topology();
1616        drop(market_read);
1617
1618        if market_event_tx
1619            .send(MarketEvent::MarketUpdated {
1620                added_components: added,
1621                removed_components: vec![],
1622                updated_components: vec![],
1623            })
1624            .is_err()
1625        {
1626            tracing::warn!("no receivers for initial MarketUpdated broadcast");
1627        }
1628
1629        // Dummy handles for feed/gas/metrics/router-fees (not running in replay mode). The market
1630        // event channel stays alive through the `market_event_tx` field on `Solver`.
1631        let feed_handle = tokio::spawn(futures::future::pending::<()>());
1632        let gas_price_handle = tokio::spawn(async { /* no-op */ });
1633        let metrics_sampler_handle = tokio::spawn(async { /* no-op */ });
1634        let router_fee_handle = tokio::spawn(async { /* no-op */ });
1635        let fee_tier_handle = tokio::spawn(async { /* no-op */ });
1636
1637        Ok(Solver {
1638            router,
1639            worker_pools,
1640            market_data,
1641            derived_data,
1642            router_fees,
1643            feed_handle,
1644            gas_price_handle,
1645            metrics_sampler_handle,
1646            router_fee_handle,
1647            fee_tier_handle,
1648            computation_handle,
1649            computation_shutdown_tx,
1650            chain,
1651            router_address,
1652            market_event_tx,
1653        })
1654    }
1655
1656    /// Signals all worker pools and the computation manager to stop, then aborts background tasks.
1657    pub fn shutdown(self) {
1658        let _ = self.computation_shutdown_tx.send(());
1659        for pool in self.worker_pools {
1660            pool.shutdown();
1661        }
1662        self.feed_handle.abort();
1663        self.gas_price_handle.abort();
1664        self.metrics_sampler_handle.abort();
1665        self.router_fee_handle.abort();
1666        self.fee_tier_handle.abort();
1667    }
1668
1669    /// Consumes the solver into its raw parts for callers that add their own layer.
1670    pub fn into_parts(self) -> SolverParts {
1671        SolverParts {
1672            router: self.router,
1673            worker_pools: self.worker_pools,
1674            market_data: self.market_data,
1675            derived_data: self.derived_data,
1676            router_fees: self.router_fees,
1677            feed_handle: self.feed_handle,
1678            gas_price_handle: self.gas_price_handle,
1679            metrics_sampler_handle: self.metrics_sampler_handle,
1680            router_fee_handle: self.router_fee_handle,
1681            fee_tier_handle: self.fee_tier_handle,
1682            computation_handle: self.computation_handle,
1683            computation_shutdown_tx: self.computation_shutdown_tx,
1684            chain: self.chain,
1685            router_address: self.router_address,
1686        }
1687    }
1688}
1689
1690/// Raw components of a [`Solver`], for callers adding their own layer (e.g., an HTTP server).
1691///
1692/// Obtained via [`Solver::into_parts`].
1693pub struct SolverParts {
1694    /// Routes quote requests across worker pools.
1695    router: WorkerPoolRouter,
1696    /// One [`WorkerPool`] per entry configured via [`FyndBuilder::add_pool`].
1697    worker_pools: Vec<WorkerPool>,
1698    /// Live market snapshot shared across all components.
1699    market_data: MarketData,
1700    /// Derived on-chain data (spot prices, depths, gas costs) shared across all components.
1701    derived_data: SharedDerivedDataRef,
1702    /// Router fee configuration, refreshed from chain by the router-fee fetcher.
1703    router_fees: SharedRouterFees,
1704    /// Background task running the Tycho market-data feed.
1705    feed_handle: JoinHandle<()>,
1706    /// Background task polling the RPC node for gas prices.
1707    gas_price_handle: JoinHandle<()>,
1708    /// Background task exporting per-protocol market metrics.
1709    metrics_sampler_handle: JoinHandle<()>,
1710    /// Background task refreshing router fees from the on-chain FeeCalculator.
1711    router_fee_handle: JoinHandle<()>,
1712    /// Background task refreshing the PropAMMRouter's Uniswap V3 fee tiers.
1713    ///
1714    /// Handed out as an [`AbortHandle`] by [`SolverParts::fee_tier_abort_handle`] rather than by
1715    /// [`SolverParts::into_components`], so adding it did not change that function's signature.
1716    fee_tier_handle: JoinHandle<()>,
1717    /// Background task running the computation manager.
1718    computation_handle: JoinHandle<()>,
1719    /// Send a unit value on this channel to trigger a graceful computation-manager shutdown.
1720    computation_shutdown_tx: broadcast::Sender<()>,
1721    /// Chain this solver is configured for.
1722    chain: Chain,
1723    /// Address of the Tycho Router contract on this chain, or `None` on a quote-only chain.
1724    router_address: Option<Bytes>,
1725}
1726
1727impl SolverParts {
1728    /// Returns the chain this solver is configured for.
1729    pub fn chain(&self) -> Chain {
1730        self.chain
1731    }
1732
1733    /// Returns the Tycho Router contract address for this chain, or `None` on a quote-only chain.
1734    pub fn router_address(&self) -> Option<&Bytes> {
1735        self.router_address.as_ref()
1736    }
1737
1738    /// Returns a reference to the worker pools.
1739    pub fn worker_pools(&self) -> &[WorkerPool] {
1740        &self.worker_pools
1741    }
1742
1743    /// Returns a reference to the shared market data.
1744    pub fn market_data(&self) -> &MarketData {
1745        &self.market_data
1746    }
1747
1748    /// Returns a reference to the shared derived data.
1749    pub fn derived_data(&self) -> &SharedDerivedDataRef {
1750        &self.derived_data
1751    }
1752
1753    /// Returns a reference to the shared router fee configuration.
1754    pub fn router_fees(&self) -> &SharedRouterFees {
1755        &self.router_fees
1756    }
1757
1758    /// Returns a handle that stops the task refreshing the PropAMMRouter's fee tiers.
1759    ///
1760    /// The handle outlives [`into_components`](Self::into_components), which drops the task's
1761    /// `JoinHandle` and so detaches the task. Take this before calling it, and abort it wherever
1762    /// the other background tasks are aborted.
1763    pub fn fee_tier_abort_handle(&self) -> AbortHandle {
1764        self.fee_tier_handle.abort_handle()
1765    }
1766
1767    /// Consumes the parts and returns the router.
1768    pub fn into_router(self) -> WorkerPoolRouter {
1769        self.router
1770    }
1771
1772    /// Consumes the parts, returning all owned components.
1773    #[allow(clippy::type_complexity)]
1774    pub fn into_components(
1775        self,
1776    ) -> (
1777        WorkerPoolRouter,
1778        Vec<WorkerPool>,
1779        MarketData,
1780        SharedDerivedDataRef,
1781        JoinHandle<()>,
1782        JoinHandle<()>,
1783        JoinHandle<()>,
1784        JoinHandle<()>,
1785        JoinHandle<()>,
1786        broadcast::Sender<()>,
1787    ) {
1788        (
1789            self.router,
1790            self.worker_pools,
1791            self.market_data,
1792            self.derived_data,
1793            self.feed_handle,
1794            self.gas_price_handle,
1795            self.metrics_sampler_handle,
1796            self.router_fee_handle,
1797            self.computation_handle,
1798            self.computation_shutdown_tx,
1799        )
1800    }
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806
1807    /// An unscoped pool resolves to `PublicOnly` — exclusive components are filtered out unless
1808    /// a pool explicitly opts in with `IncludeExclusive`.
1809    #[test]
1810    fn test_unscoped_pool_resolves_to_public_only() {
1811        let config = PoolConfig::new("most_liquid");
1812        assert_eq!(config.liquidity_scope(), None);
1813        assert_eq!(
1814            config
1815                .liquidity_scope()
1816                .unwrap_or_default(),
1817            LiquidityScope::PublicOnly
1818        );
1819    }
1820
1821    /// A deployment of nothing but exclusive-access pools would serve requests without access
1822    /// from no pool at all.
1823    #[test]
1824    fn test_build_all_exclusive_pools() {
1825        let config =
1826            PoolConfig::new("most_liquid").with_liquidity_scope(LiquidityScope::IncludeExclusive);
1827        let result = FyndBuilder::new(
1828            Chain::Ethereum,
1829            "wss://example.invalid",
1830            "https://example.invalid",
1831            vec!["uniswap_v2".to_string()],
1832            100.0,
1833        )
1834        .add_pool("exclusive", &config)
1835        .expect("add_pool should accept the config")
1836        .build();
1837
1838        assert!(matches!(result, Err(SolverBuildError::NoPublicPool)));
1839    }
1840}