Welcome Back to Arbitrage Basics

In our previous post on triangular arbitrage, we explored the fundamentals of 3-pair arbitrage opportunities. Today, we’re taking a significant leap forward by applying graph theory to discover arbitrage opportunities across any number of trading pairs. This approach transforms arbitrage detection from manual triangle checking into a systematic algorithmic problem.

By modeling trading pairs as a weighted directed graph, we can use classical algorithms like Bellman-Ford to detect negative cycles - mathematical representations of profitable arbitrage loops.

Why Graph Theory for Arbitrage?

Traditional triangular arbitrage only checks predefined 3-token paths. But what if profitable opportunities exist across 4, 5, or even 10 different tokens? Manual enumeration becomes computationally expensive and misses complex paths.

Graph theory solves this elegantly:

  • Vertices represent tokens (ETH, USDC, DAI, etc.)
  • Edges represent trading pairs with weights as negative log exchange rates
  • Negative cycles indicate arbitrage opportunities
  • Shortest path algorithms find optimal trading sequences

Mathematical Foundation

The key insight is transforming multiplicative arbitrage into additive shortest path problems using logarithms.

Exchange Rate Transformation

For an exchange rate r from token A to token B:

weight(A→B) = -log(r)

Why negative logarithm?

  • Multiplying exchange rates → Adding logarithms
  • Finding maximum profit → Finding minimum path weight
  • Profitable cycles → Negative weight cycles

Example Transformation

Consider these exchange rates:

  • ETH → USDC: 3000 (1 ETH = 3000 USDC)
  • USDC → DAI: 1.01 (1 USDC = 1.01 DAI)
  • DAI → ETH: 0.000334 (1 DAI = 0.000334 ETH)

Graph weights become:

  • ETH → USDC: -log(3000) ≈ -8.006
  • USDC → DAI: -log(1.01) ≈ -0.00995
  • DAI → ETH: -log(0.000334) ≈ 8.003

Cycle weight: -8.006 + (-0.00995) + 8.003 = -0.01295 (negative = profitable!)

The Bellman-Ford Algorithm

Bellman-Ford detects negative cycles by relaxing all edges repeatedly. If distances continue decreasing after |V|-1 iterations, a negative cycle exists.

How Bellman-Ford Works

The algorithm operates on a simple principle: if a graph contains no negative cycles, then the shortest path between any two vertices contains at most |V|-1 edges (where |V| is the number of vertices). This is because the shortest path can visit each vertex at most once.

Detailed Algorithm Steps

1. Initialize Distances

Set the distance to the source vertex as 0, and all other vertices as infinity. This represents that we can reach the source with zero cost, but don’t yet know how to reach other vertices.

// Example initialization for vertices [ETH, USDC, DAI]
const distances = {
    ETH: 0,        // Starting point
    USDC: Infinity, // Unknown distance
    DAI: Infinity   // Unknown distance
}

2. Edge Relaxation Process

For exactly |V|-1 iterations, examine every edge and update distances if a shorter path is found. Relaxation means: “Can I get to vertex B cheaper by going through vertex A?”

// Relaxation check for edge A → B with weight w
if (distance[A] + weight(A,B) < distance[B]) {
    distance[B] = distance[A] + weight(A,B);
    predecessor[B] = A;  // Remember how we got here
}

Why |V|-1 iterations? In a graph with no negative cycles, the longest possible shortest path visits each vertex exactly once. With V vertices, this path can have at most V-1 edges.

3. Negative Cycle Detection

After |V|-1 iterations, try relaxation one more time. If any distance can still be improved, a negative cycle exists.

// After |V|-1 iterations, this should never be true in a normal graph
if (distance[A] + weight(A,B) < distance[B]) {
    // Negative cycle detected!
    console.log("Arbitrage opportunity found!");
}

Step-by-Step Example

Let’s trace through a simple 3-token example:

Tokens: ETH, USDC, DAI
Exchange rates:

  • ETH → USDC: 3000 (weight: -8.006)
  • USDC → DAI: 1.01 (weight: -0.00995)
  • DAI → ETH: 0.000334 (weight: 8.003)

Iteration 0 (Initialize):

distances: { ETH: 0, USDC: ∞, DAI: ∞ }

Iteration 1:

  • Check ETH → USDC: 0 + (-8.006) = -8.006 < ∞ ✓
  • Check USDC → DAI: ∞ + (-0.00995) = ∞ (no update)
  • Check DAI → ETH: ∞ + 8.003 = ∞ (no update)
distances: { ETH: 0, USDC: -8.006, DAI: ∞ }

Iteration 2:

  • Check ETH → USDC: 0 + (-8.006) = -8.006 (no change)
  • Check USDC → DAI: -8.006 + (-0.00995) = -8.016 < ∞ ✓
  • Check DAI → ETH: ∞ + 8.003 = ∞ (no update)
distances: { ETH: 0, USDC: -8.006, DAI: -8.016 }

Iteration 3 (V-1 = 2, so this is our detection phase):

  • Check ETH → USDC: 0 + (-8.006) = -8.006 (no change)
  • Check USDC → DAI: -8.006 + (-0.00995) = -8.016 (no change)
  • Check DAI → ETH: -8.016 + 8.003 = -0.013 < 0 ✓ NEGATIVE CYCLE DETECTED!

The fact that we can still improve the distance to ETH (from 0 to -0.013) after |V|-1 iterations proves a negative cycle exists.

Understanding Negative Cycle Detection

The key insight is that negative cycles break the fundamental assumption of shortest paths. In a normal graph, once you’ve found the shortest path to a vertex, you can’t make it any shorter. But negative cycles create a paradox: you can keep going around the cycle to make paths arbitrarily short (or profitable).

Why This Means Arbitrage

In our context:

  • Negative cycle weight = Profitable trading loop
  • Cycle weight of -0.013 means we can multiply our starting amount by e^0.013 ≈ 1.013 (1.3% profit)
  • Starting with 1 ETHEnding with 1.013 ETH after completing the cycle

Tracing the Profitable Path

Once we detect a negative cycle, we need to reconstruct the actual trading sequence:

function traceCycle(predecessor, start) {
    const visited = new Set();
    const path = [];
    let current = start;
    
    // Walk back to find cycle start
    // This is crucial: we need to find where the cycle actually begins
    for (let i = 0; i < vertices.size; i++) {
        current = predecessor.get(current);
        if (!current) return []; // No cycle found
    }
    
    // Now we're guaranteed to be in the cycle
    const cycleStart = current;
    do {
        if (visited.has(current)) break;
        visited.add(current);
        path.push(current);
        current = predecessor.get(current);
    } while (current !== cycleStart && path.length < vertices.size);
    
    path.push(cycleStart); // Complete the cycle
    return path.reverse();
}

Multiple Cycles and Optimization

In real markets, you might find multiple negative cycles:

// Example output from findArbitrageOpportunities()
Found 3 arbitrage opportunities:

1. Profit: 2.1500% 
   Path: ETH  USDC  LINK  ETH
   ETH  USDC: 3000.000000
   USDC  LINK: 0.071500
   LINK  ETH: 0.000467

2. Profit: 1.3400%
   Path: USDC  DAI  USDT  USDC  
   USDC  DAI: 1.008000
   DAI  USDT: 0.999200
   USDT  USDC: 1.005100

3. Profit: 0.8900%
   Path: ETH  DAI  USDT  LINK  ETH
   ETH  DAI: 3024.000000
   DAI  USDT: 0.999200  
   USDT  LINK: 0.000071
   LINK  ETH: 0.000467

The algorithm automatically finds the most profitable cycles first due to our sorting by profit percentage.

Visual Algorithm Walkthrough

Here’s how the algorithm progresses through our example graph:

Initial Graph:
    ETH ──(-8.006)──> USDC
     ∧                  │
     │                  │(-0.00995)
(8.003)                 ∨
     │                 DAI
     └─────────────────────

Iteration 1 - Relaxation Phase:
    ETH[0] ──(-8.006)──> USDC[-8.006] ✓ UPDATED
     ∧                      │
     │                      │(-0.00995)  
(8.003)                     ∨
     │                    DAI[∞]
     └───────────────────────

Iteration 2 - Relaxation Phase:  
    ETH[0] ──(-8.006)──> USDC[-8.006]
     ∧                      │
     │                      │(-0.00995)
(8.003)                     ∨
     │                    DAI[-8.016] ✓ UPDATED
     └───────────────────────

Iteration 3 - Detection Phase:
    ETH[-0.013] ✓ CYCLE! <──(-8.006)──── USDC[-8.006]
     ∧                                      │
     │                                      │(-0.00995)
(8.003)                                     ∨
     │                                    DAI[-8.016]
     └───────────────────────────────────────

Profit Calculation:
Total cycle weight: -8.006 + (-0.00995) + 8.003 = -0.01295
Profit multiplier: e^(0.01295) = 1.013 = 1.3% profit

This visual shows how distances propagate through the graph and how the algorithm detects when a cycle allows infinite improvement (negative cycle = arbitrage opportunity).

Implementation

Let’s implement a comprehensive graph-based arbitrage detector:

import { ethers } from 'ethers';
import dotenv from 'dotenv';
dotenv.config();

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);

// Graph state
let vertices = new Set();
let edges = new Map();
let tokenAddresses = new Map();
let addressToToken = new Map();

function addToken(symbol, address) {
    vertices.add(symbol);
    tokenAddresses.set(symbol, address);
    addressToToken.set(address.toLowerCase(), symbol);
    edges.set(symbol, new Map());
}

async function addPair(tokenA, tokenB) {
    try {
        const reserves = await getReserves(
            tokenAddresses.get(tokenA),
            tokenAddresses.get(tokenB)
        );
        
        // Calculate exchange rates
        const rateAtoB = Number(reserves.reserveB) / Number(reserves.reserveA);
        const rateBtoA = Number(reserves.reserveA) / Number(reserves.reserveB);
        
        // Add edges with negative log weights
        edges.get(tokenA).set(tokenB, {
            weight: -Math.log(rateAtoB),
            rate: rateAtoB,
            reserves: reserves
        });
        
        edges.get(tokenB).set(tokenA, {
            weight: -Math.log(rateBtoA),
            rate: rateBtoA,
            reserves: { reserveA: reserves.reserveB, reserveB: reserves.reserveA }
        });
        
    } catch (error) {
        console.error(`Failed to add pair ${tokenA}/${tokenB}:`, error.message);
    }
}

async function getReserves(tokenA, tokenB) {
    const factoryABI = [
        'function getPair(address tokenA, address tokenB) external view returns (address pair)'
    ];
    
    const pairABI = [
        'function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
        'function token0() external view returns (address)',
        'function token1() external view returns (address)'
    ];
    
    const factory = new ethers.Contract(
        '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
        factoryABI,
        provider
    );
    
    const pairAddress = await factory.getPair(tokenA, tokenB);
    
    if (pairAddress === ethers.ZeroAddress) {
        throw new Error(`No pair exists for ${tokenA}/${tokenB}`);
    }
    
    const pair = new ethers.Contract(pairAddress, pairABI, provider);
    const [reserve0, reserve1] = await pair.getReserves();
    const token0 = await pair.token0();
    
    if (token0.toLowerCase() === tokenA.toLowerCase()) {
        return { reserveA: reserve0, reserveB: reserve1 };
    } else {
        return { reserveA: reserve1, reserveB: reserve0 };
    }
}

function bellmanFord(source) {
    const distance = new Map();
    const predecessor = new Map();
    
    // Initialize distances
    for (const vertex of vertices) {
        distance.set(vertex, vertex === source ? 0 : Infinity);
        predecessor.set(vertex, null);
    }
    
    // Relax edges |V|-1 times
    for (let i = 0; i < vertices.size - 1; i++) {
        for (const u of vertices) {
            if (distance.get(u) === Infinity) continue;
            
            for (const [v, edge] of edges.get(u)) {
                const newDist = distance.get(u) + edge.weight;
                if (newDist < distance.get(v)) {
                    distance.set(v, newDist);
                    predecessor.set(v, u);
                }
            }
        }
    }
    
    // Check for negative cycles
    const negativeCycles = [];
    const inCycle = new Set();
    
    for (const u of vertices) {
        if (distance.get(u) === Infinity) continue;
        
        for (const [v, edge] of edges.get(u)) {
            const newDist = distance.get(u) + edge.weight;
            if (newDist < distance.get(v) && !inCycle.has(v)) {
                // Negative cycle detected, trace it
                const cycle = traceCycle(predecessor, v);
                if (cycle.length > 0) {
                    negativeCycles.push(cycle);
                    cycle.forEach(node => inCycle.add(node));
                }
            }
        }
    }
    
    return {
        distances: distance,
        predecessors: predecessor,
        negativeCycles: negativeCycles
    };
}

function traceCycle(predecessor, start) {
    const visited = new Set();
    const path = [];
    let current = start;
    
    // Walk back to find cycle start
    for (let i = 0; i < vertices.size; i++) {
        current = predecessor.get(current);
        if (!current) return [];
    }
    
    // Trace the actual cycle
    const cycleStart = current;
    do {
        if (visited.has(current)) break;
        visited.add(current);
        path.push(current);
        current = predecessor.get(current);
    } while (current !== cycleStart && path.length < vertices.size);
    
    path.push(cycleStart); // Complete the cycle
    return path.reverse();
}

function calculateCycleProfit(cycle) {
    let totalWeight = 0;
    let path = [];
    
    for (let i = 0; i < cycle.length - 1; i++) {
        const from = cycle[i];
        const to = cycle[i + 1];
        const edge = edges.get(from).get(to);
        
        if (!edge) {
            console.error(`No edge found from ${from} to ${to}`);
            return null;
        }
        
        totalWeight += edge.weight;
        path.push({ from, to, rate: edge.rate, weight: edge.weight });
    }
    
    // Convert back from log space
    const profitMultiplier = Math.exp(-totalWeight);
    const profitPercentage = (profitMultiplier - 1) * 100;
    
    return {
        cycle: cycle,
        path: path,
        totalWeight: totalWeight,
        profitMultiplier: profitMultiplier,
        profitPercentage: profitPercentage,
        profitable: profitMultiplier > 1.01 // Account for fees
    };
}

async function findArbitrageOpportunities() {
    const opportunities = [];
    
    for (const source of vertices) {
        const result = bellmanFord(source);
        
        for (const cycle of result.negativeCycles) {
            const profit = calculateCycleProfit(cycle);
            if (profit && profit.profitable) {
                opportunities.push(profit);
            }
        }
    }
    
    // Sort by profit percentage descending
    return opportunities.sort((a, b) => b.profitPercentage - a.profitPercentage);
}

Advanced Optimizations

1. SPFA (Shortest Path Faster Algorithm)

SPFA optimizes Bellman-Ford by only processing vertices whose distances changed:

function spfa(source) {
    const distance = new Map();
    const predecessor = new Map();
    const inQueue = new Map();
    const queue = [];
    
    // Initialize
    for (const vertex of vertices) {
        distance.set(vertex, vertex === source ? 0 : Infinity);
        predecessor.set(vertex, null);
        inQueue.set(vertex, false);
    }
    
    queue.push(source);
    inQueue.set(source, true);
    
    while (queue.length > 0) {
        const u = queue.shift();
        inQueue.set(u, false);
        
        for (const [v, edge] of edges.get(u)) {
            const newDist = distance.get(u) + edge.weight;
            if (newDist < distance.get(v)) {
                distance.set(v, newDist);
                predecessor.set(v, u);
                
                if (!inQueue.get(v)) {
                    queue.push(v);
                    inQueue.set(v, true);
                }
            }
        }
    }
    
    return { distances: distance, predecessors: predecessor };
}

2. Dynamic Graph Updates

Instead of rebuilding the entire graph, update only changed pairs:

async updatePair(tokenA, tokenB) {
    const reserves = await this.getReserves(
        this.tokenAddresses.get(tokenA),
        this.tokenAddresses.get(tokenB)
    );
    
    const rateAtoB = Number(reserves.reserveB) / Number(reserves.reserveA);
    const rateBtoA = Number(reserves.reserveA) / Number(reserves.reserveB);
    
    // Update edges
    this.edges.get(tokenA).set(tokenB, {
        weight: -Math.log(rateAtoB),
        rate: rateAtoB,
        reserves: reserves,
        lastUpdate: Date.now()
    });
    
    this.edges.get(tokenB).set(tokenA, {
        weight: -Math.log(rateBtoA),
        rate: rateBtoA,
        reserves: { reserveA: reserves.reserveB, reserveB: reserves.reserveA },
        lastUpdate: Date.now()
    });
}

Usage Example

async function main() {
    // Add tokens
    const tokens = {
        WETH: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
        USDC: '0xA0b86a33E6417c7C4c7d2fD426c8bC3F1a44B9aF',
        DAI: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
        USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
        LINK: '0x514910771AF9Ca656af840dff83E8264EcF986CA'
    };
    
    for (const [symbol, address] of Object.entries(tokens)) {
        addToken(symbol, address);
    }
    
    // Add all possible pairs
    const tokenSymbols = Object.keys(tokens);
    for (let i = 0; i < tokenSymbols.length; i++) {
        for (let j = i + 1; j < tokenSymbols.length; j++) {
            await addPair(tokenSymbols[i], tokenSymbols[j]);
        }
    }
    
    // Find opportunities
    const opportunities = await findArbitrageOpportunities();
    
    console.log(`Found ${opportunities.length} arbitrage opportunities:`);
    opportunities.forEach((opp, index) => {
        console.log(`\n${index + 1}. Profit: ${opp.profitPercentage.toFixed(4)}%`);
        console.log(`   Path: ${opp.cycle.join(' → ')}`);
        opp.path.forEach(step => {
            console.log(`   ${step.from}${step.to}: ${step.rate.toFixed(6)}`);
        });
    });
}

main().catch(console.error);

Gas Optimization Considerations

Multi-pair arbitrage requires careful gas management:

1. Batch Transactions

Instead of sequential swaps, use multicall patterns:

async function executeBatchArbitrage(opportunity) {
    const calls = opportunity.path.map(step => ({
        target: routerAddress,
        callData: router.interface.encodeFunctionData('swapExactTokensForTokens', [
            step.amountIn,
            step.amountOutMin,
            [step.from, step.to],
            wallet.address,
            deadline
        ])
    }));
    
    const multicall = new ethers.Contract(multicallAddress, multicallABI, wallet);
    const tx = await multicall.aggregate(calls, { gasLimit: 800000 });
    await tx.wait();
}

Risk Management

1. Slippage Protection

Calculate minimum outputs accounting for slippage:

calculateMinOutputs(path, slippageTolerance = 0.05) {
    return path.map(step => {
        const expectedOutput = step.expectedAmount;
        return expectedOutput * BigInt(Math.floor((1 - slippageTolerance) * 1000)) / 1000n;
    });
}

2. MEV Protection

Implement private mempool submission or use services like Flashbots:

async function submitPrivateTransaction(tx) {
    const flashbotsRelay = 'https://relay.flashbots.net';
    const bundle = [{ signedTransaction: tx }];
    
    const response = await fetch(flashbotsRelay, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            method: 'eth_sendBundle',
            params: [bundle, await provider.getBlockNumber() + 1]
        })
    });
    
    return response.json();
}

Performance Benchmarks

Graph-based arbitrage detection scales significantly better than manual enumeration:

  • 3 tokens: 1 triangle vs 1 cycle detection
  • 5 tokens: 10 triangles vs 1 graph scan
  • 10 tokens: 120 triangles vs 1 graph scan
  • 20 tokens: 1140 triangles vs 1 graph scan

Time complexity: O(V³) triangular enumeration vs O(VE) Bellman-Ford

Conclusion

Graph theory transforms arbitrage detection from a manual process into a systematic algorithmic approach. By modeling trading pairs as weighted graphs and using algorithms like Bellman-Ford, we can:

  • Discover complex multi-pair arbitrage opportunities
  • Scale to any number of tokens efficiently
  • Optimize for maximum profit paths
  • Handle dynamic market conditions

The mathematical foundation provides robustness, while implementation optimizations ensure competitive performance in the fast-moving DeFi landscape.

Next Steps: Consider exploring cycle enumeration algorithms (Johnson’s algorithm), parallel processing for real-time detection, and integration with DEX aggregators for cross-protocol arbitrage.

Important: Always test on testnets first, implement proper risk management, and consider gas costs in profitability calculations. The complexity of multi-pair arbitrage increases both potential profits and potential risks.