1mod config;
32mod models;
33
34use std::{
35 cmp::{Ordering, Reverse},
36 time::{Duration, Instant},
37};
38
39use models::{
40 CandidatePathState, CandidateSearchConfig, Deadline, Discovery, ExchangeMove,
41 FullAmountOutcome, FullAmountRanking, ScoredEdge, SetupResult, SolveInput, SolveStage,
42 SplitCandidate, StepResult,
43};
44use num_bigint::{BigInt, BigUint};
45use num_traits::Zero;
46use petgraph::{graph::NodeIndex, prelude::EdgeRef};
47use rustc_hash::{FxHashMap, FxHashSet};
48use tracing::{debug, instrument, trace};
49use tycho_simulation::tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim};
50
51use super::{
52 most_liquid::DepthAndPrice,
53 paths,
54 sim_meter::{self, MeteredProtocolSim},
55 split_primitives::{
56 build_split_route, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
57 },
58 Algorithm, AlgorithmConfig, NoPathReason,
59};
60use crate::{
61 algorithm::{
62 paths::read_market,
63 request::{SolveParts, SolveRequest},
64 swap_cache::{PoolDirection, Refusal, SwapCache, SwapResult},
65 water_fill::config::{
66 BASELINE_CANDIDATES, CANDIDATE_CONNECTOR_EDGES_PER_TOKEN,
67 CANDIDATE_DIRECT_EDGES_PER_TOKEN, CANDIDATE_EDGES_PER_STATE, CANDIDATE_STATES_PER_NODE,
68 COARSE_CHUNKS, DEFAULT_MAX_CANDIDATES, DEFAULT_MAX_PATHS, DERIVED_ANCHOR_COUNT,
69 EXCHANGE_DELTA_FLOOR, EXCHANGE_MAX_SIMS, FINE_CHUNKS, MAX_DISCOVERY_CANDIDATES,
70 SHARED_FULL_PATHS, SHARED_MARGIN_PATHS, SHARED_MARGIN_PROBE_PATHS,
71 SHARED_MAX_CANDIDATES,
72 },
73 },
74 derived::{computation::ComputationRequirements, types::TokenGasPrices},
75 feed::market_data::{MarketDataView, MarketState},
76 graph::{EdgeData, GraphQueryFilter, Path, RouteSearch, TopologyGraph, TopologyGraphManager},
77 types::{ComponentId, Order, RouteResult},
78 AlgorithmError,
79};
80
81pub struct WaterFillAlgorithm {
84 query: GraphQueryFilter,
86 timeout: Duration,
87 max_candidates: usize,
88 max_paths: usize,
89}
90
91impl WaterFillAlgorithm {
92 pub(crate) fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
94 Ok(Self {
95 query: GraphQueryFilter {
96 min_hops: config.min_hops(),
97 max_hops: config.max_hops(),
98 connector_tokens: config.connector_tokens().cloned(),
99 },
100 timeout: config.timeout(),
101 max_candidates: config
102 .max_routes()
103 .unwrap_or(DEFAULT_MAX_CANDIDATES)
104 .max(DEFAULT_MAX_PATHS),
105 max_paths: DEFAULT_MAX_PATHS,
106 })
107 }
108
109 fn simulate_step<'g>(
113 path: &Path<'g, DepthAndPrice>,
114 market: &MarketState,
115 overlay: &MarketOverrides,
116 amount: BigUint,
117 ) -> Option<StepResult> {
118 let mut current = amount;
119 let mut total_gas = BigUint::zero();
120 let path_reuses_component = path_reuses_component(path);
125 let mut intra_path_states: FxHashMap<ComponentId, Box<dyn ProtocolSim>> =
126 FxHashMap::default();
127 let mut new_states: Vec<(ComponentId, Box<dyn ProtocolSim>)> =
128 Vec::with_capacity(path.len());
129
130 for (address_in, edge, address_out) in path.iter() {
131 let token_in = market.get_token(address_in)?;
132 let token_out = market.get_token(address_out)?;
133 let component_id = &edge.component_id;
134 let state = hop_state(market, component_id, Some(&intra_path_states), Some(overlay))?;
135 let result = state
136 .get_amount_out_metered(
137 component_id,
138 SolveStage::Chunking.label(),
139 current.clone(),
140 token_in,
141 token_out,
142 )
143 .ok()?;
144 total_gas += &result.gas;
145 if path_reuses_component {
146 intra_path_states.insert(component_id.clone(), result.new_state.clone_box());
147 }
148 new_states.push((component_id.clone(), result.new_state));
149 current = result.amount;
150 }
151 Some(StepResult { amount_out: current, gas: total_gas, new_states })
152 }
153
154 fn gas_cost_in_token(
156 total_gas: &BigUint,
157 gas_price_wei: &BigUint,
158 token_prices: Option<&TokenGasPrices>,
159 token_out: &Address,
160 ) -> Option<BigUint> {
161 let price = token_prices?.get(token_out)?;
162 if price.denominator.is_zero() {
163 return None;
164 }
165 Some(total_gas * gas_price_wei * &price.numerator / &price.denominator)
166 }
167
168 fn activation_cost(input: &SolveInput, gas: &BigUint) -> BigInt {
172 Self::gas_cost_in_token(
173 gas,
174 &input.gas_price,
175 input.token_prices.as_ref(),
176 input.order.token_out(),
177 )
178 .map(BigInt::from)
179 .unwrap_or_else(BigInt::zero)
180 }
181
182 fn select_disjoint(ranked: &[Path<DepthAndPrice>], max_paths: usize) -> Vec<usize> {
189 let mut visited_components: FxHashSet<&ComponentId> = FxHashSet::default();
190 let mut selected = Vec::new();
191 for (idx, path) in ranked.iter().enumerate() {
192 let path_components: Vec<&ComponentId> = path
193 .edge_iter()
194 .iter()
195 .map(|e| &e.component_id)
196 .collect();
197 if path_components
198 .iter()
199 .any(|c| visited_components.contains(*c))
200 {
201 continue;
202 }
203 for c in path_components {
204 visited_components.insert(c);
205 }
206 selected.push(idx);
207 if selected.len() >= max_paths {
208 break;
209 }
210 }
211 selected
212 }
213
214 #[instrument(level = "debug", skip_all)]
217 async fn setup<'a>(
218 &self,
219 request: SolveRequest<'a, TopologyGraph<DepthAndPrice>>,
220 deadline: Deadline,
221 ) -> Result<SetupResult<'a, 'a>, AlgorithmError> {
222 let SolveParts { graph, order, market, label, derived, exclusions } = request.into_parts();
223 let search = RouteSearch { bounds: &self.query, exclusions: &exclusions };
226 let token_prices = if let Some(ref derived) = derived {
227 derived
228 .read()
229 .await
230 .token_prices()
231 .cloned()
232 } else {
233 None
234 };
235
236 let mut scored_paths = self.top_scored_paths(graph, order, search)?;
237 let mut joined_paths = Vec::new();
238
239 let market_view = read_market(&market, label).await?;
240
241 let anchor_tokens = derive_anchor_tokens(graph);
246
247 let mut cache = SwapCache::new();
251 sim_meter::start_solve();
252
253 let discovered_paths = discover_paths(
254 graph,
255 &market_view,
256 order,
257 &mut cache,
258 CandidateSearchConfig {
259 search,
260 max_candidates: MAX_DISCOVERY_CANDIDATES,
261 anchor_tokens: &anchor_tokens,
262 source_token: order.token_in(),
263 deadline,
264 },
265 )
266 .inspect_err(|e| {
267 debug!(error = %e, "water-fill bounded discovery failed; using exhaustive candidates only")
268 })
269 .unwrap_or_default();
270
271 let mut keys: FxHashSet<Vec<ComponentId>> = scored_paths
272 .iter()
273 .map(path_key)
274 .collect();
275 for path in discovered_paths {
276 if keys.insert(path_key(&path)) {
277 joined_paths.push(path);
278 }
279 }
280 joined_paths.append(&mut scored_paths);
281
282 let component_ids: FxHashSet<&ComponentId> = joined_paths
283 .iter()
284 .flat_map(|p| {
285 p.edge_iter()
286 .iter()
287 .map(|e| &e.component_id)
288 })
289 .collect();
290 let market_state = market_view.extract_subset_with_overlay(&component_ids);
291 let gas_price = paths::fetch_gas_price(&market_state)?;
292 drop(market_view);
293
294 let amount_in = order.amount().clone();
295 let mut input = SolveInput {
297 ordered: joined_paths,
298 market: market_state,
299 gas_price,
300 token_prices,
301 order,
302 deadline,
303 };
304 let ranking = self.rank_at_full_amount(&input, &mut cache);
305
306 let build_baseline = |path_ix: usize| {
313 paths::simulate_pool_path(
314 &input.ordered[path_ix],
315 &input.market,
316 input.token_prices.as_ref(),
317 amount_in.clone(),
318 )
319 .ok()
320 };
321 let best_single = ranking
322 .by_output_net_gas
323 .iter()
324 .take(BASELINE_CANDIDATES)
325 .filter_map(|&path_ix| build_baseline(path_ix))
326 .max_by(|a, b| {
327 a.net_amount_out()
328 .cmp(b.net_amount_out())
329 })
330 .or_else(|| {
331 ranking
333 .by_output_net_gas
334 .iter()
335 .skip(BASELINE_CANDIDATES)
336 .find_map(|&path_ix| build_baseline(path_ix))
337 });
338
339 input.ordered = ranking
343 .by_output
344 .iter()
345 .map(|&path_ix| input.ordered[path_ix].clone())
346 .collect();
347
348 debug!(
349 candidate_paths = input.ordered.len(),
350 elapsed_ms = deadline.elapsed().as_millis(),
351 "water-fill discovery + full-amount ranking"
352 );
353 Ok(SetupResult { input, best_single, cache })
354 }
355
356 fn rank_at_full_amount<'g>(
368 &self,
369 input: &SolveInput<'_, 'g>,
370 cache: &mut SwapCache<'g>,
371 ) -> FullAmountRanking {
372 let paths = &input.ordered;
373 let order_amount = input.order.amount();
374 let mut outcomes_by_path: Vec<Option<FullAmountOutcome>> = vec![None; paths.len()];
375
376 for (path_ix, path) in paths.iter().enumerate() {
377 if input.deadline.expired() {
378 break;
379 }
380 if path_reuses_component(path) {
381 continue;
382 }
383 outcomes_by_path[path_ix] = Some(
384 match simulate_path(
385 path,
386 &input.market,
387 cache,
388 order_amount.clone(),
389 SolveStage::Ranking,
390 ) {
391 Some(paid) => FullAmountOutcome::Filled(paid),
392 None => FullAmountOutcome::Unfilled,
393 },
394 );
395 }
396
397 rank_outcomes(
398 outcomes_by_path,
399 &input.gas_price,
400 input.token_prices.as_ref(),
401 input.order.token_out(),
402 )
403 }
404
405 fn top_scored_paths<'a>(
408 &self,
409 graph: &'a TopologyGraph<DepthAndPrice>,
410 order: &Order,
411 search: RouteSearch<'_>,
412 ) -> Result<Vec<Path<'a, DepthAndPrice>>, AlgorithmError> {
413 let all_paths =
414 paths::find_paths(graph, order.token_in(), order.token_out(), search, None)?;
415 if all_paths.is_empty() {
416 return Err(AlgorithmError::NoPath {
417 from: order.token_in().clone(),
418 to: order.token_out().clone(),
419 reason: NoPathReason::NoGraphPath,
420 });
421 }
422
423 let path_count = all_paths.len();
424 let mut scored_count = 0usize;
425
426 let mut scored: Vec<(Path<DepthAndPrice>, f64)> = all_paths
430 .into_iter()
431 .map(|path| {
432 let score = match paths::try_score_path(&path) {
433 Some(score) => {
434 scored_count += 1;
435 score
436 }
437 None => f64::MIN,
438 };
439 (path, score)
440 })
441 .collect();
442 scored.sort_by(|(_, a), (_, b)| {
443 b.partial_cmp(a)
444 .unwrap_or(std::cmp::Ordering::Equal)
445 });
446
447 trace!(path_count, scored_count, limit = self.max_candidates, "water-fill path scoring");
448
449 scored.truncate(self.max_candidates);
450
451 Ok(scored
452 .into_iter()
453 .map(|(p, _)| p)
454 .collect())
455 }
456}
457
458impl Algorithm for WaterFillAlgorithm {
459 type GraphType = TopologyGraph<DepthAndPrice>;
460 type GraphManager = TopologyGraphManager<DepthAndPrice>;
461
462 fn name(&self) -> &str {
463 "water_fill"
464 }
465
466 #[instrument(level = "debug", skip_all, fields(order_id = %request.order().id()))]
467 async fn find_best_route(
468 &self,
469 request: SolveRequest<'_, Self::GraphType>,
470 ) -> Result<RouteResult, AlgorithmError> {
471 let order = request.order();
472 let deadline = Deadline::new(Instant::now(), self.timeout);
473 if !order.is_sell() {
474 return Err(AlgorithmError::ExactOutNotSupported);
475 }
476
477 let SetupResult { input, best_single, mut cache } = self.setup(request, deadline).await?;
478
479 let mut candidates: Vec<SplitCandidate> = Vec::new();
481 let disjoint = Self::select_disjoint(&input.ordered, self.max_paths);
486 let coarse = (disjoint.len() >= 2)
487 .then(|| self.disjoint_waterfill(&input, &disjoint, COARSE_CHUNKS, true))
488 .flatten();
489 if let Some(coarse) = coarse.as_deref() {
490 if let Some(c) = self.build_disjoint_legs(&input, &disjoint, coarse) {
492 candidates.push(c);
493 }
494 if let Some(c) =
497 self.disjoint_refine(&input, &disjoint, coarse, FINE_CHUNKS, &mut cache)
498 {
499 candidates.push(c);
500 }
501 }
502 if let Some(c) = self.fillspill_alloc(&input, FINE_CHUNKS, &mut cache) {
505 candidates.push(c);
506 }
507
508 let candidate_count = candidates.len();
511 let baseline_net = best_single
512 .as_ref()
513 .map(|b| b.net_amount_out().clone());
514 let mut best: Option<(BigInt, SplitCandidate)> = None;
515 for cand in candidates {
516 let net = cand.net(&input);
517 let beats = match (&best, &baseline_net) {
518 (Some((current, _)), _) => net > *current,
519 (None, Some(base)) => net > *base,
520 (None, None) => true,
521 };
522 if beats {
523 best = Some((net, cand));
524 }
525 }
526 let split_won = best.is_some();
527 sim_meter::report("water_fill", &input.market, || deadline.elapsed().as_millis() as u64);
528 debug!(
529 candidate_count,
530 split_won,
531 elapsed_ms = deadline.elapsed().as_millis(),
532 "water-fill selected {}",
533 if split_won { "split candidate" } else { "single path" }
534 );
535 match best {
536 Some((net, cand)) => Ok(RouteResult::new(cand.route, net, input.gas_price.clone())),
537 None => best_single.ok_or(AlgorithmError::InsufficientLiquidity),
539 }
540 }
541
542 fn computation_requirements(&self) -> ComputationRequirements {
543 ComputationRequirements::none()
544 .allow_stale("token_prices")
545 .expect("Conflicting Computation Requirements")
546 }
547
548 fn timeout(&self) -> Duration {
549 self.timeout
550 }
551}
552
553impl WaterFillAlgorithm {
554 fn disjoint_refine<'g>(
560 &self,
561 input: &SolveInput<'_, 'g>,
562 disjoint: &[usize],
563 coarse: &[BigUint],
564 fine_chunks: usize,
565 cache: &mut SwapCache<'g>,
566 ) -> Option<SplitCandidate> {
567 let active: Vec<usize> = disjoint
569 .iter()
570 .copied()
571 .zip(coarse.iter())
572 .filter(|(_, amt)| !amt.is_zero())
573 .map(|(idx, _)| idx)
574 .collect();
575 if active.is_empty() {
576 return None;
577 }
578
579 let fine = self.disjoint_waterfill(input, &active, fine_chunks, false)?;
581
582 let refined = self.disjoint_exchange(input, &active, fine_chunks, fine, cache);
586 self.build_disjoint_legs(input, &active, &refined)
587 }
588
589 fn path_net<'g>(
595 input: &SolveInput<'_, 'g>,
596 path: &Path<'g, DepthAndPrice>,
597 amount: &BigUint,
598 cache: &mut SwapCache<'g>,
599 ) -> Option<BigInt> {
600 if amount.is_zero() {
601 return Some(BigInt::zero());
602 }
603 let result =
604 simulate_path(path, &input.market, cache, amount.clone(), SolveStage::Exchange)?;
605 let activation = Self::activation_cost(input, &result.gas);
606 Some(BigInt::from(result.amount_out) - activation)
607 }
608
609 fn disjoint_exchange<'g>(
618 &self,
619 input: &SolveInput<'_, 'g>,
620 active: &[usize],
621 fine_chunks: usize,
622 alloc: Vec<BigUint>,
623 cache: &mut SwapCache<'g>,
624 ) -> Vec<BigUint> {
625 let path_count = active.len();
626 if path_count < 2 {
627 return alloc;
628 }
629 let amount_in = input.order.amount().clone();
630 let fine_chunks = fine_chunks.max(1);
631 let mut delta = &amount_in / fine_chunks;
632 let min_delta = &amount_in / (fine_chunks * EXCHANGE_DELTA_FLOOR);
633 if delta.is_zero() {
634 return alloc;
635 }
636
637 let mut cumulative_amount_in = alloc;
638 let mut net_cache: Vec<BigInt> = Vec::with_capacity(path_count);
641 for (i, &path_idx) in active.iter().enumerate() {
642 let Some(net) =
643 Self::path_net(input, &input.ordered[path_idx], &cumulative_amount_in[i], cache)
644 else {
645 return cumulative_amount_in;
647 };
648 net_cache.push(net);
649 }
650
651 let mut sims = 0usize;
652 while delta >= min_delta && !delta.is_zero() {
653 if input.deadline.expired() || sims >= EXCHANGE_MAX_SIMS {
654 break;
655 }
656
657 let mut best: Option<ExchangeMove> = None;
658 for donor in 0..path_count {
659 if sims >= EXCHANGE_MAX_SIMS {
660 break;
661 }
662 if cumulative_amount_in[donor] < delta {
663 continue;
664 }
665 let donor_amt = &cumulative_amount_in[donor] - δ
666 let Some(donor_net) =
667 Self::path_net(input, &input.ordered[active[donor]], &donor_amt, cache)
668 else {
669 continue;
670 };
671 sims += 1;
672 for recipient in 0..path_count {
673 if sims >= EXCHANGE_MAX_SIMS {
674 break;
675 }
676 if recipient == donor {
677 continue;
678 }
679 let recip_amt = &cumulative_amount_in[recipient] + δ
680 let Some(recip_net) =
681 Self::path_net(input, &input.ordered[active[recipient]], &recip_amt, cache)
682 else {
683 continue;
684 };
685 sims += 1;
686 let before = &net_cache[donor] + &net_cache[recipient];
687 let after = &donor_net + &recip_net;
688 if after <= before {
689 continue;
690 }
691 let gain = after - before;
692 if best
693 .as_ref()
694 .map(|m| gain > m.gain)
695 .unwrap_or(true)
696 {
697 best = Some(ExchangeMove {
698 donor,
699 recipient,
700 donor_net: donor_net.clone(),
701 recip_net,
702 gain,
703 });
704 }
705 }
706 }
707
708 let Some(mv) = best else {
709 delta = &delta / 2usize;
710 continue;
711 };
712 cumulative_amount_in[mv.donor] = &cumulative_amount_in[mv.donor] - δ
713 cumulative_amount_in[mv.recipient] = &cumulative_amount_in[mv.recipient] + δ
714 net_cache[mv.donor] = mv.donor_net;
715 net_cache[mv.recipient] = mv.recip_net;
716 }
717 cumulative_amount_in
718 }
719
720 fn allocation_commit<'g>(
723 path: &Path<'g, DepthAndPrice>,
724 market: &MarketState,
725 overrides: &mut MarketOverrides,
726 amount: BigUint,
727 flow_fraction: f64,
728 ) -> Option<PathAllocation> {
729 let amount_in = amount;
730 let mut current = amount_in.clone();
731 let mut hops = Vec::with_capacity(path.len());
732
733 for (address_in, edge, address_out) in path.iter() {
734 let token_in = market.get_token(address_in)?;
735 let token_out = market.get_token(address_out)?;
736 let component_id = &edge.component_id;
737 let state = hop_state(market, component_id, None, Some(overrides))?;
738 let result = state
739 .get_amount_out_metered(
740 component_id,
741 SolveStage::Assembly.label(),
742 current.clone(),
743 token_in,
744 token_out,
745 )
746 .ok()?;
747 hops.push(SimulatedHop {
748 descriptor: HopDescriptor::new(
749 component_id.clone(),
750 token_in.clone(),
751 token_out.clone(),
752 ),
753 amount_out: result.amount.clone(),
754 gas: result.gas.clone(),
755 });
756 overrides.insert(component_id.clone(), result.new_state);
757 current = result.amount;
758 }
759
760 Some(PathAllocation {
761 hops,
762 flow_fraction,
763 amount_in,
764 amount_out: current,
765 marginal_price_product: 0.0,
766 })
767 }
768
769 fn candidate_from_allocations(
773 input: &SolveInput,
774 allocations: &[PathAllocation],
775 ) -> Option<SplitCandidate> {
776 let route = build_split_route(allocations, &input.market, input.order).ok()?;
777 let gross = route.amount_out(input.order.token_out());
778 if gross.is_zero() {
779 return None;
780 }
781 let gas = route.total_gas();
782 Some(SplitCandidate { route, gross, gas })
783 }
784
785 fn build_disjoint_legs<'g>(
789 &self,
790 input: &SolveInput<'_, 'g>,
791 subset: &[usize],
792 alloc: &[BigUint],
793 ) -> Option<SplitCandidate> {
794 let amount_in = input.order.amount().clone();
795 let mut allocations = Vec::new();
796 for (i, &path_idx) in subset.iter().enumerate() {
797 if alloc[i].is_zero() {
798 continue;
799 }
800 let mut overrides = MarketOverrides::empty();
803 let allocation = Self::allocation_commit(
804 &input.ordered[path_idx],
805 &input.market,
806 &mut overrides,
807 alloc[i].clone(),
808 ratio(&alloc[i], &amount_in),
809 )?;
810 allocations.push(allocation);
811 }
812 if allocations.is_empty() {
813 return None;
814 }
815 Self::candidate_from_allocations(input, &allocations)
816 }
817
818 fn disjoint_waterfill<'g>(
822 &self,
823 input: &SolveInput<'_, 'g>,
824 subset: &[usize],
825 num_chunks: usize,
826 gate: bool,
827 ) -> Option<Vec<BigUint>> {
828 let amount_in = input.order.amount().clone();
829 let num_chunks = num_chunks.max(1);
830 let base_chunk = &amount_in / num_chunks;
831 if base_chunk.is_zero() {
832 return None;
833 }
834 let remainder = &amount_in - &base_chunk * num_chunks;
835 let path_count = subset.len();
836
837 let mut committed: Vec<MarketOverrides> = (0..path_count)
838 .map(|_| MarketOverrides::empty())
839 .collect();
840 let mut cumulative_amount_in: Vec<BigUint> = vec![BigUint::zero(); path_count];
841 let mut activated: Vec<bool> = vec![!gate; path_count];
842
843 let mut marginals: Vec<Option<StepResult>> = (0..path_count).map(|_| None).collect();
848 let mut marginals_chunk: Option<BigUint> = None;
851
852 for chunk_idx in 0..num_chunks {
853 if input.deadline.expired() {
854 break;
855 }
856 let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
857 if marginals_chunk.as_ref() != Some(&chunk) {
858 marginals
859 .iter_mut()
860 .for_each(|m| *m = None);
861 marginals_chunk = Some(chunk.clone());
862 }
863
864 let mut best: Option<(usize, BigInt)> = None;
865 for (i, &path_idx) in subset.iter().enumerate() {
866 if marginals[i].is_none() {
867 marginals[i] = Self::simulate_step(
868 &input.ordered[path_idx],
869 &input.market,
870 &committed[i],
871 chunk.clone(),
872 );
873 }
874 let Some(step) = marginals[i].as_ref() else {
875 continue;
876 };
877 let gross_marginal = BigInt::from(step.amount_out.clone());
878 let net_marginal = if activated[i] {
879 gross_marginal
880 } else {
881 let activation = Self::activation_cost(input, &step.gas);
882 gross_marginal - activation
883 };
884 if best
885 .as_ref()
886 .map(|(_, m)| &net_marginal > m)
887 .unwrap_or(true)
888 {
889 best = Some((i, net_marginal));
890 }
891 }
892
893 let Some((best_i, _)) = best else {
894 break;
895 };
896 let Some(step) = marginals[best_i].take() else {
899 break;
900 };
901
902 for (id, state) in step.new_states {
903 committed[best_i].insert(id, state);
904 }
905 cumulative_amount_in[best_i] += &chunk;
906 activated[best_i] = true;
907 }
908 Some(cumulative_amount_in)
909 }
910
911 fn select_shared_candidates<'g>(
915 &self,
916 input: &SolveInput<'_, 'g>,
917 cache: &mut SwapCache<'g>,
918 ) -> Vec<usize> {
919 let mut candidates: Vec<usize> = (0..input
920 .ordered
921 .len()
922 .min(SHARED_FULL_PATHS))
923 .collect();
924 let first_chunk = input.order.amount() / COARSE_CHUNKS;
925 if first_chunk.is_zero() {
926 return candidates;
927 }
928 let mut marginal: Vec<(usize, BigInt)> = Vec::new();
929 for (idx, path) in input
930 .ordered
931 .iter()
932 .enumerate()
933 .take(SHARED_MARGIN_PROBE_PATHS)
934 {
935 if input.deadline.expired() {
936 break;
937 }
938 let Some(probe) = simulate_path(
941 path,
942 &input.market,
943 cache,
944 first_chunk.clone(),
945 SolveStage::SetSelection,
946 ) else {
947 continue;
948 };
949 let activation = Self::activation_cost(input, &probe.gas);
950 marginal.push((idx, BigInt::from(probe.amount_out) - activation));
951 }
952 marginal.sort_by(|(_, a), (_, b)| b.cmp(a));
953 for (idx, net) in marginal
954 .into_iter()
955 .take(SHARED_MARGIN_PATHS)
956 {
957 if net <= BigInt::zero() {
958 continue;
959 }
960 if !candidates.contains(&idx) {
961 candidates.push(idx);
962 }
963 if candidates.len() >= SHARED_MAX_CANDIDATES {
964 break;
965 }
966 }
967 candidates
968 }
969
970 fn fillspill_alloc<'g>(
975 &self,
976 input: &SolveInput<'_, 'g>,
977 fine_chunks: usize,
978 cache: &mut SwapCache<'g>,
979 ) -> Option<SplitCandidate> {
980 let candidates = self.select_shared_candidates(input, cache);
981 if candidates.len() < 2 {
982 return None;
983 }
984
985 let coarse = self.fillspill_waterfill(input, &candidates, COARSE_CHUNKS, true)?;
988 let took_a_chunk: FxHashSet<usize> = coarse.iter().map(|(i, _)| *i).collect();
989 let active: Vec<usize> = candidates
990 .iter()
991 .copied()
992 .enumerate()
993 .filter(|(i, _)| took_a_chunk.contains(i))
994 .map(|(_, idx)| idx)
995 .collect();
996 if active.len() < 2 {
997 return None;
998 }
999
1000 let schedule = self.fillspill_waterfill(input, &active, fine_chunks, false)?;
1002 if schedule.is_empty() {
1003 return None;
1004 }
1005
1006 self.build_fillspill_route(input, &active, &schedule)
1007 }
1008
1009 fn fillspill_waterfill<'g>(
1013 &self,
1014 input: &SolveInput<'_, 'g>,
1015 subset: &[usize],
1016 num_chunks: usize,
1017 gate: bool,
1018 ) -> Option<Vec<(usize, BigUint)>> {
1019 let amount_in = input.order.amount().clone();
1020 let num_chunks = num_chunks.max(1);
1021 let base_chunk = &amount_in / num_chunks;
1022 if base_chunk.is_zero() {
1023 return None;
1024 }
1025 let remainder = &amount_in - &base_chunk * num_chunks;
1026
1027 let mut overlay = MarketOverrides::empty();
1028 let mut activated: Vec<bool> = vec![!gate; subset.len()];
1029 let mut active_count = if gate { 0 } else { subset.len() };
1030 let mut schedule: Vec<(usize, BigUint)> = Vec::with_capacity(num_chunks);
1031 let mut marginals: Vec<Option<StepResult>> = (0..subset.len())
1036 .map(|_| None)
1037 .collect();
1038 let mut marginals_chunk: Option<BigUint> = None;
1041
1042 for chunk_idx in 0..num_chunks {
1043 if input.deadline.expired() {
1044 break;
1045 }
1046 let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
1047 if marginals_chunk.as_ref() != Some(&chunk) {
1048 marginals
1049 .iter_mut()
1050 .for_each(|m| *m = None);
1051 marginals_chunk = Some(chunk.clone());
1052 }
1053
1054 let mut best: Option<(usize, BigInt)> = None;
1055 for (i, &path_idx) in subset.iter().enumerate() {
1056 if !activated[i] && active_count >= self.max_paths {
1057 continue;
1058 }
1059 if marginals[i].is_none() {
1060 marginals[i] = Self::simulate_step(
1061 &input.ordered[path_idx],
1062 &input.market,
1063 &overlay,
1064 chunk.clone(),
1065 );
1066 }
1067 let Some(step) = marginals[i].as_ref() else {
1068 continue;
1069 };
1070 let gross_marginal = BigInt::from(step.amount_out.clone());
1071 let net_marginal = if activated[i] {
1072 gross_marginal
1073 } else {
1074 let activation = Self::activation_cost(input, &step.gas);
1075 gross_marginal - activation
1076 };
1077 if best
1078 .as_ref()
1079 .map(|(_, m)| &net_marginal > m)
1080 .unwrap_or(true)
1081 {
1082 best = Some((i, net_marginal));
1083 }
1084 }
1085
1086 let Some((best_i, _)) = best else {
1087 break;
1088 };
1089 let Some(step) = marginals[best_i].take() else {
1093 break;
1094 };
1095
1096 let pools_moved: FxHashSet<&ComponentId> = step
1097 .new_states
1098 .iter()
1099 .map(|(id, _)| id)
1100 .collect();
1101 for (i, &path_idx) in subset.iter().enumerate() {
1102 if input.ordered[path_idx]
1103 .edge_iter()
1104 .iter()
1105 .any(|e| pools_moved.contains(&e.component_id))
1106 {
1107 marginals[i] = None;
1108 }
1109 }
1110
1111 for (id, state) in step.new_states {
1112 overlay.insert(id, state);
1113 }
1114 if !activated[best_i] {
1115 activated[best_i] = true;
1116 active_count += 1;
1117 }
1118 schedule.push((best_i, chunk));
1119 }
1120
1121 Some(schedule)
1122 }
1123
1124 fn build_fillspill_route<'g>(
1128 &self,
1129 input: &SolveInput<'_, 'g>,
1130 active: &[usize],
1131 schedule: &[(usize, BigUint)],
1132 ) -> Option<SplitCandidate> {
1133 let amount_in = input.order.amount().clone();
1134 let mut cand_in: Vec<BigUint> = vec![BigUint::zero(); active.len()];
1135 for (i, chunk) in schedule {
1136 cand_in[*i] += chunk;
1137 }
1138 let mut execution_order: Vec<usize> = (0..active.len())
1139 .filter(|&i| !cand_in[i].is_zero())
1140 .collect();
1141 if execution_order.len() < 2 {
1142 return None;
1143 }
1144 execution_order.sort_by(|&a, &b| cand_in[b].cmp(&cand_in[a]));
1145
1146 let mut overrides = MarketOverrides::empty();
1147 let mut allocations = Vec::new();
1148 for i in execution_order {
1149 let allocation = Self::allocation_commit(
1150 &input.ordered[active[i]],
1151 &input.market,
1152 &mut overrides,
1153 cand_in[i].clone(),
1154 ratio(&cand_in[i], &amount_in),
1155 )?;
1156 allocations.push(allocation);
1157 }
1158 Self::candidate_from_allocations(input, &allocations)
1159 }
1160}
1161
1162fn path_reuses_component<W>(path: &Path<'_, W>) -> bool {
1165 let mut seen: FxHashSet<&ComponentId> =
1166 FxHashSet::with_capacity_and_hasher(path.len(), Default::default());
1167 !path
1168 .edge_iter()
1169 .iter()
1170 .all(|e| seen.insert(&e.component_id))
1171}
1172
1173fn simulate_hop(
1176 market: &MarketState,
1177 component_id: &ComponentId,
1178 address_in: &Address,
1179 address_out: &Address,
1180 amount_in: &BigUint,
1181 pass: SolveStage,
1182) -> Result<SwapResult, Refusal> {
1183 let (Some(token_in), Some(token_out), Some(state)) = (
1184 market.get_token(address_in),
1185 market.get_token(address_out),
1186 market.get_simulation_state(component_id),
1187 ) else {
1188 return Err(Refusal::Failed);
1189 };
1190 state
1191 .get_amount_out_metered(component_id, pass.label(), amount_in.clone(), token_in, token_out)
1192 .map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
1193 .map_err(|error| Refusal::of(&error))
1194}
1195
1196fn hop_state<'s>(
1203 market: &'s MarketState,
1204 component_id: &ComponentId,
1205 intra_path: Option<&'s FxHashMap<ComponentId, Box<dyn ProtocolSim>>>,
1206 committed: Option<&'s MarketOverrides>,
1207) -> Option<&'s dyn ProtocolSim> {
1208 intra_path
1209 .and_then(|states| {
1210 states
1211 .get(component_id)
1212 .map(Box::as_ref)
1213 })
1214 .or_else(|| committed.and_then(|overrides| overrides.get(component_id)))
1215 .or_else(|| market.get_simulation_state(component_id))
1216}
1217
1218fn simulate_path<'a>(
1227 path: &Path<'a, DepthAndPrice>,
1228 market: &MarketState,
1229 cache: &mut SwapCache<'a>,
1230 amount_in: BigUint,
1231 stage: SolveStage,
1232) -> Option<SwapResult> {
1233 let mut hop_amount_in = amount_in;
1235 let mut path_gas = BigUint::zero();
1236
1237 for (address_in, edge, address_out) in path.iter() {
1238 let direction = PoolDirection { component_id: &edge.component_id, address_in, address_out };
1239 let hop = cache.swap(
1240 direction,
1241 &hop_amount_in,
1242 stage.label(),
1243 || {
1244 simulate_hop(
1245 market,
1246 &edge.component_id,
1247 address_in,
1248 address_out,
1249 &hop_amount_in,
1250 stage,
1251 )
1252 },
1253 stage.may_interpolate(),
1254 )?;
1255 hop_amount_in = hop.amount_out;
1256 path_gas += hop.gas;
1257 }
1258
1259 Some(SwapResult { amount_out: hop_amount_in, gas: path_gas })
1260}
1261
1262fn rank_outcomes(
1267 outcomes_by_path: Vec<Option<FullAmountOutcome>>,
1268 gas_price: &BigUint,
1269 token_prices: Option<&TokenGasPrices>,
1270 token_out: &Address,
1271) -> FullAmountRanking {
1272 let mut by_output: Vec<(usize, Option<BigInt>)> = Vec::with_capacity(outcomes_by_path.len());
1275 let mut by_output_net_gas: Vec<(usize, BigInt)> = Vec::new();
1276
1277 for (path_ix, outcome) in outcomes_by_path.into_iter().enumerate() {
1278 let Some(outcome) = outcome else {
1279 continue;
1280 };
1281 let net_output = match outcome {
1282 FullAmountOutcome::Filled(paid) => {
1283 let gas_cost = WaterFillAlgorithm::gas_cost_in_token(
1284 &paid.gas,
1285 gas_price,
1286 token_prices,
1287 token_out,
1288 );
1289 let net_output = BigInt::from(paid.amount_out.clone()) -
1290 gas_cost.map_or_else(BigInt::zero, BigInt::from);
1291 by_output_net_gas.push((path_ix, net_output.clone()));
1292 Some(net_output)
1293 }
1294 FullAmountOutcome::Unfilled => None,
1295 };
1296 by_output.push((path_ix, net_output));
1297 }
1298
1299 by_output.sort_by(|(_, a), (_, b)| b.cmp(a));
1303 by_output_net_gas.sort_by(|(_, a), (_, b)| b.cmp(a));
1304 FullAmountRanking {
1305 by_output: by_output
1306 .into_iter()
1307 .map(|(path_ix, _)| path_ix)
1308 .collect(),
1309 by_output_net_gas: by_output_net_gas
1310 .into_iter()
1311 .map(|(path_ix, _)| path_ix)
1312 .collect(),
1313 }
1314}
1315
1316fn path_key<W>(path: &Path<'_, W>) -> Vec<ComponentId> {
1318 path.edge_iter()
1319 .iter()
1320 .map(|e| e.component_id.clone())
1321 .collect()
1322}
1323
1324fn ratio(numerator: &BigUint, denominator: &BigUint) -> f64 {
1326 use num_traits::ToPrimitive;
1327 let n = numerator.to_f64().unwrap_or(0.0);
1328 let d = denominator.to_f64().unwrap_or(1.0);
1329 if d == 0.0 {
1330 0.0
1331 } else {
1332 n / d
1333 }
1334}
1335
1336fn discover_paths<'a, W>(
1349 graph: &'a TopologyGraph<W>,
1350 market: &MarketDataView<'_>,
1351 order: &Order,
1352 cache: &mut SwapCache<'a>,
1353 cfg: CandidateSearchConfig<'_>,
1354) -> Result<Vec<Path<'a, W>>, AlgorithmError>
1355where
1356 W: Clone,
1357{
1358 let (from_idx, to_idx) = get_token_ixs(graph, order)?;
1359
1360 let mut found = Vec::new();
1361 let mut frontier = vec![CandidatePathState {
1362 node: from_idx,
1363 path: Path::new(),
1364 amount_out: order.amount().clone(),
1365 }];
1366
1367 for _depth in 0..cfg.search.bounds.max_hops {
1368 if cfg.deadline.expired() || frontier.is_empty() {
1369 break;
1370 }
1371 let mut next_by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>> =
1372 FxHashMap::default();
1373 for state in frontier {
1374 if state.node == to_idx && from_idx != to_idx {
1375 continue;
1376 }
1377
1378 let mut discovery = Discovery { graph, market, cfg: &cfg, cache };
1379 expand_candidate_state(&mut discovery, to_idx, state, &mut found, &mut next_by_node);
1380 }
1381 frontier = prune_candidate_frontier(next_by_node);
1382 }
1383
1384 rank_found_candidate_paths(found, cfg.max_candidates, order)
1385}
1386
1387fn get_token_ixs<W>(
1393 graph: &TopologyGraph<W>,
1394 order: &Order,
1395) -> Result<(NodeIndex, NodeIndex), AlgorithmError> {
1396 let missing = |reason| AlgorithmError::NoPath {
1397 from: order.token_in().clone(),
1398 to: order.token_out().clone(),
1399 reason,
1400 };
1401 let from_idx = graph
1402 .get_token_ix(order.token_in())
1403 .ok_or_else(|| missing(NoPathReason::SourceTokenNotInGraph))?;
1404 let to_idx = graph
1405 .get_token_ix(order.token_out())
1406 .ok_or_else(|| missing(NoPathReason::DestinationTokenNotInGraph))?;
1407 Ok((from_idx, to_idx))
1408}
1409
1410fn expand_candidate_state<'a, W>(
1411 discovery: &mut Discovery<'a, '_, W>,
1412 target: NodeIndex,
1413 state: CandidatePathState<'a, W>,
1414 found: &mut Vec<(Path<'a, W>, BigUint)>,
1415 next_by_node: &mut FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>>,
1416) where
1417 W: Clone,
1418{
1419 let cfg = discovery.cfg;
1420 let graph = discovery.graph;
1421 let edges = candidate_edges_for_state(discovery, target, &state);
1422 for candidate in edges {
1423 if cfg.deadline.expired() {
1424 break;
1425 }
1426 let mut path = state.path.clone();
1427 path.add_hop(&graph[state.node], candidate.edge, &graph[candidate.target]);
1428 let path_state = CandidatePathState {
1429 node: candidate.target,
1430 path: path.clone(),
1431 amount_out: candidate.amount_out,
1432 };
1433 if candidate.target == target && path.len() >= cfg.search.bounds.min_hops {
1434 found.push((path.clone(), path_state.amount_out.clone()));
1435 }
1436 if path.len() < cfg.search.bounds.max_hops {
1437 next_by_node
1438 .entry(candidate.target)
1439 .or_default()
1440 .push(path_state);
1441 }
1442 }
1443}
1444
1445fn candidate_edges_for_state<'a, W>(
1446 discovery: &mut Discovery<'a, '_, W>,
1447 target: NodeIndex,
1448 state: &CandidatePathState<'a, W>,
1449) -> Vec<ScoredEdge<'a, W>> {
1450 let mut preferred = score_candidate_edges(discovery, target, state, true);
1451 if preferred.is_empty() {
1452 preferred = score_candidate_edges(discovery, target, state, false);
1453 }
1454 select_candidate_edges(preferred, CANDIDATE_EDGES_PER_STATE)
1455}
1456
1457fn score_candidate_edges<'a, W>(
1458 discovery: &mut Discovery<'a, '_, W>,
1459 target: NodeIndex,
1460 state: &CandidatePathState<'a, W>,
1461 preferred_only: bool,
1462) -> Vec<ScoredEdge<'a, W>> {
1463 let Discovery { graph, market, cfg, cache } = discovery;
1464 let graph = *graph;
1465 let market = *market;
1466 let cfg = *cfg;
1467 let mut scored = Vec::new();
1468 for edge in graph.edges(state.node) {
1469 let next_node = edge.target();
1470 let priority = match candidate_priority(graph, next_node, target, cfg) {
1473 Some(priority) => priority,
1474 None if preferred_only => continue,
1475 None => 3,
1476 };
1477 for pool in edge.weight().pools() {
1478 if !can_extend_path(graph, state, next_node, target, pool, cfg) {
1479 continue;
1480 }
1481 let address_in = &graph[state.node];
1482 let address_out = &graph[next_node];
1483 let direction =
1484 PoolDirection { component_id: &pool.component_id, address_in, address_out };
1485 let Some(hop) = cache.swap(
1486 direction,
1487 &state.amount_out,
1488 SolveStage::Discovery.label(),
1489 || simulate_edge(market, &state.amount_out, address_in, pool, address_out),
1490 SolveStage::Discovery.may_interpolate(),
1491 ) else {
1492 continue;
1493 };
1494 scored.push(ScoredEdge {
1495 target: next_node,
1496 edge: pool,
1497 amount_out: hop.amount_out,
1498 priority,
1499 });
1500 }
1501 }
1502 scored
1503}
1504
1505fn derive_anchor_tokens<W>(graph: &TopologyGraph<W>) -> FxHashSet<Address> {
1515 let mut by_pool_count: Vec<(NodeIndex, usize)> = graph
1516 .node_indices()
1517 .map(|node| {
1518 let pools = graph
1519 .edges(node)
1520 .map(|edge| edge.weight().pools().len())
1521 .sum();
1522 (node, pools)
1523 })
1524 .collect();
1525 by_pool_count.sort_unstable_by_key(|(_, pools)| Reverse(*pools));
1526 let mut anchors: FxHashSet<Address> = by_pool_count
1527 .into_iter()
1528 .take(DERIVED_ANCHOR_COUNT)
1529 .map(|(node, _)| graph[node].clone())
1530 .collect();
1531 anchors.insert(Address::from([0u8; 20]));
1532 anchors
1533}
1534
1535fn candidate_priority<W>(
1536 graph: &TopologyGraph<W>,
1537 node: NodeIndex,
1538 target: NodeIndex,
1539 cfg: &CandidateSearchConfig<'_>,
1540) -> Option<u8> {
1541 if node == target {
1542 return Some(0);
1543 }
1544 let token = &graph[node];
1545 match cfg
1546 .search
1547 .bounds
1548 .connector_tokens
1549 .as_ref()
1550 {
1551 Some(tokens) => tokens.contains(token).then_some(1),
1552 None => cfg
1553 .anchor_tokens
1554 .contains(token)
1555 .then_some(2),
1556 }
1557}
1558
1559fn can_extend_path<W>(
1560 graph: &TopologyGraph<W>,
1561 state: &CandidatePathState<'_, W>,
1562 next_node: NodeIndex,
1563 target: NodeIndex,
1564 edge: &EdgeData<W>,
1565 cfg: &CandidateSearchConfig<'_>,
1566) -> bool {
1567 let next_addr = &graph[next_node];
1568 if cfg
1569 .search
1570 .exclusions
1571 .excludes_pool(&edge.component_id)
1572 {
1573 return false;
1574 }
1575 if state
1576 .path
1577 .edge_iter()
1578 .iter()
1579 .any(|existing| existing.component_id == edge.component_id)
1580 {
1581 return false;
1582 }
1583 if state.path.tokens.contains(&next_addr) {
1584 return false;
1585 }
1586 if next_addr == cfg.source_token {
1587 return false;
1588 }
1589 cfg.search
1590 .allows_token(next_addr, (cfg.source_token, &graph[target]))
1591}
1592
1593fn simulate_edge<W>(
1594 market: &MarketDataView<'_>,
1595 amount: &BigUint,
1596 token_in_addr: &Address,
1597 edge: &EdgeData<W>,
1598 token_out_addr: &Address,
1599) -> Result<SwapResult, Refusal> {
1600 let (Some(token_in), Some(token_out), Some(state)) = (
1601 market.get_token(token_in_addr),
1602 market.get_token(token_out_addr),
1603 market.get_simulation_state(&edge.component_id),
1604 ) else {
1605 return Err(Refusal::Failed);
1606 };
1607 state
1608 .get_amount_out_metered(
1609 &edge.component_id,
1610 SolveStage::Discovery.label(),
1611 amount.clone(),
1612 token_in,
1613 token_out,
1614 )
1615 .map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
1616 .map_err(|error| Refusal::of(&error))
1617}
1618
1619fn select_candidate_edges<W>(
1620 mut scored: Vec<ScoredEdge<'_, W>>,
1621 max_edges: usize,
1622) -> Vec<ScoredEdge<'_, W>> {
1623 scored.sort_by(compare_scored_edges);
1624 let mut selected = Vec::new();
1625 let mut per_target: FxHashMap<NodeIndex, usize> = FxHashMap::default();
1626 for edge in scored {
1627 let limit = if edge.priority == 0 {
1628 CANDIDATE_DIRECT_EDGES_PER_TOKEN
1629 } else {
1630 CANDIDATE_CONNECTOR_EDGES_PER_TOKEN
1631 };
1632 let count = per_target
1633 .entry(edge.target)
1634 .or_default();
1635 if *count >= limit {
1636 continue;
1637 }
1638 *count += 1;
1639 selected.push(edge);
1640 if selected.len() >= max_edges {
1641 break;
1642 }
1643 }
1644 selected
1645}
1646
1647fn compare_scored_edges<W>(a: &ScoredEdge<'_, W>, b: &ScoredEdge<'_, W>) -> Ordering {
1648 a.priority
1649 .cmp(&b.priority)
1650 .then_with(|| b.amount_out.cmp(&a.amount_out))
1651}
1652
1653fn prune_candidate_frontier<W>(
1654 by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'_, W>>>,
1655) -> Vec<CandidatePathState<'_, W>> {
1656 by_node
1657 .into_values()
1658 .flat_map(|mut states| {
1659 states.sort_by(|a, b| b.amount_out.cmp(&a.amount_out));
1660 states.truncate(CANDIDATE_STATES_PER_NODE);
1661 states
1662 })
1663 .collect()
1664}
1665
1666fn rank_found_candidate_paths<'a, W>(
1667 mut found: Vec<(Path<'a, W>, BigUint)>,
1668 max_candidates: usize,
1669 order: &Order,
1670) -> Result<Vec<Path<'a, W>>, AlgorithmError> {
1671 found.sort_by(|(_, a), (_, b)| b.cmp(a));
1672 let mut keys = FxHashSet::default();
1673 let mut paths = Vec::new();
1674 let mut scores = Vec::new();
1675
1676 for (path, amount_out) in found {
1677 if !keys.insert(path_key(&path)) {
1678 continue;
1679 }
1680 let idx = paths.len();
1681 paths.push(path);
1682 scores.push((idx, BigInt::from(amount_out)));
1683 if paths.len() >= max_candidates {
1684 break;
1685 }
1686 }
1687
1688 if paths.is_empty() {
1689 return Err(AlgorithmError::NoPath {
1690 from: order.token_in().clone(),
1691 to: order.token_out().clone(),
1692 reason: NoPathReason::NoGraphPath,
1693 });
1694 }
1695 Ok(paths)
1696}
1697
1698#[cfg(test)]
1699mod tests {
1700 use std::time::Duration;
1701
1702 use alloy::primitives::U256;
1703 use num_bigint::BigUint;
1704 use num_traits::ToPrimitive;
1705 use tycho_simulation::evm::protocol::uniswap_v2::state::UniswapV2State;
1706
1707 use super::{super::MostLiquidAlgorithm, *};
1708 use crate::{
1709 algorithm::{
1710 split_test_harness::{
1711 evaluate_scenario, optimal_two_component_output, split_metrics, split_scenarios,
1712 two_equal_weth_usdc, TWO_EQUAL_USDC_RESERVE, TWO_EQUAL_WETH_RESERVE,
1713 },
1714 test_utils::{
1715 addr, setup_market_unweighted_topology, setup_market_weighted_boxed,
1716 token_with_decimals, ConstantProductSim, DivByZeroSim,
1717 },
1718 },
1719 graph::GraphManager,
1720 types::{quote::OrderSide, RouteExclusions},
1721 };
1722
1723 fn config() -> AlgorithmConfig {
1724 config_ms(2000)
1725 }
1726
1727 fn config_ms(ms: u64) -> AlgorithmConfig {
1728 AlgorithmConfig::new(1, 3, Duration::from_millis(ms), None).unwrap()
1729 }
1730
1731 fn whole_weth_order(token_in: &Address, token_out: &Address, weth: u64) -> Order {
1732 Order::new(
1733 token_in.clone(),
1734 token_out.clone(),
1735 BigUint::from(weth) * BigUint::from(10u64).pow(18),
1736 OrderSide::Sell,
1737 addr(0xFF),
1738 )
1739 }
1740
1741 fn v2_component(reserve_a: u128, reserve_b: u128) -> UniswapV2State {
1742 UniswapV2State::new(
1743 U256::from(reserve_a) * U256::from(10u64).pow(U256::from(18u64)),
1744 U256::from(reserve_b) * U256::from(10u64).pow(U256::from(18u64)),
1745 )
1746 }
1747
1748 fn water_fill_default() -> WaterFillAlgorithm {
1751 WaterFillAlgorithm::with_config(
1752 AlgorithmConfig::new(1, 4, Duration::from_millis(5000), None).unwrap(),
1753 )
1754 .unwrap()
1755 }
1756
1757 #[tokio::test]
1760 async fn test_water_fill_all_scenarios() {
1761 let algo = water_fill_default();
1762 for scenario in split_scenarios::all() {
1763 let name = scenario.name;
1764 let (market, gm) = scenario.build_market_weighted();
1765 let result = evaluate_scenario(&algo, &scenario, market, gm).await;
1766
1767 result.assert_passes_lower_bound();
1768 let tolerance_pct = if name == "DOUBLE_SPLIT" { 10 } else { 5 };
1774 assert!(
1775 result.within_pct_of_optimum(tolerance_pct),
1776 "'{name}': not within {tolerance_pct}% of optimum",
1777 );
1778 let route = result
1779 .route
1780 .as_ref()
1781 .unwrap_or_else(|| panic!("'{name}': expected a route"));
1782 assert!(route.validate().is_ok(), "'{name}': route validation failed");
1783 }
1784 }
1785
1786 #[tokio::test]
1789 async fn test_water_fill_gas_kills_split() {
1790 let scenario = split_scenarios::gas_kills_split();
1791 let (market, gm) = scenario.build_market_weighted();
1792 let result = evaluate_scenario(&water_fill_default(), &scenario, market, gm).await;
1793 assert_eq!(result.path_count, 1, "high gas should prevent splitting");
1794 }
1795
1796 #[tokio::test]
1802 async fn test_split_fills_when_no_single_path_can() {
1803 let a = token_with_decimals(0x01, "A", 18);
1804 let b = token_with_decimals(0x02, "B", 18);
1805 let component = || {
1809 Box::new(ConstantProductSim {
1810 reserve_0: BigUint::from(800u64) * BigUint::from(10u64).pow(18),
1811 reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1812 gas: 50_000,
1813 }) as Box<dyn ProtocolSim>
1814 };
1815 let (market, gm) = setup_market_weighted_boxed(vec![
1816 ("component_x", &a, &b, component()),
1817 ("component_y", &a, &b, component()),
1818 ]);
1819 let order = Order::new(
1820 a.address.clone(),
1821 b.address.clone(),
1822 BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
1823 OrderSide::Sell,
1824 addr(0xFF),
1825 );
1826
1827 let single = MostLiquidAlgorithm::with_config(config())
1829 .unwrap()
1830 .find_best_route(SolveRequest::new(gm.graph(), market.clone(), &order))
1831 .await;
1832 assert!(single.is_err(), "premise: no single path fills the full order");
1833
1834 let split = WaterFillAlgorithm::with_config(config())
1836 .unwrap()
1837 .find_best_route(SolveRequest::new(gm.graph(), market.clone(), &order))
1838 .await
1839 .expect("water_fill returns a split when no single path fills");
1840 assert!(split.route().swaps().len() >= 2, "expected a split across both components");
1841 }
1842
1843 #[tokio::test]
1847 async fn test_water_fill_contains_simulation_panic() {
1848 let a = token_with_decimals(0x01, "A", 18);
1849 let b = token_with_decimals(0x02, "B", 18);
1850 let healthy = Box::new(ConstantProductSim {
1851 reserve_0: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1852 reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
1853 gas: 50_000,
1854 }) as Box<dyn ProtocolSim>;
1855 let (market, gm) = setup_market_weighted_boxed(vec![
1856 ("component_ok", &a, &b, healthy),
1857 ("component_panics", &a, &b, Box::new(DivByZeroSim::default())),
1858 ]);
1859 let order = Order::new(
1860 a.address.clone(),
1861 b.address.clone(),
1862 BigUint::from(100u64) * BigUint::from(10u64).pow(18),
1863 OrderSide::Sell,
1864 addr(0xFF),
1865 );
1866
1867 let result = WaterFillAlgorithm::with_config(config())
1868 .unwrap()
1869 .find_best_route(SolveRequest::new(gm.graph(), market.clone(), &order))
1870 .await
1871 .expect("panicking component is skipped; the healthy component still fills the order");
1872
1873 assert!(
1874 result
1875 .route()
1876 .swaps()
1877 .iter()
1878 .all(|swap| swap.component_id() == "component_ok"),
1879 "route must only use the healthy component",
1880 );
1881 }
1882
1883 #[tokio::test]
1887 async fn test_water_fill_output_near_two_component_optimum() {
1888 let m = two_equal_weth_usdc(1);
1889 let trade = 500u64;
1890 let order = whole_weth_order(&m.weth, &m.usdc, trade);
1891
1892 let result = WaterFillAlgorithm::with_config(config())
1893 .unwrap()
1894 .find_best_route(
1895 SolveRequest::new(m.weighted.graph(), m.market.clone(), &order)
1896 .with_derived(m.derived.clone()),
1897 )
1898 .await
1899 .expect("split solves");
1900 let (_, path_count, gross) = split_metrics(&result, &m.weth, &m.usdc);
1901 assert_eq!(path_count, 2, "the optimum uses both components");
1902
1903 let reserve_in = TWO_EQUAL_WETH_RESERVE as f64 * 1e18;
1905 let reserve_out = TWO_EQUAL_USDC_RESERVE as f64 * 1e6;
1906 let trade_amount = trade as f64 * 1e18;
1907 let (_, optimum) = optimal_two_component_output(
1908 reserve_in,
1909 reserve_out,
1910 reserve_in,
1911 reserve_out,
1912 trade_amount,
1913 );
1914
1915 let gross = gross.to_f64().unwrap();
1916 assert!(
1917 gross >= optimum * 0.999 && gross <= optimum * 1.0001,
1918 "split gross {gross} should be within 0.1% of the two-component optimum {optimum}",
1919 );
1920 }
1921
1922 #[tokio::test]
1929 async fn test_water_fill_no_loss_under_tight_timeout() {
1930 for ms in [1u64, 5, 50] {
1931 let m = two_equal_weth_usdc(1_000_000_000);
1932 let order = whole_weth_order(&m.weth, &m.usdc, 500);
1933
1934 let split = WaterFillAlgorithm::with_config(config_ms(ms))
1935 .unwrap()
1936 .find_best_route(
1937 SolveRequest::new(m.weighted.graph(), m.market.clone(), &order)
1938 .with_derived(m.derived.clone()),
1939 )
1940 .await;
1941 let single = MostLiquidAlgorithm::with_config(config_ms(ms))
1942 .unwrap()
1943 .find_best_route(
1944 SolveRequest::new(m.weighted.graph(), m.market.clone(), &order)
1945 .with_derived(m.derived.clone()),
1946 )
1947 .await;
1948
1949 let (Ok(split), Ok(single)) = (split, single) else {
1950 continue;
1951 };
1952 let (split_net, _, _) = split_metrics(&split, &m.weth, &m.usdc);
1953 let (single_net, _, _) = split_metrics(&single, &m.weth, &m.usdc);
1954 assert!(
1955 split_net >= single_net,
1956 "split lost to single-path under {ms}ms timeout: split={split_net} single={single_net}",
1957 );
1958 }
1959 }
1960
1961 #[rstest::rstest]
1967 #[case::default(false)]
1968 #[case::excluded_endpoints(true)]
1969 #[tokio::test]
1970 async fn test_discovery_finds_and_ranks_parallel_components(#[case] exclude_endpoints: bool) {
1971 let link = token_with_decimals(0x01, "LINK", 18);
1972 let weth = token_with_decimals(0x02, "WETH", 18);
1973 let (market, graph_manager) = setup_market_unweighted_topology(vec![
1974 (
1975 "a_weak_link_weth",
1976 &link,
1977 &weth,
1978 Box::new(v2_component(2_000_000, 264)) as Box<dyn ProtocolSim>,
1979 ),
1980 (
1981 "z_strong_link_weth",
1982 &link,
1983 &weth,
1984 Box::new(v2_component(2_000_000, 5_700)) as Box<dyn ProtocolSim>,
1985 ),
1986 ]);
1987 let order = Order::new(
1988 link.address.clone(),
1989 weth.address.clone(),
1990 BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
1991 OrderSide::Sell,
1992 addr(0xFF),
1993 );
1994
1995 let exclusions = if exclude_endpoints {
1996 RouteExclusions::default().with_tokens([link.address.clone(), weth.address.clone()])
1997 } else {
1998 RouteExclusions::default()
1999 };
2000 let start = Instant::now();
2001 let view = market.read().await;
2002 let paths = discover_paths(
2003 graph_manager.graph(),
2004 &view,
2005 &order,
2006 &mut SwapCache::new(),
2007 CandidateSearchConfig {
2008 search: RouteSearch {
2009 bounds: &GraphQueryFilter { min_hops: 1, max_hops: 3, connector_tokens: None },
2010 exclusions: &exclusions,
2011 },
2012 max_candidates: 128,
2013 anchor_tokens: &FxHashSet::default(),
2014 source_token: order.token_in(),
2015 deadline: Deadline::new(start, Duration::from_millis(2000)),
2016 },
2017 )
2018 .expect("discovery finds candidates");
2019
2020 assert_eq!(paths.len(), 2, "both parallel components should be discovered");
2021 let best_path = &paths[0];
2023 assert_eq!(
2024 best_path.edge_iter()[0].component_id,
2025 "z_strong_link_weth",
2026 "discovery should rank by simulated output, not topology or edge weights",
2027 );
2028 }
2029
2030 fn hop(amount_out: u64, gas: u64) -> SwapResult {
2033 SwapResult { amount_out: BigUint::from(amount_out), gas: BigUint::from(gas) }
2034 }
2035
2036 #[test]
2039 fn test_rank_outcomes_places_unfilled_and_missing_paths() {
2040 let outcomes = vec![
2041 Some(FullAmountOutcome::Filled(hop(1000, 0))),
2042 Some(FullAmountOutcome::Unfilled),
2043 None,
2044 Some(FullAmountOutcome::Filled(hop(3000, 0))),
2045 ];
2046
2047 let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
2048
2049 assert_eq!(ranking.by_output, vec![3, 0, 1], "unfilled ranks last, missing is absent");
2050 assert_eq!(ranking.by_output_net_gas, vec![3, 0], "unfilled cannot be the baseline");
2051 }
2052
2053 #[test]
2057 fn test_rank_outcomes_orders_by_output_net_of_gas() {
2058 let token_out = addr(0x02);
2059 let mut token_prices = TokenGasPrices::default();
2060 token_prices.insert(
2061 token_out.clone(),
2062 tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
2063 BigUint::from(1u64),
2064 BigUint::from(1u64),
2065 ),
2066 );
2067 let outcomes = vec![
2068 Some(FullAmountOutcome::Filled(hop(1000, 500))),
2069 Some(FullAmountOutcome::Filled(hop(900, 10))),
2070 ];
2071
2072 let ranking =
2073 rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
2074
2075 assert_eq!(ranking.by_output, vec![1, 0], "the cheaper path ranks first");
2076 assert_eq!(ranking.by_output_net_gas, vec![1, 0], "and the baseline ordering agrees");
2077 }
2078
2079 #[test]
2083 fn test_rank_outcomes_places_a_loss_making_path_above_an_unfilled_one() {
2084 let token_out = addr(0x02);
2085 let mut token_prices = TokenGasPrices::default();
2086 token_prices.insert(
2087 token_out.clone(),
2088 tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
2089 BigUint::from(1u64),
2090 BigUint::from(1u64),
2091 ),
2092 );
2093 let outcomes = vec![
2094 Some(FullAmountOutcome::Unfilled),
2095 Some(FullAmountOutcome::Filled(hop(100, 500))),
2097 Some(FullAmountOutcome::Filled(hop(900, 10))),
2098 ];
2099
2100 let ranking =
2101 rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
2102
2103 assert_eq!(ranking.by_output, vec![2, 1, 0], "unfilled ranks below a loss-making path");
2104 }
2105
2106 #[test]
2109 fn test_rank_outcomes_ignores_gas_it_cannot_price() {
2110 let outcomes = vec![
2111 Some(FullAmountOutcome::Filled(hop(1000, 500))),
2112 Some(FullAmountOutcome::Filled(hop(900, 10))),
2113 ];
2114
2115 let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
2116
2117 assert_eq!(ranking.by_output_net_gas, vec![0, 1]);
2118 }
2119
2120 #[test]
2123 fn test_derive_anchor_tokens_ranks_hub_and_includes_native_sentinel() {
2124 let hub = token_with_decimals(0x01, "HUB", 18);
2125 let a = token_with_decimals(0x02, "A", 18);
2126 let b = token_with_decimals(0x03, "B", 18);
2127 let c = token_with_decimals(0x04, "C", 18);
2128 let (_market, graph_manager) = setup_market_unweighted_topology(vec![
2129 ("hub_a", &hub, &a, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2130 ("hub_b", &hub, &b, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2131 ("hub_c", &hub, &c, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
2132 ]);
2133
2134 let anchors = derive_anchor_tokens(graph_manager.graph());
2135
2136 assert!(anchors.contains(&hub.address), "highest-degree token should be anchored");
2137 assert!(
2138 anchors.contains(&Address::from([0u8; 20])),
2139 "native-ETH sentinel should always be anchored",
2140 );
2141 }
2142}