mod config;
mod models;
use std::{
cmp::{Ordering, Reverse},
time::{Duration, Instant},
};
use models::{
CandidatePathState, CandidateSearchConfig, Deadline, Discovery, ExchangeMove,
FullAmountOutcome, FullAmountRanking, ScoredEdge, SetupResult, SolveInput, SolveStage,
SplitCandidate, StepResult,
};
use num_bigint::{BigInt, BigUint};
use num_traits::Zero;
use petgraph::{graph::NodeIndex, prelude::EdgeRef};
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::{debug, instrument, trace};
use tycho_simulation::tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim};
use super::{
most_liquid::DepthAndPrice,
paths,
sim_meter::{self, MeteredProtocolSim},
split_primitives::{
build_split_route, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
},
Algorithm, AlgorithmConfig, NoPathReason,
};
use crate::{
algorithm::{
paths::read_market,
swap_cache::{PoolDirection, Refusal, SwapCache, SwapResult},
water_fill::config::{
BASELINE_CANDIDATES, CANDIDATE_CONNECTOR_EDGES_PER_TOKEN,
CANDIDATE_DIRECT_EDGES_PER_TOKEN, CANDIDATE_EDGES_PER_STATE, CANDIDATE_STATES_PER_NODE,
COARSE_CHUNKS, DEFAULT_MAX_CANDIDATES, DEFAULT_MAX_PATHS, DERIVED_ANCHOR_COUNT,
EXCHANGE_DELTA_FLOOR, EXCHANGE_MAX_SIMS, FINE_CHUNKS, MAX_DISCOVERY_CANDIDATES,
SHARED_FULL_PATHS, SHARED_MARGIN_PATHS, SHARED_MARGIN_PROBE_PATHS,
SHARED_MAX_CANDIDATES,
},
},
derived::{computation::ComputationRequirements, types::TokenGasPrices, SharedDerivedDataRef},
feed::market_data::{MarketData, MarketDataView, MarketState, StateLabel},
graph::{EdgeData, GraphQueryFilter, Path, TopologyGraph, TopologyGraphManager},
types::{ComponentId, Order, RouteResult},
AlgorithmError,
};
pub struct WaterFillAlgorithm {
query: GraphQueryFilter,
timeout: Duration,
max_candidates: usize,
max_paths: usize,
}
impl WaterFillAlgorithm {
pub(crate) fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
Ok(Self {
query: GraphQueryFilter {
min_hops: config.min_hops(),
max_hops: config.max_hops(),
connector_tokens: config.connector_tokens().cloned(),
},
timeout: config.timeout(),
max_candidates: config
.max_routes()
.unwrap_or(DEFAULT_MAX_CANDIDATES)
.max(DEFAULT_MAX_PATHS),
max_paths: DEFAULT_MAX_PATHS,
})
}
fn simulate_step<'g>(
path: &Path<'g, DepthAndPrice>,
market: &MarketState,
overlay: &MarketOverrides,
amount: BigUint,
) -> Option<StepResult> {
let mut current = amount;
let mut total_gas = BigUint::zero();
let path_reuses_component = path_reuses_component(path);
let mut intra_path_states: FxHashMap<ComponentId, Box<dyn ProtocolSim>> =
FxHashMap::default();
let mut new_states: Vec<(ComponentId, Box<dyn ProtocolSim>)> =
Vec::with_capacity(path.len());
for (address_in, edge, address_out) in path.iter() {
let token_in = market.get_token(address_in)?;
let token_out = market.get_token(address_out)?;
let component_id = &edge.component_id;
let state = hop_state(market, component_id, Some(&intra_path_states), Some(overlay))?;
let result = state
.get_amount_out_metered(
component_id,
SolveStage::Chunking.label(),
current.clone(),
token_in,
token_out,
)
.ok()?;
total_gas += &result.gas;
if path_reuses_component {
intra_path_states.insert(component_id.clone(), result.new_state.clone_box());
}
new_states.push((component_id.clone(), result.new_state));
current = result.amount;
}
Some(StepResult { amount_out: current, gas: total_gas, new_states })
}
fn gas_cost_in_token(
total_gas: &BigUint,
gas_price_wei: &BigUint,
token_prices: Option<&TokenGasPrices>,
token_out: &Address,
) -> Option<BigUint> {
let price = token_prices?.get(token_out)?;
if price.denominator.is_zero() {
return None;
}
Some(total_gas * gas_price_wei * &price.numerator / &price.denominator)
}
fn activation_cost(input: &SolveInput, gas: &BigUint) -> BigInt {
Self::gas_cost_in_token(
gas,
&input.gas_price,
input.token_prices.as_ref(),
input.order.token_out(),
)
.map(BigInt::from)
.unwrap_or_else(BigInt::zero)
}
fn select_disjoint(ranked: &[Path<DepthAndPrice>], max_paths: usize) -> Vec<usize> {
let mut visited_components: FxHashSet<&ComponentId> = FxHashSet::default();
let mut selected = Vec::new();
for (idx, path) in ranked.iter().enumerate() {
let path_components: Vec<&ComponentId> = path
.edge_iter()
.iter()
.map(|e| &e.component_id)
.collect();
if path_components
.iter()
.any(|c| visited_components.contains(*c))
{
continue;
}
for c in path_components {
visited_components.insert(c);
}
selected.push(idx);
if selected.len() >= max_paths {
break;
}
}
selected
}
#[instrument(level = "debug", skip_all)]
async fn setup<'o, 'g>(
&self,
graph: &'g TopologyGraph<DepthAndPrice>,
market: MarketData,
label: Option<StateLabel>,
derived: Option<SharedDerivedDataRef>,
order: &'o Order,
deadline: Deadline,
) -> Result<SetupResult<'o, 'g>, AlgorithmError> {
let token_prices = if let Some(ref derived) = derived {
derived
.read()
.await
.token_prices()
.cloned()
} else {
None
};
let mut scored_paths = self.top_scored_paths(graph, order)?;
let mut joined_paths = Vec::new();
let market_view = read_market(&market, label).await?;
let anchor_tokens = derive_anchor_tokens(graph);
let mut cache = SwapCache::new();
sim_meter::start_solve();
let discovered_paths = discover_paths(
graph,
&market_view,
order,
&mut cache,
CandidateSearchConfig {
query: &self.query,
max_candidates: MAX_DISCOVERY_CANDIDATES,
anchor_tokens: &anchor_tokens,
source_token: order.token_in(),
deadline,
},
)
.inspect_err(|e| {
debug!(error = %e, "water-fill bounded discovery failed; using exhaustive candidates only")
})
.unwrap_or_default();
let mut keys: FxHashSet<Vec<ComponentId>> = scored_paths
.iter()
.map(path_key)
.collect();
for path in discovered_paths {
if keys.insert(path_key(&path)) {
joined_paths.push(path);
}
}
joined_paths.append(&mut scored_paths);
let component_ids: FxHashSet<&ComponentId> = joined_paths
.iter()
.flat_map(|p| {
p.edge_iter()
.iter()
.map(|e| &e.component_id)
})
.collect();
let market_state = market_view.extract_subset_with_overlay(&component_ids);
let gas_price = paths::fetch_gas_price(&market_state)?;
drop(market_view);
let amount_in = order.amount().clone();
let mut input = SolveInput {
ordered: joined_paths,
market: market_state,
gas_price,
token_prices,
order,
deadline,
};
let ranking = self.rank_at_full_amount(&input, &mut cache);
let build_baseline = |path_ix: usize| {
paths::simulate_pool_path(
&input.ordered[path_ix],
&input.market,
input.token_prices.as_ref(),
amount_in.clone(),
)
.ok()
};
let best_single = ranking
.by_output_net_gas
.iter()
.take(BASELINE_CANDIDATES)
.filter_map(|&path_ix| build_baseline(path_ix))
.max_by(|a, b| {
a.net_amount_out()
.cmp(b.net_amount_out())
})
.or_else(|| {
ranking
.by_output_net_gas
.iter()
.skip(BASELINE_CANDIDATES)
.find_map(|&path_ix| build_baseline(path_ix))
});
input.ordered = ranking
.by_output
.iter()
.map(|&path_ix| input.ordered[path_ix].clone())
.collect();
debug!(
candidate_paths = input.ordered.len(),
elapsed_ms = deadline.elapsed().as_millis(),
"water-fill discovery + full-amount ranking"
);
Ok(SetupResult { input, best_single, cache })
}
fn rank_at_full_amount<'g>(
&self,
input: &SolveInput<'_, 'g>,
cache: &mut SwapCache<'g>,
) -> FullAmountRanking {
let paths = &input.ordered;
let order_amount = input.order.amount();
let mut outcomes_by_path: Vec<Option<FullAmountOutcome>> = vec![None; paths.len()];
for (path_ix, path) in paths.iter().enumerate() {
if input.deadline.expired() {
break;
}
if path_reuses_component(path) {
continue;
}
outcomes_by_path[path_ix] = Some(
match simulate_path(
path,
&input.market,
cache,
order_amount.clone(),
SolveStage::Ranking,
) {
Some(paid) => FullAmountOutcome::Filled(paid),
None => FullAmountOutcome::Unfilled,
},
);
}
rank_outcomes(
outcomes_by_path,
&input.gas_price,
input.token_prices.as_ref(),
input.order.token_out(),
)
}
fn top_scored_paths<'a>(
&self,
graph: &'a TopologyGraph<DepthAndPrice>,
order: &Order,
) -> Result<Vec<Path<'a, DepthAndPrice>>, AlgorithmError> {
let all_paths =
paths::find_paths(graph, order.token_in(), order.token_out(), &self.query, None)?;
if all_paths.is_empty() {
return Err(AlgorithmError::NoPath {
from: order.token_in().clone(),
to: order.token_out().clone(),
reason: NoPathReason::NoGraphPath,
});
}
let path_count = all_paths.len();
let mut scored_count = 0usize;
let mut scored: Vec<(Path<DepthAndPrice>, f64)> = all_paths
.into_iter()
.map(|path| {
let score = match paths::try_score_path(&path) {
Some(score) => {
scored_count += 1;
score
}
None => f64::MIN,
};
(path, score)
})
.collect();
scored.sort_by(|(_, a), (_, b)| {
b.partial_cmp(a)
.unwrap_or(std::cmp::Ordering::Equal)
});
trace!(path_count, scored_count, limit = self.max_candidates, "water-fill path scoring");
scored.truncate(self.max_candidates);
Ok(scored
.into_iter()
.map(|(p, _)| p)
.collect())
}
}
impl Algorithm for WaterFillAlgorithm {
type GraphType = TopologyGraph<DepthAndPrice>;
type GraphManager = TopologyGraphManager<DepthAndPrice>;
fn name(&self) -> &str {
"water_fill"
}
#[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
async fn find_best_route(
&self,
graph: &Self::GraphType,
market: MarketData,
label: Option<StateLabel>,
derived: Option<SharedDerivedDataRef>,
order: &Order,
) -> Result<RouteResult, AlgorithmError> {
let deadline = Deadline::new(Instant::now(), self.timeout);
if !order.is_sell() {
return Err(AlgorithmError::ExactOutNotSupported);
}
let SetupResult { input, best_single, mut cache } = self
.setup(graph, market, label, derived, order, deadline)
.await?;
let mut candidates: Vec<SplitCandidate> = Vec::new();
let disjoint = Self::select_disjoint(&input.ordered, self.max_paths);
let coarse = (disjoint.len() >= 2)
.then(|| self.disjoint_waterfill(&input, &disjoint, COARSE_CHUNKS, true))
.flatten();
if let Some(coarse) = coarse.as_deref() {
if let Some(c) = self.build_disjoint_legs(&input, &disjoint, coarse) {
candidates.push(c);
}
if let Some(c) =
self.disjoint_refine(&input, &disjoint, coarse, FINE_CHUNKS, &mut cache)
{
candidates.push(c);
}
}
if let Some(c) = self.fillspill_alloc(&input, FINE_CHUNKS, &mut cache) {
candidates.push(c);
}
let candidate_count = candidates.len();
let baseline_net = best_single
.as_ref()
.map(|b| b.net_amount_out().clone());
let mut best: Option<(BigInt, SplitCandidate)> = None;
for cand in candidates {
let net = cand.net(&input);
let beats = match (&best, &baseline_net) {
(Some((current, _)), _) => net > *current,
(None, Some(base)) => net > *base,
(None, None) => true,
};
if beats {
best = Some((net, cand));
}
}
let split_won = best.is_some();
sim_meter::report(&input.market, || deadline.elapsed().as_millis() as u64);
debug!(
candidate_count,
split_won,
elapsed_ms = deadline.elapsed().as_millis(),
"water-fill selected {}",
if split_won { "split candidate" } else { "single path" }
);
match best {
Some((net, cand)) => Ok(RouteResult::new(cand.route, net, input.gas_price.clone())),
None => best_single.ok_or(AlgorithmError::InsufficientLiquidity),
}
}
fn computation_requirements(&self) -> ComputationRequirements {
ComputationRequirements::none()
.allow_stale("token_prices")
.expect("Conflicting Computation Requirements")
}
fn timeout(&self) -> Duration {
self.timeout
}
}
impl WaterFillAlgorithm {
fn disjoint_refine<'g>(
&self,
input: &SolveInput<'_, 'g>,
disjoint: &[usize],
coarse: &[BigUint],
fine_chunks: usize,
cache: &mut SwapCache<'g>,
) -> Option<SplitCandidate> {
let active: Vec<usize> = disjoint
.iter()
.copied()
.zip(coarse.iter())
.filter(|(_, amt)| !amt.is_zero())
.map(|(idx, _)| idx)
.collect();
if active.is_empty() {
return None;
}
let fine = self.disjoint_waterfill(input, &active, fine_chunks, false)?;
let refined = self.disjoint_exchange(input, &active, fine_chunks, fine, cache);
self.build_disjoint_legs(input, &active, &refined)
}
fn path_net<'g>(
input: &SolveInput<'_, 'g>,
path: &Path<'g, DepthAndPrice>,
amount: &BigUint,
cache: &mut SwapCache<'g>,
) -> Option<BigInt> {
if amount.is_zero() {
return Some(BigInt::zero());
}
let result =
simulate_path(path, &input.market, cache, amount.clone(), SolveStage::Exchange)?;
let activation = Self::activation_cost(input, &result.gas);
Some(BigInt::from(result.amount_out) - activation)
}
fn disjoint_exchange<'g>(
&self,
input: &SolveInput<'_, 'g>,
active: &[usize],
fine_chunks: usize,
alloc: Vec<BigUint>,
cache: &mut SwapCache<'g>,
) -> Vec<BigUint> {
let path_count = active.len();
if path_count < 2 {
return alloc;
}
let amount_in = input.order.amount().clone();
let fine_chunks = fine_chunks.max(1);
let mut delta = &amount_in / fine_chunks;
let min_delta = &amount_in / (fine_chunks * EXCHANGE_DELTA_FLOOR);
if delta.is_zero() {
return alloc;
}
let mut cumulative_amount_in = alloc;
let mut net_cache: Vec<BigInt> = Vec::with_capacity(path_count);
for (i, &path_idx) in active.iter().enumerate() {
let Some(net) =
Self::path_net(input, &input.ordered[path_idx], &cumulative_amount_in[i], cache)
else {
return cumulative_amount_in;
};
net_cache.push(net);
}
let mut sims = 0usize;
while delta >= min_delta && !delta.is_zero() {
if input.deadline.expired() || sims >= EXCHANGE_MAX_SIMS {
break;
}
let mut best: Option<ExchangeMove> = None;
for donor in 0..path_count {
if sims >= EXCHANGE_MAX_SIMS {
break;
}
if cumulative_amount_in[donor] < delta {
continue;
}
let donor_amt = &cumulative_amount_in[donor] - δ
let Some(donor_net) =
Self::path_net(input, &input.ordered[active[donor]], &donor_amt, cache)
else {
continue;
};
sims += 1;
for recipient in 0..path_count {
if sims >= EXCHANGE_MAX_SIMS {
break;
}
if recipient == donor {
continue;
}
let recip_amt = &cumulative_amount_in[recipient] + δ
let Some(recip_net) =
Self::path_net(input, &input.ordered[active[recipient]], &recip_amt, cache)
else {
continue;
};
sims += 1;
let before = &net_cache[donor] + &net_cache[recipient];
let after = &donor_net + &recip_net;
if after <= before {
continue;
}
let gain = after - before;
if best
.as_ref()
.map(|m| gain > m.gain)
.unwrap_or(true)
{
best = Some(ExchangeMove {
donor,
recipient,
donor_net: donor_net.clone(),
recip_net,
gain,
});
}
}
}
let Some(mv) = best else {
delta = &delta / 2usize;
continue;
};
cumulative_amount_in[mv.donor] = &cumulative_amount_in[mv.donor] - δ
cumulative_amount_in[mv.recipient] = &cumulative_amount_in[mv.recipient] + δ
net_cache[mv.donor] = mv.donor_net;
net_cache[mv.recipient] = mv.recip_net;
}
cumulative_amount_in
}
fn allocation_commit<'g>(
path: &Path<'g, DepthAndPrice>,
market: &MarketState,
overrides: &mut MarketOverrides,
amount: BigUint,
flow_fraction: f64,
) -> Option<PathAllocation> {
let amount_in = amount;
let mut current = amount_in.clone();
let mut hops = Vec::with_capacity(path.len());
for (address_in, edge, address_out) in path.iter() {
let token_in = market.get_token(address_in)?;
let token_out = market.get_token(address_out)?;
let component_id = &edge.component_id;
let state = hop_state(market, component_id, None, Some(overrides))?;
let result = state
.get_amount_out_metered(
component_id,
SolveStage::Assembly.label(),
current.clone(),
token_in,
token_out,
)
.ok()?;
hops.push(SimulatedHop {
descriptor: HopDescriptor::new(
component_id.clone(),
token_in.clone(),
token_out.clone(),
),
amount_out: result.amount.clone(),
gas: result.gas.clone(),
});
overrides.insert(component_id.clone(), result.new_state);
current = result.amount;
}
Some(PathAllocation {
hops,
flow_fraction,
amount_in,
amount_out: current,
marginal_price_product: 0.0,
})
}
fn candidate_from_allocations(
input: &SolveInput,
allocations: &[PathAllocation],
) -> Option<SplitCandidate> {
let route = build_split_route(allocations, &input.market, input.order).ok()?;
let token_out = input.order.token_out();
let gross = route
.swaps()
.iter()
.filter(|s| s.token_out() == token_out)
.fold(BigUint::zero(), |acc, s| acc + s.amount_out());
if gross.is_zero() {
return None;
}
let gas = route.total_gas();
Some(SplitCandidate { route, gross, gas })
}
fn build_disjoint_legs<'g>(
&self,
input: &SolveInput<'_, 'g>,
subset: &[usize],
alloc: &[BigUint],
) -> Option<SplitCandidate> {
let amount_in = input.order.amount().clone();
let mut allocations = Vec::new();
for (i, &path_idx) in subset.iter().enumerate() {
if alloc[i].is_zero() {
continue;
}
let mut overrides = MarketOverrides::empty();
let allocation = Self::allocation_commit(
&input.ordered[path_idx],
&input.market,
&mut overrides,
alloc[i].clone(),
ratio(&alloc[i], &amount_in),
)?;
allocations.push(allocation);
}
if allocations.is_empty() {
return None;
}
Self::candidate_from_allocations(input, &allocations)
}
fn disjoint_waterfill<'g>(
&self,
input: &SolveInput<'_, 'g>,
subset: &[usize],
num_chunks: usize,
gate: bool,
) -> Option<Vec<BigUint>> {
let amount_in = input.order.amount().clone();
let num_chunks = num_chunks.max(1);
let base_chunk = &amount_in / num_chunks;
if base_chunk.is_zero() {
return None;
}
let remainder = &amount_in - &base_chunk * num_chunks;
let path_count = subset.len();
let mut committed: Vec<MarketOverrides> = (0..path_count)
.map(|_| MarketOverrides::empty())
.collect();
let mut cumulative_amount_in: Vec<BigUint> = vec![BigUint::zero(); path_count];
let mut activated: Vec<bool> = vec![!gate; path_count];
let mut marginals: Vec<Option<StepResult>> = (0..path_count).map(|_| None).collect();
let mut marginals_chunk: Option<BigUint> = None;
for chunk_idx in 0..num_chunks {
if input.deadline.expired() {
break;
}
let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
if marginals_chunk.as_ref() != Some(&chunk) {
marginals
.iter_mut()
.for_each(|m| *m = None);
marginals_chunk = Some(chunk.clone());
}
let mut best: Option<(usize, BigInt)> = None;
for (i, &path_idx) in subset.iter().enumerate() {
if marginals[i].is_none() {
marginals[i] = Self::simulate_step(
&input.ordered[path_idx],
&input.market,
&committed[i],
chunk.clone(),
);
}
let Some(step) = marginals[i].as_ref() else {
continue;
};
let gross_marginal = BigInt::from(step.amount_out.clone());
let net_marginal = if activated[i] {
gross_marginal
} else {
let activation = Self::activation_cost(input, &step.gas);
gross_marginal - activation
};
if best
.as_ref()
.map(|(_, m)| &net_marginal > m)
.unwrap_or(true)
{
best = Some((i, net_marginal));
}
}
let Some((best_i, _)) = best else {
break;
};
let Some(step) = marginals[best_i].take() else {
break;
};
for (id, state) in step.new_states {
committed[best_i].insert(id, state);
}
cumulative_amount_in[best_i] += &chunk;
activated[best_i] = true;
}
Some(cumulative_amount_in)
}
fn select_shared_candidates<'g>(
&self,
input: &SolveInput<'_, 'g>,
cache: &mut SwapCache<'g>,
) -> Vec<usize> {
let mut candidates: Vec<usize> = (0..input
.ordered
.len()
.min(SHARED_FULL_PATHS))
.collect();
let first_chunk = input.order.amount() / COARSE_CHUNKS;
if first_chunk.is_zero() {
return candidates;
}
let mut marginal: Vec<(usize, BigInt)> = Vec::new();
for (idx, path) in input
.ordered
.iter()
.enumerate()
.take(SHARED_MARGIN_PROBE_PATHS)
{
if input.deadline.expired() {
break;
}
let Some(probe) = simulate_path(
path,
&input.market,
cache,
first_chunk.clone(),
SolveStage::SetSelection,
) else {
continue;
};
let activation = Self::activation_cost(input, &probe.gas);
marginal.push((idx, BigInt::from(probe.amount_out) - activation));
}
marginal.sort_by(|(_, a), (_, b)| b.cmp(a));
for (idx, net) in marginal
.into_iter()
.take(SHARED_MARGIN_PATHS)
{
if net <= BigInt::zero() {
continue;
}
if !candidates.contains(&idx) {
candidates.push(idx);
}
if candidates.len() >= SHARED_MAX_CANDIDATES {
break;
}
}
candidates
}
fn fillspill_alloc<'g>(
&self,
input: &SolveInput<'_, 'g>,
fine_chunks: usize,
cache: &mut SwapCache<'g>,
) -> Option<SplitCandidate> {
let candidates = self.select_shared_candidates(input, cache);
if candidates.len() < 2 {
return None;
}
let coarse = self.fillspill_waterfill(input, &candidates, COARSE_CHUNKS, true)?;
let took_a_chunk: FxHashSet<usize> = coarse.iter().map(|(i, _)| *i).collect();
let active: Vec<usize> = candidates
.iter()
.copied()
.enumerate()
.filter(|(i, _)| took_a_chunk.contains(i))
.map(|(_, idx)| idx)
.collect();
if active.len() < 2 {
return None;
}
let schedule = self.fillspill_waterfill(input, &active, fine_chunks, false)?;
if schedule.is_empty() {
return None;
}
self.build_fillspill_route(input, &active, &schedule)
}
fn fillspill_waterfill<'g>(
&self,
input: &SolveInput<'_, 'g>,
subset: &[usize],
num_chunks: usize,
gate: bool,
) -> Option<Vec<(usize, BigUint)>> {
let amount_in = input.order.amount().clone();
let num_chunks = num_chunks.max(1);
let base_chunk = &amount_in / num_chunks;
if base_chunk.is_zero() {
return None;
}
let remainder = &amount_in - &base_chunk * num_chunks;
let mut overlay = MarketOverrides::empty();
let mut activated: Vec<bool> = vec![!gate; subset.len()];
let mut active_count = if gate { 0 } else { subset.len() };
let mut schedule: Vec<(usize, BigUint)> = Vec::with_capacity(num_chunks);
let mut marginals: Vec<Option<StepResult>> = (0..subset.len())
.map(|_| None)
.collect();
let mut marginals_chunk: Option<BigUint> = None;
for chunk_idx in 0..num_chunks {
if input.deadline.expired() {
break;
}
let chunk = if chunk_idx == 0 { &base_chunk + &remainder } else { base_chunk.clone() };
if marginals_chunk.as_ref() != Some(&chunk) {
marginals
.iter_mut()
.for_each(|m| *m = None);
marginals_chunk = Some(chunk.clone());
}
let mut best: Option<(usize, BigInt)> = None;
for (i, &path_idx) in subset.iter().enumerate() {
if !activated[i] && active_count >= self.max_paths {
continue;
}
if marginals[i].is_none() {
marginals[i] = Self::simulate_step(
&input.ordered[path_idx],
&input.market,
&overlay,
chunk.clone(),
);
}
let Some(step) = marginals[i].as_ref() else {
continue;
};
let gross_marginal = BigInt::from(step.amount_out.clone());
let net_marginal = if activated[i] {
gross_marginal
} else {
let activation = Self::activation_cost(input, &step.gas);
gross_marginal - activation
};
if best
.as_ref()
.map(|(_, m)| &net_marginal > m)
.unwrap_or(true)
{
best = Some((i, net_marginal));
}
}
let Some((best_i, _)) = best else {
break;
};
let Some(step) = marginals[best_i].take() else {
break;
};
let pools_moved: FxHashSet<&ComponentId> = step
.new_states
.iter()
.map(|(id, _)| id)
.collect();
for (i, &path_idx) in subset.iter().enumerate() {
if input.ordered[path_idx]
.edge_iter()
.iter()
.any(|e| pools_moved.contains(&e.component_id))
{
marginals[i] = None;
}
}
for (id, state) in step.new_states {
overlay.insert(id, state);
}
if !activated[best_i] {
activated[best_i] = true;
active_count += 1;
}
schedule.push((best_i, chunk));
}
Some(schedule)
}
fn build_fillspill_route<'g>(
&self,
input: &SolveInput<'_, 'g>,
active: &[usize],
schedule: &[(usize, BigUint)],
) -> Option<SplitCandidate> {
let amount_in = input.order.amount().clone();
let mut cand_in: Vec<BigUint> = vec![BigUint::zero(); active.len()];
for (i, chunk) in schedule {
cand_in[*i] += chunk;
}
let mut execution_order: Vec<usize> = (0..active.len())
.filter(|&i| !cand_in[i].is_zero())
.collect();
if execution_order.len() < 2 {
return None;
}
execution_order.sort_by(|&a, &b| cand_in[b].cmp(&cand_in[a]));
let mut overrides = MarketOverrides::empty();
let mut allocations = Vec::new();
for i in execution_order {
let allocation = Self::allocation_commit(
&input.ordered[active[i]],
&input.market,
&mut overrides,
cand_in[i].clone(),
ratio(&cand_in[i], &amount_in),
)?;
allocations.push(allocation);
}
Self::candidate_from_allocations(input, &allocations)
}
}
fn path_reuses_component<W>(path: &Path<'_, W>) -> bool {
let mut seen: FxHashSet<&ComponentId> =
FxHashSet::with_capacity_and_hasher(path.len(), Default::default());
!path
.edge_iter()
.iter()
.all(|e| seen.insert(&e.component_id))
}
fn simulate_hop(
market: &MarketState,
component_id: &ComponentId,
address_in: &Address,
address_out: &Address,
amount_in: &BigUint,
pass: SolveStage,
) -> Result<SwapResult, Refusal> {
let (Some(token_in), Some(token_out), Some(state)) = (
market.get_token(address_in),
market.get_token(address_out),
market.get_simulation_state(component_id),
) else {
return Err(Refusal::Failed);
};
state
.get_amount_out_metered(component_id, pass.label(), amount_in.clone(), token_in, token_out)
.map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
.map_err(|error| Refusal::of(&error))
}
fn hop_state<'s>(
market: &'s MarketState,
component_id: &ComponentId,
intra_path: Option<&'s FxHashMap<ComponentId, Box<dyn ProtocolSim>>>,
committed: Option<&'s MarketOverrides>,
) -> Option<&'s dyn ProtocolSim> {
intra_path
.and_then(|states| {
states
.get(component_id)
.map(Box::as_ref)
})
.or_else(|| committed.and_then(|overrides| overrides.get(component_id)))
.or_else(|| market.get_simulation_state(component_id))
}
fn simulate_path<'a>(
path: &Path<'a, DepthAndPrice>,
market: &MarketState,
cache: &mut SwapCache<'a>,
amount_in: BigUint,
stage: SolveStage,
) -> Option<SwapResult> {
let mut hop_amount_in = amount_in;
let mut path_gas = BigUint::zero();
for (address_in, edge, address_out) in path.iter() {
let direction = PoolDirection { component_id: &edge.component_id, address_in, address_out };
let hop = cache.swap(
direction,
&hop_amount_in,
stage.label(),
|| {
simulate_hop(
market,
&edge.component_id,
address_in,
address_out,
&hop_amount_in,
stage,
)
},
stage.may_interpolate(),
)?;
hop_amount_in = hop.amount_out;
path_gas += hop.gas;
}
Some(SwapResult { amount_out: hop_amount_in, gas: path_gas })
}
fn rank_outcomes(
outcomes_by_path: Vec<Option<FullAmountOutcome>>,
gas_price: &BigUint,
token_prices: Option<&TokenGasPrices>,
token_out: &Address,
) -> FullAmountRanking {
let mut by_output: Vec<(usize, Option<BigInt>)> = Vec::with_capacity(outcomes_by_path.len());
let mut by_output_net_gas: Vec<(usize, BigInt)> = Vec::new();
for (path_ix, outcome) in outcomes_by_path.into_iter().enumerate() {
let Some(outcome) = outcome else {
continue;
};
let net_output = match outcome {
FullAmountOutcome::Filled(paid) => {
let gas_cost = WaterFillAlgorithm::gas_cost_in_token(
&paid.gas,
gas_price,
token_prices,
token_out,
);
let net_output = BigInt::from(paid.amount_out.clone()) -
gas_cost.map_or_else(BigInt::zero, BigInt::from);
by_output_net_gas.push((path_ix, net_output.clone()));
Some(net_output)
}
FullAmountOutcome::Unfilled => None,
};
by_output.push((path_ix, net_output));
}
by_output.sort_by(|(_, a), (_, b)| b.cmp(a));
by_output_net_gas.sort_by(|(_, a), (_, b)| b.cmp(a));
FullAmountRanking {
by_output: by_output
.into_iter()
.map(|(path_ix, _)| path_ix)
.collect(),
by_output_net_gas: by_output_net_gas
.into_iter()
.map(|(path_ix, _)| path_ix)
.collect(),
}
}
fn path_key<W>(path: &Path<'_, W>) -> Vec<ComponentId> {
path.edge_iter()
.iter()
.map(|e| e.component_id.clone())
.collect()
}
fn ratio(numerator: &BigUint, denominator: &BigUint) -> f64 {
use num_traits::ToPrimitive;
let n = numerator.to_f64().unwrap_or(0.0);
let d = denominator.to_f64().unwrap_or(1.0);
if d == 0.0 {
0.0
} else {
n / d
}
}
fn discover_paths<'a, W>(
graph: &'a TopologyGraph<W>,
market: &MarketDataView<'_>,
order: &Order,
cache: &mut SwapCache<'a>,
cfg: CandidateSearchConfig<'_>,
) -> Result<Vec<Path<'a, W>>, AlgorithmError>
where
W: Clone,
{
let (from_idx, to_idx) = get_token_ixs(graph, order)?;
let mut found = Vec::new();
let mut frontier = vec![CandidatePathState {
node: from_idx,
path: Path::new(),
amount_out: order.amount().clone(),
}];
for _depth in 0..cfg.query.max_hops {
if cfg.deadline.expired() || frontier.is_empty() {
break;
}
let mut next_by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>> =
FxHashMap::default();
for state in frontier {
if state.node == to_idx && from_idx != to_idx {
continue;
}
let mut discovery = Discovery { graph, market, cfg: &cfg, cache };
expand_candidate_state(&mut discovery, to_idx, state, &mut found, &mut next_by_node);
}
frontier = prune_candidate_frontier(next_by_node);
}
rank_found_candidate_paths(found, cfg.max_candidates, order)
}
fn get_token_ixs<W>(
graph: &TopologyGraph<W>,
order: &Order,
) -> Result<(NodeIndex, NodeIndex), AlgorithmError> {
let missing = |reason| AlgorithmError::NoPath {
from: order.token_in().clone(),
to: order.token_out().clone(),
reason,
};
let from_idx = graph
.get_token_ix(order.token_in())
.ok_or_else(|| missing(NoPathReason::SourceTokenNotInGraph))?;
let to_idx = graph
.get_token_ix(order.token_out())
.ok_or_else(|| missing(NoPathReason::DestinationTokenNotInGraph))?;
Ok((from_idx, to_idx))
}
fn expand_candidate_state<'a, W>(
discovery: &mut Discovery<'a, '_, W>,
target: NodeIndex,
state: CandidatePathState<'a, W>,
found: &mut Vec<(Path<'a, W>, BigUint)>,
next_by_node: &mut FxHashMap<NodeIndex, Vec<CandidatePathState<'a, W>>>,
) where
W: Clone,
{
let cfg = discovery.cfg;
let graph = discovery.graph;
let edges = candidate_edges_for_state(discovery, target, &state);
for candidate in edges {
if cfg.deadline.expired() {
break;
}
let mut path = state.path.clone();
path.add_hop(&graph[state.node], candidate.edge, &graph[candidate.target]);
let path_state = CandidatePathState {
node: candidate.target,
path: path.clone(),
amount_out: candidate.amount_out,
};
if candidate.target == target && path.len() >= cfg.query.min_hops {
found.push((path.clone(), path_state.amount_out.clone()));
}
if path.len() < cfg.query.max_hops {
next_by_node
.entry(candidate.target)
.or_default()
.push(path_state);
}
}
}
fn candidate_edges_for_state<'a, W>(
discovery: &mut Discovery<'a, '_, W>,
target: NodeIndex,
state: &CandidatePathState<'a, W>,
) -> Vec<ScoredEdge<'a, W>> {
let mut preferred = score_candidate_edges(discovery, target, state, true);
if preferred.is_empty() {
preferred = score_candidate_edges(discovery, target, state, false);
}
select_candidate_edges(preferred, CANDIDATE_EDGES_PER_STATE)
}
fn score_candidate_edges<'a, W>(
discovery: &mut Discovery<'a, '_, W>,
target: NodeIndex,
state: &CandidatePathState<'a, W>,
preferred_only: bool,
) -> Vec<ScoredEdge<'a, W>> {
let Discovery { graph, market, cfg, cache } = discovery;
let graph = *graph;
let market = *market;
let cfg = *cfg;
let mut scored = Vec::new();
for edge in graph.edges(state.node) {
let next_node = edge.target();
let priority = match candidate_priority(graph, next_node, target, cfg) {
Some(priority) => priority,
None if preferred_only => continue,
None => 3,
};
for pool in edge.weight().pools() {
if !can_extend_path(graph, state, next_node, target, pool, cfg) {
continue;
}
let address_in = &graph[state.node];
let address_out = &graph[next_node];
let direction =
PoolDirection { component_id: &pool.component_id, address_in, address_out };
let Some(hop) = cache.swap(
direction,
&state.amount_out,
SolveStage::Discovery.label(),
|| simulate_edge(market, &state.amount_out, address_in, pool, address_out),
SolveStage::Discovery.may_interpolate(),
) else {
continue;
};
scored.push(ScoredEdge {
target: next_node,
edge: pool,
amount_out: hop.amount_out,
priority,
});
}
}
scored
}
fn derive_anchor_tokens<W>(graph: &TopologyGraph<W>) -> FxHashSet<Address> {
let mut by_pool_count: Vec<(NodeIndex, usize)> = graph
.node_indices()
.map(|node| {
let pools = graph
.edges(node)
.map(|edge| edge.weight().pools().len())
.sum();
(node, pools)
})
.collect();
by_pool_count.sort_unstable_by_key(|(_, pools)| Reverse(*pools));
let mut anchors: FxHashSet<Address> = by_pool_count
.into_iter()
.take(DERIVED_ANCHOR_COUNT)
.map(|(node, _)| graph[node].clone())
.collect();
anchors.insert(Address::from([0u8; 20]));
anchors
}
fn candidate_priority<W>(
graph: &TopologyGraph<W>,
node: NodeIndex,
target: NodeIndex,
cfg: &CandidateSearchConfig<'_>,
) -> Option<u8> {
if node == target {
return Some(0);
}
let token = &graph[node];
match cfg.query.connector_tokens.as_ref() {
Some(tokens) => tokens.contains(token).then_some(1),
None => cfg
.anchor_tokens
.contains(token)
.then_some(2),
}
}
fn can_extend_path<W>(
graph: &TopologyGraph<W>,
state: &CandidatePathState<'_, W>,
next_node: NodeIndex,
target: NodeIndex,
edge: &EdgeData<W>,
cfg: &CandidateSearchConfig<'_>,
) -> bool {
let next_addr = &graph[next_node];
if state
.path
.edge_iter()
.iter()
.any(|existing| existing.component_id == edge.component_id)
{
return false;
}
if state.path.tokens.contains(&next_addr) {
return false;
}
if next_addr == cfg.source_token {
return false;
}
if next_node == target {
return true;
}
cfg.query
.connector_tokens
.as_ref()
.map(|tokens| tokens.contains(next_addr))
.unwrap_or(true)
}
fn simulate_edge<W>(
market: &MarketDataView<'_>,
amount: &BigUint,
token_in_addr: &Address,
edge: &EdgeData<W>,
token_out_addr: &Address,
) -> Result<SwapResult, Refusal> {
let (Some(token_in), Some(token_out), Some(state)) = (
market.get_token(token_in_addr),
market.get_token(token_out_addr),
market.get_simulation_state(&edge.component_id),
) else {
return Err(Refusal::Failed);
};
state
.get_amount_out_metered(
&edge.component_id,
SolveStage::Discovery.label(),
amount.clone(),
token_in,
token_out,
)
.map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
.map_err(|error| Refusal::of(&error))
}
fn select_candidate_edges<W>(
mut scored: Vec<ScoredEdge<'_, W>>,
max_edges: usize,
) -> Vec<ScoredEdge<'_, W>> {
scored.sort_by(compare_scored_edges);
let mut selected = Vec::new();
let mut per_target: FxHashMap<NodeIndex, usize> = FxHashMap::default();
for edge in scored {
let limit = if edge.priority == 0 {
CANDIDATE_DIRECT_EDGES_PER_TOKEN
} else {
CANDIDATE_CONNECTOR_EDGES_PER_TOKEN
};
let count = per_target
.entry(edge.target)
.or_default();
if *count >= limit {
continue;
}
*count += 1;
selected.push(edge);
if selected.len() >= max_edges {
break;
}
}
selected
}
fn compare_scored_edges<W>(a: &ScoredEdge<'_, W>, b: &ScoredEdge<'_, W>) -> Ordering {
a.priority
.cmp(&b.priority)
.then_with(|| b.amount_out.cmp(&a.amount_out))
}
fn prune_candidate_frontier<W>(
by_node: FxHashMap<NodeIndex, Vec<CandidatePathState<'_, W>>>,
) -> Vec<CandidatePathState<'_, W>> {
by_node
.into_values()
.flat_map(|mut states| {
states.sort_by(|a, b| b.amount_out.cmp(&a.amount_out));
states.truncate(CANDIDATE_STATES_PER_NODE);
states
})
.collect()
}
fn rank_found_candidate_paths<'a, W>(
mut found: Vec<(Path<'a, W>, BigUint)>,
max_candidates: usize,
order: &Order,
) -> Result<Vec<Path<'a, W>>, AlgorithmError> {
found.sort_by(|(_, a), (_, b)| b.cmp(a));
let mut keys = FxHashSet::default();
let mut paths = Vec::new();
let mut scores = Vec::new();
for (path, amount_out) in found {
if !keys.insert(path_key(&path)) {
continue;
}
let idx = paths.len();
paths.push(path);
scores.push((idx, BigInt::from(amount_out)));
if paths.len() >= max_candidates {
break;
}
}
if paths.is_empty() {
return Err(AlgorithmError::NoPath {
from: order.token_in().clone(),
to: order.token_out().clone(),
reason: NoPathReason::NoGraphPath,
});
}
Ok(paths)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use alloy::primitives::U256;
use num_bigint::BigUint;
use num_traits::ToPrimitive;
use tycho_simulation::evm::protocol::uniswap_v2::state::UniswapV2State;
use super::{super::MostLiquidAlgorithm, *};
use crate::{
algorithm::{
split_test_harness::{
evaluate_scenario, optimal_two_component_output, split_metrics, split_scenarios,
two_equal_weth_usdc, TWO_EQUAL_USDC_RESERVE, TWO_EQUAL_WETH_RESERVE,
},
test_utils::{
addr, setup_market_unweighted_topology, setup_market_weighted_boxed,
token_with_decimals, ConstantProductSim, DivByZeroSim,
},
},
graph::GraphManager,
types::quote::OrderSide,
};
fn config() -> AlgorithmConfig {
config_ms(2000)
}
fn config_ms(ms: u64) -> AlgorithmConfig {
AlgorithmConfig::new(1, 3, Duration::from_millis(ms), None).unwrap()
}
fn whole_weth_order(token_in: &Address, token_out: &Address, weth: u64) -> Order {
Order::new(
token_in.clone(),
token_out.clone(),
BigUint::from(weth) * BigUint::from(10u64).pow(18),
OrderSide::Sell,
addr(0xFF),
)
}
fn v2_component(reserve_a: u128, reserve_b: u128) -> UniswapV2State {
UniswapV2State::new(
U256::from(reserve_a) * U256::from(10u64).pow(U256::from(18u64)),
U256::from(reserve_b) * U256::from(10u64).pow(U256::from(18u64)),
)
}
fn water_fill_default() -> WaterFillAlgorithm {
WaterFillAlgorithm::with_config(
AlgorithmConfig::new(1, 4, Duration::from_millis(5000), None).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn test_water_fill_all_scenarios() {
let algo = water_fill_default();
for scenario in split_scenarios::all() {
let name = scenario.name;
let (market, gm) = scenario.build_market_weighted();
let result = evaluate_scenario(&algo, &scenario, market, gm).await;
result.assert_passes_lower_bound();
let tolerance_pct = if name == "DOUBLE_SPLIT" { 10 } else { 5 };
assert!(
result.within_pct_of_optimum(tolerance_pct),
"'{name}': not within {tolerance_pct}% of optimum",
);
let route = result
.route
.as_ref()
.unwrap_or_else(|| panic!("'{name}': expected a route"));
assert!(route.validate().is_ok(), "'{name}': route validation failed");
}
}
#[tokio::test]
async fn test_water_fill_gas_kills_split() {
let scenario = split_scenarios::gas_kills_split();
let (market, gm) = scenario.build_market_weighted();
let result = evaluate_scenario(&water_fill_default(), &scenario, market, gm).await;
assert_eq!(result.path_count, 1, "high gas should prevent splitting");
}
#[tokio::test]
async fn test_split_fills_when_no_single_path_can() {
let a = token_with_decimals(0x01, "A", 18);
let b = token_with_decimals(0x02, "B", 18);
let component = || {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(800u64) * BigUint::from(10u64).pow(18),
reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
gas: 50_000,
}) as Box<dyn ProtocolSim>
};
let (market, gm) = setup_market_weighted_boxed(vec![
("component_x", &a, &b, component()),
("component_y", &a, &b, component()),
]);
let order = Order::new(
a.address.clone(),
b.address.clone(),
BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
OrderSide::Sell,
addr(0xFF),
);
let single = MostLiquidAlgorithm::with_config(config())
.unwrap()
.find_best_route(gm.graph(), market.clone(), None, None, &order)
.await;
assert!(single.is_err(), "premise: no single path fills the full order");
let split = WaterFillAlgorithm::with_config(config())
.unwrap()
.find_best_route(gm.graph(), market.clone(), None, None, &order)
.await
.expect("water_fill returns a split when no single path fills");
assert!(split.route().swaps().len() >= 2, "expected a split across both components");
}
#[tokio::test]
async fn test_water_fill_contains_simulation_panic() {
let a = token_with_decimals(0x01, "A", 18);
let b = token_with_decimals(0x02, "B", 18);
let healthy = Box::new(ConstantProductSim {
reserve_0: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
reserve_1: BigUint::from(10_000u64) * BigUint::from(10u64).pow(18),
gas: 50_000,
}) as Box<dyn ProtocolSim>;
let (market, gm) = setup_market_weighted_boxed(vec![
("component_ok", &a, &b, healthy),
("component_panics", &a, &b, Box::new(DivByZeroSim::default())),
]);
let order = Order::new(
a.address.clone(),
b.address.clone(),
BigUint::from(100u64) * BigUint::from(10u64).pow(18),
OrderSide::Sell,
addr(0xFF),
);
let result = WaterFillAlgorithm::with_config(config())
.unwrap()
.find_best_route(gm.graph(), market.clone(), None, None, &order)
.await
.expect("panicking component is skipped; the healthy component still fills the order");
assert!(
result
.route()
.swaps()
.iter()
.all(|swap| swap.component_id() == "component_ok"),
"route must only use the healthy component",
);
}
#[tokio::test]
async fn test_water_fill_output_near_two_component_optimum() {
let m = two_equal_weth_usdc(1);
let trade = 500u64;
let order = whole_weth_order(&m.weth, &m.usdc, trade);
let result = WaterFillAlgorithm::with_config(config())
.unwrap()
.find_best_route(
m.weighted.graph(),
m.market.clone(),
None,
Some(m.derived.clone()),
&order,
)
.await
.expect("split solves");
let (_, path_count, gross) = split_metrics(&result, &m.weth, &m.usdc);
assert_eq!(path_count, 2, "the optimum uses both components");
let reserve_in = TWO_EQUAL_WETH_RESERVE as f64 * 1e18;
let reserve_out = TWO_EQUAL_USDC_RESERVE as f64 * 1e6;
let trade_amount = trade as f64 * 1e18;
let (_, optimum) = optimal_two_component_output(
reserve_in,
reserve_out,
reserve_in,
reserve_out,
trade_amount,
);
let gross = gross.to_f64().unwrap();
assert!(
gross >= optimum * 0.999 && gross <= optimum * 1.0001,
"split gross {gross} should be within 0.1% of the two-component optimum {optimum}",
);
}
#[tokio::test]
async fn test_water_fill_no_loss_under_tight_timeout() {
for ms in [1u64, 5, 50] {
let m = two_equal_weth_usdc(1_000_000_000);
let order = whole_weth_order(&m.weth, &m.usdc, 500);
let split = WaterFillAlgorithm::with_config(config_ms(ms))
.unwrap()
.find_best_route(
m.weighted.graph(),
m.market.clone(),
None,
Some(m.derived.clone()),
&order,
)
.await;
let single = MostLiquidAlgorithm::with_config(config_ms(ms))
.unwrap()
.find_best_route(
m.weighted.graph(),
m.market.clone(),
None,
Some(m.derived.clone()),
&order,
)
.await;
let (Ok(split), Ok(single)) = (split, single) else {
continue;
};
let (split_net, _, _) = split_metrics(&split, &m.weth, &m.usdc);
let (single_net, _, _) = split_metrics(&single, &m.weth, &m.usdc);
assert!(
split_net >= single_net,
"split lost to single-path under {ms}ms timeout: split={split_net} single={single_net}",
);
}
}
#[tokio::test]
async fn test_discovery_finds_and_ranks_parallel_components() {
let link = token_with_decimals(0x01, "LINK", 18);
let weth = token_with_decimals(0x02, "WETH", 18);
let (market, graph_manager) = setup_market_unweighted_topology(vec![
(
"a_weak_link_weth",
&link,
&weth,
Box::new(v2_component(2_000_000, 264)) as Box<dyn ProtocolSim>,
),
(
"z_strong_link_weth",
&link,
&weth,
Box::new(v2_component(2_000_000, 5_700)) as Box<dyn ProtocolSim>,
),
]);
let order = Order::new(
link.address.clone(),
weth.address.clone(),
BigUint::from(1_000u64) * BigUint::from(10u64).pow(18),
OrderSide::Sell,
addr(0xFF),
);
let start = Instant::now();
let view = market.read().await;
let paths = discover_paths(
graph_manager.graph(),
&view,
&order,
&mut SwapCache::new(),
CandidateSearchConfig {
query: &GraphQueryFilter { min_hops: 1, max_hops: 3, connector_tokens: None },
max_candidates: 128,
anchor_tokens: &FxHashSet::default(),
source_token: order.token_in(),
deadline: Deadline::new(start, Duration::from_millis(2000)),
},
)
.expect("discovery finds candidates");
assert_eq!(paths.len(), 2, "both parallel components should be discovered");
let best_path = &paths[0];
assert_eq!(
best_path.edge_iter()[0].component_id,
"z_strong_link_weth",
"discovery should rank by simulated output, not topology or edge weights",
);
}
fn hop(amount_out: u64, gas: u64) -> SwapResult {
SwapResult { amount_out: BigUint::from(amount_out), gas: BigUint::from(gas) }
}
#[test]
fn test_rank_outcomes_places_unfilled_and_missing_paths() {
let outcomes = vec![
Some(FullAmountOutcome::Filled(hop(1000, 0))),
Some(FullAmountOutcome::Unfilled),
None,
Some(FullAmountOutcome::Filled(hop(3000, 0))),
];
let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
assert_eq!(ranking.by_output, vec![3, 0, 1], "unfilled ranks last, missing is absent");
assert_eq!(ranking.by_output_net_gas, vec![3, 0], "unfilled cannot be the baseline");
}
#[test]
fn test_rank_outcomes_orders_by_output_net_of_gas() {
let token_out = addr(0x02);
let mut token_prices = TokenGasPrices::default();
token_prices.insert(
token_out.clone(),
tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
BigUint::from(1u64),
BigUint::from(1u64),
),
);
let outcomes = vec![
Some(FullAmountOutcome::Filled(hop(1000, 500))),
Some(FullAmountOutcome::Filled(hop(900, 10))),
];
let ranking =
rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
assert_eq!(ranking.by_output, vec![1, 0], "the cheaper path ranks first");
assert_eq!(ranking.by_output_net_gas, vec![1, 0], "and the baseline ordering agrees");
}
#[test]
fn test_rank_outcomes_places_a_loss_making_path_above_an_unfilled_one() {
let token_out = addr(0x02);
let mut token_prices = TokenGasPrices::default();
token_prices.insert(
token_out.clone(),
tycho_simulation::tycho_common::simulation::protocol_sim::Price::new(
BigUint::from(1u64),
BigUint::from(1u64),
),
);
let outcomes = vec![
Some(FullAmountOutcome::Unfilled),
Some(FullAmountOutcome::Filled(hop(100, 500))),
Some(FullAmountOutcome::Filled(hop(900, 10))),
];
let ranking =
rank_outcomes(outcomes, &BigUint::from(1u64), Some(&token_prices), &token_out);
assert_eq!(ranking.by_output, vec![2, 1, 0], "unfilled ranks below a loss-making path");
}
#[test]
fn test_rank_outcomes_ignores_gas_it_cannot_price() {
let outcomes = vec![
Some(FullAmountOutcome::Filled(hop(1000, 500))),
Some(FullAmountOutcome::Filled(hop(900, 10))),
];
let ranking = rank_outcomes(outcomes, &BigUint::from(1u64), None, &addr(0x02));
assert_eq!(ranking.by_output_net_gas, vec![0, 1]);
}
#[test]
fn test_derive_anchor_tokens_ranks_hub_and_includes_native_sentinel() {
let hub = token_with_decimals(0x01, "HUB", 18);
let a = token_with_decimals(0x02, "A", 18);
let b = token_with_decimals(0x03, "B", 18);
let c = token_with_decimals(0x04, "C", 18);
let (_market, graph_manager) = setup_market_unweighted_topology(vec![
("hub_a", &hub, &a, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
("hub_b", &hub, &b, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
("hub_c", &hub, &c, Box::new(v2_component(1, 1)) as Box<dyn ProtocolSim>),
]);
let anchors = derive_anchor_tokens(graph_manager.graph());
assert!(anchors.contains(&hub.address), "highest-degree token should be anchored");
assert!(
anchors.contains(&Address::from([0u8; 20])),
"native-ETH sentinel should always be anchored",
);
}
}