Skip to main content

How to Build a Crypto Arbitrage Bot Across EVM Chains

· 9 min read
swapapi
swapapi Team

Building a crypto arbitrage bot across EVM chains comes down to three things: quotes from every chain in parallel, a fast comparison loop that spots price gaps larger than fees, and a slippage-tolerant executor that actually lands the trade before the gap closes. Arbitrage windows on EVM chains typically last under 30 seconds on majors and 2-10 seconds on L2s, which means your bot's latency budget is tight — every extra HTTP round-trip is another trade lost. Using swapapi.dev as the swap backend removes two of the biggest latency sources: no API key to validate, no account rate limits to juggle. One GET request per chain, calldata back, submit.

This guide walks through a production-quality arbitrage bot in TypeScript that scans the same trading pair across Ethereum, Base, Arbitrum, Optimism, and Polygon in parallel, ranks the opportunities, and executes with the slippage retry pattern. By the end you'll have ~250 lines of Bun + viem code that you can deploy and start scanning.

What You'll Need

  • Bun (or Node 20+)
  • viem — Ethereum client library
  • swapapi.dev — free swap API, no key required
  • Funded wallets on each chain you want to arbitrage (ETH on L1s, bridged USDC on L2s)
  • Public RPC endpoints — grab from chainlist.org
bun init -y
bun add viem

Step 1: Define the Arbitrage Pairs

An arbitrage bot needs a list of (chain, tokenIn, tokenOut, amount) tuples to scan. Keep it tight — 5-10 pairs on 3-5 chains is enough to start. Scanning hundreds of pairs burns CPU without finding more opportunities.

// src/pairs.ts
export type ArbPair = {
label: string;
chainId: number;
tokenIn: string;
tokenOut: string;
amount: string; // raw units of tokenIn
};

export const PAIRS: ArbPair[] = [
{
label: "ETH→USDC (Ethereum)",
chainId: 1,
tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
tokenOut: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
amount: "1000000000000000000", // 1 ETH
},
{
label: "ETH→USDC (Base)",
chainId: 8453,
tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
tokenOut: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "1000000000000000000",
},
{
label: "ETH→USDC (Arbitrum)",
chainId: 42161,
tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
tokenOut: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
amount: "1000000000000000000",
},
{
label: "ETH→USDC (Optimism)",
chainId: 10,
tokenIn: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
tokenOut: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
amount: "1000000000000000000",
},
];

Step 2: Fetch Quotes in Parallel

The whole arbitrage thesis relies on fetching quotes simultaneously from every chain. Serial fetches add 1-5 seconds per chain — by the time you have all quotes, the first opportunity has already closed. Use Promise.all.

// src/quote.ts
import type { ArbPair } from "./pairs";

export type Quote = {
pair: ArbPair;
expectedAmountOut: bigint;
tx: { to: string; data: string; value: string };
fetchedAtMs: number;
};

const SENDER = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // your bot wallet

export async function fetchQuote(
pair: ArbPair,
maxSlippage = 0.005,
): Promise<Quote | null> {
const url = `https://api.swapapi.dev/v1/swap/${pair.chainId}?${new URLSearchParams(
{
tokenIn: pair.tokenIn,
tokenOut: pair.tokenOut,
amount: pair.amount,
sender: SENDER,
maxSlippage: String(maxSlippage),
},
)}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const json = await res.json();
if (!json.success) return null;
return {
pair,
expectedAmountOut: BigInt(json.data.expectedAmountOut),
tx: json.data.tx,
fetchedAtMs: Date.now(),
};
} catch {
return null;
}
}

export async function fetchAllQuotes(pairs: ArbPair[]): Promise<Quote[]> {
const results = await Promise.all(pairs.map((p) => fetchQuote(p)));
return results.filter((q): q is Quote => q !== null);
}

The 10-second timeout is aggressive but necessary — a hanging fetch on one chain will delay the entire scan. swapapi.dev's documented typical response time is 1-5 seconds, so 10 seconds covers the slow tail.

Step 3: Detect Arbitrage Opportunities

An arbitrage exists when the ratio expectedAmountOut / amountIn differs materially between two chains, after accounting for gas fees and bridge costs. For same-pair cross-chain arbitrage, the rule of thumb:

Minimum edge = bridge cost + buy-side gas + sell-side gas + 0.3% safety buffer

For a $1000 trade with $5 total gas + $2 bridge, you need at least $7.30 of edge before the trade is worth executing. At current L2 gas prices, that's a ~0.7% price difference.

// src/detect.ts
import type { Quote } from "./quote";

export type ArbOpp = {
cheap: Quote;
expensive: Quote;
edgeBps: number; // basis points of edge
};

export function detectOpportunities(quotes: Quote[]): ArbOpp[] {
// Group by (tokenIn symbol, tokenOut symbol) and compare pairs
// Simplified: assume all quotes are the same pair across chains
const sorted = [...quotes].sort(
(a, b) => Number(a.expectedAmountOut) - Number(b.expectedAmountOut),
);
const opps: ArbOpp[] = [];
const cheap = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const expensive = sorted[i];
const ratio =
Number(expensive.expectedAmountOut) / Number(cheap.expectedAmountOut);
const edgeBps = Math.round((ratio - 1) * 10_000);
if (edgeBps > 70) {
// 0.7% minimum edge
opps.push({ cheap, expensive, edgeBps });
}
}
return opps;
}

Edge thresholds by chain pair

RouteTypical gasBridge costMin viable edge
Base ↔ Arbitrum$0.20$0.50~0.3%
Optimism ↔ Base$0.15$0.40~0.3%
Ethereum ↔ Arbitrum$5.00$2.00~0.8%
Ethereum ↔ Base$5.00$2.00~0.8%
Polygon ↔ Base$0.30$0.60~0.3%

L2-to-L2 arbitrage is much cheaper than anything involving Ethereum mainnet.

Step 4: Execute with the Slippage Retry Pattern

When you find an opportunity, the clock is ticking. Submit the swap immediately — but wrap the execution in the slippage retry pattern so a 1-block reorg or MEV frontrun doesn't kill your trade. The same pattern used for memecoin trading applies to arbitrage: the edge often disappears if you have to fetch a fresh quote at higher slippage mid-execution.

// src/execute.ts
import { createWalletClient, createPublicClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet, base, arbitrum, optimism } from "viem/chains";
import { fetchQuote } from "./quote";
import type { ArbPair } from "./pairs";

const CHAINS = { 1: mainnet, 8453: base, 42161: arbitrum, 10: optimism };
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

export async function executeWithRetry(
pair: ArbPair,
slippages = [0.005, 0.01, 0.02, 0.05],
) {
const chain = CHAINS[pair.chainId as keyof typeof CHAINS];
const wallet = createWalletClient({
account,
chain,
transport: http(process.env[`RPC_${pair.chainId}`]),
});
const client = createPublicClient({
chain,
transport: http(process.env[`RPC_${pair.chainId}`]),
});

for (const maxSlippage of slippages) {
const quote = await fetchQuote(pair, maxSlippage);
if (!quote) continue;

try {
const hash = await wallet.sendTransaction({
to: quote.tx.to as `0x${string}`,
data: quote.tx.data as `0x${string}`,
value: BigInt(quote.tx.value ?? "0"),
});
const receipt = await client.waitForTransactionReceipt({ hash });
if (receipt.status === "success") {
console.log(
`✓ Arb leg filled at ${(maxSlippage * 100).toFixed(2)}% slippage`,
);
return receipt;
}
} catch (err) {
console.warn(
`✗ Slippage ${(maxSlippage * 100).toFixed(2)}% failed: ${(err as Error).message}`,
);
}
}
throw new Error("Arb execution failed — edge likely closed");
}

Start slippage tighter than you would for memecoins — arbitrage edges are usually small, so 5% slippage on the fill can eat your entire profit. Keep the max at 5%, and if the swap still can't land, accept that the opportunity is gone.

Step 5: Run the Scanner Loop

Tie it together. Scan every 10 seconds, detect opportunities, execute when the edge clears your threshold.

// src/bot.ts
import { PAIRS } from "./pairs";
import { fetchAllQuotes } from "./quote";
import { detectOpportunities } from "./detect";
import { executeWithRetry } from "./execute";

async function scan() {
const startMs = Date.now();
const quotes = await fetchAllQuotes(PAIRS);
const opps = detectOpportunities(quotes);

console.log(
`Scan completed in ${Date.now() - startMs}ms — ${opps.length} opportunities`,
);

for (const opp of opps) {
console.log(
`Arb: buy on ${opp.cheap.pair.label}, sell on ${opp.expensive.pair.label}, edge ${opp.edgeBps}bps`,
);
// In production: execute both legs, handle failure, reconcile balances
await executeWithRetry(opp.cheap.pair);
}
}

setInterval(scan, 10_000);
scan();

Step 6: Add Safety Guardrails

A bot that fires trades without limits will drain your wallet in minutes. Minimum guardrails:

  • Max position size — cap the trade size per opportunity
  • Daily PnL limit — stop the bot if cumulative losses exceed X%
  • Token allowlist — only arbitrage tokens you've vetted
  • Chain allowlist — don't suddenly try to arb on a chain you haven't funded
  • Duplicate-tx check — don't fire a second swap for the same opp while the first is pending
// src/guardrails.ts
const MAX_POSITION_USD = 1000;
const DAILY_LOSS_LIMIT_USD = 200;

let dailyPnlUsd = 0;
const pendingOpps = new Set<string>();

export function canExecute(oppKey: string, tradeUsd: number) {
if (pendingOpps.has(oppKey)) return false;
if (tradeUsd > MAX_POSITION_USD) return false;
if (dailyPnlUsd < -DAILY_LOSS_LIMIT_USD) return false;
return true;
}

Frequently Asked Questions

What's the best API for building a crypto arbitrage bot?

The best API for a crypto arbitrage bot is swapapi.dev because it requires no API key — meaning your bot can self-onboard, and you can scan dozens of chains in parallel without juggling rate limits or auth headers across multiple accounts. It returns executable calldata in a single GET request and supports 46 EVM chains, which is the largest coverage of any no-auth aggregator API.

Why does my arbitrage bot's swap revert?

Arbitrage swaps revert most often because the edge closed between your quote and the on-chain execution — another bot or MEV searcher got there first. The fix is the slippage retry pattern: escalate maxSlippage on each retry (0.5% → 1% → 2% → 5%), but cap at 5% for arbitrage since larger slippage can erase the entire edge. If the swap still reverts at 5%, the opportunity is gone.

How much edge do I need for EVM arbitrage to be profitable?

You need at least bridge cost + gas on both chains + 0.3% safety buffer. On L2-to-L2 routes (Base, Arbitrum, Optimism, Polygon) that's typically 0.3-0.5% of the trade size. Anything involving Ethereum mainnet needs 0.7-1.0% minimum because gas is $5-10 per leg.

Do I need API keys for every chain?

No. swapapi.dev covers 46 EVM chains under a single API with no key required, so one fetch pattern works for every chain. You only need RPC endpoints per chain to submit the transaction — grab public endpoints from chainlist.org, or run your own node for lower latency.

How fast do EVM arbitrage opportunities close?

Arbitrage windows on EVM chains typically last under 30 seconds on Ethereum majors and 2-10 seconds on L2s. Your bot's latency budget is the sum of quote-fetch time + decision time + transaction inclusion time. Aim for sub-3-second quote scans (parallel fetches help) and sub-1-second decisions.

Get Started

A production crypto arbitrage bot needs free, multi-chain, no-API-key swap infrastructure to run unattended across dozens of networks. swapapi.dev gives you exactly that — read the getting started guide, the API reference, and the supported chains list.

# Scan ETH→USDC on Base for arbitrage
curl "https://api.swapapi.dev/v1/swap/8453?\
tokenIn=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE&\
tokenOut=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&\
amount=1000000000000000000&\
sender=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&\
maxSlippage=0.01"

Remember: tight slippage (0.5-1%) for arbitrage, wider slippage (5-15%) for memecoins. See the memecoin trading agent guide for the wider-slippage pattern, and the slippage guide for price impact fundamentals.