use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum PoolKind {
V2,
V3,
V4,
}
impl PoolKind {
#[must_use]
pub const fn as_u8(self) -> u8 {
match self {
PoolKind::V2 => 0,
PoolKind::V3 => 1,
PoolKind::V4 => 2,
}
}
#[must_use]
pub const fn from_u8(val: u8) -> Option<Self> {
match val {
0 => Some(PoolKind::V2),
1 => Some(PoolKind::V3),
2 => Some(PoolKind::V4),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Edge {
pub neighbor: u64,
pub pool_id: u64,
pub pool_kind: PoolKind,
}
pub type EdgeKey = (u64, PoolKind);
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
struct CompactEdge {
neighbor: u32,
pool_idx: u32,
}
pub struct PathGraph {
adj: Vec<Vec<CompactEdge>>,
token_index: HashMap<u64, u32>,
pools: Vec<(u64, PoolKind)>,
}
impl PathGraph {
#[must_use]
pub fn from_edges(edges: Vec<(u64, u64, u64, PoolKind)>) -> Self {
let n = edges.len();
let mut token_index: HashMap<u64, u32> = HashMap::with_capacity(n);
let mut pools: Vec<(u64, PoolKind)> = Vec::with_capacity(n);
let mut adj: Vec<Vec<CompactEdge>> = Vec::new();
for (token0, token1, pool_id, pool_kind) in edges {
#[expect(clippy::expect_used)]
let pool_idx = u32::try_from(pools.len()).expect("pool count exceeds u32::MAX");
pools.push((pool_id, pool_kind));
let idx0 = Self::intern_token(&mut token_index, &mut adj, token0);
let idx1 = Self::intern_token(&mut token_index, &mut adj, token1);
adj[idx0 as usize].push(CompactEdge {
neighbor: idx1,
pool_idx,
});
adj[idx1 as usize].push(CompactEdge {
neighbor: idx0,
pool_idx,
});
}
Self {
adj,
token_index,
pools,
}
}
fn intern_token(
token_index: &mut HashMap<u64, u32>,
adj: &mut Vec<Vec<CompactEdge>>,
token: u64,
) -> u32 {
if let Some(&idx) = token_index.get(&token) {
idx
} else {
#[expect(clippy::expect_used)] let idx = u32::try_from(token_index.len()).expect("token count exceeds u32::MAX");
token_index.insert(token, idx);
adj.push(Vec::new());
idx
}
}
#[must_use]
fn compact_index(&self, token: u64) -> Option<u32> {
self.token_index.get(&token).copied()
}
#[must_use]
pub fn contains_node(&self, node: u64) -> bool {
self.token_index.contains_key(&node)
}
#[must_use]
pub fn node_count(&self) -> usize {
self.adj.len()
}
#[must_use]
pub fn degree(&self, token: u64) -> Option<usize> {
self.compact_index(token)
.map(|i| self.adj[i as usize].len())
}
pub fn prune_dead_ends(&mut self) {
let n = self.adj.len();
let mut removed = vec![false; n];
loop {
let to_prune: Vec<u32> = (0..n)
.filter(|&i| !removed[i] && self.adj[i].len() <= 1)
.map(|i| {
#[expect(clippy::expect_used)]
u32::try_from(i).expect("node index exceeds u32::MAX")
})
.collect();
if to_prune.is_empty() {
break;
}
for &node_idx in &to_prune {
removed[node_idx as usize] = true;
}
self.remove_nodes(&to_prune);
}
self.token_index
.retain(|_, &mut idx| !removed[idx as usize]);
}
fn remove_nodes(&mut self, nodes: &[u32]) {
let mut reverse_removals: Vec<(u32, u32)> = Vec::new(); for &node_idx in nodes {
for edge in &self.adj[node_idx as usize] {
reverse_removals.push((edge.neighbor, edge.pool_idx));
}
}
for (neighbor, pool_idx) in reverse_removals {
if let Some(neighbor_edges) = self.adj.get_mut(neighbor as usize) {
neighbor_edges.retain(|e| e.pool_idx != pool_idx);
}
}
for &node_idx in nodes {
self.adj[node_idx as usize].clear();
}
}
#[must_use]
pub fn compute_node_valid_depths(
&self,
pool_type_per_depth: &[Option<Vec<PoolKind>>],
) -> Vec<Vec<bool>> {
let mut result = Vec::with_capacity(self.adj.len());
for edges in &self.adj {
let mut kinds = [false; 3];
for e in edges {
let kind = self.pools[e.pool_idx as usize].1;
kinds[kind.as_u8() as usize] = true;
}
let mut valid = vec![false; pool_type_per_depth.len()];
for (d, allowed) in pool_type_per_depth.iter().enumerate() {
match allowed {
None => valid[d] = true,
Some(allowed_kinds) => {
valid[d] = allowed_kinds.iter().any(|k| kinds[k.as_u8() as usize]);
}
}
}
result.push(valid);
}
result
}
}
pub struct PathFinder<'a> {
graph: &'a PathGraph,
end: u32,
min_depth: usize,
effective_max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<&'a [Option<Vec<PoolKind>>]>,
node_valid_depths: Option<&'a [Vec<bool>]>,
filter_len: usize,
stack: Vec<(u32, usize, bool)>,
working_path: Vec<u32>,
visited: Vec<bool>,
pending_reverse: Option<Vec<EdgeKey>>,
done: bool,
}
impl PathFinder<'_> {
#[must_use]
pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
if self.done {
return None;
}
if let Some(rev) = self.pending_reverse.take() {
return Some(rev);
}
while let Some(frame) = self.stack.last_mut() {
let (node, edge_idx, yield_checked) = frame;
if !*yield_checked {
*yield_checked = true;
if *node == self.end && self.working_path.len() >= self.min_depth {
let path = self.path_to_edge_keys();
if self.include_reverse {
let rev = self.reversed_path_to_edge_keys();
self.pending_reverse = Some(rev);
}
return Some(path);
}
}
if let Some(emd) = self.effective_max_depth {
if self.working_path.len() >= emd {
self.stack.pop();
if let Some(popped) = self.working_path.pop() {
self.visited[popped as usize] = false;
}
continue;
}
}
let neighbors = if let Some(n) = self.graph.adj.get(*node as usize) {
n.as_slice()
} else {
self.stack.pop();
continue;
};
let final_hop = matches!(
self.effective_max_depth,
Some(emd) if self.working_path.len() + 1 == emd
);
let mut found_edge = false;
while *edge_idx < neighbors.len() {
let edge = &neighbors[*edge_idx];
*edge_idx += 1;
let pool_idx = edge.pool_idx;
if self.visited[pool_idx as usize] {
continue;
}
if final_hop && edge.neighbor != self.end {
continue;
}
if let Some(filter) = self.pool_type_per_depth {
let depth = self.working_path.len();
if depth >= self.filter_len {
continue;
}
if let Some(allowed_kinds) = &filter[depth] {
let kind = self.graph.pools[pool_idx as usize].1;
if !allowed_kinds.contains(&kind) {
continue;
}
}
let next_depth = depth + 1;
if next_depth < self.filter_len {
if let Some(nvd) = self.node_valid_depths {
if let Some(valid) = nvd.get(edge.neighbor as usize) {
if !valid[next_depth] {
continue;
}
}
}
}
}
self.working_path.push(pool_idx);
self.visited[pool_idx as usize] = true;
self.stack.push((edge.neighbor, 0, false));
found_edge = true;
break;
}
if !found_edge {
self.stack.pop();
if let Some(popped) = self.working_path.pop() {
self.visited[popped as usize] = false;
}
}
}
self.done = true;
None
}
fn path_to_edge_keys(&self) -> Vec<EdgeKey> {
self.working_path
.iter()
.map(|&idx| self.graph.pools[idx as usize])
.collect()
}
fn reversed_path_to_edge_keys(&self) -> Vec<EdgeKey> {
self.working_path
.iter()
.rev()
.map(|&idx| self.graph.pools[idx as usize])
.collect()
}
}
impl Iterator for PathFinder<'_> {
type Item = Vec<EdgeKey>;
fn next(&mut self) -> Option<Self::Item> {
self.next_path()
}
}
pub struct OwnedPathFinder {
graph: PathGraph,
end: u32,
min_depth: usize,
effective_max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
node_valid_depths: Option<Vec<Vec<bool>>>,
filter_len: usize,
end_edges: Vec<Vec<u32>>,
stack: Vec<(u32, usize, bool)>,
working_path: Vec<u32>,
visited: Vec<bool>,
pending_reverse: bool,
done: bool,
search_started: Instant,
paths_yielded: u64,
advances_since_yield: u64,
last_heartbeat: Instant,
max_stack_depth: usize,
}
const DISCOVERY_HEARTBEAT: Duration = Duration::from_secs(10);
const HEARTBEAT_CHECK_EVERY: u64 = 4096;
#[derive(PartialEq, Eq)]
enum AdvanceOutcome {
Exhausted,
Forward,
Reversed,
}
impl OwnedPathFinder {
#[must_use]
pub fn new(
graph: PathGraph,
start: u64,
end: u64,
min_depth: usize,
max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<Vec<Option<Vec<PoolKind>>>>,
) -> Self {
let effective_max_depth: Option<usize> = match &pool_type_per_depth {
Some(filter) => {
let filter_len = filter.len();
match max_depth {
Some(md) => Some(md.min(filter_len)),
None => Some(filter_len),
}
}
None => max_depth,
};
let filter_len = pool_type_per_depth.as_ref().map_or(0, Vec::len);
let node_valid_depths = pool_type_per_depth
.as_ref()
.map(|filter| graph.compute_node_valid_depths(filter));
let start_idx = graph.compact_index(start);
let end_idx = graph.compact_index(end);
let (stack, done) = match (start_idx, end_idx) {
(Some(s), Some(_e)) => (vec![(s, 0, false)], false),
_ => (Vec::new(), true),
};
let end_idx = end_idx.unwrap_or(0);
let n_pools = graph.pools.len();
let mut end_edges: Vec<Vec<u32>> = vec![Vec::new(); graph.adj.len()];
for (node_idx, edges) in graph.adj.iter().enumerate() {
for e in edges {
if e.neighbor == end_idx {
end_edges[node_idx].push(e.pool_idx);
}
}
}
let now = Instant::now();
Self {
graph,
end: end_idx,
min_depth,
effective_max_depth,
include_reverse,
pool_type_per_depth,
node_valid_depths,
filter_len,
end_edges,
stack,
working_path: Vec::with_capacity(16),
visited: vec![false; n_pools],
pending_reverse: false,
done,
search_started: now,
paths_yielded: 0,
advances_since_yield: 0,
last_heartbeat: now,
max_stack_depth: 0,
}
}
#[expect(clippy::too_many_lines)]
fn advance(&mut self) -> AdvanceOutcome {
if self.done {
return AdvanceOutcome::Exhausted;
}
if self.pending_reverse {
self.pending_reverse = false;
self.paths_yielded += 1;
self.advances_since_yield = 0;
return AdvanceOutcome::Reversed;
}
let filter_slice = self.pool_type_per_depth.as_deref();
let nvd_ref = self.node_valid_depths.as_deref();
loop {
let stack_len = self.stack.len();
if stack_len == 0 {
break;
}
self.advances_since_yield = self.advances_since_yield.wrapping_add(1);
if stack_len > self.max_stack_depth {
self.max_stack_depth = stack_len;
}
if self
.advances_since_yield
.is_multiple_of(HEARTBEAT_CHECK_EVERY)
{
let now = Instant::now();
if now.duration_since(self.last_heartbeat) >= DISCOVERY_HEARTBEAT {
self.last_heartbeat = now;
let elapsed = now.duration_since(self.search_started);
#[expect(clippy::print_stderr)]
{
eprintln!(
"[pathfinding] discovery heartbeat: elapsed={elapsed:?} \
paths_yielded={} advances_since_yield={} max_stack_depth={}",
self.paths_yielded, self.advances_since_yield, self.max_stack_depth
);
}
}
}
let frame = &mut self.stack[stack_len - 1];
let (node, edge_idx, yield_checked) = frame;
if !*yield_checked {
*yield_checked = true;
if *node == self.end && self.working_path.len() >= self.min_depth {
if self.include_reverse {
self.pending_reverse = true;
}
self.paths_yielded += 1;
self.advances_since_yield = 0;
return AdvanceOutcome::Forward;
}
}
if let Some(emd) = self.effective_max_depth {
if self.working_path.len() >= emd {
self.stack.pop();
if let Some(popped) = self.working_path.pop() {
self.visited[popped as usize] = false;
}
continue;
}
}
let final_hop = matches!(
self.effective_max_depth,
Some(emd) if self.working_path.len() + 1 == emd
);
let penultimate_hop = matches!(
self.effective_max_depth,
Some(emd) if self.working_path.len() + 2 == emd
);
let mut found_edge = false;
if final_hop {
let end_list: &[u32] = self
.end_edges
.get(*node as usize)
.map_or([].as_slice(), Vec::as_slice);
while *edge_idx < end_list.len() {
let pool_idx = end_list[*edge_idx];
*edge_idx += 1;
if self.visited[pool_idx as usize] {
continue;
}
if let Some(filter) = filter_slice {
let depth = self.working_path.len();
if depth < self.filter_len {
if let Some(allowed_kinds) = &filter[depth] {
let kind = self.graph.pools[pool_idx as usize].1;
if !allowed_kinds.contains(&kind) {
continue;
}
}
}
}
self.working_path.push(pool_idx);
self.visited[pool_idx as usize] = true;
self.stack.push((self.end, 0, false));
found_edge = true;
break;
}
} else {
let neighbors = if let Some(n) = self.graph.adj.get(*node as usize) {
n.as_slice()
} else {
self.stack.pop();
continue;
};
while *edge_idx < neighbors.len() {
let edge = &neighbors[*edge_idx];
*edge_idx += 1;
let pool_idx = edge.pool_idx;
if self.visited[pool_idx as usize] {
continue;
}
if penultimate_hop {
let neighbor_can_close = self
.end_edges
.get(edge.neighbor as usize)
.is_some_and(|l| !l.is_empty());
if !neighbor_can_close {
continue;
}
}
if let Some(filter) = filter_slice {
let depth = self.working_path.len();
if depth >= self.filter_len {
continue;
}
if let Some(allowed_kinds) = &filter[depth] {
let kind = self.graph.pools[pool_idx as usize].1;
if !allowed_kinds.contains(&kind) {
continue;
}
}
let next_depth = depth + 1;
if next_depth < self.filter_len {
if let Some(nvd) = nvd_ref {
if let Some(valid) = nvd.get(edge.neighbor as usize) {
if !valid[next_depth] {
continue;
}
}
}
}
}
self.working_path.push(pool_idx);
self.visited[pool_idx as usize] = true;
self.stack.push((edge.neighbor, 0, false));
found_edge = true;
break;
}
}
if !found_edge {
self.stack.pop();
if let Some(popped) = self.working_path.pop() {
self.visited[popped as usize] = false;
}
}
}
self.emit_discovery_complete();
self.done = true;
AdvanceOutcome::Exhausted
}
fn emit_discovery_complete(&self) {
let elapsed = self.search_started.elapsed();
#[expect(clippy::print_stderr)]
{
eprintln!(
"[pathfinding] discovery complete: elapsed={elapsed:?} paths_yielded={} max_stack_depth={}",
self.paths_yielded, self.max_stack_depth
);
}
}
#[must_use]
pub fn next_path(&mut self) -> Option<Vec<EdgeKey>> {
match self.advance() {
AdvanceOutcome::Exhausted => None,
AdvanceOutcome::Forward => Some(self.path_to_edge_keys()),
AdvanceOutcome::Reversed => Some(self.reversed_path_to_edge_keys()),
}
}
#[must_use]
pub fn next_path_indices_into(&mut self, out: &mut Vec<u32>) -> Option<usize> {
match self.advance() {
AdvanceOutcome::Exhausted => None,
AdvanceOutcome::Forward => {
let len = self.working_path.len();
out.extend(self.working_path.iter().copied());
Some(len)
}
AdvanceOutcome::Reversed => {
let len = self.working_path.len();
out.extend(self.working_path.iter().rev().copied());
Some(len)
}
}
}
#[must_use]
pub fn pool_edge_key(&self, pool_idx: u32) -> EdgeKey {
self.graph.pools[pool_idx as usize]
}
fn path_to_edge_keys(&self) -> Vec<EdgeKey> {
self.working_path
.iter()
.map(|&idx| self.graph.pools[idx as usize])
.collect()
}
fn reversed_path_to_edge_keys(&self) -> Vec<EdgeKey> {
self.working_path
.iter()
.rev()
.map(|&idx| self.graph.pools[idx as usize])
.collect()
}
}
impl Iterator for OwnedPathFinder {
type Item = Vec<EdgeKey>;
fn next(&mut self) -> Option<Self::Item> {
self.next_path()
}
}
impl PathGraph {
#[expect(clippy::too_many_arguments)]
#[must_use]
pub fn find_paths_iter<'a>(
&'a self,
start: u64,
end: u64,
min_depth: usize,
max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<&'a [Option<Vec<PoolKind>>]>,
node_valid_depths: Option<&'a [Vec<bool>]>,
) -> PathFinder<'a> {
let effective_max_depth: Option<usize> = match pool_type_per_depth {
Some(filter) => {
let filter_len = filter.len();
match max_depth {
Some(md) => Some(md.min(filter_len)),
None => Some(filter_len),
}
}
None => max_depth,
};
let filter_len = pool_type_per_depth.map_or(0, <[Option<Vec<PoolKind>>]>::len);
let start_idx = self.compact_index(start);
let end_idx = self.compact_index(end);
let (stack, done) = match (start_idx, end_idx) {
(Some(s), Some(_e)) => (vec![(s, 0, false)], false),
_ => (Vec::new(), true),
};
let end_idx = end_idx.unwrap_or(0);
PathFinder {
graph: self,
end: end_idx,
min_depth,
effective_max_depth,
include_reverse,
pool_type_per_depth,
node_valid_depths,
filter_len,
stack,
working_path: Vec::with_capacity(16),
visited: vec![false; self.pools.len()],
pending_reverse: None,
done,
}
}
#[expect(clippy::too_many_arguments)]
#[must_use]
pub fn find_paths(
&self,
start: u64,
end: u64,
min_depth: usize,
max_depth: Option<usize>,
include_reverse: bool,
pool_type_per_depth: Option<&[Option<Vec<PoolKind>>]>,
node_valid_depths: Option<&[Vec<bool>]>,
) -> Vec<Vec<EdgeKey>> {
self.find_paths_iter(
start,
end,
min_depth,
max_depth,
include_reverse,
pool_type_per_depth,
node_valid_depths,
)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
const WETH: u64 = 1;
const A: u64 = 2;
const B: u64 = 3;
const POOL_WETH_A_1: u64 = 100;
const POOL_WETH_A_2: u64 = 101;
const POOL_A_B: u64 = 102;
const POOL_B_WETH: u64 = 103;
fn build_fixture_graph() -> PathGraph {
PathGraph::from_edges(vec![
(WETH, A, POOL_WETH_A_1, PoolKind::V2),
(WETH, A, POOL_WETH_A_2, PoolKind::V2),
(A, B, POOL_A_B, PoolKind::V2),
(B, WETH, POOL_B_WETH, PoolKind::V2),
])
}
fn edges_to_pool_ids(path: &[EdgeKey]) -> Vec<u64> {
path.iter().map(|(pid, _)| *pid).collect()
}
#[test]
fn test_from_edges_builds_adjacency() {
let graph = build_fixture_graph();
assert_eq!(graph.node_count(), 3); assert!(graph.contains_node(WETH));
assert!(graph.contains_node(A));
assert!(graph.contains_node(B));
}
#[test]
fn test_parallel_edges_preserved() {
let graph = build_fixture_graph();
assert_eq!(graph.degree(WETH), Some(3));
assert_eq!(graph.degree(A), Some(3));
}
#[test]
fn test_prune_dead_ends() {
let mut graph = PathGraph::from_edges(vec![
(A, B, 1, PoolKind::V2),
(B, 99, 2, PoolKind::V2), ]);
graph.prune_dead_ends();
assert!(!graph.contains_node(99));
assert!(!graph.contains_node(B));
assert!(!graph.contains_node(A));
}
#[test]
fn test_prune_preserves_cycle() {
let mut graph = build_fixture_graph();
graph.prune_dead_ends();
assert!(graph.contains_node(WETH));
assert!(graph.contains_node(A));
assert!(graph.contains_node(B));
}
#[test]
fn test_two_hop_pathfinding() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
assert!(!paths.is_empty(), "Should find 2-hop WETH cycles");
for path in &paths {
assert_eq!(path.len(), 2, "Each path should be exactly 2 hops");
}
}
#[test]
fn test_three_hop_pathfinding() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
assert!(!paths.is_empty(), "Should find 3-hop WETH cycles");
for path in &paths {
assert_eq!(path.len(), 3, "Each path should be exactly 3 hops");
}
}
#[test]
fn test_min_depth_excludes_shorter() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
for path in &paths {
assert_eq!(path.len(), 3, "min_depth=3 should exclude shorter paths");
}
}
#[test]
fn test_max_depth_caps() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
for path in &paths {
assert!(path.len() <= 2, "max_depth=2 should cap path length");
}
}
#[test]
fn test_include_reverse_doubles_output() {
let graph = build_fixture_graph();
let forward = graph.find_paths(WETH, WETH, 2, Some(2), false, None, None);
let with_reverse = graph.find_paths(WETH, WETH, 2, Some(2), true, None, None);
assert_eq!(
with_reverse.len(),
forward.len() * 2,
"include_reverse should double the output count"
);
}
#[test]
fn test_absent_end_token_yields_no_paths() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, 9999, 3, Some(3), true, None, None);
assert!(paths.is_empty(), "absent end token must yield no paths");
let paths_start = graph.find_paths(9999, WETH, 3, Some(3), true, None, None);
assert!(
paths_start.is_empty(),
"absent start token must yield no paths"
);
let paths_both = graph.find_paths(9999, 9998, 3, Some(3), true, None, None);
assert!(
paths_both.is_empty(),
"absent start+end must yield no paths"
);
let ok = graph.find_paths(WETH, WETH, 3, Some(3), false, None, None);
assert!(!ok.is_empty(), "present end (WETH) must still yield cycles");
}
#[test]
fn test_three_hop_filter_yields_no_two_hop_cycles() {
let graph = build_fixture_graph();
let filter = vec![
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V2]),
];
let nvd = graph.compute_node_valid_depths(&filter);
let _paths = graph.find_paths(
WETH,
WETH,
2, Some(3), false,
Some(&filter),
Some(&nvd),
);
let paths_floored = graph.find_paths(
WETH,
WETH,
3, Some(3),
false,
Some(&filter),
Some(&nvd),
);
for path in &paths_floored {
assert_eq!(
path.len(),
3,
"3-depth filter with min_depth=3 should yield only 3-hop paths"
);
}
assert!(
!paths_floored.is_empty(),
"3-depth filter should yield at least one 3-hop path"
);
}
#[test]
fn test_pool_type_per_depth_caps_max_depth() {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let nvd = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
WETH,
WETH,
2,
Some(3), false,
Some(&filter),
Some(&nvd),
);
for path in &paths {
assert_eq!(
path.len(),
2,
"2-depth filter should cap at 2-hop paths even with max_depth=3"
);
}
}
#[test]
fn test_pool_type_per_depth_with_max_depth_none() {
let graph = build_fixture_graph();
let filter = vec![Some(vec![PoolKind::V2]), Some(vec![PoolKind::V2])];
let nvd = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(
WETH,
WETH,
2,
None, false,
Some(&filter),
Some(&nvd),
);
for path in &paths {
assert_eq!(path.len(), 2);
}
}
#[test]
fn test_none_entry_allows_all_kinds() {
let graph = build_fixture_graph();
let filter = vec![None, Some(vec![PoolKind::V4])];
let nvd = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(WETH, WETH, 2, Some(2), false, Some(&filter), Some(&nvd));
assert!(
paths.is_empty(),
"V4 filter on V2-only graph should yield nothing"
);
}
#[test]
fn test_cycle_detection_prevents_reusing_pools() {
let graph = build_fixture_graph();
let paths = graph.find_paths(WETH, WETH, 2, Some(3), false, None, None);
for path in &paths {
let pool_ids = edges_to_pool_ids(path);
let unique: HashSet<u64> = pool_ids.iter().copied().collect();
assert_eq!(
pool_ids.len(),
unique.len(),
"Path should not reuse a pool: {pool_ids:?}"
);
}
}
#[test]
fn test_node_not_in_graph_returns_empty() {
let graph = build_fixture_graph();
let paths = graph.find_paths(999, 999, 2, Some(2), false, None, None);
assert!(paths.is_empty());
}
#[test]
fn test_poolkind_roundtrip() {
assert_eq!(PoolKind::V2.as_u8(), 0);
assert_eq!(PoolKind::V3.as_u8(), 1);
assert_eq!(PoolKind::V4.as_u8(), 2);
assert_eq!(PoolKind::from_u8(0), Some(PoolKind::V2));
assert_eq!(PoolKind::from_u8(1), Some(PoolKind::V3));
assert_eq!(PoolKind::from_u8(2), Some(PoolKind::V4));
assert_eq!(PoolKind::from_u8(3), None);
}
#[test]
fn test_mixed_pool_kinds() {
let graph = PathGraph::from_edges(vec![
(WETH, A, 1, PoolKind::V2),
(A, B, 2, PoolKind::V4),
(B, WETH, 3, PoolKind::V2),
]);
let filter = vec![
Some(vec![PoolKind::V2]),
Some(vec![PoolKind::V4]),
Some(vec![PoolKind::V2]),
];
let nvd = graph.compute_node_valid_depths(&filter);
let paths = graph.find_paths(WETH, WETH, 3, Some(3), false, Some(&filter), Some(&nvd));
assert!(!paths.is_empty(), "Should find a V2-V4-V2 path");
for path in &paths {
assert_eq!(path.len(), 3);
assert_eq!(path[0].1, PoolKind::V2);
assert_eq!(path[1].1, PoolKind::V4);
assert_eq!(path[2].1, PoolKind::V2);
}
}
#[test]
fn test_heartbeat_diagnostics_do_not_alter_enumeration() {
let graph = build_fixture_graph();
let run_one: Vec<Vec<u64>> = graph
.find_paths(WETH, WETH, 2, Some(3), true, None, None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
let graph2 = build_fixture_graph();
let run_two: Vec<Vec<u64>> = graph2
.find_paths(WETH, WETH, 2, Some(3), true, None, None)
.into_iter()
.map(|p| edges_to_pool_ids(&p))
.collect();
assert!(!run_one.is_empty(), "fixture must yield paths");
assert_eq!(
run_one, run_two,
"enumeration must be stable + unaffected by heartbeat wiring"
);
}
}