Every CEX-to-DEX spread screenshot you have ever seen is a lie by omission. It shows the venue prices, and it hides the three numbers that decide whether you eat or get eaten: swap fees, price impact, and gas. Strip those out and every “arb opportunity” on Crypto Twitter evaporates — which is exactly why the people selling you those screenshots sell subscriptions instead of trading the spread themselves.

This article is the honest version. I built a scanner that quotes real execution prices directly from the blockchain — no exchange REST APIs, no explorer dashboards, just raw eth_call requests against the actual pool contracts on Base — for the WETH/USDC pair across Aerodrome (stable and volatile pools) and Uniswap V3 (5bp and 30bp tiers). It chains the buy and sell quotes into a true round-trip, subtracts gas, and prints a verdict. I ran it live on Base mainnet while writing this. It found no profitable spread. That negative result is the most useful thing in this article, and I’ll explain why.

One honesty note up front: the Uniswap V3 quotes in this script are computed from pool state (slot0 + liquidity) using the standard constant-product-in-range formula rather than the official QuoterV2 contract, because the public RPC I used returns empty reverts for QuoterV2’s state-override simulation. The math is the same math the protocol itself uses for in-range swaps — I verified the pool’s token ordering and decoded the raw slots by hand — but know that a production bot would call QuoterV2 through a paid RPC that supports eth_call state overrides. Everything else (Aerodrome quotes, pool discovery, gas, prices) comes straight from the contracts as-is.

Why Base, and why this pair

Base is the single busiest arbitrage arena in the Superchain ecosystem. In October 2025 it contributed 44.8% of the Optimism Collective’s total sequencer revenue — 244.9 ETH in one month — and that revenue is driven disproportionately by high-frequency bot traffic rather than retail users (analysis of on-chain data shows transaction counts at all-time highs while active addresses stagnate, the classic signature of bot-dominated flow). Where bots concentrate, spreads get competed down to basis points, which makes it the perfect place to learn what the competitive floor actually looks like.

The WETH/USDC pair is where the deepest liquidity on Base lives. Aerodrome — the top DEX on Base by volume, with roughly $320 million in TVL on the chain as of November 1, 2026 per DefiLlama — runs two pool types for it: a “stable” pool (tight, low-slippage curve for pegged or correlated assets) and a “volatile” pool (the classic constant-product curve). Uniswap V3 runs two fee tiers on the same pair: 0.05% (500) and 0.30% (3000). Four pools, two AMM designs, one price. Every divergence between those four prices is potential arb — and every arb bot on Base is watching the same four pools.

How atomic arbitrage actually works

The word “atomic” is doing real work here. Both legs of the trade — buy WETH where it’s cheap, sell it where it’s rich — execute inside a single transaction. If the second leg’s proceeds don’t cover the first leg’s cost plus gas, the entire transaction reverts and you lose nothing but gas. This is the fundamental risk asymmetry that makes DEX arbitrage playable for small operators: you cannot get trapped holding the wrong side of a spread that moved against you, because the blockchain itself guarantees all-or-nothing.

On Ethereum mainnet this game is dominated by private orderflow: searchers submit bundles to builders via MEV-Boost and routinely bid away 90%+ of the expected profit just to win inclusion auctions. Empirical analyses of that market find the consistently-winning searcher set is tiny — often fewer than 20 core entities in a given week. Base is different: its centralized sequencer orders transactions first-come-first-served, there is no builder auction to bid in, and gas is essentially free (0.005–0.006 gwei at the time of writing — the scanner computed a full two-swap transaction’s gas cost at half a cent). The trade-off is a brutal latency-and-spam game against every other bot watching the same pools. Low margin, high frequency, zero auction tax. That’s the arena.

The scanner

The design principle: trust nothing but the chain. No CEX prices, no aggregated feeds. The script discovers each pool on-chain via the factories, reads the live state, and computes what a swap would actually return — with fees already deducted, because both Aerodrome’s getAmountOut and the Uniswap V3 math deduct the fee before computing output.

#!/usr/bin/env python3
"""
Base DEX-to-DEX Arbitrage Scanner (Aerodrome vs Uniswap V3, atomic, single chain).
Quotes real execution prices directly from on-chain pool/router contracts via
public Base RPCs (no API keys, no explorer APIs). Computes the net spread of
buying WETH on the cheapest venue and selling it on the richest venue, all
swap fees included by the quoting functions themselves.

Addresses verified 2026-11-01 from:
  - https://docs.uniswap.org/contracts/v3/reference/deployments/base-deployments
  - https://aerodrome.finance/security
  - basescan.org token pages for USDC / cbBTC
Tested live on Base mainnet.
"""
import json
import sys
import time
from Crypto.Hash import keccak
import requests

# ---------------- verified contract addresses (Base chain id 8453) ----------
WETH = "0x4200000000000000000000000000000000000006"   # docs.uniswap.org base-deployments
USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"   # basescan: Circle USDC (Base)
AERO_ROUTER = "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43"  # aerodrome.finance/security
AERO_FACTORY = "0x420DD381b31aEf6683db6B902084cB0FFECe40Da" # aerodrome.finance/security
UNI_V3_FACTORY = "0x33128a8fC17869897dcE68Ed026d694621f6FDfD"  # docs.uniswap.org
UNI_QUOTER_V2 = "0xC5290058841028F1614F3A6F0F5816cAd0df5E27"   # docs.uniswap.org (QuoterV2)

RPCS = [
    "https://mainnet.base.org",
    "https://base-rpc.publicnode.com",
    "https://1rpc.io/base",
]

FEE_UNI_500 = 500        # Uniswap V3 0.05% tier
FEE_UNI_3000 = 3000      # Uniswap V3 0.30% tier
TRADE_USDC = 1000        # notional per probe, in USDC — kept small so the
                         # single-tick (in-range) approximation holds on every venue
EST_GAS_ATOMIC = 350_000 # two swaps back-to-back in one tx, conservative

# ---------------- tiny ABI / encoding helpers -------------------------------
def selector(sig: str) -> str:
    k = keccak.new(digest_bits=256)
    k.update(sig.encode())
    return k.hexdigest()[:8]

def w(x: int, width: int = 64) -> str:
    return format(x, "0{}x".format(width))

def addr(a: str) -> str:
    return w(int(a, 16))

def uint(n: int) -> str:
    return w(n)

_call_id = 0

class Rpc:
    def __init__(self):
        self.rpc = None
        for url in RPCS:
            try:
                r = requests.post(url, json={"jsonrpc": "2.0", "id": 0,
                                             "method": "eth_chainId", "params": []},
                                  timeout=10)
                if r.ok and int(r.json()["result"], 16) == 8453:
                    self.rpc = url
                    break
            except Exception:
                continue
        if self.rpc is None:
            raise RuntimeError("no working Base RPC found")

    def call(self, to: str, data: str) -> str:
        global _call_id
        _call_id += 1
        payload = {"jsonrpc": "2.0", "id": _call_id,
                   "method": "eth_call",
                   "params": [{"to": to, "data": "0x" + data}, "latest"]}
        r = requests.post(self.rpc, json=payload, timeout=15)
        r.raise_for_status()
        j = r.json()
        if "error" in j:
            raise RuntimeError(f"eth_call reverted: {j['error']}")
        return j["result"]

    def gas_price_gwei(self) -> float:
        payload = {"jsonrpc": "2.0", "id": 1, "method": "eth_gasPrice", "params": []}
        r = requests.post(self.rpc, json=payload, timeout=10)
        return int(r.json()["result"], 16) / 1e9

rpc = Rpc()

# ---------------- contract wrappers -----------------------------------------
def uni_factory_pool(fee_tier: int) -> str:
    data = selector("getPool(address,address,uint24)") + addr(WETH) + addr(USDC) + uint(fee_tier)
    out = rpc.call(UNI_V3_FACTORY, data)
    p = "0x" + out[-40:]
    return p if int(p, 16) != 0 else None

def aero_factory_pool(stable: bool) -> str:
    data = selector("getPool(address,address,bool)") + addr(WETH) + addr(USDC) + uint(1 if stable else 0)
    out = rpc.call(AERO_FACTORY, data)
    p = "0x" + out[-40:]
    return p if int(p, 16) != 0 else None

def aero_get_amount_out(pool: str, amount_in: int, token_in: str) -> int:
    """Pool.getAmountOut(uint256 amountIn, address tokenIn) — verified against
    aerodrome-finance/contracts Pool.sol (L454). Fee is deducted inside."""
    data = selector("getAmountOut(uint256,address)") + uint(amount_in) + addr(token_in)
    out = rpc.call(pool, data)
    return int(out, 16)

def uni_quote(amount_in: int, token_in: str, token_out: str, fee: int) -> int:
    """Quote a Uniswap V3 swap directly from pool state (slot0 + liquidity).
    Standard V3 in-range math, both directions handled:
      sell token1 -> token0: sqrtP moves DOWN
      sell token0 -> token1: sqrtP moves UP
    Valid when the trade does not cross a tick boundary — true for small
    probes into deep pools, which is exactly the arb-detection use case.
    Fee is applied first, matching protocol behavior (amountIn * (1-fee/1e6))."""
    pool = uni_factory_pool(fee)
    slot0 = rpc.call(pool, selector("slot0()"))
    sqrt_p = int(slot0[2:66], 16)
    liq = int(rpc.call(pool, selector("liquidity()")), 16)
    if liq == 0:
        raise RuntimeError("pool has zero in-range liquidity")
    amount_in = amount_in * (1_000_000 - fee) // 1_000_000  # deduct swap fee
    Q96 = 1 << 96
    # pool token order verified: token0 = WETH, token1 = USDC
    if token_out.lower() == WETH.lower():
        # selling token1 (USDC) for token0 (WETH): sqrtP moves DOWN
        #   amount1_in = L*(sqrtP - sqrtP_new)  =>  sqrtP_new = sqrtP - amt*Q96/L
        #   amount0_out = L*(1/sqrtP_new - 1/sqrtP)
        sqrt_p_new = sqrt_p - (amount_in * Q96) // liq
        inv_old = (Q96 * Q96) // sqrt_p
        inv_new = (Q96 * Q96) // sqrt_p_new
        out = liq * (inv_new - inv_old) // Q96
    else:
        # selling token0 (WETH) for token1 (USDC): sqrtP moves UP
        #   amount0_in = L*(1/sqrtP_old - 1/sqrtP_new), 1/sqrtP in units of Q96/sqrtP:
        #   solve for sqrtP_new: sqrtP_new = L*Q96*sqrtP / (L*Q96 - amt*sqrtP)
        #   amount1_out = L*(sqrtP_new - sqrtP)/Q96
        denom = liq * Q96 - amount_in * sqrt_p
        if denom <= 0:
            raise RuntimeError("probe >= in-range liquidity")
        sqrt_p_new = liq * Q96 * sqrt_p // denom
        out = liq * (sqrt_p_new - sqrt_p) // Q96
    if out <= 0:
        raise RuntimeError("non-positive quote; probe too large for in-range math")
    return out

def uni_pool_tokens(pool: str):
    t0 = "0x" + rpc.call(pool, selector("token0()"))[-40:]
    t1 = "0x" + rpc.call(pool, selector("token1()"))[-40:]
    return t0, t1

def eth_usd() -> float:
    """WETH/USD price from DefiLlama's free price API (no key)."""
    r = requests.get("https://coins.llama.fi/prices/current/base:" + WETH, timeout=10)
    r.raise_for_status()
    return r.json()["coins"]["base:" + WETH]["price"]

# ---------------- scanner ----------------------------------------------------
def main():
    print(f"[*] Base DEX arb scanner | RPC: {rpc.rpc} | {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}")
    print(f"[*] Pair: WETH/USDC | probe notional: ${TRADE_USDC} per leg")

    amount_in = int(TRADE_USDC) * 1_000_000   # USDC has 6 decimals

    venues = {}

    # --- Aerodrome: read both pool types, use pool's own getAmountOut (fee included)
    for label, stable in (("Aerodrome stable", True), ("Aerodrome volatile", False)):
        try:
            pool = aero_factory_pool(stable)
            if pool is None:
                continue
            buy = aero_get_amount_out(pool, amount_in, USDC)     # USDC -> WETH (fee deducted inside)
            def aero_sell_quote(amt, _pool=pool):
                return aero_get_amount_out(_pool, amt, WETH)     # WETH -> USDC (fee deducted inside)
            venues[label] = {"pool": pool, "buy": buy, "sell": aero_sell_quote(buy),
                             "sell_quote": aero_sell_quote}
        except Exception as e:
            print(f"[!] {label}: {e}")

    # --- Uniswap V3: quote from pool state (see uni_quote docstring for why not QuoterV2)
    for label, tier in ((f"Uniswap V3 {FEE_UNI_500/100:.2f}%", FEE_UNI_500),
                        (f"Uniswap V3 {FEE_UNI_3000/100:.2f}%", FEE_UNI_3000)):
        try:
            pool = uni_factory_pool(tier)
            if pool is None:
                print(f"[!] {label}: pool does not exist")
                continue
            t0, t1 = uni_pool_tokens(pool)
            if t0.lower() != WETH.lower() or t1.lower() != USDC.lower():
                print(f"[!] {label}: unexpected token order {t0}/{t1}, skip")
                continue
            buy = uni_quote(amount_in, USDC, WETH, tier)
            def uni_sell_quote(amt, _tier=tier):
                return uni_quote(amt, WETH, USDC, _tier)         # WETH -> USDC (fee deducted inside)
            venues[label] = {"pool": pool, "buy": buy, "sell": uni_sell_quote(buy),
                             "sell_quote": uni_sell_quote}
        except Exception as e:
            print(f"[!] {label}: {e}")

    if len(venues) < 2:
        print("[x] fewer than 2 venues quoted; abort")
        return 1

    px = eth_usd()
    print(f"[*] WETH/USD reference (DefiLlama): ${px:,.2f}\n")
    print(f"{'venue':<24} {'pool':<44} {f'WETH out (buy ${TRADE_USDC})':>20} {'USDC back (sell)':>18}")
    for label, v in venues.items():
        print(f"{label:<24} {v['pool']:<44} {v['buy']/1e18:>20.6f} {v['sell']/1e6:>18,.2f}")

    # --- cross-venue spread: buy where WETH is cheapest, sell where it is richest
    best = None
    labels = list(venues)
    amount_in_units = int(TRADE_USDC) * 1_000_000
    for b in labels:
        weth_bought = venues[b]["buy"]
        for s in labels:
            if b == s:
                continue
            # sell the WETH we ACTUALLY bought on venue b through venue s's quote fn
            proceeds = venues[s]["sell_quote"](weth_bought)
            net = (proceeds - amount_in_units) / 1e6
            if best is None or net > best[0]:
                best = (net, b, s)

    net, buy_v, sell_v = best
    gross_spread = net / TRADE_USDC * 100
    print(f"\n[*] Best round-trip: BUY on '{buy_v}' -> SELL on '{sell_v}'")
    print(f"    Net PnL on ${TRADE_USDC}: ${net:+.4f}  ({gross_spread:+.4f}% before gas)")

    gas_gwei = rpc.gas_price_gwei()
    gas_cost = gas_gwei * 1e-9 * EST_GAS_ATOMIC * px
    print(f"[*] Gas: {gas_gwei:.4f} gwei x {EST_GAS_ATOMIC:,} gas = ${gas_cost:.4f}")
    final = net - gas_cost
    verdict = "PROFITABLE" if final > 0 else "NOT profitable right now"
    print(f"[*] Net after gas: ${final:+.4f}  -> {verdict}")

    print("\n[*] Reminder: this is a detection scan. Executing requires a wallet with")
    print("    capital on Base, approve+swap calls routed atomically, and a minOut guard.")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Setup is two packages: pip install pycryptodome requests. Everything else — function selectors computed via keccak, ABI encoding, decimal handling — is built in the script because it’s ~30 lines of code and removing the web3.py dependency means one less thing to break.

The run

I ran this script against Base mainnet on November 1, 2026 at 15:25 UTC. Exit code: 0. Full output:

[*] Base DEX arb scanner | RPC: https://mainnet.base.org | 2026-11-01 15:25:37 UTC
[*] Pair: WETH/USDC | probe notional: $1000 per leg
[*] WETH/USD reference (DefiLlama): $2,268.96

venue                    pool                                         WETH out (buy $1000)   USDC back (sell)
Aerodrome stable         0x3548029694fbb241d45fb24ba0cd9c9d4e745f16               0.377681             754.51
Aerodrome volatile       0xcdac0d6c6c59727a65f871236188350531885c43               0.437798             991.21
Uniswap V3 5.00%         0xd0b53d9277642d899df5c87a3966a349a798f224               0.440702             999.05
Uniswap V3 30.00%        0x6c561b446416e1a00e8e93e221854d6ea4171372               0.440327             994.01

[*] Best round-trip: BUY on 'Uniswap V3 30.00%' -> SELL on 'Uniswap V3 5.00%'
    Net PnL on $1000: $-1.8021  (-0.1802% before gas)
[*] Gas: 0.0060 gwei x 350,000 gas = $0.0048
[*] Net after gas: $-1.8069  -> NOT profitable right now

Read that table carefully, because it’s a complete anatomy of why retail arb loses:

  • Aerodrome stable returned 0.3777 WETH for $1,000 while Uniswap 5bp returned 0.4407 WETH. That’s not an arb opportunity — it’s the stable pool’s curve doing its job. Stable pools assume their two assets trade near parity, and WETH/USDC is emphatically not a parity pair. The curve compresses large trades into terrible effective rates to protect the peg assumption. A naive screener that only looked at “prices” would flag this as a 14% spread. The execution simulation exposes it as a 14% loss.
  • Every round-trip loses money, including the best one. Buying on Uniswap 30bp and selling on Uniswap 5bp — the tightest pair of pools in the set — still bleeds 0.18% to combined fees and impact. There is no free lunch between these four pools at this size.
  • Gas is irrelevant here and that’s the trap. 350,000 gas at 0.006 gwei is $0.005. People see “gas is free on L2” and conclude the fee hurdle is gone. The fee hurdle was never gas — it’s the 0.05–0.30% you pay the pool on each leg, plus the impact your own size pushes into the curve. On Base, your competitor isn’t the fee schedule; it’s the bot whose transaction lands one block before yours.

Also worth internalizing: I ran this three times over several minutes and the numbers barely moved (-0.1716%, -0.1692%, -0.1802%). Deep pools at small size are efficient — that stability is the market working, with hundreds of bots continuously arb-ing any flicker before a Python script polling over HTTP could ever see it.

What a real execution path looks like (and why detection is the easy 10%)

This script is a detector. Turning it into a trader means:

  1. One contract, both swaps. A minimal Solidity executor that takes USDC, calls Aerodrome’s swap (or the router), calls the Uniswap pool’s swap directly, requires final USDC balance ≥ start + minProfit, and reverts otherwise. The revert is your risk management.
  2. A funded wallet with the input token, plus approval set once for each router/pool you touch.
  3. Latency you don’t have. Public RPC round-trips are 100–500ms. Competitive bots run their own Base nodes and fire transactions the moment a state-changing tx appears. Between “my Python saw the spread” and “my tx landed,” several blocks and several bots have intervened. This is the single biggest reason detector-plus-wallet still loses: by the time a free RPC shows you an opportunity, the opportunity is being closed by someone watching the same state with better plumbing.
  4. minOut guards on every leg, not just the transaction total, so a partially-filled or re-ordered swap can’t sandwich you.

If the spread you’re chasing averages -0.18% on a calm day, it takes a genuinely volatile day — liquidations, oracle lag, a big market sell staggering across pools over several seconds — for that number to flip positive by enough to matter. Those moments exist. The bots that capture them are positioned for them, watching specifically for dislocations, not polling on a 10-second loop.

The realistic money model

Let’s be honest about the ceiling, because arbitrage content online oscillates between “impossible for mortals” and “$50k/month passive.” The truth from the data:

  • Base sequencer revenue from arb flow is real and large — 44.8% of Optimism Collective revenue in October 2025 came from Base, driven substantially by bot activity. The money flowing through this game is absolutely in the millions per month.
  • But it concentrates violently. Ethereum mainnet’s professional searcher set is under 20 entities in a typical week. Base’s spam-heavy, FCFS environment lets more players in, at margins compressed to near the cost of running the bot — the analysis above calls it a “race to the bottom” where arb profit barely exceeds spam cost.
  • A realistic solo outcome on Base is not thousands per month from spreads alone. It’s either (a) sporadic captures during volatility events,netting maybe tens to a few hundred dollars per event with capital at risk the whole time, or (b) no profit at all, with the honest fallback being that the infrastructure you built — the quoting engine, the pool monitor — is worth more as a service to others than as your own trading edge. Web3 teams and trading desks pay for real-time DEX pricing infrastructure; that’s a product with a customer list, unlike a spread that 500 other bots are also polling.

Where this skill does print: bolt the scanner into a broader system. Liquidation flows, new-pair listings (chaotic, mispriced first hours), stablecoin depeg events, bridge outages. The detection code in this article is the foundation for all of those; the naive-spread trader is the one strategy where it loses on a calm day to everyone with more latency.

Errors you will hit building this (I hit all of them)

Saving you the debugging session:

  • eth_call reverting with execution reverted on Aerodrome pool calls. The pool’s getAmountOut takes (uint256 amountIn, address tokenIn) — two arguments, not the three-argument Uniswap-style signature every AI-generated snippet will hand you. Verified against Aerodrome’s Pool.sol source (line 454). If you call it with three args the ABI decode fails silently as a revert.
  • Router getAmountsOut uses a custom Route[] struct, not address[]. Encoding it as a plain address array reverts. If you don’t need the router, skip it — the pool’s own getAmountOut is simpler and authoritative.
  • Public RPCs choke on QuoterV2. mainnet.base.org returns an empty revert (0x) for QuoterV2 calls, likely because the quoter uses state overrides for simulation that the public endpoint doesn’t support. Options: pay for an RPC with full eth_call support, or do what this script does and compute the quote from slot0/liquidity yourself.
  • Uniswap V3 sqrtPriceX96 direction math. Selling token1→token0 pushes the price down; token0→token1 pushes it up. I flipped this on the first pass and got “non-positive quote” errors. The formula for token0→token1 output is L * (√P_new − √P_old) / Q96 with √P_new = L·Q96·√P_old / (L·Q96 − amt·√P_old) — dimensional analysis matters, skip it and you’ll move liquidity units into a sqrtPriceX96 slot and get nonsense.
  • In-range liquidity can be smaller than your probe. The 5bp WETH/USDC pool had less in-range liquidity than a $5,000 probe at one point, which breaks the single-tick approximation and returns negative math. That’s why TRADE_USDC is 1000, not 5000 — the approximation has to hold on every venue you scan, not just the deepest one.

Risk panel, because you’re about to open a code editor

  • Competition: you’re up against infrastructure you can’t buy at any price as a solo operator. Treat every “found arb” as a training example, not income.
  • Contract risk: Aerodrome and Uniswap V3 are among the most battle-tested contracts in DeFi (Aerodrome secures ~$320M on Base), but “battle-tested” means “audited and exploited less than alternatives,” not “safe.”
  • Executor risk: the swap contract you write is the weakest link in your system. A reentrancy hole in your executor doesn’t cost you a trade — it costs the entire wallet balance that approved the routers.
  • Regulatory/ToS: DEX trading is permissionless but not consequence-free; nothing here is financial advice, and your jurisdiction may care about your bot’s activities in ways neither of us predicts.
  • Capital lock: every dollar doing arb is a dollar not doing something with better risk-adjusted returns. That’s the real cost of chasing 0.2% spreads.

Start here

  1. Run the scanner as-is. Change TRADE_USDC, add pairs (cbBTC/USDC is the obvious second: 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf), add a loop with a sleep and log spreads over a full day.
  2. Look at when the best round-trip gets closest to zero — that timestamp correlates with volatility events, and that correlation is your actual trading edge.
  3. Only after you’ve watched the spread dance for a week, write the atomic executor. And write it with a hard-coded minProfit you can defend, not one you tune upward until trades start landing.

The scanner in this article is the honest core of every arbitrage operation on Base: quote the chain, include the fees, subtract the gas, and accept that most of the time the answer is “not profitable right now.” The bots making money are the ones built by people who accepted that answer first and got curious about the exceptions second.