use std::fmt;
const NATIVE_CURRENCY: &str = "0x0000000000000000000000000000000000000000";
pub struct DirectionHop<'a> {
pub token0: &'a str,
pub token1: &'a str,
pub identity: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DirectionError {
Mismatch {
hop: usize,
hops: usize,
identity: String,
token0: String,
token1: String,
tracked: String,
start: String,
},
Unclosed {
output: String,
input: String,
identities: Vec<String>,
},
}
impl fmt::Display for DirectionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Mismatch {
hop,
hops,
identity,
token0,
token1,
tracked,
start,
} => write!(
f,
"hop {hop}/{hops}: pool {identity} has token0={token0} token1={token1}; \
expected either to carry the tracked input token {tracked} (path starts at {start})"
),
Self::Unclosed {
output,
input,
identities,
} => write!(
f,
"cycle does not close: final output {output} != input {input}; \
pools=[{}]",
identities.join(", ")
),
}
}
}
pub fn resolve_directions(
hops: &[DirectionHop<'_>],
input_token: &str,
weth: &str,
) -> Result<Vec<bool>, Box<DirectionError>> {
let start = input_token.to_ascii_lowercase();
let weth = weth.to_ascii_lowercase();
let mut tracked = start.clone();
let mut zfos = Vec::with_capacity(hops.len());
for (i, hop) in hops.iter().enumerate() {
let token0 = {
let t = hop.token0.to_ascii_lowercase();
if t == NATIVE_CURRENCY {
weth.clone()
} else {
t
}
};
let token1 = {
let t = hop.token1.to_ascii_lowercase();
if t == NATIVE_CURRENCY {
weth.clone()
} else {
t
}
};
let zfo = if token0 == tracked {
true
} else if token1 == tracked {
false
} else {
return Err(Box::new(DirectionError::Mismatch {
hop: i,
hops: hops.len(),
identity: hop.identity.to_string(),
token0,
token1,
tracked,
start,
}));
};
tracked = if zfo { token1 } else { token0 };
zfos.push(zfo);
}
if tracked != start {
return Err(Box::new(DirectionError::Unclosed {
output: tracked,
input: start,
identities: hops.iter().map(|hop| hop.identity.to_string()).collect(),
}));
}
Ok(zfos)
}
#[cfg(test)]
#[expect(
clippy::expect_used,
clippy::panic,
reason = "tests assert on known-valid fixture contents"
)]
mod tests {
use super::*;
const WETH: &str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
fn hop<'a>(token0: &'a str, token1: &'a str) -> DirectionHop<'a> {
DirectionHop {
token0,
token1,
identity: "test-pool",
}
}
#[test]
fn two_hop_cycle_closes_with_expected_zfos() {
let hops = [
hop(
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
),
hop(
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
),
];
let zfos = resolve_directions(&hops, WETH, WETH).expect("cycle closes");
assert_eq!(zfos, vec![false, false]);
}
#[test]
fn v4_native_sentinel_matches_weth() {
let native = "0x0000000000000000000000000000000000000000";
let hops = [hop(native, USDC), hop(USDC, native)];
let zfos = resolve_directions(&hops, WETH, WETH).expect("cycle closes");
assert_eq!(zfos, vec![true, true]);
}
#[test]
fn empty_hop_list_trivially_closes() {
let zfos = resolve_directions(&[], WETH, WETH).expect("empty cycle closes");
assert!(zfos.is_empty());
}
#[test]
fn mid_path_mismatch_names_the_hop_and_tokens() {
let hops = [
hop(WETH, USDC),
hop(
"0x111111111117dc0aa78b770fa6a738034120c302",
"0x22222222222222fe6d179adbf7e7b9385e0e4313",
),
];
let err = resolve_directions(&hops, WETH, WETH).expect_err("second hop orphans the chain");
match *err {
DirectionError::Mismatch {
hop,
hops,
identity,
tracked,
start,
..
} => {
assert_eq!(hop, 1);
assert_eq!(hops, 2);
assert_eq!(identity, "test-pool");
assert_eq!(tracked, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48");
assert_eq!(start, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2");
}
other @ DirectionError::Unclosed { .. } => panic!("expected Mismatch, got {other:?}"),
}
}
#[test]
fn unclosed_cycle_reports_output_and_identities() {
let hops = [
DirectionHop {
token0: WETH,
token1: USDC,
identity: "pool-a",
},
DirectionHop {
token0: USDC,
token1: "0x111111111117dc0aa78b770fa6a738034120c302",
identity: "pool-b",
},
];
let err = resolve_directions(&hops, WETH, WETH).expect_err("chain ends off-cycle");
match *err {
DirectionError::Unclosed {
output,
input,
identities,
} => {
assert_eq!(output, "0x111111111117dc0aa78b770fa6a738034120c302");
assert_eq!(input, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2");
assert_eq!(identities, vec!["pool-a".to_string(), "pool-b".to_string()]);
}
other @ DirectionError::Mismatch { .. } => panic!("expected Unclosed, got {other:?}"),
}
}
#[test]
fn error_display_carries_the_operator_message() {
let err = DirectionError::Unclosed {
output: "0xabc".to_string(),
input: "0xdef".to_string(),
identities: vec!["pool-a".to_string()],
};
assert_eq!(
err.to_string(),
"cycle does not close: final output 0xabc != input 0xdef; pools=[pool-a]"
);
}
}