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:
- Your contract calls the pool’s
flashLoan/flashLoanSimplefunction. - The pool transfers tokens to your contract and invokes a callback on it (
executeOperation). - Inside the callback you do whatever you want with the money: swap, liquidate, refinance.
- Before the callback returns, you approve the pool to pull back
amount + premium. - 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:
- the flash loan premium (0.05% on Aave V3 at the time of writing),
- the swap fees on every hop (typically 0.3% per Uniswap V2-style pool),
- the gas for the transaction.
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.
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:
- Uniswap: 1 WETH = 3,000 USDC
- SushiSwap: 1 WETH = 2,970 USDC
The strategy, ignoring price impact for a moment:
- Borrow 1,000 WETH from Aave.
- Sell 1,000 WETH on Uniswap at 3,000 → receive 3,000,000 USDC.
- Buy WETH on SushiSwap at 2,970 with those USDC → receive ~1,010.1 WETH.
- Repay Aave 1,000.5 WETH (loan + 0.05% premium).
- 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:
require(balance >= owed + minProfit)is your safety net. If the market moved against you between detection and execution, the swap minimums or this check fail, the transaction reverts, and the loan unwinds. You lose the gas and nothing else.minOutSell/minOutBuymust be computed off-chain from live reserves and passed in. Hardcoding0(as many tutorials do) leaves the trade open to being sandwiched.- Only your contract may initiate. The
initiator == address(this)check plus the owner guard onexecuteArbitragestops anyone else from driving your executor.
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
- Reverted attempts still cost gas. Atomicity protects the borrowed capital, not your gas budget. A bot that fires on stale data can bleed ETH on reverts; simulate before sending.
- Slippage and fees compound. Every hop takes its fee and moves its pool. A spread that looks profitable on mid-prices often is not after both are accounted for — always compute with
getAmountOut-style math, never spot prices. - Smart contract bugs are unforgiving. A missing access check or a misused approval can strand funds or let a third party drive your contract. Test on a mainnet fork (Foundry’s
anvil --fork-urlor Hardhat) before touching real capital. - “Flash loan attack” headlines are a different thing. Arbitrage repays the loan by exploiting price differences between venues; attacks repay it by exploiting a bug in a victim protocol (oracle manipulation, broken accounting). The mechanism is the same, the legality and ethics are not.
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.