Adapting MEV Pool Scanners for Aerodrome V2 & Heterogeneous DEX Protocols on Base

When moving a mature Ethereum-mainnet EVM arbitrage bot or market-making liquidity scanner to Base, more than 90% of quantitative engineers encounter the same first critical failure: pool discovery goes silent, or every on-chain simulation reverts.

On Ethereum mainnet or Arbitrum, monitoring Uniswap V2 and traditional clones such as Sushiswap and Pancakeswap is straightforward. Subscribe to the factory’s PairCreated(address token0, address token1, address pair, uint), read (uint112, uint112, uint32) reserves through getReserves(), and apply a fixed 0.3% fee, represented as 9970 per 10,000, to the constant-product formula (x ⋅ y = k) to derive the optimal input.

Those assumptions break against Aerodrome, which accounts for at least 60% of Base DEX volume:

  1. Event signatures and tuple decoding differ: Aerodrome V2 emits PoolCreated(address indexed token0, address indexed token1, bool indexed stable, address pool, uint256) rather than PairCreated. With a Uniswap V2 ABI, viem or ethers.js either silently skips logs or raises decoding errors.
  2. Two fundamentally different curves: one Aerodrome factory deploys volatile pools using x ⋅ y = k and stable pools using the higher-order polynomial x3y + y3x = k. Unless the scanner immediately excludes stable == true, constant-product closed-form inputs diverge sharply from actual on-chain outputs, causing 100% of transactions to fail with InsufficientProfit and waste gas.
  3. Dynamic fees and wider reserves: Aerodrome fees are governance-controlled, ranging from 0.05% to 0.3%, rather than hardcoded at 0.3%. Reserves use uint256, avoiding uint112 overflow limits.
  4. Partially written state and routes: after scanning thousands of blocks and building hundreds of cross-pool cycles, a crash or full disk can corrupt persisted files. On restart, the bot loses its cursor and either replays data or misses opportunities.

Using our production Base scanner (pool-scanner.ts), this article explains resilient liquidity monitoring, reverse multihop topology generation, and atomic persistence for Aerodrome and heterogeneous DEX protocols.


Scanner Architecture: From Log Batches to Acyclic Topology Generation

Adapting MEV Pool Scanners for Aerodrome V2 & Heterogeneous DEX Protocols on Base

Base averages 2 seconds per block and tens of thousands of transactions per day during peak periods. To build cross-DEX arbitrage topologies from large numbers of long-tail pools in milliseconds, the scanner uses a layered pipeline:

+-----------------------------------------------------------------------------------+
|                       Base Heterogeneous DEX Scanner Pipeline                      |
+-----------------------------------------------------------------------------------+
|  1. Event Ingestion with Failover                                                   |
|     - Dynamic 2000-block batches: getLogs([fromBlock, toBlock])                      |
|     - Catch 429 Too Many Requests; seamlessly rotate RPC nodes (HttpPoolFailover)    |
+-----------------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------------+
|  2. Decoding & Curve Classification Gate                                            |
|     - Aerodrome V2: PoolCreated(token0, token1, stable, pool, uint256)            |
|     - Hard filter: if (stable) return null; (exclude x^3y + y^3x = k stable curves)   |
|     - Quote tokens restricted to core bridges WETH / USDC / AERO / VIRTUAL           |
+-----------------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------------+
|  3. Dynamic Fee Calibration & Depth Filter                                          |
|     - Multicall factory.getFee(pool, false), binding feeBps precisely                |
|     - Check both reserves; filter dead/honeypot pools with side liquidity < 0.5 WETH |
+-----------------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------------+
|  4. Multi-Hop Cyclic Topology Construction & Strict Acyclic Constraints                                            |
|     - Two-hop WETH -> Token -> WETH and cross-quote triangles WETH -> USDC -> T      |
|     - Mirror topology: reverse Hop order, swap tokenIn/tokenOut and direction flags  |
|     - Directed acyclic graph assertion: require(new Set(hops.map(h => h.pool)).size === hops.length)|
+-----------------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------------+
|  5. Atomic State & Pair Persistence                                                 |
|     - Isolate temp file: writeFileSync(`${FILE}.tmp`) -> renameSync (avoid partial writes) |
|     - Advance two files monotonically: watch_pairs.json and scanner_state.json       |
+-----------------------------------------------------------------------------------+

Generic V2 Scanner vs. Base-Aware Scanner

The architectural differences are summarized below before the implementation details:

Dimension Generic Ethereum V2 scanner Base-aware heterogeneous scanner (Fone architecture)
Events Recognizes only PairCreated Parses Uniswap V2 PairCreated and Aerodrome PoolCreated
Curve model Applies x ⋅ y = k to all pools Rejects stable == true; only constant-product pools enter the optimizer
Fee discovery Assumes 30 bps (0.3% / 997) Reads per-pool feeBps on-chain (supports 997 per thousand and 9970 per ten thousand)
Multihop routes Fixed WETH two-pool trades only Combines two-hop trades and three-hop cycles through AERO/USDC/VIRTUAL
Route symmetry Hardcoded one-way paths miss reverse opportunities Generates forward/reverse hops and enforces unique pools
Persistence resilience Overwrites JSON directly; power failure corrupts it Write .tmp, then atomically replace with fs.renameSync for resumable scanning

Core Implementation: pool-scanner.ts

These excerpts come directly from our production Base code.

1. Normalize Heterogeneous Logs and Exclude Stable Pools

parseFactoryLogToPool imposes strict filters for Aerodrome:

// From pool-scanner.ts
export interface DiscoveredPool {
  token: Address;      // Long-tail target token
  quoteToken: Address; // Paired quote token (WETH / USDC / AERO / VIRTUAL)
  pool: Address;
  dexName: string;
  feeBps: number;
  token0: Address;
  token1: Address;
}

export function parseFactoryLogToPool(
  dexName: string,
  defaultFeeBps: number,
  token0Raw: Address,
  token1Raw: Address,
  poolRaw: Address,
  stable = false
): DiscoveredPool | null {
  // Core defense: exclude stable curves (x^3*y + y^3*x = k); keep only constant-product volatile pools
  if (stable) return null;

  const t0 = token0Raw.toLowerCase() as Address;
  const t1 = token1Raw.toLowerCase() as Address;
  const pool = poolRaw.toLowerCase() as Address;

  // Restrict the valid quote-token set
  const validQuotes = new Set<string>([
    SCANNER_CONFIG.WETH.toLowerCase(),
    ...Object.values(SCANNER_CONFIG.BRIDGE_TOKENS).map((a) => a.toLowerCase()),
  ]);

  let targetToken: Address | null = null;
  let quoteToken: Address | null = null;

  // Prefer WETH as quote token when either side is WETH
  const wethLower = SCANNER_CONFIG.WETH.toLowerCase();
  if (t0 === wethLower) {
    quoteToken = t0;
    targetToken = t1;
  } else if (t1 === wethLower) {
    quoteToken = t1;
    targetToken = t0;
  } else if (validQuotes.has(t0)) {
    quoteToken = t0;
    targetToken = t1;
  } else if (validQuotes.has(t1)) {
    quoteToken = t1;
    targetToken = t0;
  }

  // Discard long-tail pools paired with neither WETH nor a major bridge stablecoin
  if (!targetToken || !quoteToken) return null;

  return {
    token: targetToken,
    quoteToken,
    pool,
    dexName,
    feeBps: defaultFeeBps,
    token0: t0,
    token1: t1,
  };
}

This filtering bypasses non-constant-product stable pairs, such as USDbC/USDC and cbETH/WETH, preventing mathematical model mismatches from contaminating calculations.

2. Build Cross-DEX Cycles and Symmetric Reverse Routes

When two pools share a long-tail token, the engine needs more than “buy in pool A → sell in pool B.” It must also support triangles such as “borrow WETH in pool A and buy token → sell token for AERO in pool B → sell AERO for WETH in pool C.”

Arbitrage opportunities are brief, and direction can change abruptly. A whale buying on Uniswap V3 pushes the opportunity toward “buy Aerodrome → sell Uniswap,” while a whale selling on Aerodrome reverses the direction completely. The scanner must therefore generate fully symmetric forward and reverse hop configurations:

// From pool-scanner.ts
export interface HopConfig {
  dexType: number; // 0: V2/Aerodrome Volatile, 1: V3
  pool: Address;
  tokenIn: Address;
  tokenOut: Address;
  feeBps: number; // Post-fee multiplier per 10,000, e.g. 0.3% -> 9970
  tokenInIsToken0: boolean;
}

// Add forward and reverse routes, enforcing no repeated pool within a cycle
const addDirections = (name: string, hops: HopConfig[]) => {
  // Topology safety: the same liquidity pool must never appear twice in one cycle!
  if (new Set(hops.map(h => h.pool)).size !== hops.length) return;

  // Forward route
  multiHopRoutes.push({ name, hops });

  // Reverse route: reverse Hop order and swap each hop's input/output tokens
  multiHopRoutes.push({
    name: `${name}:reverse`,
    hops: [...hops].reverse().map(h => ({
      ...h,
      tokenIn: h.tokenOut,
      tokenOut: h.tokenIn,
      tokenInIsToken0: !h.tokenInIsToken0,
    }))
  });
};

// Iterate valid cross-DEX pool pairs for the long-tail token
for (let i = 0; i < healthyWethPools.length; i++) {
  for (let j = i + 1; j < healthyWethPools.length; j++) {
    if (i === 0 && j === 1) continue; // Reserve the deepest primary pair for the traditional direct path
    const a = healthyWethPools[i];
    const b = healthyWethPools[j];

    addDirections(`pair:${a.pool}:${b.pool}`, [
      {
        dexType: 0,
        pool: a.pool,
        tokenIn: wethLower,
        tokenOut: token,
        feeBps: a.feeBps,
        tokenInIsToken0: a.token0 === wethLower
      },
      {
        dexType: 0,
        pool: b.pool,
        tokenIn: token,
        tokenOut: wethLower,
        feeBps: b.feeBps,
        tokenInIsToken0: b.token0 === token
      },
    ]);
  }
}

The reverse transformation must swap tokenIn and tokenOut and invert tokenInIsToken0 (!h.tokenInIsToken0). The resident engine can then read the correct reserve index in O(1) without repeating address-string comparisons at every block.

3. Multi-Node Failover and Resilient 429 Backoff

During high-frequency Base scanning, public RPCs or free Alchemy/QuickNode tiers often return HTTP 429 Too Many Requests. The scanner uses a failover connection pool:

// Pseudocode for failover and batch scanning
export class HttpPoolFailover {
  private clients: PublicClient[];
  private currentIndex = 0;

  constructor(rpcUrls: string[]) {
    this.clients = rpcUrls.map(url => createPublicClient({
      transport: http(url, { timeout: 10_000, retryCount: 2 })
    }));
  }

  async executeWithFailover<T>(fn: (client: PublicClient) => Promise<T>): Promise<T> {
    let lastError: any;
    for (let attempts = 0; attempts < this.clients.length; attempts++) {
      const client = this.clients[this.currentIndex];
      try {
        return await fn(client);
      } catch (err: any) {
        lastError = err;
        // On 429 rate limits or connection timeout, immediately rotate to the next backup RPC
        if (err.status === 429 || err.message?.includes('429') || err.message?.includes('timeout')) {
          console.warn(`[Failover] RPC 节点 ${this.currentIndex} 遭遇限流或超时,自动切换...`);
          this.currentIndex = (this.currentIndex + 1) % this.clients.length;
          await new Promise(r => setTimeout(r, 500)); // Add a short jitter backoff
          continue;
        }
        throw err;
      }
    }
    throw new Error(`所有 RPC 节点均不可用: ${lastError?.message}`);
  }
}

4. Atomic Persistence with .tmp and renameSync

Directly calling fs.writeFileSync(TARGET_FILE) is dangerous in distributed-system infrastructure. If a node runs out of memory or loses power while writing several megabytes of JSON, the destination remains truncated and invalid.

The scanner enforces POSIX-level atomic file replacement:

// From pool-scanner.ts
// 6. Atomically write config and cursor (write .tmp, then rename)
const tmpOutput = `${SCANNER_CONFIG.OUTPUT_FILE}.tmp`;
fs.writeFileSync(tmpOutput, JSON.stringify(activeWatchPairs, null, 2));
fs.renameSync(tmpOutput, SCANNER_CONFIG.OUTPUT_FILE);

const tmpState = `${SCANNER_CONFIG.STATE_FILE}.tmp`;
fs.writeFileSync(
  tmpState,
  JSON.stringify({
    lastScannedBlock: currentBlock.toString(),
    knownPools: Array.from(poolRegistry.values())
  }, null, 2)
);
fs.renameSync(tmpState, SCANNER_CONFIG.STATE_FILE);

console.log(`[Scanner] 刷新完毕!当前活跃长尾套利对: ${activeWatchPairs.length} 组`);

On most modern operating systems and filesystems, fs.renameSync atomically replaces the directory entry’s pointer. A crash while writing .tmp leaves the existing watch_pairs.json in its last complete state, letting the resident engine resume after restart.


Offline Regression Tests and Verification

We built strict offline regression tests to ensure scanner output integrates with downstream FlashMultiHopArb.sol and resident-bot.ts execution engines.

All 87 unit and integration assertions passed in a single automated run:

  ---
  duration_ms: 0.976334
  ...
1..87
# tests 87
# suites 0
# pass 87
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 3231.566375

Scanner-specific assertions include:

  1. Stable-pool filtering: inject an Aerodrome factory creation event with stable: true and require exactly null.
  2. Reverse-route topology preservation: require hop 0’s output in a generated :reverse route to equal hop 1’s input and the endpoint to return precisely to WETH.
  3. No repeated pool in a cycle: assert that no generated hops list contains duplicate pool addresses.
  4. Adaptive fee scaling: whether the factory supplies 997 per thousand or 9970 per ten thousand, normalization must produce the contract’s correct integer basis-point multiplier.

Four Production Guidelines for Base MEV and Liquidity Engineers

  1. Always inspect stable: Aerodrome’s factory is not Uniswap. Its deep stable pools use a fundamentally different invariant. Mixing them into one constant-product formula causes costly production reverts.
  2. Avoid uint112 reserve assumptions: some Base DEXs use uint256. In Solidity, use (uint256 r0, uint256 r1, ) = pair.getReserves() instead of coercing to uint112, preventing overflow truncation.
  3. Cover both directions completely: DEX spreads fluctuate. Maintain symmetric forward/reverse sequences for every candidate pool set to double the engine’s opportunity-capture efficiency.
  4. Preserve atomic file writes: bots using filesystem persistence and hot reload should always write .tmp and replace with renameSync, preventing state corruption after process crashes.

Enterprise DeFi Infrastructure and Custom Development

Fone delivers full-stack EVM high-frequency arbitrage engines, low-latency off-chain pool scanners, DEXs, and cross-chain asset gateways. Our solutions are backed by complete runnable local production source repositories and rigorous unit and mainnet-fork testing: