Flash Loan Arbitrage Bot on Arbitrum

Build a flash loan arbitrage system targeting Uniswap V3 pools on Arbitrum.

Introduction

30 Second Summary

Imagine borrowing millions of dollars, using it to exploit a tiny price gap between two exchanges, and repaying the entire loan in a single transaction that takes less than a second. That's flash loan arbitrage, and it's one of the most powerful mechanisms in DeFi.

In this project, you will build a complete flash loan arbitrage system on Arbitrum that exploits price discrepancies between Uniswap V3 pools. The system combines a Solidity smart contract for atomic on-chain execution with a TypeScript bot for off-chain price monitoring.

What You'll Build

A two-component arbitrage system with an on-chain contract that executes atomic flash loan swaps via Aave V3 and an off-chain monitoring bot that detects profitable opportunities across Uniswap V3 fee tiers in real time.

By the end of this project, you'll have:

  • A production-grade ArbitrageExecutor smart contract that borrows USDC via flash loans, executes multi-pool swaps on Uniswap V3, and repays atomically in one transaction.
  • A TypeScript monitoring bot that reads live pool prices from Arbitrum, calculates profitability, and triggers on-chain execution when the spread is profitable.
  • A comprehensive Foundry test suite running against forked Arbitrum mainnet state that proves the system works with real protocol liquidity.
  • Secret Mission: A multi-path route optimizer that evaluates multiple token pairs and fee tiers simultaneously and picks the most profitable arbitrage per block.

Are there any prerequisites?

This project assumes familiarity with Solidity, EVM concepts, and DeFi protocols like AMMs and flash loans. Experience with Foundry or Hardhat is expected.

You do not need prior experience with Arbitrum specifically. The project guides you through every integration step.

Set Up Your Development Environment

Flash loan arbitrage requires precise tooling. Your contract needs to compile, deploy, and test against real blockchain state. Your monitoring bot needs to read live pool data and trigger transactions.

In this step, you will install Foundry (the Solidity development framework used by DeFi trading firms), set up an Alchemy RPC connection to Arbitrum, and initialize both the on-chain and off-chain components of your project.

In this step, get ready to:
  • Install Foundry for Solidity development and fork testing.
  • Set up an Alchemy RPC endpoint for Arbitrum mainnet access.
  • Initialize the project with all Solidity and TypeScript dependencies.
Install Foundry in a bash environment

Foundry is the Solidity development toolkit used by professional DeFi teams. It includes forge (compile and test), anvil (local fork node), and cast (interact with contracts on-chain). Foundry requires a bash environment to install.

macOS

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.
  • Install Foundry by running this command:
curl -L https://foundry.paradigm.xyz | bash

What does this command do?

This downloads and runs the foundryup installer script. It adds the foundryup command to your shell path.

  • Close and reopen your terminal, then run the installer:
foundryup

You should see output confirming that forge, cast, anvil, and chisel were installed.

  • Verify the installation by running:
forge --version

You should see a version number like forge 0.3.x (the exact number depends on the latest Foundry release).

Windows

Foundry requires a bash environment. On Windows, you need WSL 2 (Windows Subsystem for Linux) to run it.

  • Press the Windows key to open your search bar.
  • Type PowerShell and select Run as administrator.
  • Install WSL 2 by running this command:
wsl --install

What does this command do?

This installs WSL 2 with Ubuntu as the default Linux distribution. You may need to restart your computer after installation.

  • After restarting, open Ubuntu from your Start menu.
  • Set a username and password when prompted.
  • Install Foundry inside WSL by running this command:
curl -L https://foundry.paradigm.xyz | bash
  • Close and reopen the Ubuntu terminal, then run:
foundryup

You should see output confirming that forge, cast, anvil, and chisel were installed.

  • Verify the installation by running:
forge --version

You should see a version number like forge 0.3.x (the exact number depends on the latest Foundry release).

Forge command not found?

Make sure you closed and reopened your terminal after running the curl command. The installer adds ~/.foundry/bin to your PATH, but only new terminal sessions pick it up.

If it still does not work, manually add the path by running source ~/.bashrc (or source ~/.zshrc on macOS).

help me troubleshoot Foundry installation

Create an Alchemy account and get your Arbitrum RPC URL

Your project needs to read real-time data from Arbitrum mainnet. Alchemy provides free RPC endpoints that let you query blockchain state without running your own node.

  • Open https://www.alchemy.com/ in your browser.
  • Click Sign Up and create a free account.
  • Sign in to the Alchemy Dashboard and open Team Overview from your team menu.
  • Open the Apps tab, then select Create new app.
  • Enter Flash Loan Arb as the app name.
  • Select Arbitrum as the chain.
  • Click Create App.
  • Open the Endpoints tab on your new app.
  • Copy the HTTPS endpoint URL. It looks like https://arb-mainnet.g.alchemy.com/v2/YOUR_API_KEY.

Why Alchemy instead of a public RPC?

Public RPCs are rate-limited and unreliable for high-frequency polling. Alchemy's free tier gives you 25 requests per second and stable uptime. Your monitoring bot will poll pool prices every 2 seconds, so reliable access matters.

Keep this URL handy. You will paste it into your .env file shortly.

Don't see an Arbitrum endpoint?

Make sure you selected Arbitrum (not Ethereum) when creating the app. You can check this under the app's settings and update the chain if needed.

help me find my Alchemy Arbitrum RPC endpoint

Initialize the project and install dependencies

Now you will scaffold the project structure. The Foundry side handles Solidity compilation and testing. The bot/ directory handles the TypeScript monitoring bot.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project with Foundry by running this command:
forge init flash-loan-arb && cd flash-loan-arb

What does forge init do?

This creates a new directory called flash-loan-arb with a standard Foundry project layout: src/ for contracts, test/ for tests, script/ for deployment scripts, and lib/ for dependencies. It also initializes a git repository and installs forge-std (the standard testing library).

  • Install the OpenZeppelin and Aave V3 contracts by running these commands:
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge install aave/aave-v3-core --no-commit

What are these dependencies?

OpenZeppelin provides the IERC20 interface for interacting with tokens. Aave V3 core provides the IPool and IFlashLoanSimpleReceiver interfaces your contract needs to borrow via flash loans.

The --no-commit flag prevents Forge from creating a git commit for each dependency install. This keeps your git history clean.

  • Confirm both libraries installed by running:
ls lib/

You should see aave-v3-core, forge-std, and openzeppelin-contracts listed.

Seeing a git error during forge install?

Forge uses git submodules to manage dependencies. If git is not configured with your name and email, it will fail. Run these commands first:

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

Then retry the forge install commands.

help me fix git errors during forge install

  • Now set up the TypeScript bot directory by running these commands:
mkdir bot && cd bot
npm init -y
npm install ethers typescript ts-node @types/node

What does this set up?

This creates a bot/ directory with its own package.json. The ethers library connects to Arbitrum and reads pool data. The typescript and ts-node packages let you run TypeScript files directly without a separate compile step.

You should see a node_modules/ folder and package.json inside bot/.

  • Create the TypeScript configuration file by running:
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}
EOF

What does tsconfig.json configure?

This tells TypeScript to compile to ES2020 (modern JavaScript with BigInt support, which you need for sqrtPriceX96 math). Strict mode catches type errors early. The commonjs module format works with ts-node for running scripts directly.

  • Navigate back to the project root by running:
cd ..
  • Create your environment file by running:
cat > .env << 'EOF'
ARBITRUM_RPC_URL=your-alchemy-arbitrum-rpc-url
PRIVATE_KEY=your-private-key-here
EOF

What goes in the .env file?

Replace your-alchemy-arbitrum-rpc-url with the HTTPS endpoint you copied from Alchemy (e.g. https://arb-mainnet.g.alchemy.com/v2/abc123). The PRIVATE_KEY is used by the bot to sign transactions. For local testing with Anvil, you can use any of Anvil's pre-funded test keys.

Never commit your .env file

Your .env contains sensitive credentials. Make sure it is listed in your .gitignore file (Foundry's default template includes this).

  • Now update foundry.toml with remappings so Forge can find your dependencies. Open foundry.toml in VS Code and replace its contents with:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]

remappings = [
    "@openzeppelin/=lib/openzeppelin-contracts/",
    "@aave/=lib/aave-v3-core/",
]

What do remappings do?

Remappings tell the Solidity compiler where to find imported packages. When your contract writes import "@openzeppelin/contracts/token/ERC20/IERC20.sol", Forge resolves it to lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol.

  • Save foundry.toml.
  • Verify Forge recognizes the remappings by running:
forge remappings

You should see your @openzeppelin/ and @aave/ remappings listed in the output.

Forge remappings shows empty output?

Make sure you saved foundry.toml and that the remappings array is inside the [profile.default] section. Also confirm you are running forge remappings from the project root (flash-loan-arb/), not from inside the bot/ directory.

help me debug my foundry.toml remappings

✔️ Awesome, I've got everything!

Double check you have saved foundry.toml and your .env file contains your Alchemy RPC URL.

ⓧ I'd like to double check the full code

Here are all the configuration files you should have at this point:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]

remappings = [
    "@openzeppelin/=lib/openzeppelin-contracts/",
    "@aave/=lib/aave-v3-core/",
]
ARBITRUM_RPC_URL=your-alchemy-arbitrum-rpc-url
PRIVATE_KEY=your-private-key-here
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}

Your environment is ready. You have Foundry installed, an Alchemy RPC endpoint for Arbitrum, and a project structure with all the Solidity and TypeScript dependencies in place. Next up, you will build the flash loan arbitrage contract itself.

Build the Flash Loan Arbitrage Contract

Your development environment is configured and ready to go. Now it is time to write the smart contract that actually executes the arbitrage.

The core challenge of flash loan arbitrage is atomicity. You borrow funds, execute trades across multiple pools, repay the loan plus a premium, and keep any profit. All of this happens in a single transaction. If any step fails or if the trade is not profitable, the entire transaction reverts and you lose nothing except gas.

In this step, get ready to:
  • Define the external protocol interfaces for Uniswap V3 and Aave V3.
  • Build the ArbitrageExecutor contract with immutable protocol addresses and access control.
  • Implement the two-leg swap callback and flash loan entry point.
Set up the contract file with protocol interfaces

Your contract needs to talk to two external protocols: Aave V3 for the flash loan and Uniswap V3 for the token swaps. You need Solidity interface definitions so your contract knows how to call their functions.

  • Create a new file called ArbitrageExecutor.sol inside the src/ folder of your Foundry project.
  • Add the following imports and interface definitions to the file:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

// Interface for Uniswap V3 SwapRouter - used to execute token swaps
interface ISwapRouter {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 deadline;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }

    function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
}

// Interface for reading Uniswap V3 pool price data (used by the off-chain bot)
interface IUniswapV3Pool {
    function slot0()
        external
        view
        returns (
            uint160 sqrtPriceX96,
            int24 tick,
            uint16 observationIndex,
            uint16 observationCardinality,
            uint16 observationCardinalityNext,
            uint8 feeProtocol,
            bool unlocked
        );
}

What does this code do?

The imports pull in pre-built interfaces from OpenZeppelin and Aave V3. IERC20 lets you call approve() and transfer() on any ERC-20 token. IPool gives access to Aave's flashLoanSimple() function.

The ISwapRouter interface defines how your contract calls Uniswap V3's swap function. The ExactInputSingleParams struct packages everything a swap needs: which tokens, which fee tier, and how much to swap.

The IUniswapV3Pool interface exposes slot0(), which returns the current price (as sqrtPriceX96) of a pool. Your off-chain bot will use this to detect arbitrage opportunities.

You won't see a change yet. The contract body in the next chunk uses these interfaces.

  • Below the IUniswapV3Pool interface, add the contract declaration with its state variables and constructor:
contract ArbitrageExecutor {
    // Immutable protocol references - set once at deployment
    IPool public immutable POOL;
    IPoolAddressesProvider public immutable ADDRESSES_PROVIDER;
    ISwapRouter public immutable SWAP_ROUTER;
    address public immutable WETH;
    address public immutable USDC;
    address public owner;

    // Uniswap V3 fee tiers (in hundredths of a basis point)
    uint24 public constant FEE_LOW = 500; // 0.05%
    uint24 public constant FEE_HIGH = 3000; // 0.3%

    // Custom errors - cheaper than require strings
    error NotOwner();
    error NotPool();
    error Unprofitable();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor(
        address _addressesProvider,
        address _swapRouter,
        address _weth,
        address _usdc
    ) {
        ADDRESSES_PROVIDER = IPoolAddressesProvider(_addressesProvider);
        POOL = IPool(IPoolAddressesProvider(_addressesProvider).getPool());
        SWAP_ROUTER = ISwapRouter(_swapRouter);
        WETH = _weth;
        USDC = _usdc;
        owner = msg.sender;
    }
}

What does this code do?

The state variables store references to every protocol the contract interacts with. Marking them immutable saves gas because the values are embedded directly in the contract bytecode instead of being read from storage on every call.

FEE_LOW (500) represents Uniswap V3's 0.05% fee tier. FEE_HIGH (3000) represents the 0.3% fee tier. The arbitrage exploits price differences between pools with these two fee levels.

Custom errors (NotOwner, NotPool, Unprofitable) are cheaper than require() strings because they don't store error message data on-chain.

Why use flashLoanSimple instead of flashLoan?

Aave V3 offers two flash loan functions. flashLoan supports borrowing multiple assets simultaneously. flashLoanSimple borrows a single asset and uses less gas.

Since this arbitrage only needs to borrow USDC, flashLoanSimple is the right choice. Every gas unit saved increases your net profit.

  • Save src/ArbitrageExecutor.sol.
  • Verify the file compiles by running:
forge build
  • You should see output showing successful compilation with no errors.

Seeing import errors?

If you see "Source not found" errors, check that your foundry.toml remappings match the library folder names in lib/. Run ls lib/ to confirm that openzeppelin-contracts and aave-v3-core directories exist.

Help me fix Foundry import resolution errors for OpenZeppelin and Aave

Implement the executeOperation callback

When Aave sends your contract the borrowed USDC, it calls a function named executeOperation on your contract. This is where the arbitrage logic lives. Your contract receives the USDC, performs two swaps through different fee-tier pools, and must end up with enough USDC to repay the loan plus a 0.05% premium.

  • In src/ArbitrageExecutor.sol, find the closing } brace of the contract (after the constructor).
  • Add the following executeOperation function above that closing brace:
    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata /* params */
    ) external returns (bool) {
        if (msg.sender != address(POOL)) revert NotPool();

        uint256 usdcBalance = IERC20(USDC).balanceOf(address(this));

        // Leg 1: Swap USDC -> WETH via low fee pool (0.05%)
        IERC20(USDC).approve(address(SWAP_ROUTER), amount);
        uint256 wethReceived = SWAP_ROUTER.exactInputSingle(
            ISwapRouter.ExactInputSingleParams({
                tokenIn: USDC,
                tokenOut: WETH,
                fee: FEE_LOW,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: amount,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            })
        );

        // Leg 2: Swap WETH -> USDC via high fee pool (0.3%)
        IERC20(WETH).approve(address(SWAP_ROUTER), wethReceived);
        uint256 usdcReceived = SWAP_ROUTER.exactInputSingle(
            ISwapRouter.ExactInputSingleParams({
                tokenIn: WETH,
                tokenOut: USDC,
                fee: FEE_HIGH,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: wethReceived,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            })
        );

        // Repay flash loan
        uint256 totalDebt = amount + premium;
        if (usdcReceived < totalDebt) revert Unprofitable();

        IERC20(USDC).approve(address(POOL), totalDebt);

        return true;
    }

What does this code do?

This function is a callback. Aave calls it automatically after depositing the borrowed USDC into your contract. The first line checks that only the Aave Pool can trigger this function, preventing unauthorized calls.

  • Leg 1 swaps all borrowed USDC for WETH through the 0.05% fee pool. If this pool prices WETH slightly cheaper, you get more WETH per USDC.
  • Leg 2 swaps that WETH back to USDC through the 0.3% fee pool. If this pool prices WETH slightly higher, you get more USDC back than you started with.
  • The final check ensures you received enough USDC to cover the original loan plus the 0.05% Aave premium. If not, the entire transaction reverts with Unprofitable.

Why is amountOutMinimum set to 0?

Setting amountOutMinimum: 0 means the swap accepts any amount of output tokens. This is safe here because the entire transaction reverts at the end if the trade is unprofitable. The profitability check at if (usdcReceived < totalDebt) revert Unprofitable() serves as the real slippage guard for the whole operation.

  • Save src/ArbitrageExecutor.sol and run forge build to confirm it still compiles.

Seeing a type mismatch error?

Make sure the function is placed inside the ArbitrageExecutor contract (before its closing }), not outside it. Also check that the struct field names inside ExactInputSingleParams match exactly, including the curly braces around the named arguments.

Help me debug my executeOperation function compilation error

Add the flash loan entry point and profit withdrawal

The executeOperation callback handles what happens after the loan arrives. Now you need the function that actually requests the flash loan from Aave. You also need a way to withdraw any accumulated profit from the contract.

  • In src/ArbitrageExecutor.sol, add the following two functions below executeOperation (still inside the contract, before the final closing brace):
    function executeArbitrage(uint256 flashAmount) external onlyOwner {
        POOL.flashLoanSimple(
            address(this),
            USDC,
            flashAmount,
            bytes(""),
            0
        );
    }

    function withdrawProfit(address token) external onlyOwner {
        uint256 balance = IERC20(token).balanceOf(address(this));
        IERC20(token).transfer(owner, balance);
    }

What does this code do?

  • executeArbitrage is the external entry point your bot will call. It requests a flashLoanSimple from Aave, borrowing flashAmount of USDC. Aave then immediately calls executeOperation on your contract with the borrowed funds.
  • withdrawProfit lets the owner pull any profit that accumulates in the contract after successful arbitrage trades. Both functions use the onlyOwner modifier so only the deployer can trigger them.
  • Save src/ArbitrageExecutor.sol.
  • Run the final compilation check by running:
forge build
  • You should see successful compilation output with no warnings or errors. The compiled artifact will appear in the out/ directory.

Compiler says function is not found or undefined?

Check that executeArbitrage and withdrawProfit are inside the ArbitrageExecutor contract braces. Make sure you did not accidentally place them after the contract's closing } or inside another function.

Help me fix the forge build error for my ArbitrageExecutor contract

✔️ Awesome, I've got everything!

Great. Double check you have saved src/ArbitrageExecutor.sol and that forge build passes cleanly.

ⓧ I'd like to double check the full code

Here is the complete src/ArbitrageExecutor.sol file. Make sure yours matches exactly:

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

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

interface ISwapRouter {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 deadline;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }

    function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
}

interface IUniswapV3Pool {
    function slot0()
        external
        view
        returns (
            uint160 sqrtPriceX96,
            int24 tick,
            uint16 observationIndex,
            uint16 observationCardinality,
            uint16 observationCardinalityNext,
            uint8 feeProtocol,
            bool unlocked
        );
}

contract ArbitrageExecutor {
    IPool public immutable POOL;
    IPoolAddressesProvider public immutable ADDRESSES_PROVIDER;
    ISwapRouter public immutable SWAP_ROUTER;
    address public immutable WETH;
    address public immutable USDC;
    address public owner;

    uint24 public constant FEE_LOW = 500; // 0.05%
    uint24 public constant FEE_HIGH = 3000; // 0.3%

    error NotOwner();
    error NotPool();
    error Unprofitable();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor(
        address _addressesProvider,
        address _swapRouter,
        address _weth,
        address _usdc
    ) {
        ADDRESSES_PROVIDER = IPoolAddressesProvider(_addressesProvider);
        POOL = IPool(IPoolAddressesProvider(_addressesProvider).getPool());
        SWAP_ROUTER = ISwapRouter(_swapRouter);
        WETH = _weth;
        USDC = _usdc;
        owner = msg.sender;
    }

    function executeArbitrage(uint256 flashAmount) external onlyOwner {
        POOL.flashLoanSimple(
            address(this),
            USDC,
            flashAmount,
            bytes(""),
            0
        );
    }

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata /* params */
    ) external returns (bool) {
        if (msg.sender != address(POOL)) revert NotPool();

        uint256 usdcBalance = IERC20(USDC).balanceOf(address(this));

        // Leg 1: Swap USDC -> WETH via low fee pool (0.05%)
        IERC20(USDC).approve(address(SWAP_ROUTER), amount);
        uint256 wethReceived = SWAP_ROUTER.exactInputSingle(
            ISwapRouter.ExactInputSingleParams({
                tokenIn: USDC,
                tokenOut: WETH,
                fee: FEE_LOW,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: amount,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            })
        );

        // Leg 2: Swap WETH -> USDC via high fee pool (0.3%)
        IERC20(WETH).approve(address(SWAP_ROUTER), wethReceived);
        uint256 usdcReceived = SWAP_ROUTER.exactInputSingle(
            ISwapRouter.ExactInputSingleParams({
                tokenIn: WETH,
                tokenOut: USDC,
                fee: FEE_HIGH,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: wethReceived,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            })
        );

        // Repay flash loan
        uint256 totalDebt = amount + premium;
        if (usdcReceived < totalDebt) revert Unprofitable();

        IERC20(USDC).approve(address(POOL), totalDebt);

        return true;
    }

    function withdrawProfit(address token) external onlyOwner {
        uint256 balance = IERC20(token).balanceOf(address(this));
        IERC20(token).transfer(owner, balance);
    }
}

Your arbitrage contract compiles and is ready for deployment. Next up, you will write fork tests that prove this contract actually works against real Arbitrum mainnet liquidity.

Write Fork Tests Against Live Arbitrum State

Your ArbitrageExecutor contract compiles, but you have no proof it actually works against real DeFi protocols. Smart contracts that look correct in isolation can fail spectacularly when they interact with real pool liquidity, real token approvals, and real flash loan callbacks.

Foundry solves this with fork testing. You can fork live Arbitrum mainnet state into your local environment, deploy your contract against it, and run transactions against real protocol deployments. In this step, you will write a test suite that proves your contract works with real Aave and Uniswap liquidity.

In this step, get ready to:
  • Set up a fork test contract that deploys your ArbitrageExecutor against live Arbitrum state.
  • Write a test that executes the full flash loan arbitrage flow.
  • Write protection tests that verify the contract reverts when unprofitable or called by unauthorized addresses.
Set up the test contract with fork configuration

Foundry's vm.createSelectFork cheatcode pulls the entire state of a live blockchain into your test environment. Every contract, every token balance, every pool's liquidity position becomes available locally.

  • Create a new file called test/ArbitrageExecutor.t.sol inside your project's test/ directory.
  • Add the imports, contract declaration, constants, and setUp function by pasting this code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import {ArbitrageExecutor} from "../src/ArbitrageExecutor.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ArbitrageExecutorTest is Test {
    ArbitrageExecutor public executor;

    // Verified Arbitrum mainnet addresses
    address constant AAVE_POOL_ADDRESSES_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb;
    address constant UNISWAP_SWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;
    address constant USDC_WHALE = 0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7;

    function setUp() public {
        // Fork live Arbitrum state into this test environment
        vm.createSelectFork(vm.envString("ARBITRUM_RPC_URL"));

        // Deploy our contract against the forked state
        executor = new ArbitrageExecutor(
            AAVE_POOL_ADDRESSES_PROVIDER,
            UNISWAP_SWAP_ROUTER,
            WETH,
            USDC
        );
    }
}

What does this code do?

The constants at the top are verified Arbitrum mainnet addresses for Aave V3, Uniswap V3, WETH, and native USDC. These are the real deployed protocol contracts your test will interact with.

vm.createSelectFork(vm.envString("ARBITRUM_RPC_URL")) pulls the current Arbitrum state via your Alchemy RPC. Then new ArbitrageExecutor(...) deploys your contract into that forked state, so it can call the real Aave Pool and real Uniswap Router.

  • Save test/ArbitrageExecutor.t.sol.
  • Confirm it compiles by running:
forge build

You should see Compiler run successful with no errors.

Seeing an import error?

If the compiler cannot find forge-std/Test.sol, make sure you installed forge-std in Step 1. Run forge install foundry-rs/forge-std --no-commit to add it.

Check that your remappings.txt or foundry.toml includes the correct paths for forge-std and @openzeppelin.

Why can't my test file find forge-std?

Write the flash loan execution test

The first test proves the full flash loan flow executes against real protocol state. It borrows 100,000 USDC from Aave, swaps through both Uniswap pools, and logs whatever profit (or loss) results.

  • In test/ArbitrageExecutor.t.sol, add this test function inside the ArbitrageExecutorTest contract, below the setUp() function:
function test_FlashLoanArbitrage_ExecutesSuccessfully() public {
    // Flash loan 100k USDC and attempt arbitrage
    uint256 flashAmount = 100_000 * 1e6; // 100k USDC (6 decimals)

    // Execute the arbitrage
    executor.executeArbitrage(flashAmount);

    // Log any profit the contract retained
    uint256 profit = IERC20(USDC).balanceOf(address(executor));
    emit log_named_uint("Profit (USDC wei):", profit);
}

What does this test do?

100_000 * 1e6 is 100,000 USDC (USDC uses 6 decimals, not 18). The test calls executeArbitrage which triggers the full flash loan cycle: borrow from Aave, swap through both pools, repay.

If the swap results in more USDC than the flash loan + premium, the contract keeps the difference. If not, the contract's Unprofitable error will revert the transaction. This test passes when market conditions happen to provide a small spread between the two fee-tier pools.

Why might this test revert?

Under normal market equilibrium, arbitrageurs have already closed any price gap between fee tiers. This means your contract will revert with Unprofitable because swapping through two pools with fees always loses money when prices are balanced. That is exactly what the next test validates.

  • Save test/ArbitrageExecutor.t.sol.
Write protection tests for unprofitable and unauthorized calls

A production smart contract must protect against two failure modes: executing when there is no profit (losing money to fees), and unauthorized callers draining funds. These tests prove both guards work.

  • Add the unprofitability revert test below your first test function:
function test_FlashLoanArbitrage_RevertsWhenUnprofitable() public {
    // At equilibrium, swapping through two fee-tier pools always loses money
    uint256 flashAmount = 100_000 * 1e6;

    // Expect the Unprofitable custom error
    vm.expectRevert(ArbitrageExecutor.Unprofitable.selector);
    executor.executeArbitrage(flashAmount);
}

What does this test do?

vm.expectRevert(ArbitrageExecutor.Unprofitable.selector) tells Foundry that the next call MUST revert with the Unprofitable custom error. If the call succeeds instead, the test fails.

This test is your safety proof. It confirms that when prices are in equilibrium (no arbitrage opportunity), your contract refuses to execute rather than losing money to swap fees and the flash loan premium.

  • Now add the two access control tests below the revert test:
function test_OnlyOwnerCanExecute() public {
    // Simulate a call from an unauthorized address
    address attacker = makeAddr("attacker");
    vm.prank(attacker);
    vm.expectRevert(ArbitrageExecutor.NotOwner.selector);
    executor.executeArbitrage(1000 * 1e6);
}

function test_OnlyOwnerCanWithdraw() public {
    // Simulate an attacker trying to drain profits
    address attacker = makeAddr("attacker");
    vm.prank(attacker);
    vm.expectRevert(ArbitrageExecutor.NotOwner.selector);
    executor.withdrawProfit(USDC);
}

What do these tests do?

makeAddr("attacker") creates a fresh address. vm.prank(attacker) makes the next call appear to come from that address instead of the test contract (which is the owner).

These tests verify that only the contract deployer can trigger arbitrage or withdraw profits. Without these guards, anyone could call your contract and steal accumulated funds.

  • Save test/ArbitrageExecutor.t.sol.
  • Run the protection tests (which do not require a fork) by running:
forge test --fork-url $ARBITRUM_RPC_URL -vv

You should see the access control tests and the revert test pass. The -vv flag shows verbose output including any emit log_named_uint values from your execution test.

Which tests should pass?

test_FlashLoanArbitrage_RevertsWhenUnprofitable, test_OnlyOwnerCanExecute, and test_OnlyOwnerCanWithdraw will reliably pass because they test revert conditions that hold regardless of market state.

test_FlashLoanArbitrage_ExecutesSuccessfully depends on whether a price discrepancy exists between the 0.05% and 0.3% fee pools at the forked block. Under normal conditions it will revert with Unprofitable. This is expected behavior. The test demonstrates the execution path and logs profit when an opportunity does exist.

Tests failing with RPC errors?

Make sure your ARBITRUM_RPC_URL environment variable is set. Run echo $ARBITRUM_RPC_URL to check. If it is empty, export it with your Alchemy URL.

If you see rate limit errors, wait 30 seconds and try again. Alchemy's free tier allows 300 requests per second, but fork tests can be heavy.

Why are my fork tests failing with RPC errors?

✔️ Awesome, I've got everything!

Great. Double check you have saved test/ArbitrageExecutor.t.sol and your protection tests pass.

ⓧ I'd like to double check the full code

Here is the complete test/ArbitrageExecutor.t.sol file:

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

import "forge-std/Test.sol";
import {ArbitrageExecutor} from "../src/ArbitrageExecutor.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ArbitrageExecutorTest is Test {
    ArbitrageExecutor public executor;

    address constant AAVE_POOL_ADDRESSES_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb;
    address constant UNISWAP_SWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;
    address constant USDC_WHALE = 0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7;

    function setUp() public {
        vm.createSelectFork(vm.envString("ARBITRUM_RPC_URL"));
        executor = new ArbitrageExecutor(
            AAVE_POOL_ADDRESSES_PROVIDER,
            UNISWAP_SWAP_ROUTER,
            WETH,
            USDC
        );
    }

    function test_FlashLoanArbitrage_ExecutesSuccessfully() public {
        uint256 flashAmount = 100_000 * 1e6;
        executor.executeArbitrage(flashAmount);
        uint256 profit = IERC20(USDC).balanceOf(address(executor));
        emit log_named_uint("Profit (USDC wei):", profit);
    }

    function test_FlashLoanArbitrage_RevertsWhenUnprofitable() public {
        uint256 flashAmount = 100_000 * 1e6;
        vm.expectRevert(ArbitrageExecutor.Unprofitable.selector);
        executor.executeArbitrage(flashAmount);
    }

    function test_OnlyOwnerCanExecute() public {
        address attacker = makeAddr("attacker");
        vm.prank(attacker);
        vm.expectRevert(ArbitrageExecutor.NotOwner.selector);
        executor.executeArbitrage(1000 * 1e6);
    }

    function test_OnlyOwnerCanWithdraw() public {
        address attacker = makeAddr("attacker");
        vm.prank(attacker);
        vm.expectRevert(ArbitrageExecutor.NotOwner.selector);
        executor.withdrawProfit(USDC);
    }
}

Your test suite now proves the contract integrates correctly with live Aave and Uniswap deployments. Next up, you will build the off-chain TypeScript bot that monitors pool prices in real time and triggers your contract when it spots a profitable spread.

Build the Off-Chain Price Monitor

Your smart contract is tested and ready. The Foundry fork tests prove ArbitrageExecutor can borrow, swap, and repay within a single transaction against live Arbitrum state.

Now you need the off-chain brain that watches pool prices and decides when to pull the trigger. In this step, you will build a TypeScript monitoring bot that reads real-time price data from Uniswap V3 pools and fires the contract when an opportunity appears.

In this step, get ready to:
  • Create price math utilities that convert raw Uniswap V3 pool data into human-readable prices.
  • Build a profitability calculator that accounts for flash loan premiums and gas costs.
  • Wire up a polling loop that monitors prices every 2 seconds and triggers the contract when the spread is profitable.
Create the price utilities and pool reader

Uniswap V3 stores prices in a compressed format called sqrtPriceX96. This is the square root of the price, scaled by 2^96 for fixed-point precision. Your bot needs utility functions to decode this into dollars-per-ETH values it can compare.

  • Create a new file called bot/utils.ts and add the following price math utilities:
import { ethers } from "ethers";

/**
 * Converts sqrtPriceX96 from a Uniswap V3 pool to a human-readable price.
 * @param sqrtPriceX96 - The sqrtPriceX96 value from pool.slot0()
 * @param token0Decimals - Decimals of token0
 * @param token1Decimals - Decimals of token1
 * @returns Price of token0 in terms of token1
 */
export function sqrtPriceX96ToPrice(
  sqrtPriceX96: bigint,
  token0Decimals: number,
  token1Decimals: number
): number {
  const numerator = sqrtPriceX96 * sqrtPriceX96;
  const denominator = BigInt(2) ** BigInt(192);
  const rawPrice = Number(numerator) / Number(denominator);
  return rawPrice * 10 ** (token0Decimals - token1Decimals);
}

/**
 * Calculates the minimum profit threshold accounting for gas costs on Arbitrum.
 * @param gasPrice - Current gas price in wei
 * @param estimatedGas - Estimated gas units for the arbitrage tx
 * @param ethPrice - Current ETH price in USD
 * @returns Minimum profit in USD to break even
 */
export function calculateMinProfit(
  gasPrice: bigint,
  estimatedGas: bigint,
  ethPrice: number
): number {
  const gasCostWei = gasPrice * estimatedGas;
  const gasCostEth = Number(ethers.formatEther(gasCostWei));
  return gasCostEth * ethPrice;
}

/**
 * Formats a USDC amount from wei (6 decimals) to human-readable string.
 */
export function formatUSDC(amount: bigint): string {
  return ethers.formatUnits(amount, 6);
}

/**
 * Checks if a given spread between two prices exceeds the Aave flash loan premium.
 * @param priceLow - Lower price (buy side)
 * @param priceHigh - Higher price (sell side)
 * @returns true if spread exceeds 0.05% (Aave premium)
 */
export function isSpreadProfitable(priceLow: number, priceHigh: number): boolean {
  const spreadBps = ((priceHigh - priceLow) / priceLow) * 10000;
  return spreadBps > 5; // Must exceed 5 bps (0.05%) to cover flash loan premium
}

What does this code do?

This utility module exports four helper functions your bot will use.

  • sqrtPriceX96ToPrice decodes Uniswap V3's compressed price format. It squares the value, divides by 2^192, and adjusts for token decimal differences (WETH has 18 decimals, USDC has 6).
  • calculateMinProfit converts gas costs from wei into USD so you can compare them against expected arbitrage profit.
  • isSpreadProfitable checks whether the price difference between two pools exceeds Aave's 5 basis point (0.05%) flash loan premium.
  • Save bot/utils.ts.

Now create the main monitor file that uses these utilities to read live pool prices.

  • Create a new file called bot/monitor.ts and add the imports, constants, and pool-reading functions:
import { ethers } from "ethers";

// Arbitrum contract addresses
const UNISWAP_POOL_005 = "0xC6962004f452bE9203591991D15f6b388e09E8D0"; // WETH/USDC 0.05%
const UNISWAP_POOL_030 = "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443"; // WETH/USDC 0.3%
const ARBITRAGE_EXECUTOR = "your-deployed-contract-address-here";

const POOL_ABI = [
  "function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)",
];

const EXECUTOR_ABI = [
  "function executeArbitrage(uint256 flashAmount) external",
];

// Trading parameters
const AAVE_PREMIUM_BPS = 5; // 0.05%
const MIN_PROFIT_USD = 1; // Minimum $1 profit to execute
const FLASH_AMOUNT = ethers.parseUnits("100000", 6); // 100k USDC

// Convert Uniswap V3 sqrtPriceX96 to a human-readable price
function sqrtPriceX96ToPrice(sqrtPriceX96: bigint, decimals0: number, decimals1: number): number {
  const price = Number(sqrtPriceX96) ** 2 / 2 ** 192;
  return price * 10 ** (decimals0 - decimals1);
}

// Read the current price from a Uniswap V3 pool's slot0
async function getPriceFromPool(pool: ethers.Contract): Promise<number> {
  const slot0 = await pool.slot0();
  const sqrtPriceX96 = slot0.sqrtPriceX96;
  // WETH is token0, USDC is token1 on Arbitrum pools
  // Price = how much USDC per WETH
  return sqrtPriceX96ToPrice(sqrtPriceX96, 18, 6);
}

What does this code do?

This sets up your monitor's foundation.

  • UNISWAP_POOL_005 and UNISWAP_POOL_030 are the two Uniswap V3 pool contracts your bot will compare. They trade the same pair (WETH/USDC) but charge different swap fees.
  • POOL_ABI defines the minimal interface to call slot0() on each pool. This function returns the current price state without requiring a transaction.
  • FLASH_AMOUNT is set to 100,000 USDC. This is the amount the bot will borrow via flash loan when it detects an opportunity.
  • getPriceFromPool calls slot0() on a pool contract and converts the raw sqrtPriceX96 to a dollars-per-WETH price using WETH's 18 decimals and USDC's 6 decimals.

Why two sqrtPriceX96ToPrice functions?

The version in utils.ts uses BigInt math for precision and is exported for reuse across modules. The version in monitor.ts is a simpler inline implementation using Number math, optimized for the hot polling loop where speed matters more than edge-case precision.

  • Save bot/monitor.ts.

Seeing import errors in your editor?

Make sure you installed ethers in the bot/ directory during Step 1. Run npm list ethers inside the bot/ folder to confirm it is installed.

help me fix ethers import errors in my TypeScript bot

Implement the profitability calculation

Reading prices is only half the job. Your bot needs to determine whether the price difference between the two pools is large enough to cover the flash loan premium and still leave a profit.

  • In bot/monitor.ts, add the following function below getPriceFromPool:
// Calculate expected profit from the two-leg arbitrage
function calculateProfitability(priceLow: number, priceHigh: number, amount: number): number {
  // Buy WETH with USDC at priceLow pool, sell WETH for USDC at priceHigh pool
  const wethReceived = amount / priceLow;
  const usdcReceived = wethReceived * priceHigh;
  const premium = amount * (AAVE_PREMIUM_BPS / 10000);
  const profit = usdcReceived - amount - premium;
  return profit;
}

What does this code do?

This function simulates the full arbitrage trade off-chain before committing gas.

  • It calculates how much WETH you would receive by swapping your USDC at the lower-priced pool (0.05% fee).
  • It then calculates how much USDC you would get back by selling that WETH at the higher-priced pool (0.3% fee).
  • Finally it subtracts the original amount and the Aave premium (5 basis points on the borrowed amount) to get the net profit.
  • Save bot/monitor.ts.

You will not see any output yet. The next chunk adds the main polling loop that calls this function.

Add the execution trigger and polling loop

The final piece is the main loop. It connects to your Arbitrum RPC, polls both pools every 2 seconds, and calls the smart contract when the spread is profitable.

  • In bot/monitor.ts, add the following monitor() function and its invocation below calculateProfitability:
async function monitor() {
  const provider = new ethers.JsonRpcProvider(process.env.ARBITRUM_RPC_URL);
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);

  const poolLow = new ethers.Contract(UNISWAP_POOL_005, POOL_ABI, provider);
  const poolHigh = new ethers.Contract(UNISWAP_POOL_030, POOL_ABI, provider);
  const executor = new ethers.Contract(ARBITRAGE_EXECUTOR, EXECUTOR_ABI, wallet);

  console.log("Starting arbitrage monitor...");
  console.log(`Flash amount: ${ethers.formatUnits(FLASH_AMOUNT, 6)} USDC`);

  setInterval(async () => {
    try {
      const [priceLow, priceHigh] = await Promise.all([
        getPriceFromPool(poolLow),
        getPriceFromPool(poolHigh),
      ]);

      const spreadBps = ((priceHigh - priceLow) / priceLow) * 10000;
      const profit = calculateProfitability(
        priceLow,
        priceHigh,
        Number(ethers.formatUnits(FLASH_AMOUNT, 6))
      );

      console.log(
        `[${new Date().toISOString()}] ` +
          `Low pool: $${priceLow.toFixed(2)} | ` +
          `High pool: $${priceHigh.toFixed(2)} | ` +
          `Spread: ${spreadBps.toFixed(2)} bps | ` +
          `Est. profit: $${profit.toFixed(2)}`
      );

      if (profit > MIN_PROFIT_USD) {
        console.log(`EXECUTING ARBITRAGE - Expected profit: $${profit.toFixed(2)}`);
        const tx = await executor.executeArbitrage(FLASH_AMOUNT);
        console.log(`TX submitted: ${tx.hash}`);
        const receipt = await tx.wait();
        console.log(`TX confirmed in block ${receipt.blockNumber}`);
      }
    } catch (error) {
      console.error("Error:", error);
    }
  }, 2000); // Poll every 2 seconds
}

monitor();

What does this code do?

The monitor() function is the bot's main event loop.

  • It connects to Arbitrum using your Alchemy RPC URL and creates contract instances for both pools and your ArbitrageExecutor.
  • Every 2 seconds, it reads slot0() from both pools in parallel using Promise.all to minimize latency.
  • It logs the current spread and estimated profit on every tick so you can observe market conditions.
  • When estimated profit exceeds MIN_PROFIT_USD ($1), it calls executeArbitrage on the deployed contract and waits for confirmation.

Why poll instead of using WebSocket events?

Production MEV bots subscribe to pending transactions via WebSockets for sub-second reaction times. Polling at 2-second intervals is simpler to debug and sufficient for demonstrating the architecture. You can swap in a WebSocket provider and event listeners later without changing the contract.

  • Save bot/monitor.ts.
  • Compile the TypeScript to verify there are no type errors by running:
cd bot && npx tsc --noEmit

You should see no output if compilation succeeds. Any type errors will be printed to the terminal.

Seeing TypeScript compilation errors?

  • If you see errors about process.env, make sure your tsconfig.json includes "types": ["node"] and you have @types/node installed.
  • If you see Cannot find module 'ethers', run npm install ethers inside the bot/ directory.

help me fix TypeScript compilation errors in my arbitrage bot

✔️ Awesome, I've got everything!

Great. Double check you have saved both bot/utils.ts and bot/monitor.ts.

ⓧ I'd like to double check the full code

Here is the complete bot/utils.ts:

import { ethers } from "ethers";

export function sqrtPriceX96ToPrice(
  sqrtPriceX96: bigint,
  token0Decimals: number,
  token1Decimals: number
): number {
  const numerator = sqrtPriceX96 * sqrtPriceX96;
  const denominator = BigInt(2) ** BigInt(192);
  const rawPrice = Number(numerator) / Number(denominator);
  return rawPrice * 10 ** (token0Decimals - token1Decimals);
}

export function calculateMinProfit(
  gasPrice: bigint,
  estimatedGas: bigint,
  ethPrice: number
): number {
  const gasCostWei = gasPrice * estimatedGas;
  const gasCostEth = Number(ethers.formatEther(gasCostWei));
  return gasCostEth * ethPrice;
}

export function formatUSDC(amount: bigint): string {
  return ethers.formatUnits(amount, 6);
}

export function isSpreadProfitable(priceLow: number, priceHigh: number): boolean {
  const spreadBps = ((priceHigh - priceLow) / priceLow) * 10000;
  return spreadBps > 5;
}

Here is the complete bot/monitor.ts:

import { ethers } from "ethers";

const UNISWAP_POOL_005 = "0xC6962004f452bE9203591991D15f6b388e09E8D0";
const UNISWAP_POOL_030 = "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443";
const ARBITRAGE_EXECUTOR = "your-deployed-contract-address-here";

const POOL_ABI = [
  "function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)",
];

const EXECUTOR_ABI = [
  "function executeArbitrage(uint256 flashAmount) external",
];

const AAVE_PREMIUM_BPS = 5;
const MIN_PROFIT_USD = 1;
const FLASH_AMOUNT = ethers.parseUnits("100000", 6);

function sqrtPriceX96ToPrice(sqrtPriceX96: bigint, decimals0: number, decimals1: number): number {
  const price = Number(sqrtPriceX96) ** 2 / 2 ** 192;
  return price * 10 ** (decimals0 - decimals1);
}

async function getPriceFromPool(pool: ethers.Contract): Promise<number> {
  const slot0 = await pool.slot0();
  const sqrtPriceX96 = slot0.sqrtPriceX96;
  return sqrtPriceX96ToPrice(sqrtPriceX96, 18, 6);
}

function calculateProfitability(priceLow: number, priceHigh: number, amount: number): number {
  const wethReceived = amount / priceLow;
  const usdcReceived = wethReceived * priceHigh;
  const premium = amount * (AAVE_PREMIUM_BPS / 10000);
  const profit = usdcReceived - amount - premium;
  return profit;
}

async function monitor() {
  const provider = new ethers.JsonRpcProvider(process.env.ARBITRUM_RPC_URL);
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
  const poolLow = new ethers.Contract(UNISWAP_POOL_005, POOL_ABI, provider);
  const poolHigh = new ethers.Contract(UNISWAP_POOL_030, POOL_ABI, provider);
  const executor = new ethers.Contract(ARBITRAGE_EXECUTOR, EXECUTOR_ABI, wallet);
  console.log("Starting arbitrage monitor...");
  console.log(`Flash amount: ${ethers.formatUnits(FLASH_AMOUNT, 6)} USDC`);
  setInterval(async () => {
    try {
      const [priceLow, priceHigh] = await Promise.all([getPriceFromPool(poolLow), getPriceFromPool(poolHigh)]);
      const spreadBps = ((priceHigh - priceLow) / priceLow) * 10000;
      const profit = calculateProfitability(priceLow, priceHigh, Number(ethers.formatUnits(FLASH_AMOUNT, 6)));
      console.log(`[${new Date().toISOString()}] Low pool: $${priceLow.toFixed(2)} | High pool: $${priceHigh.toFixed(2)} | Spread: ${spreadBps.toFixed(2)} bps | Est. profit: $${profit.toFixed(2)}`);
      if (profit > MIN_PROFIT_USD) {
        console.log(`EXECUTING ARBITRAGE - Expected profit: $${profit.toFixed(2)}`);
        const tx = await executor.executeArbitrage(FLASH_AMOUNT);
        console.log(`TX submitted: ${tx.hash}`);
        const receipt = await tx.wait();
        console.log(`TX confirmed in block ${receipt.blockNumber}`);
      }
    } catch (error) {
      console.error("Error:", error);
    }
  }, 2000);
}

monitor();

Your off-chain monitor is ready. It can read live prices, calculate profitability, and trigger your smart contract. Next up, you will run a full integration test with Anvil to prove the entire pipeline works end-to-end.

Run Integration Tests with Anvil Fork

Your contract compiles, your tests pass against forked state, and your monitoring bot is ready to read prices. The missing piece is proving that all these components actually work together as a system.

In this step, you will deploy your contract to a local Anvil fork of Arbitrum mainnet and run your TypeScript bot against it. This is the same integration testing pattern that trading firms use before going live: fork mainnet, deploy, and simulate the full data-to-execution pipeline locally.

In this step, get ready to:
  • Start a local Anvil fork of Arbitrum and deploy the ArbitrageExecutor contract.
  • Run the TypeScript monitoring bot against the local fork end-to-end.
  • Validate the system by checking on-chain state and transaction logs.
Start Anvil and deploy the contract

Anvil is Foundry's local Ethereum node. When you start it with a fork URL, it clones the entire state of Arbitrum mainnet into memory. Your contract can then interact with real Aave and Uniswap deployments without spending real money.

  • Open a new terminal window in your project directory. You will keep this terminal dedicated to the Anvil process.
  • Start the Anvil fork by running this command:
anvil --fork-url $ARBITRUM_RPC_URL

What does this command do?

Anvil spins up a local JSON-RPC server on http://127.0.0.1:8545 that mirrors the current Arbitrum mainnet state. It also provides 10 pre-funded test accounts with 10,000 ETH each.

Every contract that exists on Arbitrum (Aave, Uniswap, all token balances) is now accessible locally. Any transaction you send here is free and instant.

  • You should see output showing available accounts, private keys, and a line that says Listening on 127.0.0.1:8545.
  • Copy the first private key from the output (it starts with 0xac0974...). You will use this to deploy and sign transactions.

Anvil not starting?

Make sure your ARBITRUM_RPC_URL environment variable is set. Try echo $ARBITRUM_RPC_URL to confirm it is not empty. If it is blank, export it again with your Alchemy URL.

help me debug why Anvil won't start with my fork URL

Now you need a Foundry deployment script. This script deploys your ArbitrageExecutor contract with the real Arbitrum protocol addresses.

  • Open a second terminal window (keep the Anvil process running in the first).
  • Create the script/ directory and the deployment file by running:
mkdir -p script && touch script/Deploy.s.sol
  • Open script/Deploy.s.sol in your editor and paste the following deployment script:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import {ArbitrageExecutor} from "../src/ArbitrageExecutor.sol";

contract DeployScript is Script {
    // Arbitrum mainnet protocol addresses
    address constant AAVE_POOL_ADDRESSES_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb;
    address constant UNISWAP_SWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;

    function run() external {
        vm.startBroadcast();
        ArbitrageExecutor executor = new ArbitrageExecutor(
            AAVE_POOL_ADDRESSES_PROVIDER,
            UNISWAP_SWAP_ROUTER,
            WETH,
            USDC
        );
        console.log("ArbitrageExecutor deployed at:", address(executor));
        vm.stopBroadcast();
    }
}

What does this script do?

Foundry scripts use the same Solidity language as contracts. The vm.startBroadcast() and vm.stopBroadcast() cheatcodes tell Forge to actually send the enclosed transactions on-chain (or to the local fork).

The script deploys ArbitrageExecutor with the same verified Arbitrum addresses used in your tests. After deployment, it logs the new contract address.

  • Save script/Deploy.s.sol.
  • Deploy the contract to your local Anvil fork by running this command (replace the private key with the one you copied from the Anvil output):
forge script script/Deploy.s.sol:DeployScript --rpc-url http://127.0.0.1:8545 --private-key [[ANVIL_PRIVATE_KEY="the first private key from your Anvil output"]] --broadcast
  • You should see output that includes ArbitrageExecutor deployed at: 0x... followed by a contract address. Copy this address.

Deployment failing with compilation errors?

Make sure your src/ArbitrageExecutor.sol compiles cleanly first. Run forge build to check. If the script fails with "could not find artifact", confirm the import path ../src/ArbitrageExecutor.sol matches your file structure.

help me fix my Foundry deployment script

Run the monitoring bot against the local fork

Your contract is now live on the local fork. The next step is pointing your TypeScript bot at the same fork so it reads real pool prices and can trigger your contract.

  • In bot/monitor.ts, find this line at the top of the file:
const ARBITRAGE_EXECUTOR = "your-deployed-contract-address-here";
  • Replace your-deployed-contract-address-here with the contract address you copied from the deployment output, so it looks like this:
const ARBITRAGE_EXECUTOR = "0xYourDeployedAddress";
  • Save bot/monitor.ts.
  • Now run the monitoring bot with the RPC URL pointed at your local Anvil fork. Set both environment variables and execute the bot:
ARBITRUM_RPC_URL=http://127.0.0.1:8545 PRIVATE_KEY=[[ANVIL_PRIVATE_KEY="the first private key from your Anvil output"]] npx ts-node bot/monitor.ts

What should I see?

The bot connects to your local Anvil fork and starts polling every 2 seconds. You should see log output like this:

Starting arbitrage monitor...
Flash amount: 100000.0 USDC
[2026-05-07T10:00:00.000Z] Low pool: $3421.50 | High pool: $3422.10 | Spread: 1.75 bps | Est. profit: $-32.50

The prices come from real Arbitrum pool state at the block Anvil forked from. A negative profit is expected because cross-fee-tier prices are typically in equilibrium. The important thing is that the bot successfully reads slot0() from both pools and calculates the spread.

  • Let the bot run for 3-4 polling cycles to confirm stable output, then stop it with Ctrl+C.

Bot crashing with connection errors?

Confirm your Anvil process is still running in the other terminal. If it stopped, restart it with the same anvil --fork-url command. Also verify you typed http://127.0.0.1:8545 (not https).

If you see a "PRIVATE_KEY" error, make sure you included the full key including the 0x prefix from the Anvil output.

help me debug my bot connection to the Anvil fork

Validate the system with cast

The bot confirmed it can read prices. Now use Foundry's cast tool to inspect on-chain state directly and verify the contract is deployed and callable.

  • Check the USDC balance of your deployed contract by running this command (replace the address with your deployed contract address):
cast call 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 "balanceOf(address)(uint256)" [[DEPLOYED_CONTRACT="your deployed contract address"]] --rpc-url http://127.0.0.1:8545

What does this command do?

This calls the balanceOf function on the USDC token contract, passing your ArbitrageExecutor's address as the argument. It returns how many USDC tokens your contract holds.

You should see 0 because no profitable trade has been executed. The contract starts with zero balance and only holds profit after a successful arbitrage.

  • Verify the contract's owner is set correctly by calling the owner function:
cast call [[DEPLOYED_CONTRACT="your deployed contract address"]] "owner()(address)" --rpc-url http://127.0.0.1:8545
  • The output should match the first Anvil account address (the one that deployed the contract). This confirms the access control is wired up correctly.
  • Try triggering the flash loan directly via cast to see the execution flow. Run this command to attempt the arbitrage:
cast send [[DEPLOYED_CONTRACT="your deployed contract address"]] "executeArbitrage(uint256)" 100000000000 --rpc-url http://127.0.0.1:8545 --private-key [[ANVIL_PRIVATE_KEY="the first private key from your Anvil output"]]

What should I see?

The value 100000000000 is 100,000 USDC (6 decimals). This transaction will likely revert with Unprofitable() because the pools are in equilibrium. That is the correct behavior.

The revert proves your entire execution path works: the flash loan fires, both swaps execute against real Uniswap V3 pools, and the profitability check correctly catches the loss. In production, you would only call this function when the bot detects a real spread.

  • Check the Anvil terminal for the transaction log. You should see the transaction hash and revert reason printed in the Anvil output.

Seeing a different error than Unprofitable?

If you see NotOwner(), the private key you are using does not match the one that deployed the contract. Use the same Anvil private key for both deployment and calling.

If you see a generic revert with no custom error, the flash loan itself may have failed. Confirm Anvil is still forking correctly by running cast call 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb "getPool()(address)" --rpc-url http://127.0.0.1:8545 to verify the Aave Pool is accessible.

help me debug my flash loan execution on the Anvil fork

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the full deployment script for comparison:

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

import "forge-std/Script.sol";
import {ArbitrageExecutor} from "../src/ArbitrageExecutor.sol";

contract DeployScript is Script {
    address constant AAVE_POOL_ADDRESSES_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb;
    address constant UNISWAP_SWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;

    function run() external {
        vm.startBroadcast();
        ArbitrageExecutor executor = new ArbitrageExecutor(
            AAVE_POOL_ADDRESSES_PROVIDER,
            UNISWAP_SWAP_ROUTER,
            WETH,
            USDC
        );
        console.log("ArbitrageExecutor deployed at:", address(executor));
        vm.stopBroadcast();
    }
}

You have now proven the entire pipeline works end-to-end: your bot reads real pool prices, calculates profitability, and your contract executes the full flash loan flow against live protocol state. This is a production-grade integration testing setup that DeFi teams use daily.

Secret mission

Multi-Path Route Optimizer

Your bot currently watches a single pair of pools. Production trading systems evaluate dozens of opportunities simultaneously and execute only the most profitable one per block. Build a multi-path optimizer that scans WETH/USDT, ARB/USDC, and your existing WETH/USDC pools to find the best arbitrage in real time.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally against a forked Arbitrum mainnet, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Foundry project folder on your Desktop (src/, test/, bot/ directories).
  • Anvil local fork process (if still running in your terminal).
  • Alchemy account (free tier, no charges).
  • Node.js dependencies (node_modules/ folder inside bot/).
  • Compiled contract artifacts (out/ directory).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Foundry project files remain on your Desktop.
  • If Anvil is still running, it continues serving forked state from the terminal window where you started it.
  • Your Alchemy free tier account stays active with no charges.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping all your files intact.

  • Stop the Anvil fork by pressing Ctrl+C in the terminal where it is running.
  • Stop the TypeScript monitor bot by pressing Ctrl+C in its terminal window.
  • Your project files, compiled artifacts, and Alchemy API key all remain ready for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Anvil fork and monitor bot by pressing Ctrl+C in each terminal window.
  • Delete your project folder from the Desktop by running rm -rf ~/Desktop/flash-loan-arb in your terminal.
  • Optionally delete your Alchemy account by going to your Alchemy dashboard, selecting Settings, and choosing Delete Account.
  • Optionally uninstall Foundry by running rm -rf ~/.foundry in your terminal.

That's a Wrap!

That's a Wrap!

Nice work! You just shipped a complete flash loan arbitrage system targeting price discrepancies between Uniswap V3 pools on Arbitrum.

You've learned how to:

  • Build a production-grade flash loan receiver contract implementing Aave V3's IFlashLoanSimpleReceiver interface with multi-pool DEX execution via Uniswap V3's exactInputSingle.
  • Build a TypeScript off-chain monitoring bot that reads real-time pool prices from slot0(), detects profitable arbitrage opportunities, and triggers on-chain execution.
  • Write fork tests against live Arbitrum mainnet state using Foundry's vm.createSelectFork and run full integration tests with Anvil proving the complete data-to-execution pipeline works.
  • Secret Mission: Added a multi-path route optimizer that evaluates multiple token pairs and fee tiers simultaneously to find the most profitable arbitrage per block.

Ready to quiz yourself?