Ethereum Arbitrage Development Tutorial Guide
This guide is a step-by-step tutorial based on a practical walk-through of deploying, interacting, and
testing arbitrage strategies on Ethereum (Sepolia testnet). It covers environment setup, smart
contract deployment, interacting with Uniswap-style DEX routers, running swaps, and finally
building atomic arbitrage contracts using flash swaps. All examples shown here were executed on
Sepolia testnet, but lessons are extendable to Ethereum mainnet.
1. Environment Setup
We use **Foundry** (`cast` and `forge`) to interact with smart contracts. Ensure you have: -
Installed Foundry (`curl -L [Link] | bash`). - Funded a test wallet with Sepolia
ETH, USDC, and WETH. - Configured RPC URL and account key in environment variables.
export SEPOLIA_RPC_URL="[Link]
export PRIVATE_KEY="your_private_key"
2. Checking Balances
We use `cast call` to check ERC20 token balances. Example for WETH and USDC:
# Check USDC balance
cast call 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 "balanceOf(address)(uint256)" 0x463aa2E43
# Check WETH balance
cast call 0xfff9976782d46cc05630d1f6ebab18b2324d6b14 "balanceOf(address)(uint256)" 0x463aa2E43
The output shows balances in token decimals. For USDC (6 decimals), `130491556` means 130.49
USDC. For WETH (18 decimals), `3.21e16` means 0.0321 WETH.
3. Swapping Tokens
We use UniswapV2 router’s `swapExactTokensForTokens` function. Example: swapping **10
USDC for WETH**.
cast send 0xeaBcE3E74EF41FB40024a21Cc2ee2F5dDc615791 "swapExactTokensForTokens(uint256,uint256
0 "[0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238,0xfff9976782d46cc05630d1f6ebab18b2324d6b14]"
The transaction executes a swap from USDC → WETH. Logs confirm token transfers and liquidity
pool updates.
4. Detecting Arbitrage
We compare output amounts from two different routers (Router1 and Router2) using
`getAmountsOut`:
# Router1
cast call 0xeE567Fe1712Faf6149d80dA1E6934E354124CfE3 "getAmountsOut(uint256,address[])(uint[])
# Router2
cast call 0xeaBcE3E74EF41FB40024a21Cc2ee2F5dDc615791 "getAmountsOut(uint256,address[])(uint[])
Differences in output between the two routers reveal arbitrage opportunities. For example, 1 USDC
→ Router2 yields more WETH than Router1, suggesting a profitable round-trip swap.
5. Atomic Arbitrage Contracts
To avoid holding inventory and risking price movement between legs, bundle both swaps into a
single transaction. Below is a simplified **Atomic Two-Swap contract** that executes USDC →
WETH on Router1 and then WETH → USDC on Router2.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@uniswap/v2-periphery/contracts/interfaces/[Link]";
import "@openzeppelin/contracts/token/ERC20/[Link]";
contract TwoSwapArbitrage {
IUniswapV2Router02 public router1;
IUniswapV2Router02 public router2;
address public owner;
constructor(address _router1, address _router2) {
router1 = IUniswapV2Router02(_router1);
router2 = IUniswapV2Router02(_router2);
owner = [Link];
}
function executeArbitrage(
uint amountIn,
address[] calldata path1,
address[] calldata path2
) external {
require([Link] == owner, "Not authorized");
IERC20(path1[0]).transferFrom([Link], address(this), amountIn);
IERC20(path1[0]).approve(address(router1), amountIn);
uint[] memory amountsOut1 = [Link](
amountIn, 0, path1, address(this), [Link]
);
uint amountReceived = amountsOut1[[Link] - 1];
IERC20(path2[0]).approve(address(router2), amountReceived);
uint[] memory amountsOut2 = [Link](
amountReceived, 0, path2, [Link], [Link]
);
}
}
This contract ensures both swaps happen atomically within the same transaction.
6. Flash Swaps (UniswapV2)
Alternatively, use **UniswapV2 flash swaps**, which let you borrow tokens without upfront capital
as long as you return them (plus fee) in the same transaction. This enables risk-free arbitrage
execution without needing large initial funds.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@uniswap/v2-core/contracts/interfaces/[Link]";
import "@uniswap/v2-periphery/contracts/interfaces/[Link]";
import "@openzeppelin/contracts/token/ERC20/[Link]";
contract FlashSwapArbitrage {
address public factory;
IUniswapV2Router02 public router2;
address public owner;
constructor(address _factory, address _router2) {
factory = _factory;
router2 = IUniswapV2Router02(_router2);
owner = [Link];
}
function startFlashSwap(
address token0,
address token1,
uint amount0Out,
uint amount1Out
) external {
address pair = IUniswapV2Factory(factory).getPair(token0, token1);
require(pair != address(0), "Pair not found");
IUniswapV2Pair(pair).swap(amount0Out, amount1Out, address(this), [Link]("flashloan")
}
function uniswapV2Call(address, uint amount0, uint amount1, bytes calldata) external {
uint amountToken = amount0 > 0 ? amount0 : amount1;
address tokenBorrow = amount0 > 0 ? [Link] : [Link];
// Example: swap borrowed token on router2
IERC20(tokenBorrow).approve(address(router2), amountToken);
address[] memory path = new address[](2);
path[0] = tokenBorrow;
path[1] = /* other token */;
[Link](
amountToken, 0, path, address(this), [Link]
);
// repay flash loan + fee here
}
}
This contract leverages Uniswap’s flash swap functionality to perform arbitrage without needing
upfront capital. When porting to mainnet, carefully account for **gas costs, slippage, liquidity depth,
and MEV risk**.