1use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
14
15use num_cpus;
16use serde::{Deserialize, Serialize};
17use tokio::{sync::broadcast, task::JoinHandle};
18use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
19#[cfg(feature = "experimental")]
20use tycho_simulation::evm::stream::BlockStepController;
21#[cfg(feature = "test-utils")]
22use tycho_simulation::tycho_ethereum::gas::{BlockGasPrice, GasPrice};
23use tycho_simulation::{
24 evm::pending::PendingBlockProcessor,
25 tycho_common::{models::Chain, traits::TxDeltaIndexer, Bytes},
26 tycho_core::models::Address,
27 tycho_ethereum::rpc::EthereumRpcClient,
28};
29
30use crate::{
31 algorithm::{AlgorithmConfig, AlgorithmError},
32 derived::{ComputationManager, ComputationManagerConfig, SharedDerivedDataRef},
33 encoding::{encoder::Encoder, fee_fetcher::RouterFeeFetcher, router_fees::SharedRouterFees},
34 feed::{
35 events::{MarketEvent, MarketEventHandler},
36 exclusivity::ExclusivityPolicy,
37 gas::GasPriceFetcher,
38 market_data::MarketData,
39 metrics_sampler::MetricsSampler,
40 tycho_feed::TychoFeed,
41 TychoFeedConfig,
42 },
43 graph::EdgeWeightUpdaterWithDerived,
44 price_guard::{
45 guard::PriceGuard, provider::PriceProvider, provider_registry::PriceProviderRegistry,
46 },
47 types::constants::native_token,
48 worker_pool::{
49 pool::{WorkerPool, WorkerPoolBuilder},
50 registry::UnknownAlgorithmError,
51 },
52 worker_pool_router::{
53 config::WorkerPoolRouterConfig, LiquidityScope, SolverPoolHandle, WorkerPoolRouter,
54 },
55 Algorithm, Quote, QuoteRequest, SolveError,
56};
57
58pub mod defaults {
64 use std::time::Duration;
65
66 pub const MIN_TOKEN_QUALITY: i32 = 100;
68 pub const TRADED_N_DAYS_AGO: u64 = 3;
70 pub const TVL_BUFFER_RATIO: f64 = 1.1;
72 pub const GAS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
74 pub const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_secs(10);
76 pub const ROUTER_FEE_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
78 pub const RECONNECT_DELAY: Duration = Duration::from_secs(5);
80 pub const ROUTER_MIN_RESPONSES: usize = 0;
83 pub const POOL_TASK_QUEUE_CAPACITY: usize = 1000;
85 pub const POOL_MIN_HOPS: usize = 1;
87 pub const POOL_MAX_HOPS: usize = 3;
89 pub const POOL_TIMEOUT_MS: u64 = 100;
91}
92
93const DEFAULT_TYCHO_USE_TLS: bool = true;
95const DEFAULT_DEPTH_SLIPPAGE_THRESHOLD: f64 = 0.01;
96const DEFAULT_ROUTER_TIMEOUT: Duration = Duration::from_secs(10);
99
100fn default_task_queue_capacity() -> usize {
103 defaults::POOL_TASK_QUEUE_CAPACITY
104}
105
106fn default_min_hops() -> usize {
107 defaults::POOL_MIN_HOPS
108}
109
110fn default_max_hops() -> usize {
111 defaults::POOL_MAX_HOPS
112}
113
114fn default_algo_timeout_ms() -> u64 {
115 defaults::POOL_TIMEOUT_MS
116}
117
118fn parse_connector_tokens(
119 raw: Option<&[String]>,
120) -> Result<Option<HashSet<Address>>, SolverBuildError> {
121 let Some(strings) = raw else {
122 return Ok(None);
123 };
124 let mut set = HashSet::with_capacity(strings.len());
125 for s in strings {
126 let addr = Address::from_str(s).map_err(|e| AlgorithmError::InvalidConfiguration {
127 reason: format!("connector_tokens: invalid address {s:?}: {e}"),
128 })?;
129 set.insert(addr);
130 }
131 Ok(Some(set))
132}
133
134#[must_use]
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PoolConfig {
138 algorithm: String,
140 #[serde(default = "num_cpus::get")]
142 num_workers: usize,
143 #[serde(default = "default_task_queue_capacity")]
145 task_queue_capacity: usize,
146 #[serde(default = "default_min_hops")]
148 min_hops: usize,
149 #[serde(default = "default_max_hops")]
151 max_hops: usize,
152 #[serde(default = "default_algo_timeout_ms")]
154 timeout_ms: u64,
155 #[serde(default)]
157 max_routes: Option<usize>,
158 #[serde(default)]
161 connector_tokens: Option<Vec<String>>,
162 #[serde(default)]
166 liquidity_scope: Option<LiquidityScope>,
167}
168
169impl PoolConfig {
170 pub fn new(algorithm: impl Into<String>) -> Self {
172 Self {
173 algorithm: algorithm.into(),
174 num_workers: num_cpus::get(),
175 task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
176 min_hops: defaults::POOL_MIN_HOPS,
177 max_hops: defaults::POOL_MAX_HOPS,
178 timeout_ms: defaults::POOL_TIMEOUT_MS,
179 max_routes: None,
180 connector_tokens: None,
181 liquidity_scope: None,
182 }
183 }
184
185 pub fn algorithm(&self) -> &str {
187 &self.algorithm
188 }
189
190 pub fn liquidity_scope(&self) -> Option<LiquidityScope> {
192 self.liquidity_scope
193 }
194
195 pub fn with_liquidity_scope(mut self, scope: LiquidityScope) -> Self {
197 self.liquidity_scope = Some(scope);
198 self
199 }
200
201 pub fn num_workers(&self) -> usize {
203 self.num_workers
204 }
205
206 pub fn with_num_workers(mut self, num_workers: usize) -> Self {
208 self.num_workers = num_workers;
209 self
210 }
211
212 pub fn with_task_queue_capacity(mut self, task_queue_capacity: usize) -> Self {
214 self.task_queue_capacity = task_queue_capacity;
215 self
216 }
217
218 pub fn with_min_hops(mut self, min_hops: usize) -> Self {
220 self.min_hops = min_hops;
221 self
222 }
223
224 pub fn with_max_hops(mut self, max_hops: usize) -> Self {
226 self.max_hops = max_hops;
227 self
228 }
229
230 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
232 self.timeout_ms = timeout_ms;
233 self
234 }
235
236 pub fn with_max_routes(mut self, max_routes: Option<usize>) -> Self {
238 self.max_routes = max_routes;
239 self
240 }
241
242 pub fn task_queue_capacity(&self) -> usize {
244 self.task_queue_capacity
245 }
246
247 pub fn min_hops(&self) -> usize {
249 self.min_hops
250 }
251
252 pub fn max_hops(&self) -> usize {
254 self.max_hops
255 }
256
257 pub fn timeout_ms(&self) -> u64 {
259 self.timeout_ms
260 }
261
262 pub fn max_routes(&self) -> Option<usize> {
264 self.max_routes
265 }
266
267 pub fn with_connector_tokens(mut self, tokens: Vec<String>) -> Self {
270 self.connector_tokens = Some(tokens);
271 self
272 }
273
274 pub fn connector_tokens(&self) -> Option<&[String]> {
276 self.connector_tokens.as_deref()
277 }
278}
279
280#[derive(Debug, thiserror::Error)]
282#[error("timed out after {timeout_ms}ms waiting for market data and derived computations")]
283pub struct WaitReadyError {
284 timeout_ms: u64,
285}
286
287#[non_exhaustive]
289#[derive(Debug, thiserror::Error)]
290pub enum SolverBuildError {
291 #[error("failed to create ethereum RPC client: {0}")]
293 RpcClient(String),
294 #[error(transparent)]
296 AlgorithmConfig(#[from] AlgorithmError),
297 #[error("failed to create computation manager: {0}")]
299 ComputationManager(String),
300 #[error("failed to create encoder: {0}")]
302 Encoder(String),
303 #[error("failed to create router fee fetcher: {0}")]
305 RouterFeeFetcher(String),
306 #[error(transparent)]
308 UnknownAlgorithm(#[from] UnknownAlgorithmError),
309 #[error("gas token not configured for chain")]
311 GasToken,
312 #[error("no worker pools configured")]
314 NoPools,
315 #[error(
319 "a worker pool sets liquidity_scope but no exclusivity policy is configured; \
320 set one via FyndBuilder::exclusivity_policy"
321 )]
322 LiquidityScopeWithoutPolicy,
323 #[cfg(feature = "test-utils")]
325 #[error("replay failed: {0}")]
326 Replay(String),
327 #[error("feed setup failed before delivering pending processor: {0}")]
332 FeedSetup(String),
333 #[error("pending processor channel closed before processor was delivered")]
336 PendingChannelClosed,
337 #[cfg(feature = "experimental")]
340 #[error("step controller channel closed before controller was delivered")]
341 StepControllerChannelClosed,
342}
343
344enum PoolEntry {
346 BuiltIn {
347 name: String,
348 algorithm: String,
349 num_workers: usize,
350 task_queue_capacity: usize,
351 min_hops: usize,
352 max_hops: usize,
353 timeout_ms: u64,
354 max_routes: Option<usize>,
355 connector_tokens: Option<HashSet<Address>>,
356 liquidity_scope: Option<LiquidityScope>,
357 },
358 Custom(CustomPoolEntry),
359}
360
361impl PoolEntry {
362 fn liquidity_scope(&self) -> Option<LiquidityScope> {
364 match self {
365 PoolEntry::BuiltIn { liquidity_scope, .. } => *liquidity_scope,
366 PoolEntry::Custom(custom) => custom.liquidity_scope,
367 }
368 }
369}
370
371struct CustomPoolEntry {
373 name: String,
374 num_workers: usize,
375 task_queue_capacity: usize,
376 min_hops: usize,
377 max_hops: usize,
378 timeout_ms: u64,
379 max_routes: Option<usize>,
380 liquidity_scope: Option<LiquidityScope>,
382 configure: Box<dyn FnOnce(WorkerPoolBuilder) -> WorkerPoolBuilder + Send>,
384}
385
386struct BuiltComponents {
389 tycho_feed: TychoFeed,
390 gas_price_fetcher: GasPriceFetcher<EthereumRpcClient>,
391 router_fee_fetcher: Option<RouterFeeFetcher>,
392 computation_manager: ComputationManager,
393 computation_event_rx: broadcast::Receiver<MarketEvent>,
394 computation_shutdown_tx: broadcast::Sender<()>,
395 computation_shutdown_rx: broadcast::Receiver<()>,
396 router: WorkerPoolRouter,
397 worker_pools: Vec<WorkerPool>,
398 market_data: MarketData,
399 derived_data: SharedDerivedDataRef,
400 router_fees: SharedRouterFees,
401 chain: Chain,
402 router_address: Option<Bytes>,
403 pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
404 market_event_tx: broadcast::Sender<MarketEvent>,
405}
406
407#[must_use = "a builder does nothing until .build() is called"]
412pub struct FyndBuilder {
413 chain: Chain,
414 tycho_url: String,
415 rpc_url: String,
416 protocols: Vec<String>,
417 min_tvl: f64,
418 tycho_api_key: Option<String>,
419 tycho_use_tls: bool,
420 min_token_quality: i32,
421 traded_n_days_ago: u64,
422 tvl_buffer_ratio: f64,
423 gas_refresh_interval: Duration,
424 reconnect_delay: Duration,
425 blocklisted_components: HashSet<String>,
426 partial_blocks: bool,
427 router_timeout: Duration,
428 router_min_responses: usize,
429 encoder: Option<Encoder>,
430 pools: Vec<PoolEntry>,
431 price_guard_enabled: bool,
432 price_providers: Vec<Box<dyn PriceProvider>>,
433 pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
434 exclusivity_policy: Option<ExclusivityPolicy>,
438}
439
440impl FyndBuilder {
441 pub fn new(
443 chain: Chain,
444 tycho_url: impl Into<String>,
445 rpc_url: impl Into<String>,
446 protocols: Vec<String>,
447 min_tvl: f64,
448 ) -> Self {
449 Self {
450 chain,
451 tycho_url: tycho_url.into(),
452 rpc_url: rpc_url.into(),
453 protocols,
454 min_tvl,
455 tycho_api_key: None,
456 tycho_use_tls: DEFAULT_TYCHO_USE_TLS,
457 min_token_quality: defaults::MIN_TOKEN_QUALITY,
458 traded_n_days_ago: defaults::TRADED_N_DAYS_AGO,
459 tvl_buffer_ratio: defaults::TVL_BUFFER_RATIO,
460 gas_refresh_interval: defaults::GAS_REFRESH_INTERVAL,
461 reconnect_delay: defaults::RECONNECT_DELAY,
462 blocklisted_components: HashSet::new(),
463 partial_blocks: false,
464 router_timeout: DEFAULT_ROUTER_TIMEOUT,
465 router_min_responses: defaults::ROUTER_MIN_RESPONSES,
466 encoder: None,
467 pools: Vec::new(),
468 price_guard_enabled: false,
469 price_providers: Vec::new(),
470 pending_indexers: Vec::new(),
471 exclusivity_policy: None,
472 }
473 }
474
475 pub fn chain(&self) -> Chain {
477 self.chain
478 }
479
480 pub fn tycho_api_key(mut self, key: impl Into<String>) -> Self {
482 self.tycho_api_key = Some(key.into());
483 self
484 }
485
486 pub fn min_tvl(mut self, min_tvl: f64) -> Self {
488 self.min_tvl = min_tvl;
489 self
490 }
491
492 pub fn tycho_use_tls(mut self, use_tls: bool) -> Self {
494 self.tycho_use_tls = use_tls;
495 self
496 }
497
498 pub fn min_token_quality(mut self, quality: i32) -> Self {
501 self.min_token_quality = quality;
502 self
503 }
504
505 pub fn traded_n_days_ago(mut self, days: u64) -> Self {
507 self.traded_n_days_ago = days;
508 self
509 }
510
511 pub fn tvl_buffer_ratio(mut self, ratio: f64) -> Self {
513 self.tvl_buffer_ratio = ratio;
514 self
515 }
516
517 pub fn gas_refresh_interval(mut self, interval: Duration) -> Self {
519 self.gas_refresh_interval = interval;
520 self
521 }
522
523 pub fn reconnect_delay(mut self, delay: Duration) -> Self {
525 self.reconnect_delay = delay;
526 self
527 }
528
529 pub fn blocklisted_components(mut self, components: HashSet<String>) -> Self {
531 self.blocklisted_components = components;
532 self
533 }
534
535 pub fn partial_blocks(mut self, enabled: bool) -> Self {
541 self.partial_blocks = enabled;
542 self
543 }
544
545 pub fn worker_router_timeout(mut self, timeout: Duration) -> Self {
547 self.router_timeout = timeout;
548 self
549 }
550
551 pub fn worker_router_min_responses(mut self, min: usize) -> Self {
553 self.router_min_responses = min;
554 self
555 }
556
557 pub fn encoder(mut self, encoder: Encoder) -> Self {
559 self.encoder = Some(encoder);
560 self
561 }
562
563 pub fn algorithm(mut self, algorithm: impl Into<String>) -> Self {
565 self.pools.push(PoolEntry::BuiltIn {
566 name: "default".to_string(),
567 algorithm: algorithm.into(),
568 num_workers: num_cpus::get(),
569 task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
570 min_hops: defaults::POOL_MIN_HOPS,
571 max_hops: defaults::POOL_MAX_HOPS,
572 timeout_ms: defaults::POOL_TIMEOUT_MS,
573 max_routes: None,
574 connector_tokens: None,
575 liquidity_scope: None,
576 });
577 self
578 }
579
580 pub fn with_algorithm<A, F>(mut self, name: impl Into<String>, factory: F) -> Self
584 where
585 A: Algorithm + 'static,
586 A::GraphManager: MarketEventHandler + EdgeWeightUpdaterWithDerived + 'static,
587 F: Fn(AlgorithmConfig) -> A + Clone + Send + Sync + 'static,
588 {
589 let name = name.into();
590 let algo_name = name.clone();
591 let configure =
592 Box::new(move |builder: WorkerPoolBuilder| builder.with_algorithm(algo_name, factory));
593 self.pools
594 .push(PoolEntry::Custom(CustomPoolEntry {
595 name,
596 num_workers: num_cpus::get(),
597 task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
598 min_hops: defaults::POOL_MIN_HOPS,
599 max_hops: defaults::POOL_MAX_HOPS,
600 timeout_ms: defaults::POOL_TIMEOUT_MS,
601 max_routes: None,
602 liquidity_scope: None,
603 configure,
604 }));
605 self
606 }
607
608 pub fn add_default_price_providers(self) -> Self {
615 self.register_price_provider(Box::new(
616 crate::price_guard::hyperliquid::HyperliquidProvider::default(),
617 ))
618 .register_price_provider(Box::new(
619 crate::price_guard::binance_ws::BinanceWsProvider::default(),
620 ))
621 }
622
623 pub fn register_price_provider(mut self, provider: Box<dyn PriceProvider>) -> Self {
628 self.price_providers.push(provider);
629 self
630 }
631
632 pub fn with_pending_indexer(
638 mut self,
639 extractor: impl Into<String>,
640 indexer: Box<dyn TxDeltaIndexer>,
641 ) -> Self {
642 self.pending_indexers
643 .push((extractor.into(), indexer));
644 self
645 }
646
647 pub fn price_guard_enabled(mut self, enabled: bool) -> Self {
654 self.price_guard_enabled = enabled;
655 self
656 }
657
658 pub fn exclusivity_policy<F>(mut self, predicate: F) -> Self
663 where
664 F: Fn(&tycho_simulation::tycho_common::models::protocol::ProtocolComponent) -> bool
665 + Send
666 + Sync
667 + 'static,
668 {
669 self.exclusivity_policy = Some(ExclusivityPolicy::new(predicate));
670 self
671 }
672
673 pub fn add_pool(
680 mut self,
681 name: impl Into<String>,
682 config: &PoolConfig,
683 ) -> Result<Self, SolverBuildError> {
684 let connector_tokens = parse_connector_tokens(config.connector_tokens())?;
685 self.pools.push(PoolEntry::BuiltIn {
686 name: name.into(),
687 algorithm: config.algorithm().to_string(),
688 num_workers: config.num_workers(),
689 task_queue_capacity: config.task_queue_capacity(),
690 min_hops: config.min_hops(),
691 max_hops: config.max_hops(),
692 timeout_ms: config.timeout_ms(),
693 max_routes: config.max_routes(),
694 connector_tokens,
695 liquidity_scope: config.liquidity_scope(),
696 });
697 Ok(self)
698 }
699
700 fn assemble_components(mut self) -> Result<BuiltComponents, SolverBuildError> {
703 if self.pools.is_empty() {
704 return Err(SolverBuildError::NoPools);
705 }
706
707 if self.exclusivity_policy.is_none() &&
713 self.pools
714 .iter()
715 .any(|p| p.liquidity_scope().is_some())
716 {
717 return Err(SolverBuildError::LiquidityScopeWithoutPolicy);
718 }
719
720 if self.price_providers.is_empty() {
722 self = self.add_default_price_providers();
723 }
724
725 let market_data = MarketData::new_shared();
726
727 let tycho_feed_config = TychoFeedConfig::new(
728 self.tycho_url,
729 self.chain,
730 self.tycho_api_key,
731 self.tycho_use_tls,
732 self.protocols,
733 self.min_tvl,
734 )
735 .tvl_buffer_ratio(self.tvl_buffer_ratio)
736 .reconnect_delay(self.reconnect_delay)
737 .min_token_quality(self.min_token_quality)
738 .traded_n_days_ago(self.traded_n_days_ago)
739 .blocklisted_components(self.blocklisted_components)
740 .partial_blocks(self.partial_blocks);
741
742 let ethereum_client = EthereumRpcClient::new(self.rpc_url.as_str())
743 .map_err(|e| SolverBuildError::RpcClient(e.to_string()))?;
744
745 let gas_price_fetcher =
746 GasPriceFetcher::new(ethereum_client, market_data.clone(), self.gas_refresh_interval);
747
748 let tycho_feed = TychoFeed::new(tycho_feed_config, market_data.clone());
749 let market_event_tx = tycho_feed.event_sender();
750
751 let gas_token = native_token(&self.chain).map_err(|_| SolverBuildError::GasToken)?;
752 let computation_config = ComputationManagerConfig::new()
753 .with_gas_token(gas_token)
754 .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
755 let (computation_manager, _) =
758 ComputationManager::new(computation_config, market_data.clone())
759 .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
760
761 let derived_data: SharedDerivedDataRef = computation_manager.store();
762 let derived_event_tx = computation_manager.event_sender();
763
764 let computation_event_rx = tycho_feed.subscribe();
766 let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
767
768 let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
769 let mut worker_pools: Vec<WorkerPool> = Vec::new();
770
771 let exclusivity_policy = self.exclusivity_policy.take();
774 let pools = std::mem::take(&mut self.pools);
775
776 for pool_entry in pools {
777 let pool_event_rx = tycho_feed.subscribe();
778 let derived_rx = derived_event_tx.subscribe();
779
780 let pool_scope = pool_entry
784 .liquidity_scope()
785 .unwrap_or_default();
786 let pool_policy = match pool_scope {
787 LiquidityScope::PublicOnly => exclusivity_policy.clone(),
788 LiquidityScope::All => None,
789 };
790
791 let (worker_pool, task_handle) = match pool_entry {
792 PoolEntry::BuiltIn {
793 name,
794 algorithm,
795 num_workers,
796 task_queue_capacity,
797 min_hops,
798 max_hops,
799 timeout_ms,
800 max_routes,
801 connector_tokens,
802 liquidity_scope: _,
803 } => {
804 let mut algo_cfg = AlgorithmConfig::new(
805 min_hops,
806 max_hops,
807 Duration::from_millis(timeout_ms),
808 max_routes,
809 )?;
810 if let Some(tokens) = connector_tokens {
811 algo_cfg = algo_cfg.with_connector_tokens(tokens);
812 }
813 let builder = WorkerPoolBuilder::new()
814 .name(name)
815 .algorithm(algorithm)
816 .algorithm_config(algo_cfg)
817 .num_workers(num_workers)
818 .task_queue_capacity(task_queue_capacity)
819 .exclusivity_policy(pool_policy.clone());
820 builder.build(
821 market_data.clone(),
822 Arc::clone(&derived_data),
823 pool_event_rx,
824 derived_rx,
825 )?
826 }
827 PoolEntry::Custom(custom) => {
828 let algo_cfg = AlgorithmConfig::new(
829 custom.min_hops,
830 custom.max_hops,
831 Duration::from_millis(custom.timeout_ms),
832 custom.max_routes,
833 )?;
834 let builder = WorkerPoolBuilder::new()
835 .name(custom.name)
836 .algorithm_config(algo_cfg)
837 .num_workers(custom.num_workers)
838 .task_queue_capacity(custom.task_queue_capacity)
839 .exclusivity_policy(pool_policy.clone());
840 let builder = (custom.configure)(builder);
841 builder.build(
842 market_data.clone(),
843 Arc::clone(&derived_data),
844 pool_event_rx,
845 derived_rx,
846 )?
847 }
848 };
849
850 solver_pool_handles.push(
851 SolverPoolHandle::new(worker_pool.name(), task_handle)
852 .with_liquidity_scope(pool_scope),
853 );
854 worker_pools.push(worker_pool);
855 }
856
857 let encoder = match self.encoder {
858 Some(enc) => enc,
859 None => {
860 let registry = SwapEncoderRegistry::new(self.chain)
861 .add_default_encoders(None)
862 .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
863 Encoder::new(self.chain, registry)
864 .map_err(|e| SolverBuildError::Encoder(e.to_string()))?
865 }
866 };
867
868 let chain = self.chain;
869 let router_address = encoder.router_address().cloned();
870 let router_fees = encoder.router_fees();
871
872 let router_fee_fetcher = match &router_address {
873 Some(addr) => Some(
874 RouterFeeFetcher::new(
875 self.rpc_url.as_str(),
876 addr,
877 router_fees.clone(),
878 defaults::ROUTER_FEE_REFRESH_INTERVAL,
879 )
880 .map_err(|e| SolverBuildError::RouterFeeFetcher(e.to_string()))?,
881 ),
882 None => {
883 tracing::warn!(
884 %chain,
885 "no Tycho router for this chain; running quote-only (encoding disabled)"
886 );
887 None
888 }
889 };
890
891 let router_config = WorkerPoolRouterConfig::default()
894 .with_timeout(self.router_timeout)
895 .with_min_responses(self.router_min_responses);
896 let mut router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
897
898 if let Some(ref policy) = exclusivity_policy {
899 router = router.with_exclusivity_policy(policy.clone());
900 }
901
902 if self.price_guard_enabled {
903 let mut registry = PriceProviderRegistry::new();
904 let mut worker_handles = Vec::new();
905 for mut provider in self.price_providers {
906 worker_handles.push(provider.start(market_data.clone()));
907 registry = registry.register(provider);
908 }
909 let price_guard = PriceGuard::new(registry, worker_handles);
910 router = router.with_price_guard(price_guard);
911 }
912
913 Ok(BuiltComponents {
914 tycho_feed,
915 gas_price_fetcher,
916 router_fee_fetcher,
917 computation_manager,
918 computation_event_rx,
919 computation_shutdown_tx,
920 computation_shutdown_rx,
921 router,
922 worker_pools,
923 market_data,
924 derived_data,
925 router_fees,
926 chain,
927 router_address,
928 pending_indexers: self.pending_indexers,
929 market_event_tx,
930 })
931 }
932
933 pub fn build(self) -> Result<Solver, SolverBuildError> {
939 let mut c = self.assemble_components()?;
940
941 let feed_handle = tokio::spawn(async move {
942 if let Err(e) = c.tycho_feed.run().await {
943 metrics::counter!("tycho_feed_failures_total").increment(1);
944 tracing::error!(error = %e, "tycho feed error");
945 }
946 });
947 let gas_price_handle = tokio::spawn(async move {
948 c.gas_price_fetcher.run().await;
949 });
950 let metrics_sampler =
951 MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
952 let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
953 let router_fee_handle = match c.router_fee_fetcher {
954 Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
955 None => tokio::spawn(async {}),
956 };
957 let computation_handle = tokio::spawn(async move {
958 c.computation_manager
959 .run(c.computation_event_rx, c.computation_shutdown_rx)
960 .await;
961 });
962
963 Ok(Solver {
964 router: c.router,
965 worker_pools: c.worker_pools,
966 market_data: c.market_data,
967 derived_data: c.derived_data,
968 router_fees: c.router_fees,
969 feed_handle,
970 gas_price_handle,
971 metrics_sampler_handle,
972 router_fee_handle,
973 computation_handle,
974 computation_shutdown_tx: c.computation_shutdown_tx,
975 chain: c.chain,
976 router_address: c.router_address,
977 market_event_tx: c.market_event_tx,
978 })
979 }
980
981 pub async fn build_with_pending(
993 self,
994 ) -> Result<(Solver, PendingBlockProcessor), SolverBuildError> {
995 let mut c = self.assemble_components()?;
996
997 let (pending_tx, pending_rx) =
998 tokio::sync::oneshot::channel::<Result<PendingBlockProcessor, String>>();
999
1000 let pending_indexers = c.pending_indexers;
1001 let feed_handle = tokio::spawn(async move {
1002 if let Err(e) = c
1003 .tycho_feed
1004 .run_with_pending(pending_tx, pending_indexers)
1005 .await
1006 {
1007 metrics::counter!("tycho_feed_failures_total").increment(1);
1008 tracing::error!(error = %e, "tycho feed error");
1009 }
1010 });
1011 let gas_price_handle = tokio::spawn(async move {
1012 c.gas_price_fetcher.run().await;
1013 });
1014 let metrics_sampler =
1015 MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1016 let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1017 let router_fee_handle = match c.router_fee_fetcher {
1018 Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1019 None => tokio::spawn(async {}),
1020 };
1021 let computation_handle = tokio::spawn(async move {
1022 c.computation_manager
1023 .run(c.computation_event_rx, c.computation_shutdown_rx)
1024 .await;
1025 });
1026
1027 let pending = pending_rx
1028 .await
1029 .map_err(|_| SolverBuildError::PendingChannelClosed)?
1030 .map_err(SolverBuildError::FeedSetup)?;
1031
1032 Ok((
1033 Solver {
1034 router: c.router,
1035 worker_pools: c.worker_pools,
1036 market_data: c.market_data,
1037 derived_data: c.derived_data,
1038 router_fees: c.router_fees,
1039 feed_handle,
1040 gas_price_handle,
1041 metrics_sampler_handle,
1042 router_fee_handle,
1043 computation_handle,
1044 computation_shutdown_tx: c.computation_shutdown_tx,
1045 chain: c.chain,
1046 router_address: c.router_address,
1047 market_event_tx: c.market_event_tx,
1048 },
1049 pending,
1050 ))
1051 }
1052
1053 #[cfg(feature = "experimental")]
1068 pub async fn build_with_step_controller(
1069 self,
1070 ) -> Result<(Solver, BlockStepController), SolverBuildError> {
1071 let mut c = self.assemble_components()?;
1072
1073 let (controller_tx, controller_rx) =
1074 tokio::sync::oneshot::channel::<Result<BlockStepController, String>>();
1075
1076 let feed_handle = tokio::spawn(async move {
1077 if let Err(e) = c
1078 .tycho_feed
1079 .run_with_step_controller(controller_tx)
1080 .await
1081 {
1082 tracing::error!(error = %e, "tycho feed error");
1083 }
1084 });
1085 let gas_price_handle = tokio::spawn(async move {
1086 c.gas_price_fetcher.run().await;
1087 });
1088 let metrics_sampler =
1089 MetricsSampler::new(c.market_data.clone(), defaults::METRICS_SAMPLE_INTERVAL);
1090 let metrics_sampler_handle = tokio::spawn(async move { metrics_sampler.run().await });
1091 let router_fee_handle = match c.router_fee_fetcher {
1092 Some(fetcher) => tokio::spawn(async move { fetcher.run().await }),
1093 None => tokio::spawn(async {}),
1094 };
1095 let computation_handle = tokio::spawn(async move {
1096 c.computation_manager
1097 .run(c.computation_event_rx, c.computation_shutdown_rx)
1098 .await;
1099 });
1100
1101 let controller = controller_rx
1102 .await
1103 .map_err(|_| SolverBuildError::StepControllerChannelClosed)?
1104 .map_err(SolverBuildError::FeedSetup)?;
1105
1106 Ok((
1107 Solver {
1108 router: c.router,
1109 worker_pools: c.worker_pools,
1110 market_data: c.market_data,
1111 derived_data: c.derived_data,
1112 router_fees: c.router_fees,
1113 feed_handle,
1114 gas_price_handle,
1115 metrics_sampler_handle,
1116 router_fee_handle,
1117 computation_handle,
1118 computation_shutdown_tx: c.computation_shutdown_tx,
1119 chain: c.chain,
1120 router_address: c.router_address,
1121 market_event_tx: c.market_event_tx,
1122 },
1123 controller,
1124 ))
1125 }
1126} pub struct Solver {
1130 router: WorkerPoolRouter,
1131 worker_pools: Vec<WorkerPool>,
1132 market_data: MarketData,
1133 derived_data: SharedDerivedDataRef,
1134 router_fees: SharedRouterFees,
1135 feed_handle: JoinHandle<()>,
1136 gas_price_handle: JoinHandle<()>,
1137 metrics_sampler_handle: JoinHandle<()>,
1138 router_fee_handle: JoinHandle<()>,
1139 computation_handle: JoinHandle<()>,
1140 computation_shutdown_tx: broadcast::Sender<()>,
1141 chain: Chain,
1142 router_address: Option<Bytes>,
1143 market_event_tx: broadcast::Sender<MarketEvent>,
1144}
1145
1146impl Solver {
1147 pub fn market_data(&self) -> MarketData {
1149 self.market_data.clone()
1150 }
1151
1152 pub fn router_address(&self) -> Option<&Bytes> {
1154 self.router_address.as_ref()
1155 }
1156
1157 pub fn derived_data(&self) -> SharedDerivedDataRef {
1159 Arc::clone(&self.derived_data)
1160 }
1161
1162 pub fn subscribe_market_events(&self) -> broadcast::Receiver<crate::feed::events::MarketEvent> {
1167 self.market_event_tx.subscribe()
1168 }
1169
1170 pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
1176 self.router.quote(request).await
1177 }
1178
1179 pub async fn wait_until_ready(&self, timeout: Duration) -> Result<(), WaitReadyError> {
1196 const POLL_INTERVAL: Duration = Duration::from_millis(500);
1197
1198 let deadline = tokio::time::Instant::now() + timeout;
1199
1200 loop {
1201 let market_ready = self
1202 .market_data
1203 .read()
1204 .await
1205 .last_updated()
1206 .is_some();
1207 let derived_ready = self
1208 .derived_data
1209 .read()
1210 .await
1211 .derived_data_ready();
1212
1213 if market_ready && derived_ready {
1214 return Ok(());
1215 }
1216
1217 if tokio::time::Instant::now() >= deadline {
1218 return Err(WaitReadyError { timeout_ms: timeout.as_millis() as u64 });
1219 }
1220
1221 tokio::time::sleep(POLL_INTERVAL).await;
1222 }
1223 }
1224
1225 #[cfg(feature = "test-utils")]
1238 pub async fn from_recording(
1239 chain: Chain,
1240 updates: Vec<tycho_simulation::protocol::models::Update>,
1241 pools: std::collections::HashMap<String, PoolConfig>,
1242 gas_price_wei: Option<num_bigint::BigUint>,
1243 ) -> Result<Self, SolverBuildError> {
1244 if pools.is_empty() {
1245 return Err(SolverBuildError::NoPools);
1246 }
1247
1248 let market_data = MarketData::new_shared();
1249
1250 let feed_config =
1252 TychoFeedConfig::new("ws://replay".to_string(), chain, None, false, vec![], 0.0);
1253 let feed = TychoFeed::new(feed_config, market_data.clone());
1254 let market_event_tx = feed.event_sender();
1255 let _feed_rx = feed.subscribe();
1256
1257 for update in updates {
1258 feed.handle_tycho_message(update)
1259 .await
1260 .map_err(|e| SolverBuildError::Replay(e.to_string()))?;
1261 }
1262
1263 let gas_price = match gas_price_wei {
1265 Some(price) => price,
1266 None => {
1267 tracing::warn!("no recorded gas price, defaulting to 10 gwei");
1268 num_bigint::BigUint::from(10_000_000_000u64)
1269 }
1270 };
1271 let block_number = match market_data.read().await.last_updated() {
1272 Some(block) => block.number(),
1273 None => {
1274 tracing::warn!("no block number from replayed updates, defaulting to 0");
1275 0
1276 }
1277 };
1278 {
1279 let mut market = market_data.write().await;
1280 market.update_gas_price(BlockGasPrice {
1281 block_number,
1282 block_hash: Default::default(),
1283 block_timestamp: 0,
1284 pricing: GasPrice::Legacy { gas_price },
1285 });
1286 }
1287
1288 let gas_token = native_token(&chain).map_err(|_| SolverBuildError::GasToken)?;
1290 let computation_config = ComputationManagerConfig::new()
1291 .with_gas_token(gas_token)
1292 .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
1293 let (computation_manager, _) =
1294 ComputationManager::new(computation_config, market_data.clone())
1295 .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
1296
1297 let derived_data: SharedDerivedDataRef = computation_manager.store();
1298 let derived_event_tx = computation_manager.event_sender();
1299
1300 let computation_event_rx = feed.subscribe();
1301 let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
1302
1303 let computation_handle = tokio::spawn(async move {
1304 computation_manager
1305 .run(computation_event_rx, computation_shutdown_rx)
1306 .await;
1307 });
1308
1309 let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
1311 let mut worker_pools: Vec<WorkerPool> = Vec::new();
1312 let mut max_timeout_ms = 0u64;
1313
1314 for (name, pool_cfg) in &pools {
1315 let algo_cfg = AlgorithmConfig::new(
1316 pool_cfg.min_hops(),
1317 pool_cfg.max_hops(),
1318 Duration::from_millis(pool_cfg.timeout_ms()),
1319 pool_cfg.max_routes(),
1320 )?;
1321
1322 let pool_event_rx = feed.subscribe();
1323 let derived_rx = derived_event_tx.subscribe();
1324
1325 let (worker_pool, task_handle) = WorkerPoolBuilder::new()
1326 .name(name.clone())
1327 .algorithm(pool_cfg.algorithm().to_string())
1328 .algorithm_config(algo_cfg)
1329 .num_workers(pool_cfg.num_workers())
1330 .task_queue_capacity(pool_cfg.task_queue_capacity())
1331 .build(market_data.clone(), Arc::clone(&derived_data), pool_event_rx, derived_rx)?;
1332
1333 solver_pool_handles.push(SolverPoolHandle::new(worker_pool.name(), task_handle));
1334 max_timeout_ms = max_timeout_ms.max(pool_cfg.timeout_ms());
1335 worker_pools.push(worker_pool);
1336 }
1337
1338 let encoder = {
1340 let registry = SwapEncoderRegistry::new(chain)
1341 .add_default_encoders(None)
1342 .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
1343 Encoder::new(chain, registry).map_err(|e| SolverBuildError::Encoder(e.to_string()))?
1344 };
1345
1346 let router_address = encoder.router_address().cloned();
1347 let router_fees = encoder.router_fees();
1351 router_fees.set(crate::encoding::router_fees::RouterFees::new(
1352 100_000_000,
1353 0,
1354 0,
1355 std::collections::HashMap::new(),
1356 ));
1357 let router_config = WorkerPoolRouterConfig::default()
1358 .with_timeout(Duration::from_millis(max_timeout_ms.max(5000)))
1359 .with_min_responses(defaults::ROUTER_MIN_RESPONSES);
1360 let router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
1361
1362 let market_read = market_data.read().await;
1364 let added = market_read.component_topology();
1365 drop(market_read);
1366
1367 if market_event_tx
1368 .send(MarketEvent::MarketUpdated {
1369 added_components: added,
1370 removed_components: vec![],
1371 updated_components: vec![],
1372 })
1373 .is_err()
1374 {
1375 tracing::warn!("no receivers for initial MarketUpdated broadcast");
1376 }
1377
1378 let feed_handle = tokio::spawn(futures::future::pending::<()>());
1381 let gas_price_handle = tokio::spawn(async { });
1382 let metrics_sampler_handle = tokio::spawn(async { });
1383 let router_fee_handle = tokio::spawn(async { });
1384
1385 Ok(Solver {
1386 router,
1387 worker_pools,
1388 market_data,
1389 derived_data,
1390 router_fees,
1391 feed_handle,
1392 gas_price_handle,
1393 metrics_sampler_handle,
1394 router_fee_handle,
1395 computation_handle,
1396 computation_shutdown_tx,
1397 chain,
1398 router_address,
1399 market_event_tx,
1400 })
1401 }
1402
1403 pub fn shutdown(self) {
1405 let _ = self.computation_shutdown_tx.send(());
1406 for pool in self.worker_pools {
1407 pool.shutdown();
1408 }
1409 self.feed_handle.abort();
1410 self.gas_price_handle.abort();
1411 self.metrics_sampler_handle.abort();
1412 self.router_fee_handle.abort();
1413 }
1414
1415 pub fn into_parts(self) -> SolverParts {
1417 SolverParts {
1418 router: self.router,
1419 worker_pools: self.worker_pools,
1420 market_data: self.market_data,
1421 derived_data: self.derived_data,
1422 router_fees: self.router_fees,
1423 feed_handle: self.feed_handle,
1424 gas_price_handle: self.gas_price_handle,
1425 metrics_sampler_handle: self.metrics_sampler_handle,
1426 router_fee_handle: self.router_fee_handle,
1427 computation_handle: self.computation_handle,
1428 computation_shutdown_tx: self.computation_shutdown_tx,
1429 chain: self.chain,
1430 router_address: self.router_address,
1431 }
1432 }
1433}
1434
1435pub struct SolverParts {
1439 router: WorkerPoolRouter,
1441 worker_pools: Vec<WorkerPool>,
1443 market_data: MarketData,
1445 derived_data: SharedDerivedDataRef,
1447 router_fees: SharedRouterFees,
1449 feed_handle: JoinHandle<()>,
1451 gas_price_handle: JoinHandle<()>,
1453 metrics_sampler_handle: JoinHandle<()>,
1455 router_fee_handle: JoinHandle<()>,
1457 computation_handle: JoinHandle<()>,
1459 computation_shutdown_tx: broadcast::Sender<()>,
1461 chain: Chain,
1463 router_address: Option<Bytes>,
1465}
1466
1467impl SolverParts {
1468 pub fn chain(&self) -> Chain {
1470 self.chain
1471 }
1472
1473 pub fn router_address(&self) -> Option<&Bytes> {
1475 self.router_address.as_ref()
1476 }
1477
1478 pub fn worker_pools(&self) -> &[WorkerPool] {
1480 &self.worker_pools
1481 }
1482
1483 pub fn market_data(&self) -> &MarketData {
1485 &self.market_data
1486 }
1487
1488 pub fn derived_data(&self) -> &SharedDerivedDataRef {
1490 &self.derived_data
1491 }
1492
1493 pub fn router_fees(&self) -> &SharedRouterFees {
1495 &self.router_fees
1496 }
1497
1498 pub fn into_router(self) -> WorkerPoolRouter {
1500 self.router
1501 }
1502
1503 #[allow(clippy::type_complexity)]
1505 pub fn into_components(
1506 self,
1507 ) -> (
1508 WorkerPoolRouter,
1509 Vec<WorkerPool>,
1510 MarketData,
1511 SharedDerivedDataRef,
1512 JoinHandle<()>,
1513 JoinHandle<()>,
1514 JoinHandle<()>,
1515 JoinHandle<()>,
1516 JoinHandle<()>,
1517 broadcast::Sender<()>,
1518 ) {
1519 (
1520 self.router,
1521 self.worker_pools,
1522 self.market_data,
1523 self.derived_data,
1524 self.feed_handle,
1525 self.gas_price_handle,
1526 self.metrics_sampler_handle,
1527 self.router_fee_handle,
1528 self.computation_handle,
1529 self.computation_shutdown_tx,
1530 )
1531 }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537
1538 #[rstest::rstest]
1540 #[case::all(LiquidityScope::All)]
1541 #[case::public_only(LiquidityScope::PublicOnly)]
1542 fn test_build_rejects_scoped_pool_without_policy(#[case] scope: LiquidityScope) {
1543 let config = PoolConfig::new("most_liquid").with_liquidity_scope(scope);
1544 let result = FyndBuilder::new(
1545 Chain::Ethereum,
1546 "wss://example.invalid",
1547 "https://example.invalid",
1548 vec!["uniswap_v2".to_string()],
1549 100.0,
1550 )
1551 .add_pool("surplus", &config)
1552 .expect("add_pool should accept the config")
1553 .build();
1554
1555 assert!(matches!(result, Err(SolverBuildError::LiquidityScopeWithoutPolicy)));
1556 }
1557}