Welcome Back to Arbitrage Basics
This is part three of our arbitrage series, and it is the one where the math starts running. Part one built a triangular arbitrage checker for Uniswap V2 and walked through pairs, reserves, and the exact getAmountOut formula. Part two generalized detection with graph theory: tokens become vertices, pairs become edges weighted by negative log exchange rates, and Bellman-Ford surfaces profitable cycles of any length as negative cycles. Both posts end at the same place, a function that takes fresh reserves and returns candidate trading loops.
This post is about everything around that function. A crypto arbitrage bot is not a detection algorithm; it is an event-driven system in which detection is one stage out of four. Here we build the other three: the trigger that tells the bot the market just moved, the simulation that proves a candidate is real before any gas is spent, and the execution path that prices the transaction and submits it. All code is ethers.js v6 and runs against Ethereum mainnet.
One promise before we start. This is a crypto arbitrage bot tutorial in the architectural sense: it teaches you how the machine is built, not how to print money. On-chain arbitrage is brutally competitive, and the simple opportunities this bot can see are contested by professionals with faster infrastructure and better execution. We will be honest about that at the end. The architecture is still worth learning, because it is the same skeleton used for liquidations, backruns, and most of what a MEV searcher runs in production.
The shape of the bot
The bots in parts one and two polled: sleep ten seconds, fetch every reserve, check every path, repeat. Polling is fine for learning and hopeless for competing, because state you fetched nine seconds ago describes a market that no longer exists. A real bot inverts the flow. It holds the market model hot in memory and lets the chain push changes to it, reacting within the same block interval in which the change happened.
That gives the bot a fixed shape: a trigger stage that listens for new blocks and pending transactions, a detection stage that keeps the reserve graph current and runs the cycle search from part two, a simulation stage that replays the exact transaction with eth_call before spending anything, and an execution stage that prices gas and submits. Each stage is a filter. Thousands of events enter the trigger per minute, a handful of candidate cycles come out of detection, and on most days zero of them survive simulation with a profit after gas.
Stage 1: the trigger
Two events matter. A new block means state changed for certain: swaps executed, reserves moved, and any mispricing they created is now live and visible to everyone. A pending transaction is the earlier, noisier signal: a swap sitting in the mempool has not moved reserves yet, but if it is large enough you know which pools it is about to unbalance, and you can have your transaction ready to land right behind it. Block-triggered arbitrage reacts to mispricings that exist; mempool-triggered arbitrage predicts the ones about to exist. Serious bots run both.
Both triggers are one subscription each over a WebSocket provider:
import { ethers } from 'ethers';
// Subscriptions need a WebSocket endpoint; plain HTTP cannot push events.
// On BLAZED.sh, talk to the co-located node over its local WebSocket (ws://eth:8545).
const RPC_URL = process.env.RPC_URL || 'ws://eth:8545';
const provider = new ethers.WebSocketProvider(RPC_URL);
provider.on('block', (blockNumber) => {
onNewBlock(blockNumber).catch(console.error);
});
provider.on('pending', (txHash) => {
onPendingTx(txHash).catch(() => {}); // txs can drop before we fetch them
});
The pending stream hands you hashes, and turning a hash into a decoded swap is its own topic; we covered the full mechanics, including streaming complete transaction bodies and decoding router calldata, in how to access the Ethereum mempool. For the bot, the useful distillation is small: hydrate the hash, keep only transactions addressed to routers you care about, decode the swap, and mark the pools along its path as about to move.
const ROUTER = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'; // Uniswap V2 router
async function onPendingTx(txHash) {
const tx = await provider.getTransaction(txHash);
if (!tx || !tx.to || tx.to.toLowerCase() !== ROUTER.toLowerCase()) return;
const swap = tryDecodeSwap(tx); // Interface.parseTransaction, see the mempool post
if (!swap) return;
// The pools along this path are about to move. They are where the
// next mispricing appears, so they go to the front of the queue.
markHot(swap.args.path);
}
Stage 2: detection, keeping the graph hot
Part two’s detector rebuilt its graph by calling getReserves on every pair, which is exactly the work we now want to avoid repeating. The trick is that Uniswap V2 pairs tell you when their reserves change: every swap, mint, and burn emits a Sync event carrying the new reserves. Subscribe to Sync on each pair you track and your in-memory graph updates itself, no re-polling required.
const pairABI = ['event Sync(uint112 reserve0, uint112 reserve1)'];
let dirty = false;
function watchPair(tokenA, tokenB, pairAddress) {
const pair = new ethers.Contract(pairAddress, pairABI, provider);
pair.on('Sync', (reserve0, reserve1) => {
updateEdge(tokenA, tokenB, reserve0, reserve1); // recompute -log(rate) weights
dirty = true;
});
}
async function onNewBlock(blockNumber) {
if (!dirty) return; // nothing we track moved in this block
dirty = false;
const opportunities = findArbitrageOpportunities(); // Bellman-Ford, from part two
for (const opp of opportunities) {
await tryExecute(opp); // stages 3 and 4
}
}
updateEdge and findArbitrageOpportunities are the functions from part two, with one structural change: because Sync events feed the edges, detection itself no longer touches the network at all. It is a pure in-memory computation that runs in microseconds, which is what lets the bot re-check the whole market on every block that matters.
One refinement carries over from part one. The log-space weights find the cycle, but they price an infinitesimal trade; a real trade moves the pools it passes through. Before a candidate goes to simulation, walk the cycle with the exact fee-adjusted getAmountOut formula to pick the input size. Profit as a function of input is a curve that rises, peaks, and collapses into slippage, so start small, step the input up while the simulated profit still grows, and stop at the peak. For a two-pool cycle there is a closed form for the optimal input; for longer cycles the ten-iteration search is cheap because it happens entirely in memory.
Stage 3: simulation, the free dress rehearsal
Detection says a cycle should be profitable. Simulation asks the node whether the actual transaction, byte for byte, is profitable against the current state, without spending gas to find out. This is where eth_call earns its place in every serious bot.
First we need the transaction itself. Part one executed a triangle as three sequential swaps, and flagged that as a simplification; three transactions means three chances to be left holding an intermediate token when the market moves. The V2 router fixes this for single-DEX cycles: swapExactTokensForTokens accepts a multi-hop path, so a cycle that starts and ends in WETH becomes one atomic transaction. Better still, amountOutMin becomes a profit guard. Set it to your input plus a minimum profit, and the entire loop reverts unless it ends in gain; the worst case costs you gas, never inventory.
const routerABI = [
'function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external returns (uint[] memory amounts)'
];
const router = new ethers.Contract(ROUTER, routerABI, wallet);
function buildArbTx(cycle, amountIn, minProfitWei) {
// cycle comes from the detector, e.g. ['WETH', 'USDC', 'DAI', 'WETH']
const path = cycle.map((symbol) => tokens[symbol].address);
const data = router.interface.encodeFunctionData('swapExactTokensForTokens', [
amountIn,
amountIn + minProfitWei, // the whole loop reverts unless it ends in profit
path,
wallet.address,
Math.floor(Date.now() / 1000) + 30
]);
return { to: ROUTER, data, from: wallet.address };
}
As in part one, the router needs a one-time approve() for WETH before it can pull your input, or every simulation fails with an allowance error. And the single-router path is the honest limitation of this design: it only executes cycles whose every hop is a V2 pair on one DEX. Cross-DEX and cross-fee-tier cycles, where most surviving opportunities actually live, need your own executor contract that performs arbitrary hops and reverts the bundle if the final balance check fails. That contract is the standard first piece of custom infrastructure a searcher writes; the architecture around it stays exactly the same.
Now the rehearsal. eth_call executes the transaction against the latest state and throws the result away, so a revert here is free. If it succeeds, the returned amounts give the real output, and gas estimation plus current fee data give the real cost:
async function simulate(txRequest, amountIn) {
let ret;
try {
ret = await provider.call(txRequest); // eth_call: full execution, zero gas spent
} catch {
return null; // would revert on-chain: stale reserves or someone beat us to it
}
const [amounts] = router.interface.decodeFunctionResult('swapExactTokensForTokens', ret);
const amountOut = amounts[amounts.length - 1];
const gas = await provider.estimateGas(txRequest);
const fee = await provider.getFeeData();
const gasCost = gas * fee.maxFeePerGas;
const netProfit = amountOut - amountIn - gasCost; // all in wei, WETH in, ETH gas out
return netProfit > 0n ? { gas, netProfit } : null;
}
The quiet detail that decides everything: simulation is only as good as the state it runs against, and that state is whatever your node has at this instant. Simulate against a node that is one block behind and you will happily submit transactions into opportunities that died twelve seconds ago, paying gas for reverts. Which brings us to the race.
Stage 4: execution, pricing the bid
If simulation passes, the remaining questions are how much to bid and where to send the transaction. Under EIP-1559 your priority fee is the bid. Every competing bot saw the same mispricing at roughly the same time, and validators order equally-valid transactions largely by tip, so a profitable opportunity starts an auction that continues until the tip consumes most of the margin. The discipline is mechanical: your tip must stay below your simulated net profit, because a won auction that cost more than the prize is just a donation.
async function execute(txRequest, sim, tipWei) {
const fee = await provider.getFeeData();
return wallet.sendTransaction({
...txRequest,
gasLimit: (sim.gas * 12n) / 10n, // 20% headroom over the estimate
maxFeePerGas: fee.maxFeePerGas * 2n, // survive a base fee jump next block
maxPriorityFeePerGas: tipWei, // the bid; keep it below sim.netProfit
nonce: await provider.getTransactionCount(wallet.address)
});
}
Public submission through your own node works and is the right way to learn, but it exposes you: your pending transaction is itself visible in the mempool, and losing the race means paying gas for a revert. Production searchers submit bundles through private channels such as Flashbots instead, where a losing bundle simply does not land and costs nothing, and where you can bind your transaction to execute directly after the pending swap that triggered it. Part two showed the minimal bundle submission; the mechanics deserve their own post, but the architectural point is that execution is a pluggable back end. Trigger, detect, and simulate do not change when you swap the submission path.
Why latency decides who wins
Walk back through the four stages and count the network round-trips: the trigger event reaching you, the getTransaction hydration, every Sync event feeding the graph, the eth_call, the gas estimate, the fee data, the submission. The strategy in the middle is pure CPU and effectively free. Everything else is waiting on a socket, and the bot that finishes waiting first gets the block position.
This is why the same code produces completely different outcomes depending on where it runs. Over a public RPC gateway, every one of those round-trips crosses the public internet, compounding with region, TLS, and provider load, and your mempool view is a filtered, second-hand copy of whatever the gateway’s nodes saw. Run the bot on the same machine as the node and the entire loop collapses onto a local socket: you see pending transactions the moment the node’s peers gossip them, your simulations run against state that is current by definition, and your submission enters the network from the node itself.
That co-location is the model BLAZED.sh is built on. Your container or script deploys onto a host running a fully synced Ethereum mainnet node and talks to it over the local socket (ws://eth:8545 today; IPC is the maximum-performance local transport generally and a future direction), with sub-10ms round-trips and an unfiltered mempool view served by the very node your code sits on. Ethereum mainnet is the live network today. The full argument for why this matters more than any single optimization in your code is in why MEV is a latency game, and the mempool access guide ends with the container setup for deploying exactly the kind of stream this bot’s trigger stage runs on.
What you are actually up against
Now the honesty we promised. The V2 triangle and cycle opportunities this bot detects are the most visible in all of DeFi, and they are contested by full-time searchers running custom executor contracts with hand-optimized calldata, private order flow you will never see, direct builder relationships, and infrastructure tuned end to end for exactly this race. The gas auction squeezes whatever margin survives toward zero. Run this bot unmodified and the realistic outcome is a stream of correctly-detected opportunities that someone else captures, which is itself an education: watching your simulated profit evaporate into other people’s blocks teaches you more about MEV than any article can.
The value of building it anyway is that the skeleton transfers. Liquidation bots, backrunners, long-tail token arbitrage, cross-DEX strategies with your own executor contract: all of them are this same trigger, detect, simulate, execute loop with a different detection stage bolted in. What a professional searcher’s setup adds is depth at every stage, not a different shape.
Conclusion
A working crypto arbitrage bot is four filters in a loop. Blocks and pending transactions trigger it, an in-memory reserve graph fed by Sync events detects candidate cycles with the math from parts one and two, eth_call rehearses the exact transaction for free, and a gas-priced, revert-guarded submission executes it. Every stage is cheap except the waiting, and the waiting is decided by how far your code sits from the node.
Build it against a mainnet fork first; anvil --fork-url gives you real state with fake money, which is the right place to lose your first hundred races. Test everything, assume the competition is faster than you, and never risk more than you can afford to lose. When the code is solid and the bottleneck is measurably the network, that is the moment co-location stops being an optimization and becomes the architecture: the deployment docs cover moving the whole loop onto the node.