Price discrepancies for the same trading pair between Uniswap v3 and Sushiswap pop up all the time. This usually happens during high volatility spikes when a fat market order tanks or pumps the price in one pool, while the other hasn't caught up yet. Flash Loans let you capitalize on this spread without risking a single cent of your own capital.
A Flash Loan is an atomic transaction on the EVM: you borrow $1,000,000 from a lending protocol (Aave, Balancer, Uniswap), buy low on DEX A, sell high on DEX B, pay back the principal plus the protocol fee, and pocket the net profit. If a single step in the call chain fails or if your final balance comes up short of the repayment amount, the EVM reverts the entire transaction. You only lose the gas fees spent.
1. Market Mechanics & Hidden Risks of "Risk-Free" Arbitrage
The term "risk-free" strictly refers to zero directional position risk and zero risk of losing your underlying principal. In reality, MEV searchers face several critical bottlenecks that can wreck a trade.

Main Pitfalls
- Slippage & Price Impact: The larger your Flash Loan size, the more your own swap moves the price against you (price impact). Borrowing too much capital will completely wipe out the spread via slippage.
- MEV & Front-running: Broadcasting trades to the public mempool makes you easy prey for searcher bots. They'll front-run your transaction by bribing validators with a higher Priority Fee.
- Gas Overhead: Executing a complex smart contract with multiple external swaps burns anywhere from 250,000 to 450,000 gas units. On Ethereum L1 with gas at 30-50 Gwei, transaction fees will easily eat micro-spreads alive.
2. Unit Economics & Trade Sizing Math
To pull off a profitable arb, you need to calculate the optimal borrow size x that maximizes your net return after all fees.
In Constant Product pools (x · y = k, like Uniswap v2), spot prices depend on pool reserves Rx and Ry. When executing a swap with input amount Δx, the output formula is:
Δy = (Ry · Δx · (1 - γ)) / (Rx + Δx · (1 - γ))
where γ is the pool fee tier (e.g., 0.003 for 0.3%).
Profit Model
Profit(x) = SwapOutDEX2(SwapOutDEX1(x)) - x - FlashLoanFee(x) - GasCost
Flash loan fee structures across major liquidity providers:
- Balancer v2: 0% fee
- Uniswap v3 (Flash Swaps): Tier-dependent (0.05%, 0.3%, 1.0%)
- Aave v3: 0.05%
3. Flash Loan Provider Breakdown
| Provider | Borrow Fee | Gas Overhead | Key Takeaways |
|---|---|---|---|
| Balancer v2 | 0.00% | Low | Single entry point via Vault; zero fees make it the best choice for beginners |
| Aave v3 | 0.05% | Moderate | Deep liquidity, multi-chain deployment across L2s (Polygon, Arbitrum, Optimism) |
| Uniswap v3 | 0.05% – 0.30% | High | Enables native Flash Swaps directly out of liquidity pools without needing an external lender |
4. Step-by-Step Execution Flow
- Spread Monitoring: Continuously monitor price differentials between DEX A (buy side) and DEX B (sell side) using WebSocket node subscriptions or gRPC streams.
- Profit Simulation: Solve for the optimal trade size
x, accounting for pool fees, protocol flash loan cuts, and live Base Fee + Priority Fee costs. - Trigger Loan: Contract requests a Flash Loan directly from the Balancer Vault.
- Callback Handling: The Vault transfers requested tokens to your arb contract and triggers the
receiveFlashLoancallback execution. - Leg 1 Swap: Contract routes borrowed funds to DEX A (Uniswap v3) to acquire the target asset.
- Leg 2 Swap: Contract dumps the acquired target asset back into DEX B (Sushiswap/Uniswap v2) for the starting token.
- Repayment Approval: Contract sets approvals or directly transfers the principal balance back to the Balancer Vault.
- Profit Assertion: Contract asserts that ending token balances exceed starting balances plus target margin. If it fails, it throws a revert.
5. Production-Ready Solidity Contract (OpenZeppelin + Balancer v2)
Here is a battle-tested Solidity implementation for EVM networks, tapping into Balancer v2 for zero-fee flash loans.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IBalancerVault {
function flashLoan(
address recipient,
IERC20[] memory tokens,
uint256[] memory amounts,
bytes memory userData
) external;
}
interface ISwapRouterV3 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 deadline;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(
ExactInputSingleParams calldata params
) external returns (uint256 amountOut);
}
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
}
contract FlashArbEngine {
using SafeERC20 for IERC20;
address private immutable owner;
IBalancerVault private immutable balancerVault;
ISwapRouterV3 private immutable uniswapV3Router;
IUniswapV2Router private immutable sushiswapRouter;
bool private arbitrageActive;
error NotOwner();
error ZeroAddress();
error IdenticalTokens();
error InvalidAmount();
error ArbitrageActive();
error InvalidCaller();
error InvalidArrayLength();
error InsufficientProfit();
struct ArbParams {
address tokenA;
address tokenB;
uint24 poolFeeV3;
uint256 loanAmount;
uint256 minAmountOutV3; // Calculated off-chain for leg 1 slippage protection
uint256 minProfit;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(
address _balancerVault,
address _uniswapV3Router,
address _sushiswapRouter
) {
if (_balancerVault == address(0) || _uniswapV3Router == address(0) || _sushiswapRouter == address(0)) {
revert ZeroAddress();
}
owner = msg.sender;
balancerVault = IBalancerVault(_balancerVault);
uniswapV3Router = ISwapRouterV3(_uniswapV3Router);
sushiswapRouter = IUniswapV2Router(_sushiswapRouter);
}
function executeArbitrage(ArbParams calldata params) external onlyOwner {
if (arbitrageActive) revert ArbitrageActive();
if (params.tokenA == address(0) || params.tokenB == address(0)) revert ZeroAddress();
if (params.tokenA == params.tokenB) revert IdenticalTokens();
if (params.loanAmount == 0 || params.minProfit == 0 || params.minAmountOutV3 == 0) revert InvalidAmount();
arbitrageActive = true;
IERC20[] memory tokens = new IERC20[](1);
tokens[0] = IERC20(params.tokenA);
uint256[] memory amounts = new uint256[](1);
amounts[0] = params.loanAmount;
balancerVault.flashLoan(
address(this),
tokens,
amounts,
abi.encode(params)
);
arbitrageActive = false;
}
function receiveFlashLoan(
IERC20[] memory tokens,
uint256[] memory amounts,
uint256[] memory feeAmounts,
bytes memory userData
) external {
if (msg.sender != address(balancerVault)) revert InvalidCaller();
if (!arbitrageActive) revert ArbitrageActive();
// Validate array payload integrity from Balancer
if (tokens.length != 1 || amounts.length != 1 || feeAmounts.length != 1) {
revert InvalidArrayLength();
}
ArbParams memory params = abi.decode(userData, (ArbParams));
uint256 loanAmount = amounts[0];
uint256 repayAmount = loanAmount + feeAmounts[0];
// Capture initial balance BEFORE executing swaps (includes borrowed loan + pre-existing dust)
uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
// 1. Swap TokenA -> TokenB on Uniswap v3 (dynamic slippage protection)
IERC20(params.tokenA).forceApprove(address(uniswapV3Router), loanAmount);
ISwapRouterV3.ExactInputSingleParams memory swapParams =
ISwapRouterV3.ExactInputSingleParams({
tokenIn: params.tokenA,
tokenOut: params.tokenB,
fee: params.poolFeeV3,
recipient: address(this),
deadline: block.timestamp,
amountIn: loanAmount,
amountOutMinimum: params.minAmountOutV3,
sqrtPriceLimitX96: 0
});
uint256 tokenBBalanceBefore = IERC20(params.tokenB).balanceOf(address(this));
uniswapV3Router.exactInputSingle(swapParams);
uint256 tokenBReceived = IERC20(params.tokenB).balanceOf(address(this)) - tokenBBalanceBefore;
// 2. Swap TokenB -> TokenA on SushiSwap (enforce min output = repayAmount + minProfit)
IERC20(params.tokenB).forceApprove(address(sushiswapRouter), tokenBReceived);
address[] memory path = new address[](2);
path[0] = params.tokenB;
path[1] = params.tokenA;
sushiswapRouter.swapExactTokensForTokens(
tokenBReceived,
repayAmount + params.minProfit,
path,
address(this),
block.timestamp
);
// 3. Verify liquidity sufficiency PRIOR to repaying loan
uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
if (balanceBeforeRepayment < repayAmount + params.minProfit) {
revert InsufficientProfit();
}
// 4. Return borrowed funds to Balancer Vault
IERC20(params.tokenA).safeTransfer(
address(balancerVault),
repayAmount
);
// 5. Verify net profit retention accounting for pre-existing contract balance
uint256 balanceAfter = IERC20(params.tokenA).balanceOf(address(this));
if (balanceAfter < balanceBefore - loanAmount + params.minProfit) {
revert InsufficientProfit();
}
}
function withdrawToken(address token) external onlyOwner {
if (arbitrageActive) revert ArbitrageActive();
uint256 balance = IERC20(token).balanceOf(address(this));
if (balance == 0) revert InvalidAmount();
IERC20(token).safeTransfer(owner, balance);
}
}For standard ERC-20 tokens, the swap execution sequence and loan repayment logic in this code are fully synchronized. Before deploying to mainnet, make sure to double-check router addresses on your target chain, verify current Balancer protocol fee configurations, and calculate precise minAmountOutV3 thresholds per trade.
6. MEV Mitigation & Private Transaction Routing
Sending arbitrage transactions through public RPC endpoints (Infura, Alchemy) directly into the public mempool is a fast track to getting front-run or sandwich-attacked by MEV bots. Searchers monitor pending mempool state constantly to hijack profitable routes.
The Fix: Private RPC Routing (Flashbots Builder)
- Route your transactions directly to block builders (Flashbots, Beaverbuild, Titan) via private RPC endpoints (
https://rpc.flashbots.net). - If you're building in Node.js or Python, submit transaction bundles via
eth_sendBundle, configuring priority tips to validators conditionally upon trade execution success.
7. Interacting with MEV Infrastructure via Flashbots (Node.js / Viem)
Firing arbitrage transactions publicly using standard eth_sendTransaction in 2026 is a guaranteed way to bleed cash to MEV bots. The only reliable way to protect your trade is by submitting an atomic Bundle directly to block builders (Flashbots, Titan, Beaverbuild, BuilderNet).
If DEX conditions shift and the spread dries up before your transaction gets block inclusion, the simulation run will drop the bundle, and the builder simply leaves it out of the block. You won't burn a single Wei on gas.
import {
FlashbotsBundleProvider,
FlashbotsBundleResolution
} from '@flashbots/ethers-provider-bundle';
import { providers, Wallet, utils } from 'ethers';
const CHAIN_ID = 1;
const RELAY_URL = 'https://relay.flashbots.net';
const INITIAL_GAS_LIMIT = 400000;
const GAS_BUFFER_PERCENT = 120;
const PRIORITY_FEE_GWEI = '3';
if (
!process.env.ETH_RPC_URL ||
!process.env.FLASHBOTS_AUTH_KEY ||
!process.env.EXECUTOR_PRIVATE_KEY
) {
throw new Error('[Flashbots] Missing required environment variables');
}
const provider = new providers.JsonRpcProvider(process.env.ETH_RPC_URL);
const authSigner = new Wallet(process.env.FLASHBOTS_AUTH_KEY);
const executorWallet = new Wallet(
process.env.EXECUTOR_PRIVATE_KEY,
provider
);
// Process bundles sequentially within a single Node.js process.
let executionQueue = Promise.resolve();
export function queueArbitrageBundle(
arbContractAddress,
calldata,
targetBlockNumber
) {
const task = executionQueue.then(() =>
processBundle(arbContractAddress, calldata, targetBlockNumber)
);
executionQueue = task.catch((error) => {
console.error('[Bundle Queue Error]', error);
});
return task;
}
async function processBundle(
arbContractAddress,
calldata,
targetBlockNumber
) {
if (!utils.isAddress(arbContractAddress)) {
throw new Error('[Flashbots] Invalid arbitrage contract address');
}
if (
typeof calldata !== 'string' ||
!utils.isHexString(calldata)
) {
throw new Error('[Flashbots] Invalid calldata');
}
if (
!Number.isSafeInteger(targetBlockNumber) ||
targetBlockNumber < 1
) {
throw new Error('[Flashbots] Invalid target block number');
}
const network = await provider.getNetwork();
if (network.chainId !== CHAIN_ID) {
throw new Error(
`[Flashbots] Network mismatch: ${network.chainId}; expected Mainnet (${CHAIN_ID})`
);
}
const flashbotsProvider = await FlashbotsBundleProvider.create(
provider,
authSigner,
RELAY_URL,
CHAIN_ID
);
const latestBlock = await provider.getBlockNumber();
if (targetBlockNumber <= latestBlock) {
console.warn(
`[Flashbots] Target block ${targetBlockNumber} has already passed`
);
return;
}
// Sync nonce with public RPC.
// Note: Queue only guards this single Node.js instance.
const nonce = await provider.getTransactionCount(
executorWallet.address,
'pending'
);
const feeData = await provider.getFeeData();
const maxPriorityFeePerGas = utils.parseUnits(
PRIORITY_FEE_GWEI,
'gwei'
);
const maxFeePerGas = feeData.lastBaseFeePerGas
? feeData.lastBaseFeePerGas.mul(2).add(maxPriorityFeePerGas)
: utils.parseUnits('60', 'gwei');
const transaction = {
chainId: CHAIN_ID,
type: 2,
to: arbContractAddress,
data: calldata,
value: 0,
nonce,
gasLimit: INITIAL_GAS_LIMIT,
maxFeePerGas,
maxPriorityFeePerGas
};
// Initial dry-run with baseline gas limit.
let signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
let simulation = await flashbotsProvider.simulate(
signedBundle,
targetBlockNumber
);
if ('error' in simulation) {
console.error(
`[Simulation Error] ${simulation.error.message}`
);
return;
}
if (simulation.firstRevert) {
console.warn(
'[Simulation Revert]',
simulation.firstRevert
);
return;
}
const firstResult = simulation.results?.[0];
if (!firstResult || firstResult.error || firstResult.gasUsed == null) {
console.error(
'[Simulation Error] Missing successful transaction result'
);
return;
}
const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
if (txGasUsed.lte(0)) {
console.error('[Simulation Error] Invalid gas usage');
return;
}
// Pad gas limit by 20%.
const adjustedGasLimit = txGasUsed
.mul(GAS_BUFFER_PERCENT)
.div(100);
if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
console.error(
`[Gas Limit] Calculated limit ${adjustedGasLimit.toString()} exceeds initial cap of ${INITIAL_GAS_LIMIT}`
);
return;
}
transaction.gasLimit = adjustedGasLimit;
// Re-sign after adjusting gasLimit.
signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
// Re-simulate signed transaction.
simulation = await flashbotsProvider.simulate(
signedBundle,
targetBlockNumber
);
if ('error' in simulation) {
console.error(
`[Second Simulation Error] ${simulation.error.message}`
);
return;
}
if (simulation.firstRevert) {
console.warn(
'[Second Simulation Revert]',
simulation.firstRevert
);
return;
}
const secondResult = simulation.results?.[0];
if (
!secondResult ||
secondResult.error ||
secondResult.gasUsed == null
) {
console.error(
'[Second Simulation Error] Transaction validation failed'
);
return;
}
// Verify target block hasn't closed yet.
const blockBeforeSubmission = await provider.getBlockNumber();
if (targetBlockNumber <= blockBeforeSubmission) {
console.warn(
`[Flashbots] Block ${targetBlockNumber} already closed post-simulation`
);
return;
}
// Target submission strictly to specified block.
const submission = await flashbotsProvider.sendRawBundle(
signedBundle,
targetBlockNumber
);
if ('error' in submission) {
console.error(
`[Submission Error] ${submission.error.message}`
);
return;
}
console.log(
`[Flashbots] Bundle submitted for block ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
);
const resolution = await submission.wait();
switch (resolution) {
case FlashbotsBundleResolution.BundleIncluded:
console.log(
`[Success] Bundle included in block ${targetBlockNumber}`
);
break;
case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
console.log(
`[Missed] Block ${targetBlockNumber} mined without bundle`
);
break;
case FlashbotsBundleResolution.AccountNonceTooHigh:
console.error(
'[Nonce Error] Executor nonce ahead of expected'
);
break;
default:
console.warn(
`[Flashbots] Execution result: ${resolution}`
);
}
}8. Off-Chain Monitoring Architecture & Spread Discovery
Relying on polling for state updates (via setInterval or REST APIs) introduces a 500–1500 ms latency hit—effectively killing any chance of landing a winning arb. Production setups run event-driven, reactive pipelines built in Node.js, Go, or Rust.

Core Bot Architecture
- 1. Unified In-Memory State: Keep track of live reserves (Uniswap v2) and tick vectors (Uniswap v3) directly in Node.js/Rust process memory.
- 2. Pool Log Subscriptions: Hook up WebSocket listeners to
Sync(v2) andSwap(v3) events. - 3. Instant Recalculations: As soon as a new log hits, recalculate *only* the specific pair affected—no waste of cycles spamming RPC calls to poll every contract.
// arbitrage-monitor.mjs
// Reactive off-chain Uniswap V2/V3 pool monitor.
// WebSocket events, unified in-memory state, pair-local recalculation.
// Spread detection only; no transaction execution.
import {
createPublicClient,
webSocket,
parseAbi,
getAddress,
} from 'viem';
import { mainnet } from 'viem/chains';
// -----------------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------------
const RPC_URL = process.env.ETH_WS_URL;
if (!RPC_URL) {
throw new Error('ETH_WS_URL is required');
}
const client = createPublicClient({
chain: mainnet,
transport: webSocket(RPC_URL, {
reconnect: true,
retryCount: 10,
retryDelay: 1000,
keepAlive: {
interval: 15_000,
},
}),
});
const V2_FACTORY = getAddress(
'0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
);
const V3_FACTORY = getAddress(
'0x1F98431c8aD98523631AE4a59f267346ea31F984',
);
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
// Set POOLS_JSON to a JSON array of known pools.
// Example:
// [{"version":2,"address":"0x..."},
// {"version":3,"address":"0x...","fee":3000}]
const INITIAL_POOLS = JSON.parse(process.env.POOLS_JSON || '[]');
const V2_FEE_PPM = 3000;
const FEE_DENOMINATOR = 1_000_000;
// -----------------------------------------------------------------------------
// ABIs
// -----------------------------------------------------------------------------
const ERC20_ABI = parseAbi([
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
]);
const V2_FACTORY_ABI = parseAbi([
'event PairCreated(address indexed token0, address indexed token1, address pair, uint256)',
]);
const V2_PAIR_ABI = parseAbi([
'event Sync(uint112 reserve0, uint112 reserve1)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
]);
const V3_FACTORY_ABI = parseAbi([
'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
]);
const V3_POOL_ABI = parseAbi([
'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function liquidity() view returns (uint128)',
]);
// -----------------------------------------------------------------------------
// Unified in-memory state
// -----------------------------------------------------------------------------
// pools: checksummed pool address -> pool state
// pairs: normalized token pair -> Set of pool addresses
// unwatchPool: pool address -> unsubscribe function
// initializing: prevents duplicate concurrent initialization
const pools = new Map();
const pairs = new Map();
const unwatchPool = new Map();
const initializing = new Set();
function normalizeAddress(address) {
return getAddress(address);
}
function pairKey(token0, token1) {
const a = normalizeAddress(token0).toLowerCase();
const b = normalizeAddress(token1).toLowerCase();
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function registerPool(pool) {
pools.set(pool.address, pool);
const key = pairKey(pool.token0, pool.token1);
if (!pairs.has(key)) {
pairs.set(key, new Set());
}
pairs.get(key).add(pool.address);
}
function registerMetadata(pool, token0Metadata, token1Metadata) {
pool.token0Decimals = token0Metadata.decimals;
pool.token1Decimals = token1Metadata.decimals;
pool.token0Symbol = token0Metadata.symbol;
pool.token1Symbol = token1Metadata.symbol;
}
async function readTokenMetadata(address) {
const [decimals, symbol] = await Promise.all([
client.readContract({
address,
abi: ERC20_ABI,
functionName: 'decimals',
}),
client.readContract({
address,
abi: ERC20_ABI,
functionName: 'symbol',
}).catch(() => address.slice(0, 10)),
]);
if (decimals > 36) {
throw new Error(`Unsupported token decimals: ${address}`);
}
return { decimals, symbol };
}
// -----------------------------------------------------------------------------
// Price calculation
// -----------------------------------------------------------------------------
// Prices are token1 per token0, expressed in human-readable token units.
// Number is used only for preliminary ranking, not transaction amounts.
function getV2Price(pool) {
if (pool.reserve0 === 0n || pool.reserve1 === 0n) {
return null;
}
const rawRatio =
Number(pool.reserve1) / Number(pool.reserve0);
return rawRatio *
10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getV3Price(pool) {
if (pool.sqrtPriceX96 === 0n) {
return null;
}
const sqrtRatio =
Number(pool.sqrtPriceX96) / 2 ** 96;
const rawRatio = sqrtRatio * sqrtRatio;
return rawRatio *
10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getPoolPrice(pool) {
if (pool.version === 2) return getV2Price(pool);
if (pool.version === 3) return getV3Price(pool);
return null;
}
function getFeeRate(pool) {
// Uniswap V2: 0.30% swap fee.
// Uniswap V3 fee values are denominated in millionths.
const fee = pool.version === 2 ? V2_FEE_PPM : pool.fee;
return fee / FEE_DENOMINATOR;
}
// -----------------------------------------------------------------------------
// Candidate detection: recalculate only the affected token pair
// -----------------------------------------------------------------------------
function findSpreadCandidates(poolStates) {
const candidates = [];
for (let i = 0; i < poolStates.length; i++) {
for (let j = i + 1; j < poolStates.length; j++) {
const a = poolStates[i];
const b = poolStates[j];
const priceA = getPoolPrice(a);
const priceB = getPoolPrice(b);
if (
priceA === null ||
priceB === null ||
!Number.isFinite(priceA) ||
!Number.isFinite(priceB) ||
priceA <= 0 ||
priceB <= 0
) {
continue;
}
const [buyPool, sellPool, buyPrice, sellPrice] =
priceA <= priceB
? [a, b, priceA, priceB]
: [b, a, priceB, priceA];
const feeBuy = getFeeRate(buyPool);
const feeSell = getFeeRate(sellPool);
if (
feeBuy < 0 || feeBuy >= 1 ||
feeSell < 0 || feeSell >= 1
) {
continue;
}
// Approximate fee-adjusted spread.
// Does not account for price impact or trade size.
const effectiveBuyPrice = buyPrice / (1 - feeBuy);
const effectiveSellPrice = sellPrice * (1 - feeSell);
const grossSpreadPercent =
((sellPrice - buyPrice) / buyPrice) * 100;
const estimatedSpreadPercent =
((effectiveSellPrice - effectiveBuyPrice) /
effectiveBuyPrice) * 100;
if (grossSpreadPercent <= 0) continue;
candidates.push({
token0: a.token0,
token1: a.token1,
token0Symbol: a.token0Symbol,
token1Symbol: a.token1Symbol,
buyPool: buyPool.address,
sellPool: sellPool.address,
buyVersion: buyPool.version,
sellVersion: sellPool.version,
buyPrice,
sellPrice,
grossSpreadPercent,
estimatedSpreadPercent,
});
}
}
return candidates;
}
function onPoolUpdated(pool) {
const addresses = pairs.get(
pairKey(pool.token0, pool.token1),
);
if (!addresses) return;
const states = [];
for (const address of addresses) {
const state = pools.get(address);
if (state) states.push(state);
}
for (const candidate of findSpreadCandidates(states)) {
console.log('[Spread candidate]', {
pair: `${candidate.token0Symbol}/${candidate.token1Symbol}`,
buyPool: candidate.buyPool,
sellPool: candidate.sellPool,
buyVersion: candidate.buyVersion,
sellVersion: candidate.sellVersion,
buyPrice: candidate.buyPrice,
sellPrice: candidate.sellPrice,
grossSpreadPercent: candidate.grossSpreadPercent,
estimatedSpreadPercent: candidate.estimatedSpreadPercent,
});
// Next stage:
// 1. Calculate optimal trade size x*.
// 2. Simulate both swaps against current pool state.
// 3. Deduct gas, flash-loan costs and execution costs.
// 4. Simulate the complete transaction.
// 5. Submit only if expected net profit exceeds the configured threshold.
}
}
// -----------------------------------------------------------------------------
// V2 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV2Pool(rawAddress) {
const address = normalizeAddress(rawAddress);
if (
pools.has(address) ||
initializing.has(address) ||
unwatchPool.has(address)
) {
return;
}
initializing.add(address);
let queuedLogs = [];
let ready = false;
let unwatch;
try {
// Subscribe before reading the snapshot to reduce the initialization gap.
unwatch = client.watchContractEvent({
address,
abi: V2_PAIR_ABI,
eventName: 'Sync',
strict: true,
onLogs(logs) {
if (!ready) {
queuedLogs.push(...logs);
return;
}
processV2Logs(address, logs);
},
onError(error) {
console.error(`[V2 ${address}]`, error.message);
},
});
unwatchPool.set(address, unwatch);
const [token0, token1] = await Promise.all([
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'token0',
}),
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'token1',
}),
]);
const normalizedToken0 = normalizeAddress(token0);
const normalizedToken1 = normalizeAddress(token1);
const [metadata0, metadata1, reserves, blockNumber] =
await Promise.all([
readTokenMetadata(normalizedToken0),
readTokenMetadata(normalizedToken1),
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'getReserves',
}),
client.getBlockNumber(),
]);
const pool = {
address,
version: 2,
token0: normalizedToken0,
token1: normalizedToken1,
reserve0: reserves[0],
reserve1: reserves[1],
fee: V2_FEE_PPM,
lastBlock: blockNumber,
lastLogIndex: -1,
};
registerMetadata(pool, metadata0, metadata1);
registerPool(pool);
ready = true;
// Logs already reflected in the snapshot are discarded.
// Later logs are applied in block/log order.
queuedLogs.sort(compareLogs);
for (const log of queuedLogs) {
if (log.blockNumber > blockNumber) {
processV2Logs(address, [log]);
}
}
queuedLogs = [];
onPoolUpdated(pool);
console.log('[V2 subscribed]', address);
} catch (error) {
unwatch?.();
unwatchPool.delete(address);
console.error(`[V2 init ${address}]`, error.message);
throw error;
} finally {
initializing.delete(address);
}
}
function compareLogs(a, b) {
if (a.blockNumber < b.blockNumber) return -1;
if (a.blockNumber > b.blockNumber) return 1;
return (a.logIndex ?? 0) - (b.logIndex ?? 0);
}
function processV2Logs(address, logs) {
const pool = pools.get(address);
if (!pool) return;
for (const log of [...logs].sort(compareLogs)) {
if (log.removed) {
console.error('[V2 reorg detected]', address);
continue;
}
const block = log.blockNumber ?? 0n;
const index = log.logIndex ?? 0;
if (
block < pool.lastBlock ||
(block === pool.lastBlock && index <= pool.lastLogIndex)
) {
continue;
}
const { reserve0, reserve1 } = log.args;
if (reserve0 === undefined || reserve1 === undefined) {
continue;
}
pool.reserve0 = reserve0;
pool.reserve1 = reserve1;
pool.lastBlock = block;
pool.lastLogIndex = index;
onPoolUpdated(pool);
}
}
// -----------------------------------------------------------------------------
// V3 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV3Pool(rawAddress, fee) {
const address = normalizeAddress(rawAddress);
if (
pools.has(address) ||
initializing.has(address) ||
unwatchPool.has(address)
) {
return;
}
initializing.add(address);
let queuedLogs = [];
let ready = false;
let unwatch;
try {
unwatch = client.watchContractEvent({
address,
abi: V3_POOL_ABI,
eventName: 'Swap',
strict: true,
onLogs(logs) {
if (!ready) {
queuedLogs.push(...logs);
return;
}
processV3Logs(address, logs);
},
onError(error) {
console.error(`[V3 ${address}]`, error.message);
},
});
unwatchPool.set(address, unwatch);
const [token0, token1] = await Promise.all([
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'token0',
}),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'token1',
}),
]);
const normalizedToken0 = normalizeAddress(token0);
const normalizedToken1 = normalizeAddress(token1);
const [metadata0, metadata1, slot0, liquidity, blockNumber] =
await Promise.all([
readTokenMetadata(normalizedToken0),
readTokenMetadata(normalizedToken1),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'slot0',
}),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'liquidity',
}),
client.getBlockNumber(),
]);
const pool = {
address,
version: 3,
token0: normalizedToken0,
token1: normalizedToken1,
sqrtPriceX96: slot0[0],
tick: slot0[1],
liquidity,
fee,
lastBlock: blockNumber,
lastLogIndex: -1,
};
registerMetadata(pool, metadata0, metadata1);
registerPool(pool);
ready = true;
queuedLogs.sort(compareLogs);
for (const log of queuedLogs) {
if (log.blockNumber > blockNumber) {
processV3Logs(address, [log]);
}
}
queuedLogs = [];
onPoolUpdated(pool);
console.log('[V3 subscribed]', address, 'fee:', fee);
} catch (error) {
unwatch?.();
unwatchPool.delete(address);
console.error(`[V3 init ${address}]`, error.message);
throw error;
} finally {
initializing.delete(address);
}
}
function processV3Logs(address, logs) {
const pool = pools.get(address);
if (!pool) return;
for (const log of [...logs].sort(compareLogs)) {
if (log.removed) {
console.error('[V3 reorg detected]', address);
continue;
}
const block = log.blockNumber ?? 0n;
const index = log.logIndex ?? 0;
if (
block < pool.lastBlock ||
(block === pool.lastBlock && index <= pool.lastLogIndex)
) {
continue;
}
const { sqrtPriceX96, tick, liquidity } = log.args;
if (
sqrtPriceX96 === undefined ||
tick === undefined ||
liquidity === undefined
) {
continue;
}
pool.sqrtPriceX96 = sqrtPriceX96;
pool.tick = tick;
pool.liquidity = liquidity;
pool.lastBlock = block;
pool.lastLogIndex = index;
onPoolUpdated(pool);
}
}
// -----------------------------------------------------------------------------
// Factory subscriptions: discover newly created pools
// -----------------------------------------------------------------------------
const unwatchV2Factory = client.watchContractEvent({
address: V2_FACTORY,
abi: V2_FACTORY_ABI,
eventName: 'PairCreated',
strict: true,
onLogs(logs) {
for (const log of logs) {
const address = log.args.pair;
if (address && address !== ZERO_ADDRESS) {
subscribeV2Pool(address).catch(() => {});
}
}
},
onError(error) {
console.error('[V2 factory]', error.message);
},
});
const unwatchV3Factory = client.watchContractEvent({
address: V3_FACTORY,
abi: V3_FACTORY_ABI,
eventName: 'PoolCreated',
strict: true,
onLogs(logs) {
for (const log of logs) {
const { pool, fee } = log.args;
if (
pool &&
fee !== undefined &&
pool !== ZERO_ADDRESS
) {
subscribeV3Pool(pool, fee).catch(() => {});
}
}
},
onError(error) {
console.error('[V3 factory]', error.message);
},
});
// -----------------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------------
async function main() {
for (const entry of INITIAL_POOLS) {
if (!entry.address || ![2, 3].includes(entry.version)) {
throw new Error(
'Each initial pool needs an address and version 2 or 3',
);
}
if (entry.version === 2) {
await subscribeV2Pool(entry.address);
} else {
if (!Number.isInteger(entry.fee)) {
throw new Error(
`V3 pool ${entry.address} requires its fee tier`,
);
}
await subscribeV3Pool(entry.address, entry.fee);
}
}
console.log('[Monitor running] Ethereum mainnet');
}
function shutdown() {
console.log('[Monitor stopping]');
unwatchV2Factory();
unwatchV3Factory();
for (const unwatch of unwatchPool.values()) {
unwatch();
}
unwatchPool.clear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
main().catch((error) => {
console.error('[Startup failed]', error);
shutdown();
});
Spin it up:
npm install viemexport ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'
export POOLS_JSON='[]'
node arbitrage-monitor.mjsLeaving POOLS_JSON='[]' means the monitor will only listen for pools created after boot. To track existing pools, pass their addresses and fee tiers right into POOLS_JSON.
9. Gas Optimization Checklist
When executing complex smart contract logic, the bot with the leanest gas footprint wins. Every 10,000 gas you save is extra margin you can convert into a higher Priority Fee to outrun competing searchers in the PGA race.
- Ditch
requirefor Custom Errors: Standardrequire(condition, "error string")checks incinerate at least 100–200 extra gas just storing revert strings. Switching toif (!condition) revert InsufficientProfit()cuts overhead drastically. - Leverage
immutableandconstantVariables: Hardcode router addresses, the Balancer Vault, and base token addresses asimmutable. This bakes values straight into the contract bytecode, bypassing expensive storage reads (SLOADcosts 2,100 gas a pop). - Direct Transfers over
transferFrom: If your contract routes through intermediate tokens, push them straight to the next pool using a rawtransfer. Don't waste gas chaining uselessapprove/transferFromcalls. Transient Storage (TSTORE / TLOAD via EIP-1153):
On chains with EIP-1153 enabled, use transient storage for reentrancy guards. It drops state write costs from a brutal 20,000 gas down to a clean 100 gas.
10. Step-by-Step Deployment and Testing Guide
Step 1: Mainnet Forking (Foundry / Hardhat)
Before throwing real capital onto mainnet, simulate your strategy against a local mainnet fork using live pool states.
# Spin up a local fork node via Anvil (Foundry) anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000Step 2: Deploy to Your Local Fork
Deploy the
FlashArbEnginecontract configured with real mainnet infrastructure addresses:- Balancer Vault v2:
0xBA12222222228d8Ba445958a75a0704d566BF2C8 - Uniswap v3 SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 - Sushiswap V2 Router:
0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
- Balancer Vault v2:
Step 3: Simulate a Price Discrepancy
Fire off a script to dump a large chunk of WETH into the Sushiswap pool, manually creating price impact and skewing pool reserves.
Step 4: Trigger
executeArbitrageCall your executor contract with the exact loan parameters. Verify that the transaction lands cleanly, repays the principal to Balancer, and leaves net profit sitting in
FlashArbEngine.
Production-grade flash loan arbitrage is where low-level Solidity engineering, DeFi protocol mechanics, and off-chain MEV infrastructure meet. Pairing zero-fee loans via Balancer v2 with tight, gas-optimized contracts and private tx submission channels gives you a battle-tested foundation for a fully automated MEV stack.