DEV Community

Superwavvy
Superwavvy

Posted on

Building a Multi-DEX Arbitrage Scanner on Arbitrum: When APIs Fail, Query the Blockchain

I decided to challenge myself, so I built two DeFi monitoring bots that worked flawlessly. Then I tried to add a third: an arbitrage detector then I hit a wall so hard I almost gave up.

This is the story of what went wrong, why it went wrong, and how querying smart contracts directly solved every problem that free APIs created.

The Plan (That Didn't Work)

Simple idea: monitor prices on Uniswap and Sushiswap, find discrepancies, alert on profit opportunities.

I started with what seemed logical: use the 1inch API. Free tier, no keys needed, returns aggregate prices across all DEXs in one call.

async function getPrices(tokenAddresses) {
  const url = `https://api.1inch.com/price/v1.1/${CHAIN_ID}`;

  const response = await axios.post(url, {
    tokens: tokenAddresses,
    currency: 'USD'
  });
  return response.data;
}
Enter fullscreen mode Exit fullscreen mode

Neat, right? One line of logic. One API call. Done.

Except it wasn't.

Problem 1: The 402 That Wouldn't Stop

First test run: API error: timeout of 5000ms exceeded

Second test run: API error: Request failed with status code 402 (Payment Required)

Third run: Same 402.

The 1inch API free tier was rejecting my requests. Either I was rate-limited or the endpoint didn't support the way I was querying it. Either way, the API was unreliable for what I needed.

The lesson: Free APIs have hard limits that don't care about your timeline. When they hit that limit, you're blocked.

Problem 2: The Alchemy RPC Timeout

Okay, skip 1inch. I then used Alchemy's RPC directly with ethers.js. Queried the contracts myself.

const provider = new ethers.providers.JsonRpcProvider(
  `https://arb-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`
);

const blockNumber = await provider.getBlockNumber();
Enter fullscreen mode Exit fullscreen mode

Neat concept. Direct blockchain access. No middleman API.

Except Alchemy's RPC to Arbitrum kept timing out.

30 seconds of waiting. Nothing. Then: JsonRpcProvider failed to detect network and cannot start up

I had the right Alchemy key, the right RPC endpoint, everything looked correct. But Alchemy's Arbitrum endpoint was flaky from Termux on my phone's internet.

The lesson: Even paid providers have reliability issues when you're querying from unconventional environments.

Problem 3: The Wrong Chain

After hours of debugging, I realized something: my .env file had an Alchemy key for Ethereum Mainnet, not Arbitrum.

I was trying to query Arbitrum with an Ethereum endpoint. No wonder it was timing out.

After hours of debugging, I caught it then hardcoded the Arbitrum chain ID and moved on.

const CHAIN_ID = 42161; // Arbitrum
const provider = new ethers.JsonRpcProvider(
  `https://arb-mainnet.g.alchemy.com/v2/${ARBITRUM_API_KEY}`
);
Enter fullscreen mode Exit fullscreen mode

The lesson: Simple mistakes kill hours. Always verify your configuration matches your target chain.

Problem 4: The Phantom Arbitrage (Ghost Pools)

Data started flowing. I saw spreads like:

  • Uniswap V3: 1 WETH = $2269 USDC
  • Sushiswap: 1 WETH = $626 USDC

A 262% difference. Free money, right?

Wrong. Sushiswap doesn't have real liquidity on Arbitrum. It's a ghost town. The contract exists, but there's no actual trading happening. The "price" the contract returned was meaningless.

This is called a phantom arbitrage — a spread that looks profitable until you realize one side of the trade has zero liquidity.

The fix: Before calculating profit, check if the pool actually has liquidity.

const uniswapLiquidity = await factoryContract.getPool(
  TOKEN_A,
  TOKEN_B,
  FEE_TIER
);

// If liquidity is 0, skip this pair
if (uniswapLiquidity.liquidity === 0n) {
  console.log('Dead pool, skipping');
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The lesson: Prices without liquidity are hallucinations. You need both.

Problem 5: The Uniswap V3 Quoter Address Mismatch

Uniswap V3 has a QuoterV2 contract. It gives you exact prices based on trade size.

I was using the Ethereum Mainnet QuoterV2 address on Arbitrum. Different chain, different address, different contract. It failed silently.

// WRONG - Ethereum address
const QUOTER = '0x61fFE014bA17989E8aBf2F2B629bDA3dB2e02C00';

// RIGHT - Arbitrum address
const QUOTER = '0x61ffE014ba17989E8aBf2F2b629BdA3Db2e02C00';
Enter fullscreen mode Exit fullscreen mode

The point: Chain-specific addresses matter. Copy-pasting from Ethereum docs broke everything.

The fix: Use Uniswap's official contract addresses for each chain, or query the factory directly.

Problem 6: Camelot V3 Uses Different ABIs

Uniswap V3 works one way. Camelot (another DEX on Arbitrum) uses the same Uniswap v3-compatible interface but with slightly different function signatures.

My Uniswap V3 code crashed on Camelot because the ABI expected different return values.

// Uniswap V3 QuoterV2
const response = await quoter.quoteExactInputSingle({
  tokenIn,
  tokenOut,
  fee,
  amountIn
});

// Camelot V3 (Algebra protocol)
// Different function name, different return structure
const response = await quoter.quoteExactInputSingle({
  tokenIn,
  tokenOut,
  amountIn
});
// Returns 4 values, not 1
Enter fullscreen mode Exit fullscreen mode

The lesson: Even "compatible" protocols have subtle differences. You need to test against each one.

The Solution: Query Contracts Directly

After all this, I realised something simple: forget APIs. Query the DEX contracts directly.

const { ethers } = require('ethers');

const provider = new ethers.JsonRpcProvider(
  'https://arb1.arbitrum.io:8545'
);

// Get Uniswap V3 factory
const factoryAbi = ['function getPool(address,address,uint24) view returns (address)'];
const factory = new ethers.Contract(
  '0x1F98431c8aD98523631AE4a59f267346ea3113F8',
  factoryAbi,
  provider
);

// Query the pool directly
const poolAddress = await factory.getPool(
  WETH_ADDRESS,
  USDC_ADDRESS,
  FEE_3000
);

// Check if pool exists and has liquidity
const poolAbi = ['function liquidity() view returns (uint128)'];
const pool = new ethers.Contract(poolAddress, poolAbi, provider);
const liquidity = await pool.liquidity();

if (liquidity === 0n) {
  console.log('No liquidity, skip');
}
Enter fullscreen mode Exit fullscreen mode

No API rate limits. No timeouts. No phantom spreads. Just blockchain data.

The cost? You have to understand smart contract ABIs and know which addresses to query. But once you know the contracts, it's rock solid.

The Trade-off

APIs are convenient until they're not. They ease complexity, handle rate limiting, and return clean JSON. But they fail at inconvenient times, have hard limits, and cost money at scale.

Direct contract queries are slower to set up but more reliable once working. You talk to the source of truth (the blockchain) instead of a middleman's interpretation of it.

For a bot running 24/7 on my phone with limited bandwidth, direct queries won.

What's Running Now

The arbitrage detector queries:

  • Uniswap V3 (checks liquidity + gets quotes)
  • Camelot V3 (different ABI, but same idea)
  • Filters out dead pools
  • Calculates spread after fees
  • Alerts only on >0.3% spreads (to account for gas)
async function checkArbitrage() {
  const wethPool = await getPoolLiquidity(WETH, USDC, uniswap);
  if (wethPool.liquidity === 0n) return;

  const uniPrice = await getPrice(wethPool);
  const camelotPrice = await getPrice(await getPoolLiquidity(WETH, USDC, camelot));

  const spread = ((uniPrice - camelotPrice) / camelotPrice) * 100;

  if (spread > 0.3) {
    // Alert: profitable opportunity
    await sendAlert(`Spread: ${spread}% | Buy on Camelot, sell on Uniswap`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Running on Arbitrum. No API complaints. No timeouts. Real data every 5 minutes.

The Real Lesson

Building production bots taught me something that tutorials don't: constraints are features.

Being limited to free APIs, running from a phone on mobile data, and targeting an L2 that most devs ignore forced me to think about reliability instead of convenience.

The code is messier because it accounts for multiple DEX ABIs, liquidity checks, and contract-specific quirks. But it's more honest about what the blockchain actually looks like.

APIs smooth over these details. Sometimes that's helpful. But when the bot needs to run 24/7, you need to know the real shape of the thing you're monitoring.

That's why I query contracts directly now.


Running on: Arbitrum, Ethers.js v6, Alchemy RPC (Arbitrum), Termux on Android, real data.

Current spreads: Uniswap V3 vs Camelot V3 on WETH/USDC. Threshold: 0.3% (after fees).

Next: 30 days of data collection and pattern analysis, while I try to incorporate either telegram/discord notifications into the codebase to alert for arbitrage opportunities.

Top comments (0)