Welcome to Arbitrage Basics

In this little blog post series we want to educate you about the math, finance and programming aspects of arbitrage trading. In today’s episode we will explain some basics and show you how you can get started writing your own arbitrage trading scripts in JS. Obviously this wont generate any real profits, but knowing the basics is important to understand how the ones that do work.

The most basic kind of Arbitrage: Triangular Arbitrage

Triangular arbitrage exploits price discrepancies between three trading pairs to generate profits.
The concept is elegantly simple: start with ETH, trade it for LINK, then LINK for SHIB, then SHIB back to ETH. If exchange rates are misaligned, you end up with more ETH than you started with. The profit comes from temporary price inefficiencies that occur due to large trades or delayed arbitrage by other traders.

Understanding Liquidity (Uniswap) Pairs

So for today’s example we will assume we are working with classic Constant Product Liquidity Pools. Each of these Uniswap pairs is a smart contract holding reserves of two tokens. Exchange rates follow the constant product formula: x * y = k, where x and y are token reserves and k remains constant.

When you trade token A for token B, you deposit A (increasing its reserve) and withdraw B (decreasing its reserve). The withdrawal amount maintains the constant product relationship, minus a 0.3% trading fee.

Key Functions We’ll Use

  • getReserves() returns current reserves of both tokens plus a timestamp. This tells us the current exchange rate and lets us calculate trade outputs.
  • getPair() from the factory contract finds the pair address for any two tokens. Returns zero address if no pair exists.
  • token0() and token1() reveal token ordering within pairs. Uniswap stores tokens based on their contract addresses, and we need this to interpret reserves correctly.

Project Setup

First, let’s set up our development environment:

npm init -y
npm install ethers dotenv

Add "type": "module" to your package.json to enable ES modules:

{
  "name": "triangular-arbitrage-bot",
  "version": "1.0.0",
  "type": "module",
  "main": "arbitrage.js",
  "dependencies": {
    "ethers": "^6.14.4",
    "dotenv": "^17.0.0"
  }
}

Create a .env file with your configuration:

RPC_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID
PRIVATE_KEY=your_private_key_here

Understanding Contract Interactions with Ethers.js

Before diving into the core implementation, let’s understand how to interact with smart contracts using ethers.js and where to get the necessary ABIs.

What is an ABI?

ABI (Application Binary Interface) is a JSON file that describes how to interact with a smart contract. It contains function signatures, parameter types, and return types. Think of it as a contract’s API documentation that tells your code how to call its functions.

Getting Contract ABIs

There are several ways to obtain contract ABIs:

  1. Etherscan: For verified contracts, visit etherscan.io, search for the contract address, and copy the ABI from the “Contract” tab
  2. GitHub repositories: Many projects publish their ABIs in their official repositories
  3. Uniswap SDK: Contains pre-built ABIs for Uniswap contracts
  4. Manual creation: Define only the functions you need

Contract Interaction Patterns

Here’s how ethers.js contract interactions work:

// 1. Create contract instance
const contract = new ethers.Contract(contractAddress, abi, provider);

// 2. Read data (view functions) - free, no gas required
const reserves = await contract.getReserves();
const token0Address = await contract.token0();

// 3. Write data (state-changing functions) - requires gas and wallet
const contractWithSigner = contract.connect(wallet);
const tx = await contractWithSigner.swap(amount, path, to, deadline);
await tx.wait(); // Wait for transaction confirmation

ABI Optimization

For our arbitrage bot, we only need specific functions, so we can use minimal ABIs:

// Minimal Uniswap V2 Factory ABI
const factoryABI = [
    'function getPair(address tokenA, address tokenB) external view returns (address pair)'
];

// Minimal Uniswap V2 Pair ABI
const pairABI = [
    'function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
    'function token0() external view returns (address)',
    'function token1() external view returns (address)'
];

// Minimal Uniswap V2 Router ABI
const routerABI = [
    'function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external returns (uint[] memory amounts)'
];

This approach reduces bundle size and improves performance by only including necessary function definitions.

Core Implementation

Now let’s build our triangular arbitrage bot step by step:

import { ethers } from 'ethers';
import dotenv from 'dotenv';
dotenv.config();

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

const routerAddress = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D';
const factoryAddress = '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f';

const tokens = {
    WETH: { address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', decimals: 18 },
    USDC: { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', decimals: 6 },
    DAI: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 },
    USDT: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }
};

const minProfitThreshold = ethers.parseEther('0.01');

Fetching Pair Reserves

The first crucial step is retrieving current reserves from Uniswap pairs:

async function getReserves(tokenA, tokenB) {
    const factory = new ethers.Contract(factoryAddress, factoryABI, provider);
    const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
    
    if (pairAddress === ethers.ZeroAddress) {
        throw new Error(`No pair exists for ${tokenA.address}/${tokenB.address}`);
    }
    
    const pair = new ethers.Contract(pairAddress, pairABI, provider);
    const [reserve0, reserve1] = await pair.getReserves();
    const token0 = await pair.token0();
    
    // Match reserves to correct tokens
    if (token0.toLowerCase() === tokenA.address.toLowerCase()) {
        return { reserveA: reserve0, reserveB: reserve1 };
    } else {
        return { reserveA: reserve1, reserveB: reserve0 };
    }
}

This function finds the pair contract, gets current reserves, and matches them to the correct tokens. Token ordering matters because Uniswap stores them alphabetically by address.

Calculating Trade Outputs

Next, we need to calculate exactly how many tokens we’ll receive from each trade:

function getAmountOut(amountIn, reserveIn, reserveOut) {
    if (amountIn <= 0n || reserveIn <= 0n || reserveOut <= 0n) {
        throw new Error('Invalid input parameters');
    }
    
    const amountInWithFee = amountIn * 997n;
    const numerator = amountInWithFee * reserveOut;
    const denominator = (reserveIn * 1000n) + amountInWithFee;
    
    return numerator / denominator;
}

This implements Uniswap’s exact formula. The input amount is multiplied by 997 to account for the 0.3% fee (1000 - 3 = 997). The calculation follows the constant product formula.

Understanding the ’n’ Suffix (BigInt)

You’ll notice the n suffix on numbers like 997n, 1000n, and 0n throughout the code. This denotes BigInt literals in JavaScript, which are essential for Ethereum development.

Ethereum deals with very large numbers - token amounts can have up to 18 decimal places (wei is the smallest unit). JavaScript’s regular Number type loses precision with large integers, which would cause calculation errors when dealing with token amounts.

The BigInt type maintains exact precision for integer arithmetic:

  • 997n represents the fee calculation (1000 - 3 = 997 for the 0.3% Uniswap fee)
  • 0n is used for zero comparisons in BigInt context
  • 95n / 100n calculates 5% slippage protection

Without the n suffix, these would be regular numbers and cause precision issues that could result in failed transactions or incorrect profit calculations.

Understanding Token Decimals

Different tokens use different decimal precision:

  • WETH, DAI: 18 decimals (1 token = 1e18 units)
  • USDC, USDT: 6 decimals (1 token = 1e6 units)

This is crucial for arbitrage calculations. When trading between tokens with different decimals, Uniswap handles the conversion automatically through its constant product formula. The reserves are stored in each token’s native decimal format, so our calculations work correctly without manual conversion.

For example, when trading 1 WETH (1e18) for USDT, you might receive 3000 USDT (3000e6), which is mathematically correct even though the raw numbers look very different.

Finding Arbitrage Opportunities

Now for the core logic that identifies profitable triangular arbitrage opportunities:

async function checkArbitrageOpportunity(tokenA, tokenB, tokenC, amountIn) {
    try {
        const reservesAB = await getReserves(tokenA, tokenB);
        const reservesBC = await getReserves(tokenB, tokenC);
        const reservesCA = await getReserves(tokenC, tokenA);
        
        const amountB = getAmountOut(
            amountIn,
            reservesAB.reserveA,
            reservesAB.reserveB
        );
        
        const amountC = getAmountOut(
            amountB,
            reservesBC.reserveA,
            reservesBC.reserveB
        );
        
        const finalAmountA = getAmountOut(
            amountC,
            reservesCA.reserveA,
            reservesCA.reserveB
        );
        
        const profit = finalAmountA - amountIn;
        const profitPercentage = (Number(profit) / Number(amountIn)) * 100;
        
        return {
            profitable: profit > minProfitThreshold,
            profit: profit,
            profitPercentage: profitPercentage,
            path: [tokenA.address, tokenB.address, tokenC.address, tokenA.address],
            amounts: [amountIn, amountB, amountC, finalAmountA]
        };
        
    } catch (error) {
        console.error('Error checking arbitrage opportunity:', error.message);
        return { profitable: false, profit: 0n, profitPercentage: 0 };
    }
}

This function traces the trading path through all three pairs, calculating expected outputs at each step. If we end up with more than we started with, we have a profitable opportunity.

Executing Trades

When we find a profitable opportunity, we need to execute it quickly:

async function executeArbitrage(opportunity) {
    if (!opportunity.profitable) {
        console.log('No profitable opportunity found');
        return;
    }
    
    console.log(`Executing arbitrage with ${opportunity.profitPercentage.toFixed(4)}% profit`);
    
    const routerABI = [
        'function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external returns (uint[] memory amounts)'
    ];
    
    const router = new ethers.Contract(routerAddress, routerABI, wallet);
    const deadline = Math.floor(Date.now() / 1000) + 60 * 20;
    
    try {
        // Execute three sequential swaps
        const tx1 = await router.swapExactTokensForTokens(
            opportunity.amounts[0],
            opportunity.amounts[1] * 95n / 100n,
            [opportunity.path[0], opportunity.path[1]],
            wallet.address,
            deadline,
            { gasLimit: 300000 }
        );
        
        await tx1.wait();
        
        const tx2 = await router.swapExactTokensForTokens(
            opportunity.amounts[1],
            opportunity.amounts[2] * 95n / 100n,
            [opportunity.path[1], opportunity.path[2]],
            wallet.address,
            deadline,
            { gasLimit: 300000 }
        );
        
        await tx2.wait();
        
        const tx3 = await router.swapExactTokensForTokens(
            opportunity.amounts[2],
            opportunity.amounts[3] * 95n / 100n,
            [opportunity.path[2], opportunity.path[3]],
            wallet.address,
            deadline,
            { gasLimit: 300000 }
        );
        
        await tx3.wait();
        console.log('Arbitrage completed successfully!');
        
    } catch (error) {
        console.error('Error executing arbitrage:', error.message);
    }
}

Three separate transactions execute the complete arbitrage cycle. Each includes 5% slippage protection and a 20-minute deadline.

Monitoring Loop

Finally, we need a continuous monitoring system to scan for opportunities:

async function startMonitoring() {
    console.log('Starting triangular arbitrage monitoring...');
    
    // All possible triangular paths with 4 currencies
    const triangles = [
        // WETH as starting point
        [tokens.WETH, tokens.USDC, tokens.DAI],
        [tokens.WETH, tokens.USDC, tokens.USDT],
        [tokens.WETH, tokens.DAI, tokens.USDC],
        [tokens.WETH, tokens.DAI, tokens.USDT],
        [tokens.WETH, tokens.USDT, tokens.USDC],
        [tokens.WETH, tokens.USDT, tokens.DAI],
        
        // USDC as starting point
        [tokens.USDC, tokens.WETH, tokens.DAI],
        [tokens.USDC, tokens.WETH, tokens.USDT],
        [tokens.USDC, tokens.DAI, tokens.WETH],
        [tokens.USDC, tokens.DAI, tokens.USDT],
        [tokens.USDC, tokens.USDT, tokens.WETH],
        [tokens.USDC, tokens.USDT, tokens.DAI],
        
        // DAI as starting point
        [tokens.DAI, tokens.WETH, tokens.USDC],
        [tokens.DAI, tokens.WETH, tokens.USDT],
        [tokens.DAI, tokens.USDC, tokens.WETH],
        [tokens.DAI, tokens.USDC, tokens.USDT],
        [tokens.DAI, tokens.USDT, tokens.WETH],
        [tokens.DAI, tokens.USDT, tokens.USDC],
        
        // USDT as starting point
        [tokens.USDT, tokens.WETH, tokens.USDC],
        [tokens.USDT, tokens.WETH, tokens.DAI],
        [tokens.USDT, tokens.USDC, tokens.WETH],
        [tokens.USDT, tokens.USDC, tokens.DAI],
        [tokens.USDT, tokens.DAI, tokens.WETH],
        [tokens.USDT, tokens.DAI, tokens.USDC]
    ];
    
    while (true) {
        console.log(`\nScanning ${triangles.length} triangular arbitrage paths...`);
        
        for (let i = 0; i < triangles.length; i++) {
            const [tokenA, tokenB, tokenC] = triangles[i];
            
            // Use appropriate amount based on starting token decimals
            let startAmount;
            if (tokenA.decimals === 18) {
                startAmount = ethers.parseEther('1'); // 1 token with 18 decimals
            } else {
                startAmount = BigInt(1000000); // 1 token with 6 decimals (1 * 10^6)
            }
            
            const opportunity = await checkArbitrageOpportunity(
                tokenA, tokenB, tokenC, startAmount
            );
            
            if (opportunity.profitable) {
                console.log(`🎯 PROFITABLE ARBITRAGE FOUND!`);
                console.log(`Path: ${opportunity.path.map(getTokenSymbol).join(' -> ')}`);
                console.log(`Profit: ${formatTokenAmount(opportunity.profit, tokenA.decimals)} ${getTokenSymbol(tokenA.address)}`);
                console.log(`Profit percentage: ${opportunity.profitPercentage.toFixed(4)}%`);
                
                await executeArbitrage(opportunity);
            } else {
                // Show detailed breakdown for large losses
                console.log(`LOSS - ${getTokenSymbol(tokenA.address)} -> ${getTokenSymbol(tokenB.address)} -> ${getTokenSymbol(tokenC.address)}`);
                console.log(`   Amount: ${formatTokenAmount(opportunity.profit, tokenA.decimals)} ${getTokenSymbol(tokenA.address)} (${opportunity.profitPercentage.toFixed(2)}%)`);
            }
            
            // Small delay between checks
            await new Promise(resolve => setTimeout(resolve, 100));
        }
        
        await new Promise(resolve => setTimeout(resolve, 10000));
    }
}

// Helper functions
function getTokenSymbol(address) {
    const symbolMap = {
        [tokens.WETH.address]: 'WETH',
        [tokens.USDC.address]: 'USDC', 
        [tokens.DAI.address]: 'DAI',
        [tokens.USDT.address]: 'USDT'
    };
    return symbolMap[address] || address.slice(0, 6) + '...';
}

function formatTokenAmount(amount, decimals) {
    if (decimals === 18) {
        return ethers.formatEther(amount);
    } else if (decimals === 6) {
        return (Number(amount) / 1000000).toFixed(6);
    }
    return amount.toString();
}

// Start the bot
startMonitoring().catch(console.error);

The bot continuously scans predefined token triangles every 10 seconds, focusing on high-liquidity pairs most likely to present opportunities.

Key Risks to Consider

Gas Costs

Gas costs can eliminate profits quickly. Each arbitrage requires three transactions, and during network congestion, fees can spike dramatically. Always calculate total gas costs before executing trades.

Slippage Risk

Slippage occurs when prices change between identifying an opportunity and executing trades. Markets move fast, and large trades by others can shift pool reserves, affecting your exchange rates.

Liquidity Risk

Liquidity risk becomes critical with smaller tokens. Low-liquidity pairs experience significant price impact from moderate trades, potentially eliminating profits through excessive slippage.

Performance Optimization

To maximize your bot’s effectiveness, consider these optimizations:

Conclusion

Triangular arbitrage requires speed, precision, and careful risk management. While opportunities exist, they’re becoming scarcer as more bots compete. Success demands continuous optimization and adaptation to changing market conditions.

Important: Test thoroughly on testnets before deploying real capital, and never risk more than you can afford to lose. The DeFi space moves quickly, and what works today may not work tomorrow.

Remember that this guide provides a foundation for understanding triangular arbitrage, but real-world implementation requires additional considerations around gas optimization, MEV protection, and risk management strategies.