[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-deep-evm-14-arbitrage-cycle-finder-dfs-pool-graph":3},{"article":4,"author":54},{"id":5,"category_id":6,"title":7,"slug":8,"excerpt":9,"content_md":10,"content_html":11,"locale":12,"author_id":13,"published":14,"published_at":15,"meta_title":7,"meta_description":16,"focus_keyword":17,"og_image":18,"canonical_url":18,"robots_meta":19,"created_at":15,"updated_at":15,"tags":20,"category_name":34,"related_articles":35},"d0000000-0000-0000-0000-000000000114","a0000000-0000-0000-0000-000000000006","Deep EVM #14: Building an Arbitrage Cycle Finder — DFS on a Pool Graph","deep-evm-14-arbitrage-cycle-finder-dfs-pool-graph","Build a production arbitrage cycle finder in Rust using depth-first search on a token-pool graph. Handle thousands of pools, millions of cycles, and deduplicate with keccak256.","## The Token-Pool Graph\n\nAn arbitrage cycle is a sequence of swaps that starts and ends with the same token, producing more output than input. To find these cycles systematically, we model the DeFi ecosystem as a directed graph:\n\n- **Nodes** = tokens (WETH, USDC, DAI, WBTC, ...)\n- **Edges** = liquidity pools that enable swaps between two tokens\n\nA Uniswap V2 pair (WETH\u002FUSDC) creates two directed edges: WETH→USDC and USDC→WETH. A Uniswap V3 pool does the same, but with different pricing (concentrated liquidity). A Balancer weighted pool with 3 tokens creates 6 directed edges (every pair in both directions).\n\n```rust\nuse std::collections::HashMap;\n\n#[derive(Clone, Debug)]\npub struct Pool {\n    pub address: Address,\n    pub token0: Address,\n    pub token1: Address,\n    pub protocol: Protocol,\n    pub fee: u32,        \u002F\u002F basis points\n    pub reserve0: U256,\n    pub reserve1: U256,\n}\n\n#[derive(Clone, Debug)]\npub enum Protocol {\n    UniswapV2,\n    UniswapV3,\n    SushiSwap,\n    Curve,\n    Balancer,\n}\n\npub struct TokenGraph {\n    \u002F\u002F token_address → vec of (other_token, pool)\n    pub adjacency: HashMap\u003CAddress, Vec\u003C(Address, Pool)>>,\n}\n\nimpl TokenGraph {\n    pub fn new() -> Self {\n        Self {\n            adjacency: HashMap::new(),\n        }\n    }\n\n    pub fn add_pool(&mut self, pool: Pool) {\n        self.adjacency\n            .entry(pool.token0)\n            .or_default()\n            .push((pool.token1, pool.clone()));\n        self.adjacency\n            .entry(pool.token1)\n            .or_default()\n            .push((pool.token0, pool));\n    }\n}\n```\n\nOn Ethereum mainnet, there are ~80,000 active liquidity pools. The graph has ~15,000 unique tokens and ~160,000 directed edges.\n\n## DFS Cycle Detection\n\nWe find arbitrage cycles using depth-first search starting from a \"base token\" (typically WETH, since we want profit denominated in ETH).\n\n```rust\n#[derive(Clone, Debug)]\npub struct Cycle {\n    pub pools: Vec\u003CPool>,\n    pub tokens: Vec\u003CAddress>,  \u002F\u002F token path: [WETH, USDC, DAI, WETH]\n    pub id: [u8; 32],         \u002F\u002F keccak256 hash for deduplication\n}\n\npub fn find_cycles(\n    graph: &TokenGraph,\n    start_token: Address,\n    max_depth: usize,   \u002F\u002F typically 2-4 hops\n) -> Vec\u003CCycle> {\n    let mut results = Vec::new();\n    let mut path_tokens = vec![start_token];\n    let mut path_pools: Vec\u003CPool> = Vec::new();\n    let mut visited = HashSet::new();\n    visited.insert(start_token);\n\n    dfs(\n        graph,\n        start_token,\n        start_token,\n        max_depth,\n        &mut path_tokens,\n        &mut path_pools,\n        &mut visited,\n        &mut results,\n    );\n\n    results\n}\n\nfn dfs(\n    graph: &TokenGraph,\n    current: Address,\n    start: Address,\n    max_depth: usize,\n    path_tokens: &mut Vec\u003CAddress>,\n    path_pools: &mut Vec\u003CPool>,\n    visited: &mut HashSet\u003CAddress>,\n    results: &mut Vec\u003CCycle>,\n) {\n    if path_pools.len() >= max_depth {\n        return;\n    }\n\n    let neighbors = match graph.adjacency.get(&current) {\n        Some(n) => n,\n        None => return,\n    };\n\n    for (next_token, pool) in neighbors {\n        \u002F\u002F If we've returned to start with >= 2 hops, we found a cycle\n        if *next_token == start && path_pools.len() >= 2 {\n            let mut cycle_tokens = path_tokens.clone();\n            cycle_tokens.push(start);\n            let mut cycle_pools = path_pools.clone();\n            cycle_pools.push(pool.clone());\n\n            let id = compute_cycle_id(&cycle_pools);\n            results.push(Cycle {\n                pools: cycle_pools,\n                tokens: cycle_tokens,\n                id,\n            });\n            continue;\n        }\n\n        \u002F\u002F Don't revisit tokens (except start)\n        if visited.contains(next_token) {\n            continue;\n        }\n\n        visited.insert(*next_token);\n        path_tokens.push(*next_token);\n        path_pools.push(pool.clone());\n\n        dfs(\n            graph,\n            *next_token,\n            start,\n            max_depth,\n            path_tokens,\n            path_pools,\n            visited,\n            results,\n        );\n\n        path_tokens.pop();\n        path_pools.pop();\n        visited.remove(next_token);\n    }\n}\n```\n\n## The `max_depth` Parameter\n\nThe depth parameter controls cycle length (number of hops):\n\n- **depth=2:** Direct arbitrage. WETH→USDC→WETH (two pools). Simple, competitive.\n- **depth=3:** Triangular arbitrage. WETH→USDC→DAI→WETH. The sweet spot — complex enough that fewer searchers compete, simple enough to simulate quickly.\n- **depth=4:** Quadrilateral arbitrage. Higher potential profit but exponentially more cycles to evaluate and higher gas costs.\n- **depth=5+:** Rarely profitable after gas costs. The search space explodes combinatorially.\n\n```\nPools: 80,000\nDepth 2 cycles: ~50,000\nDepth 3 cycles: ~15,000,000\nDepth 4 cycles: ~2,000,000,000+\n```\n\nIn practice, depth 3 is the production sweet spot. We find ~15 million triangular cycles and filter them down to ~100,000 that are worth simulating.\n\n## Deduplication with Keccak256\n\nThe DFS produces duplicate cycles: WETH→USDC→DAI→WETH and WETH→DAI→USDC→WETH are the same cycle traversed in opposite directions. We deduplicate using a canonical hash:\n\n```rust\nuse tiny_keccak::{Hasher, Keccak};\n\nfn compute_cycle_id(pools: &[Pool]) -> [u8; 32] {\n    \u002F\u002F Sort pool addresses to create a canonical representation\n    let mut addresses: Vec\u003CAddress> = pools.iter().map(|p| p.address).collect();\n    addresses.sort();\n\n    let mut hasher = Keccak::v256();\n    for addr in &addresses {\n        hasher.update(addr.as_bytes());\n    }\n    let mut output = [0u8; 32];\n    hasher.finalize(&mut output);\n    output\n}\n```\n\nBy sorting pool addresses before hashing, both directions of the same cycle produce the same ID. Insert into a `HashSet\u003C[u8; 32]>` and skip duplicates.\n\nWhy keccak256 instead of a simpler hash? In a set of 15 million cycles, collision probability with a 128-bit hash is non-trivial. With 256 bits, collisions are astronomically unlikely.\n\n## Filtering: Reducing Millions to Thousands\n\nNot all cycles are worth simulating. Apply filters:\n\n### Liquidity Filter\n\nDiscard cycles containing pools with reserves below a threshold. A pool with $100 of liquidity cannot produce meaningful arbitrage.\n\n```rust\nfn has_sufficient_liquidity(cycle: &Cycle, min_reserve_usd: f64) -> bool {\n    cycle.pools.iter().all(|pool| {\n        let reserve_usd = estimate_reserve_usd(pool);\n        reserve_usd >= min_reserve_usd\n    })\n}\n```\n\n### Staleness Filter\n\nDiscard cycles where all pools have been idle (no swaps) for > N blocks. Stale pools rarely have arbitrage.\n\n### Token Blacklist\n\nExclude scam tokens, fee-on-transfer tokens, and tokens with transfer restrictions. These look profitable in simulation but fail on-chain.\n\n```rust\nconst BLACKLISTED_TOKENS: &[&str] = &[\n    \"0x...\",  \u002F\u002F known fee-on-transfer\n    \"0x...\",  \u002F\u002F known honeypot\n];\n\nfn contains_blacklisted_token(cycle: &Cycle) -> bool {\n    cycle.tokens.iter().any(|t| {\n        BLACKLISTED_TOKENS.contains(&format!(\"{:?}\", t).as_str())\n    })\n}\n```\n\n### Static Profitability Estimate\n\nBefore full simulation, compute a rough profitability estimate using constant-product formula:\n\n```rust\nfn estimate_output_v2(\n    amount_in: U256,\n    reserve_in: U256,\n    reserve_out: U256,\n    fee_bps: u32,\n) -> U256 {\n    let fee_factor = 10000 - fee_bps;  \u002F\u002F e.g., 9970 for 0.3% fee\n    let numerator = amount_in * reserve_out * U256::from(fee_factor);\n    let denominator = reserve_in * U256::from(10000) + amount_in * U256::from(fee_factor);\n    numerator \u002F denominator\n}\n\nfn estimate_cycle_profit(\n    cycle: &Cycle,\n    input_amount: U256,\n) -> Option\u003CU256> {\n    let mut current_amount = input_amount;\n\n    for (i, pool) in cycle.pools.iter().enumerate() {\n        let token_in = cycle.tokens[i];\n        let (reserve_in, reserve_out) = if token_in == pool.token0 {\n            (pool.reserve0, pool.reserve1)\n        } else {\n            (pool.reserve1, pool.reserve0)\n        };\n\n        current_amount = estimate_output_v2(\n            current_amount,\n            reserve_in,\n            reserve_out,\n            pool.fee,\n        );\n    }\n\n    if current_amount > input_amount {\n        Some(current_amount - input_amount)\n    } else {\n        None\n    }\n}\n```\n\nThis estimate is imperfect (ignores Uniswap V3 tick math, Curve stableswap curves, etc.) but eliminates 95% of unprofitable cycles before expensive simulation.\n\n## Scaling: Parallelism and Incremental Updates\n\nBuilding the full graph and running DFS on 80,000 pools takes 5-10 seconds on a single core. For a production bot, this is fine as a startup cost. But pool state changes every block (12 seconds), so we need incremental updates:\n\n```rust\npub struct CycleIndex {\n    pub cycles_by_pool: HashMap\u003CAddress, Vec\u003CCycleId>>,\n    pub all_cycles: HashMap\u003CCycleId, Cycle>,\n}\n\nimpl CycleIndex {\n    \u002F\u002F\u002F When a pool's reserves change, invalidate and re-evaluate\n    \u002F\u002F\u002F only the cycles that include this pool.\n    pub fn on_pool_update(&self, pool_address: Address) -> Vec\u003C&Cycle> {\n        self.cycles_by_pool\n            .get(&pool_address)\n            .map(|ids| {\n                ids.iter()\n                    .filter_map(|id| self.all_cycles.get(id))\n                    .collect()\n            })\n            .unwrap_or_default()\n    }\n}\n```\n\nWhen a `Sync` event fires on a Uniswap V2 pool, we update that pool's reserves and re-evaluate only the cycles containing that pool. This reduces per-block work from millions of cycles to a few hundred.\n\nFor the DFS itself, we parallelize across base tokens using Rayon:\n\n```rust\nuse rayon::prelude::*;\n\nlet base_tokens = vec![weth, usdc, usdt, dai, wbtc];\n\nlet all_cycles: Vec\u003CCycle> = base_tokens\n    .par_iter()\n    .flat_map(|base| find_cycles(&graph, *base, 3))\n    .collect();\n```\n\n## Summary\n\nThe cycle finder is the first stage of the MEV pipeline. It transforms a flat list of liquidity pools into a structured set of arbitrage opportunities. The key decisions — graph representation, DFS depth, deduplication strategy, and filtering heuristics — determine the quality and coverage of your opportunity set. In the next article, we will simulate these cycles: binary search over borrow amounts, state forks, and racing against the 12-second block deadline.","\u003Ch2 id=\"the-token-pool-graph\">The Token-Pool Graph\u003C\u002Fh2>\n\u003Cp>An arbitrage cycle is a sequence of swaps that starts and ends with the same token, producing more output than input. To find these cycles systematically, we model the DeFi ecosystem as a directed graph:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Nodes\u003C\u002Fstrong> = tokens (WETH, USDC, DAI, WBTC, …)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Edges\u003C\u002Fstrong> = liquidity pools that enable swaps between two tokens\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A Uniswap V2 pair (WETH\u002FUSDC) creates two directed edges: WETH→USDC and USDC→WETH. A Uniswap V3 pool does the same, but with different pricing (concentrated liquidity). A Balancer weighted pool with 3 tokens creates 6 directed edges (every pair in both directions).\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use std::collections::HashMap;\n\n#[derive(Clone, Debug)]\npub struct Pool {\n    pub address: Address,\n    pub token0: Address,\n    pub token1: Address,\n    pub protocol: Protocol,\n    pub fee: u32,        \u002F\u002F basis points\n    pub reserve0: U256,\n    pub reserve1: U256,\n}\n\n#[derive(Clone, Debug)]\npub enum Protocol {\n    UniswapV2,\n    UniswapV3,\n    SushiSwap,\n    Curve,\n    Balancer,\n}\n\npub struct TokenGraph {\n    \u002F\u002F token_address → vec of (other_token, pool)\n    pub adjacency: HashMap&lt;Address, Vec&lt;(Address, Pool)&gt;&gt;,\n}\n\nimpl TokenGraph {\n    pub fn new() -&gt; Self {\n        Self {\n            adjacency: HashMap::new(),\n        }\n    }\n\n    pub fn add_pool(&amp;mut self, pool: Pool) {\n        self.adjacency\n            .entry(pool.token0)\n            .or_default()\n            .push((pool.token1, pool.clone()));\n        self.adjacency\n            .entry(pool.token1)\n            .or_default()\n            .push((pool.token0, pool));\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>On Ethereum mainnet, there are ~80,000 active liquidity pools. The graph has ~15,000 unique tokens and ~160,000 directed edges.\u003C\u002Fp>\n\u003Ch2 id=\"dfs-cycle-detection\">DFS Cycle Detection\u003C\u002Fh2>\n\u003Cp>We find arbitrage cycles using depth-first search starting from a “base token” (typically WETH, since we want profit denominated in ETH).\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">#[derive(Clone, Debug)]\npub struct Cycle {\n    pub pools: Vec&lt;Pool&gt;,\n    pub tokens: Vec&lt;Address&gt;,  \u002F\u002F token path: [WETH, USDC, DAI, WETH]\n    pub id: [u8; 32],         \u002F\u002F keccak256 hash for deduplication\n}\n\npub fn find_cycles(\n    graph: &amp;TokenGraph,\n    start_token: Address,\n    max_depth: usize,   \u002F\u002F typically 2-4 hops\n) -&gt; Vec&lt;Cycle&gt; {\n    let mut results = Vec::new();\n    let mut path_tokens = vec![start_token];\n    let mut path_pools: Vec&lt;Pool&gt; = Vec::new();\n    let mut visited = HashSet::new();\n    visited.insert(start_token);\n\n    dfs(\n        graph,\n        start_token,\n        start_token,\n        max_depth,\n        &amp;mut path_tokens,\n        &amp;mut path_pools,\n        &amp;mut visited,\n        &amp;mut results,\n    );\n\n    results\n}\n\nfn dfs(\n    graph: &amp;TokenGraph,\n    current: Address,\n    start: Address,\n    max_depth: usize,\n    path_tokens: &amp;mut Vec&lt;Address&gt;,\n    path_pools: &amp;mut Vec&lt;Pool&gt;,\n    visited: &amp;mut HashSet&lt;Address&gt;,\n    results: &amp;mut Vec&lt;Cycle&gt;,\n) {\n    if path_pools.len() &gt;= max_depth {\n        return;\n    }\n\n    let neighbors = match graph.adjacency.get(&amp;current) {\n        Some(n) =&gt; n,\n        None =&gt; return,\n    };\n\n    for (next_token, pool) in neighbors {\n        \u002F\u002F If we've returned to start with &gt;= 2 hops, we found a cycle\n        if *next_token == start &amp;&amp; path_pools.len() &gt;= 2 {\n            let mut cycle_tokens = path_tokens.clone();\n            cycle_tokens.push(start);\n            let mut cycle_pools = path_pools.clone();\n            cycle_pools.push(pool.clone());\n\n            let id = compute_cycle_id(&amp;cycle_pools);\n            results.push(Cycle {\n                pools: cycle_pools,\n                tokens: cycle_tokens,\n                id,\n            });\n            continue;\n        }\n\n        \u002F\u002F Don't revisit tokens (except start)\n        if visited.contains(next_token) {\n            continue;\n        }\n\n        visited.insert(*next_token);\n        path_tokens.push(*next_token);\n        path_pools.push(pool.clone());\n\n        dfs(\n            graph,\n            *next_token,\n            start,\n            max_depth,\n            path_tokens,\n            path_pools,\n            visited,\n            results,\n        );\n\n        path_tokens.pop();\n        path_pools.pop();\n        visited.remove(next_token);\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"the-max-depth-parameter\">The \u003Ccode>max_depth\u003C\u002Fcode> Parameter\u003C\u002Fh2>\n\u003Cp>The depth parameter controls cycle length (number of hops):\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>depth=2:\u003C\u002Fstrong> Direct arbitrage. WETH→USDC→WETH (two pools). Simple, competitive.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>depth=3:\u003C\u002Fstrong> Triangular arbitrage. WETH→USDC→DAI→WETH. The sweet spot — complex enough that fewer searchers compete, simple enough to simulate quickly.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>depth=4:\u003C\u002Fstrong> Quadrilateral arbitrage. Higher potential profit but exponentially more cycles to evaluate and higher gas costs.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>depth=5+:\u003C\u002Fstrong> Rarely profitable after gas costs. The search space explodes combinatorially.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cpre>\u003Ccode>Pools: 80,000\nDepth 2 cycles: ~50,000\nDepth 3 cycles: ~15,000,000\nDepth 4 cycles: ~2,000,000,000+\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>In practice, depth 3 is the production sweet spot. We find ~15 million triangular cycles and filter them down to ~100,000 that are worth simulating.\u003C\u002Fp>\n\u003Ch2 id=\"deduplication-with-keccak256\">Deduplication with Keccak256\u003C\u002Fh2>\n\u003Cp>The DFS produces duplicate cycles: WETH→USDC→DAI→WETH and WETH→DAI→USDC→WETH are the same cycle traversed in opposite directions. We deduplicate using a canonical hash:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use tiny_keccak::{Hasher, Keccak};\n\nfn compute_cycle_id(pools: &amp;[Pool]) -&gt; [u8; 32] {\n    \u002F\u002F Sort pool addresses to create a canonical representation\n    let mut addresses: Vec&lt;Address&gt; = pools.iter().map(|p| p.address).collect();\n    addresses.sort();\n\n    let mut hasher = Keccak::v256();\n    for addr in &amp;addresses {\n        hasher.update(addr.as_bytes());\n    }\n    let mut output = [0u8; 32];\n    hasher.finalize(&amp;mut output);\n    output\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>By sorting pool addresses before hashing, both directions of the same cycle produce the same ID. Insert into a \u003Ccode>HashSet&lt;[u8; 32]&gt;\u003C\u002Fcode> and skip duplicates.\u003C\u002Fp>\n\u003Cp>Why keccak256 instead of a simpler hash? In a set of 15 million cycles, collision probability with a 128-bit hash is non-trivial. With 256 bits, collisions are astronomically unlikely.\u003C\u002Fp>\n\u003Ch2 id=\"filtering-reducing-millions-to-thousands\">Filtering: Reducing Millions to Thousands\u003C\u002Fh2>\n\u003Cp>Not all cycles are worth simulating. Apply filters:\u003C\u002Fp>\n\u003Ch3>Liquidity Filter\u003C\u002Fh3>\n\u003Cp>Discard cycles containing pools with reserves below a threshold. A pool with $100 of liquidity cannot produce meaningful arbitrage.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">fn has_sufficient_liquidity(cycle: &amp;Cycle, min_reserve_usd: f64) -&gt; bool {\n    cycle.pools.iter().all(|pool| {\n        let reserve_usd = estimate_reserve_usd(pool);\n        reserve_usd &gt;= min_reserve_usd\n    })\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Staleness Filter\u003C\u002Fh3>\n\u003Cp>Discard cycles where all pools have been idle (no swaps) for &gt; N blocks. Stale pools rarely have arbitrage.\u003C\u002Fp>\n\u003Ch3>Token Blacklist\u003C\u002Fh3>\n\u003Cp>Exclude scam tokens, fee-on-transfer tokens, and tokens with transfer restrictions. These look profitable in simulation but fail on-chain.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">const BLACKLISTED_TOKENS: &amp;[&amp;str] = &amp;[\n    \"0x...\",  \u002F\u002F known fee-on-transfer\n    \"0x...\",  \u002F\u002F known honeypot\n];\n\nfn contains_blacklisted_token(cycle: &amp;Cycle) -&gt; bool {\n    cycle.tokens.iter().any(|t| {\n        BLACKLISTED_TOKENS.contains(&amp;format!(\"{:?}\", t).as_str())\n    })\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Static Profitability Estimate\u003C\u002Fh3>\n\u003Cp>Before full simulation, compute a rough profitability estimate using constant-product formula:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">fn estimate_output_v2(\n    amount_in: U256,\n    reserve_in: U256,\n    reserve_out: U256,\n    fee_bps: u32,\n) -&gt; U256 {\n    let fee_factor = 10000 - fee_bps;  \u002F\u002F e.g., 9970 for 0.3% fee\n    let numerator = amount_in * reserve_out * U256::from(fee_factor);\n    let denominator = reserve_in * U256::from(10000) + amount_in * U256::from(fee_factor);\n    numerator \u002F denominator\n}\n\nfn estimate_cycle_profit(\n    cycle: &amp;Cycle,\n    input_amount: U256,\n) -&gt; Option&lt;U256&gt; {\n    let mut current_amount = input_amount;\n\n    for (i, pool) in cycle.pools.iter().enumerate() {\n        let token_in = cycle.tokens[i];\n        let (reserve_in, reserve_out) = if token_in == pool.token0 {\n            (pool.reserve0, pool.reserve1)\n        } else {\n            (pool.reserve1, pool.reserve0)\n        };\n\n        current_amount = estimate_output_v2(\n            current_amount,\n            reserve_in,\n            reserve_out,\n            pool.fee,\n        );\n    }\n\n    if current_amount &gt; input_amount {\n        Some(current_amount - input_amount)\n    } else {\n        None\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This estimate is imperfect (ignores Uniswap V3 tick math, Curve stableswap curves, etc.) but eliminates 95% of unprofitable cycles before expensive simulation.\u003C\u002Fp>\n\u003Ch2 id=\"scaling-parallelism-and-incremental-updates\">Scaling: Parallelism and Incremental Updates\u003C\u002Fh2>\n\u003Cp>Building the full graph and running DFS on 80,000 pools takes 5-10 seconds on a single core. For a production bot, this is fine as a startup cost. But pool state changes every block (12 seconds), so we need incremental updates:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub struct CycleIndex {\n    pub cycles_by_pool: HashMap&lt;Address, Vec&lt;CycleId&gt;&gt;,\n    pub all_cycles: HashMap&lt;CycleId, Cycle&gt;,\n}\n\nimpl CycleIndex {\n    \u002F\u002F\u002F When a pool's reserves change, invalidate and re-evaluate\n    \u002F\u002F\u002F only the cycles that include this pool.\n    pub fn on_pool_update(&amp;self, pool_address: Address) -&gt; Vec&lt;&amp;Cycle&gt; {\n        self.cycles_by_pool\n            .get(&amp;pool_address)\n            .map(|ids| {\n                ids.iter()\n                    .filter_map(|id| self.all_cycles.get(id))\n                    .collect()\n            })\n            .unwrap_or_default()\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>When a \u003Ccode>Sync\u003C\u002Fcode> event fires on a Uniswap V2 pool, we update that pool’s reserves and re-evaluate only the cycles containing that pool. This reduces per-block work from millions of cycles to a few hundred.\u003C\u002Fp>\n\u003Cp>For the DFS itself, we parallelize across base tokens using Rayon:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use rayon::prelude::*;\n\nlet base_tokens = vec![weth, usdc, usdt, dai, wbtc];\n\nlet all_cycles: Vec&lt;Cycle&gt; = base_tokens\n    .par_iter()\n    .flat_map(|base| find_cycles(&amp;graph, *base, 3))\n    .collect();\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"summary\">Summary\u003C\u002Fh2>\n\u003Cp>The cycle finder is the first stage of the MEV pipeline. It transforms a flat list of liquidity pools into a structured set of arbitrage opportunities. The key decisions — graph representation, DFS depth, deduplication strategy, and filtering heuristics — determine the quality and coverage of your opportunity set. In the next article, we will simulate these cycles: binary search over borrow amounts, state forks, and racing against the 12-second block deadline.\u003C\u002Fp>\n","en","b0000000-0000-0000-0000-000000000001",true,"2026-03-28T10:44:22.918770Z","Build a production arbitrage cycle finder in Rust: token-pool graph, DFS cycle detection, keccak256 deduplication, and scaling to millions of cycles.","arbitrage cycle finder dfs",null,"index, follow",[21,26,30],{"id":22,"name":23,"slug":24,"created_at":25},"c0000000-0000-0000-0000-000000000016","EVM","evm","2026-03-28T10:44:21.513630Z",{"id":27,"name":28,"slug":29,"created_at":25},"c0000000-0000-0000-0000-000000000019","MEV","mev",{"id":31,"name":32,"slug":33,"created_at":25},"c0000000-0000-0000-0000-000000000001","Rust","rust","Engineering",[36,42,48],{"id":37,"title":38,"slug":39,"excerpt":40,"locale":12,"category_name":34,"published_at":41},"d0200000-0000-0000-0000-000000000003","Why Bali Is Becoming Southeast Asia's Impact-Tech Hub in 2026","why-bali-becoming-southeast-asia-impact-tech-hub-2026","Bali ranks #16 among Southeast Asian startup ecosystems. With a growing concentration of Web3 builders, AI sustainability startups, and eco-travel tech companies, the island is carving a niche as the region's impact-tech capital.","2026-03-28T10:44:37.748283Z",{"id":43,"title":44,"slug":45,"excerpt":46,"locale":12,"category_name":34,"published_at":47},"d0200000-0000-0000-0000-000000000002","ASEAN Data Protection Patchwork: A Developer's Compliance Checklist","asean-data-protection-patchwork-developer-compliance-checklist","Seven ASEAN countries now have comprehensive data protection laws, each with different consent models, localization requirements, and penalty structures. Here is a practical compliance checklist for developers building multi-country applications.","2026-03-28T10:44:37.374741Z",{"id":49,"title":50,"slug":51,"excerpt":52,"locale":12,"category_name":34,"published_at":53},"d0200000-0000-0000-0000-000000000001","Indonesia's $29 Billion Digital Transformation: Opportunities for Software Companies","indonesia-29-billion-digital-transformation-opportunities-software-companies","Indonesia's IT services market is projected to reach $29.03 billion in 2026, up from $24.37 billion in 2025. Cloud infrastructure, AI, e-commerce, and data centers are driving the fastest growth in Southeast Asia.","2026-03-28T10:44:37.349311Z",{"id":13,"name":55,"slug":56,"bio":57,"photo_url":18,"linkedin":18,"role":58,"created_at":59,"updated_at":59},"Open Soft Team","open-soft-team","The engineering team at Open Soft, building premium software solutions from Bali, Indonesia.","Engineering Team","2026-03-28T08:31:22.226811Z"]