use crate::{
graph::{ArbGraph, PoolEdge},
opportunity::ArbPath,
};
use scematica_core::config::ArbConfig;
use solana_sdk::pubkey::Pubkey;
use std::collections::HashSet;
use tracing::{debug, info};
pub struct ArbSearcher {
pub graph: ArbGraph,
pub config: ArbConfig,
}
impl ArbSearcher {
pub fn new(graph: ArbGraph, config: ArbConfig) -> Self {
Self { graph, config }
}
pub fn search(&self, start_mint: &Pubkey, start_amount: u128) -> Vec<ArbPath> {
let start_idx = match self.graph.get_idx(start_mint) {
Some(idx) => idx,
None => {
debug!("Start mint not in graph: {}", start_mint);
return vec![];
}
};
let mut all_paths = vec![];
let mut seen_ids: HashSet<String> = HashSet::new();
let mut amount = start_amount;
let min_amount = 1_000_000u128;
for _ in 0..self.config.amount_levels {
if amount < min_amount {
break;
}
let mut paths = vec![];
self.dfs(
start_idx,
start_idx,
amount,
amount,
vec![start_idx],
vec![],
vec![],
&mut paths,
&mut seen_ids,
);
all_paths.extend(paths);
amount /= 2;
}
all_paths.sort_by(|a, b| b.profit.cmp(&a.profit));
all_paths
}
fn dfs(
&self,
start_idx: usize,
curr_idx: usize,
init_amount: u128,
curr_amount: u128,
mint_path: Vec<usize>,
pool_path: Vec<PoolEdge>,
hop_amounts: Vec<u64>,
results: &mut Vec<ArbPath>,
seen_ids: &mut HashSet<String>,
) {
if mint_path.len() > self.config.max_hops + 1 {
return;
}
let neighbors = self.graph.neighbors(curr_idx);
for next_idx in neighbors {
if mint_path.contains(&next_idx) && next_idx != start_idx {
continue;
}
let edges = self.graph.edges_between(curr_idx, next_idx);
for edge in edges {
let out_amount = edge.get_quote(curr_amount);
if out_amount == 0 {
continue;
}
let mut new_mint_path = mint_path.clone();
new_mint_path.push(next_idx);
let mut new_pool_path = pool_path.clone();
new_pool_path.push(edge.clone());
let mut new_hop_amounts = hop_amounts.clone();
new_hop_amounts.push(curr_amount as u64);
if next_idx == start_idx {
if out_amount > init_amount {
let _start_mint = self.graph.get_mint(start_idx).unwrap_or_default();
let mint_pubkeys: Vec<Pubkey> = new_mint_path
.iter()
.filter_map(|&idx| self.graph.get_mint(idx))
.collect();
let path = ArbPath::new(
mint_pubkeys,
new_pool_path,
init_amount,
out_amount,
new_hop_amounts,
);
if !seen_ids.contains(&path.id) {
seen_ids.insert(path.id.clone());
info!(
"💰 Arb found: {} → profit: {} ({:.3}%)",
path.hops(),
path.profit,
path.profit_pct
);
results.push(path);
}
}
} else if !mint_path.contains(&next_idx) {
self.dfs(
start_idx,
next_idx,
init_amount,
out_amount,
new_mint_path,
new_pool_path,
new_hop_amounts,
results,
seen_ids,
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::PoolEdge;
use scematica_core::{config::ArbConfig, types::DexKind};
use solana_sdk::pubkey::Pubkey;
fn make_edge(reserve_a: u64, reserve_b: u64) -> PoolEdge {
PoolEdge {
pool_address: Pubkey::new_unique(),
dex: DexKind::Raydium,
reserve_a,
reserve_b,
fee_numerator: 25,
fee_denominator: 10_000,
}
}
#[test]
fn test_no_arb_balanced_pools() {
let graph = ArbGraph::new();
let mint_a = Pubkey::new_unique();
let mint_b = Pubkey::new_unique();
let mint_c = Pubkey::new_unique();
graph.add_pool(mint_a, mint_b, make_edge(1_000_000, 1_000_000));
graph.add_pool(mint_b, mint_c, make_edge(1_000_000, 1_000_000));
graph.add_pool(mint_c, mint_a, make_edge(1_000_000, 1_000_000));
let config = ArbConfig {
max_hops: 3,
amount_levels: 1,
..ArbConfig::default()
};
let searcher = ArbSearcher::new(graph, config);
let paths = searcher.search(&mint_a, 100_000);
assert!(paths.iter().all(|p| p.profit <= 0));
}
#[test]
fn test_arb_imbalanced_pools() {
let graph = ArbGraph::new();
let mint_a = Pubkey::new_unique();
let mint_b = Pubkey::new_unique();
let pool1 = PoolEdge {
pool_address: Pubkey::new_unique(),
dex: DexKind::Raydium,
reserve_a: 100_000_000,
reserve_b: 200_000_000, fee_numerator: 0,
fee_denominator: 10_000,
};
let pool2 = PoolEdge {
pool_address: Pubkey::new_unique(),
dex: DexKind::Orca,
reserve_a: 200_000_000, reserve_b: 200_000_000,
fee_numerator: 0,
fee_denominator: 10_000,
};
graph.add_pool(mint_a, mint_b, pool1);
graph.add_pool(mint_b, mint_a, pool2);
let config = ArbConfig {
max_hops: 2,
amount_levels: 1,
..ArbConfig::default()
};
let searcher = ArbSearcher::new(graph, config);
let paths = searcher.search(&mint_a, 1_000_000);
assert!(!paths.is_empty(), "Expected at least one arb path");
assert!(paths[0].profit > 0, "Expected positive profit");
}
#[test]
fn test_quote_formula() {
let edge = make_edge(1_000_000, 1_000_000);
let out = edge.get_quote(100_000);
assert!(out > 0);
assert!(out < 100_000); }
}