Arbitrage Without Capital

In the earlier posts of this series we covered how to find arbitrage: triangular arbitrage on Uniswap for the three-pair case, and graph-based detection with Bellman-Ford for cycles of any length. Both posts quietly assumed you already hold the tokens you want to trade with. That assumption is the biggest practical barrier to arbitrage: a 0.5% spread on a 10 ETH trade pays 0.05 ETH, and scaling it means parking serious capital in hot wallets.

Flash loans remove that barrier. You borrow the full size of the trade, execute the arbitrage, repay the loan, and pocket the difference — all inside a single transaction, with zero collateral. This post explains how that works mechanically and walks through building a minimal flash loan arbitrage bot: a Solidity executor contract and an ethers.js scanner that feeds it.

What a Flash Loan Actually Is

A flash loan is an uncollateralized loan that must be repaid within the same transaction it was taken out in. The lender can offer this safely because of one EVM property: atomicity. A transaction either executes completely or reverts completely; there is no in-between state. The lending protocol’s contract transfers you the funds, calls back into your contract, and at the end of that callback checks that it has been repaid (plus a fee). If the check fails, the protocol reverts — and because the whole transaction reverts, the loan itself never happened. The lender’s worst case is the status quo.

The flow looks like this:

  1. Your contract calls the pool’s flashLoan / flashLoanSimple function.
  2. The pool transfers tokens to your contract and invokes a callback on it (executeOperation).
  3. Inside the callback you do whatever you want with the money: swap, liquidate, refinance.
  4. Before the callback returns, you approve the pool to pull back amount + premium.
  5. The pool pulls the repayment. Anything left in your contract is profit.

This pattern is standardized as EIP-3156 (flashLoan, flashFee, maxFlashLoan), which Aave V3, most Uniswap-style adapters, and many other lenders implement, so one executor contract can often talk to several liquidity sources through the same interface.

Why Flash Loans and Arbitrage Fit Together

Classic arbitrage needs inventory: to exploit WETH being expensive on Uniswap and cheap on SushiSwap, you need WETH to sell on Uniswap in the first place. A flash loan turns that on its head — the borrowed funds are the inventory, and the arbitrage only has to beat three costs:

If the price spread between the two venues exceeds that total, the trade is profitable with none of your own capital at risk — only the gas of the attempt, which you pay whether the transaction succeeds or reverts.

Flash loan arbitrage as one atomic transaction: the Aave V3 pool lends WETH with no collateral, your contract sells it where it is expensive, buys it back where it is cheap, repays the loan plus premium, and the owner keeps the spread; any failure reverts everything

Where to Borrow

The major flash loan sources on Ethereum mainnet, with their fees as of mid-2026 (verify on-chain before sizing a trade — fees are set by governance and do change):

Provider Interface Fee
Aave V3 flashLoanSimple / EIP-3156 0.05% of the borrowed amount
Uniswap V3 flash on the pool contract the pool’s swap fee tier on the borrowed amount
Balancer V2 flashLoan on the Vault 0% on many pools
MakerDAO flash-minted DAI 0%, capped by the debt ceiling

Aave is the usual default: deep liquidity across major assets, a clean EIP-3156-style interface, and a predictable premium. Uniswap V3’s flash is attractive when the pool’s fee tier is low and you are already trading on that pool. Balancer’s zero-fee loans are the cheapest source when the asset is available there.

A Worked Example

Suppose the WETH/USDC rate diverges between two pools:

The strategy, ignoring price impact for a moment:

  1. Borrow 1,000 WETH from Aave.
  2. Sell 1,000 WETH on Uniswap at 3,000 → receive 3,000,000 USDC.
  3. Buy WETH on SushiSwap at 2,970 with those USDC → receive ~1,010.1 WETH.
  4. Repay Aave 1,000.5 WETH (loan + 0.05% premium).
  5. Remainder: ~9.6 WETH before swap fees and gas.

Now subtract reality. Each 1,000-WETH-sized swap pays a 0.3% pool fee (~3 WETH equivalent per hop, so ~6 WETH total) and moves the pool price against you — the bigger your trade relative to pool depth, the worse your average fill. Optimal trade size is therefore not “as much as possible” but the amount that maximizes spread minus price impact, typically found with a short binary search against getAmountOut. Gas for a two-swap flash arbitrage is usually in the 300–500k range; at 20 gwei that is roughly 0.006–0.01 ETH, negligible at this size but decisive on thinner spreads.

Building It

Two components: an on-chain executor contract that holds the atomic logic, and an off-chain bot that finds spreads and calls the executor. The code below is educational — it is deliberately simple, skips the off-chain slippage checks a production bot needs, and has not been audited. Do not deploy it with real funds.

The Solidity executor

The contract implements Aave V3’s IFlashLoanSimpleReceiver. It sells the borrowed asset where it is expensive, buys it back where it is cheap, enforces a minimum profit, and approves the repayment:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IFlashLoanSimpleReceiver} from "@aave/core-v3/contracts/flashloan/interfaces/IFlashLoanSimpleReceiver.sol";
import {IPoolAddressesProvider} from "@aave/core-v3/contracts/interfaces/IPoolAddressesProvider.sol";
import {IPool} from "@aave/core-v3/contracts/interfaces/IPool.sol";
import {IERC20} from "@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol";

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract FlashArbExecutor is IFlashLoanSimpleReceiver {
    IPoolAddressesProvider public immutable ADDRESSES_PROVIDER;
    IPool public immutable POOL;
    address public immutable owner;

    // Aave V3 mainnet addresses provider
    constructor(address provider) {
        ADDRESSES_PROVIDER = IPoolAddressesProvider(provider);
        POOL = IPool(ADDRESSES_PROVIDER.getPool());
        owner = msg.sender;
    }

    struct ArbParams {
        address midAsset;    // the quote token, e.g. USDC
        address routerSell;  // venue where the borrowed asset is expensive
        address routerBuy;   // venue where it is cheap
        uint minOutSell;     // computed off-chain, protects against slippage
        uint minOutBuy;
        uint minProfit;      // in units of the borrowed asset
    }

    function executeArbitrage(address borrowAsset, uint amount, ArbParams calldata p) external {
        require(msg.sender == owner, "not owner");
        POOL.flashLoanSimple(address(this), borrowAsset, amount, abi.encode(p), 0);
    }

    function executeOperation(
        address asset,
        uint amount,
        uint premium,
        address initiator,
        bytes calldata params
    ) external override returns (bool) {
        require(msg.sender == address(POOL), "caller must be pool");
        require(initiator == address(this), "initiator must be self");

        ArbParams memory p = abi.decode(params, (ArbParams));

        // 1. sell the borrowed asset where it is expensive
        address[] memory sellPath = new address[](2);
        sellPath[0] = asset;
        sellPath[1] = p.midAsset;
        IERC20(asset).approve(p.routerSell, amount);
        uint[] memory sold = IUniswapV2Router(p.routerSell).swapExactTokensForTokens(
            amount, p.minOutSell, sellPath, address(this), block.timestamp
        );

        // 2. buy it back where it is cheap
        address[] memory buyPath = new address[](2);
        buyPath[0] = p.midAsset;
        buyPath[1] = asset;
        IERC20(p.midAsset).approve(p.routerBuy, sold[1]);
        IUniswapV2Router(p.routerBuy).swapExactTokensForTokens(
            sold[1], p.minOutBuy, buyPath, address(this), block.timestamp
        );

        // 3. enforce profit, then repay
        uint owed = amount + premium;
        uint balance = IERC20(asset).balanceOf(address(this));
        require(balance >= owed + p.minProfit, "arb not profitable");
        IERC20(asset).approve(address(POOL), owed);
        return true;
    }

    function withdraw(address token) external {
        require(msg.sender == owner, "not owner");
        IERC20(token).transfer(owner, IERC20(token).balanceOf(address(this)));
    }
}

The three details that matter most:

The scanner bot

Off-chain, the bot watches new blocks, reads reserves from both pools, computes the profit for both trade directions, and fires when the net beats a threshold. With ethers.js v6:

import { ethers } from "ethers";

const provider = new ethers.WebSocketProvider(process.env.RPC_URL);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const PAIR_ABI = [
  "function getReserves() view returns (uint112 r0, uint112 r1, uint32 ts)",
];
const EXECUTOR_ABI = [
  "function executeArbitrage(address borrowAsset, uint256 amount, tuple(address midAsset, address routerSell, address routerBuy, uint256 minOutSell, uint256 minOutBuy, uint256 minProfit) p) external",
];

// Uniswap V2-style formula with the 0.3% fee baked in
function getAmountOut(amountIn, reserveIn, reserveOut) {
  const inWithFee = amountIn * 997n;
  return (inWithFee * reserveOut) / (reserveIn * 1000n + inWithFee);
}

const AAVE_PREMIUM_BPS = 5n; // 0.05%

provider.on("block", async () => {
  const [uniR, sushiR] = await Promise.all([
    uniPair.getReserves(),
    sushiPair.getReserves(),
  ]);

  // direction: borrow WETH, sell on Uniswap, buy back on SushiSwap
  const loan = ethers.parseEther("1000");
  const usdcOut = getAmountOut(loan, uniR.r0, uniR.r1);        // WETH -> USDC
  const wethBack = getAmountOut(usdcOut, sushiR.r1, sushiR.r0); // USDC -> WETH

  const owed = loan + (loan * AAVE_PREMIUM_BPS) / 10_000n;
  const gross = wethBack - owed;
  if (gross <= 0n) return;

  // net = gross minus gas; skip if it doesn't clear our threshold
  const tx = await executor.executeArbitrage.populateTransaction(WETH, loan, {
    midAsset: USDC,
    routerSell: UNI_ROUTER,
    routerBuy: SUSHI_ROUTER,
    minOutSell: (usdcOut * 995n) / 1000n,   // 0.5% tolerance
    minOutBuy: owed,                        // never accept less than we owe
    minProfit: MIN_PROFIT,
  });
  const gasCost = (await provider.estimateGas({ ...tx, from: wallet.address }))
    * (await provider.getFeeData()).gasPrice;
  if (gross - gasCost < MIN_PROFIT) return;

  await wallet.sendTransaction(tx); // in production: a private relay, not the public mempool
});

This is the smallest thing that demonstrates the loop. A serious version sizes the loan with a binary search over getAmountOut, monitors more than two pools (the graph-theory post generalizes detection to any number of venues), simulates every candidate with eth_call before spending gas on it, and submits through a private relay instead of the public mempool.

The Competitive Reality

Flash loan arbitrage is not free money, and it is worth being honest about why.

The public mempool is a trap for naive bots. An unprofitable-to-front-run design is the exception. Anything profitable you broadcast can be copied by generalized frontrunners, who replay your transaction’s calldata with their own contract and a higher bid. Submitting through a private relay such as Flashbots Protect keeps the transaction out of the public pool until it lands in a block.

You are competing on latency. Two bots that spot the same spread in the same block usually resolve the tie by who saw it first and bid better. Every network round-trip in your detect-evaluate-submit loop is time handed to a competitor — the latency breakdown in our MEV post walks through where those milliseconds go. This is the entire reason searchers co-locate with nodes: reading reserves over a local socket instead of a remote gateway collapses the slowest part of the loop.

Margins are thin and bursty. Obvious two-venue spreads get competed away within a block or two of appearing. What remains is either short-lived (volatility spikes, large trades moving one pool before the others) or hard to see (multi-hop paths, obscure tokens, cross-protocol dislocations). The bots that survive treat detection quality and infrastructure as the product, not the strategy.

Running the scanner next to the node it reads from is the natural deployment shape for this. On BLAZED.sh the bot is a container or a script on the same host as a fully synced Ethereum node, talking to it over a local socket (ws://eth:8545) — sub-10ms reads, an unfiltered view of the mempool, and no shared rate limits in the hot path.

Risks

Conclusion

A flash loan converts arbitrage from a capital problem into a pure detection-and-execution problem: borrow the inventory, trade the spread, repay inside one atomic transaction, and keep what is left. The contract above is under a hundred lines because the hard part was never the mechanics — it is finding spreads before everyone else, which is a data and latency problem. The rest of this series covers the detection side: triangular arbitrage for the fundamentals and graph-based cycle detection for scaling it across the whole market.

When you are ready to run something live, the BLAZED.sh docs show how to deploy the bot as a container or script directly onto the node host, and the pricing page breaks down the per-request credits your scanner will burn. Test on a fork first, size your minProfit honestly, and never risk funds you cannot afford to lose.