A Uniswap v2 pool has its own contract address. So does a v3 pool. A v4 pool does not: it lives inside the singleton PoolManager and is identified by a PoolId.

That difference breaks a common new-pool detector. Watching newly deployed contracts can find v2 and v3 pools, but it is the wrong signal for v4. Watch the protocol’s creation or initialization events instead, and preserve the fields that identify the pool.

The monitor below reads all three event types on Ethereum mainnet. It uses ethers.js v6, bounded log queries and a block-hash checkpoint. It does not sign transactions or trade. Discovery is only the first input to a pool graph, not a claim that the pool has usable liquidity.

The three contracts to watch

Version Ethereum mainnet emitter Event
V2 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f PairCreated
V3 0x1F98431c8aD98523631AE4a59f267346ea31F984 PoolCreated
V4 0x000000000004444c5dc75cB358380D2e3dE08A90 Initialize

These are the canonical Uniswap deployments, not every fork with a similar ABI. Confirm addresses against the v2 factory reference, v3 deployment list and v4 deployment list when adapting the monitor to another network.

Uniswap pool discovery by generation: v2 PairCreated and v3 PoolCreated identify separate contracts; v4 Initialize identifies a PoolId inside the singleton PoolManager. None of these events guarantees usable liquidity.

V2: PairCreated identifies the pair contract

The ABI is PairCreated(address indexed token0, address indexed token1, address pair, uint256). The tokens are sorted by address, and pair is the new contract address. The unnamed final integer is the factory’s running pair count.

PairCreated contains no reserve price. A newly created pair has no useful reserve ratio until assets are supplied and its reserves are updated. Read getReserves() at the state you intend to evaluate rather than inferring liquidity from the factory event.

V3: PoolCreated includes the fee tier

The ABI is PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool). The fee uses hundredths of a basis point: 500 is 0.05%, 3000 is 0.30%.

A pair can have several pools with different fee tiers. Pool creation and initial price setting are separate operations. The pool’s later Initialize event belongs to that individual pool contract, not the factory event watched here.

Do not merge all pools for a pair into one address. Fee, tick spacing and liquidity distribution affect which route a bot can execute.

V4: Initialize identifies state inside PoolManager

The IPoolManager interface defines Initialize with a pool ID, two currencies, fee, tick spacing, hook address, initial square-root price and initial tick.

The PoolId is the keccak256 hash of the ABI-encoded PoolKey: (currency0, currency1, fee, tickSpacing, hooks). The Solidity wrapper types Currency, PoolId and IHooks appear in an ethers event ABI as address, bytes32 and address respectively.

Store the whole key with the ID. Two v4 pools can share currencies but differ by hook or tick spacing. The zero currency address represents native ETH, not a token contract, and fee = 0x800000 is the dynamic-fee flag rather than a fixed percentage.

Discovery is not a liquidity check

The program emits pool metadata discovered. For v4, the same event supplies the initial price. It does not subscribe to each v3 pool’s later initialization, and it does not declare any pool tradable.

A Mint or ModifyLiquidity event tells you that a liquidity operation occurred, not that your proposed trade will work now. Liquidity may later be removed; concentrated positions may be out of range; a hook may reject your caller. Track subsequent state and simulate the actual amount and execution path.

For v2, begin with current reserves. For v3 and v4, examine the current price and liquidity across the range the trade would traverse. A first deposit is not a permanent boolean you can set to hasLiquidity: true.

A bounded scanner with a checkpoint

The scanner processes at most 100 blocks per chunk and asks for six descendant blocks before processing a block by default. At a 12-second slot cadence that is roughly 72 seconds, plus polling, execution and any missed-slot delay. Confirmation depth reduces exposure to shallow reorgs; it is not finality.

Before querying a chunk, the program records its end-block hash and verifies the previous checkpoint. After fetching logs, it checks those hashes again. If the node’s canonical view changed, it stops before printing that chunk. On a later run it also refuses to resume from a checkpoint whose hash is no longer canonical.

Save this as pool-discover.mjs. It requires Node.js 22 or newer and npm install ethers@6.

import { Interface, JsonRpcProvider, WebSocketProvider, toQuantity } from "ethers";
import { readFile, writeFile, rename } from "node:fs/promises";

function integer(value, name, minimum = 1) {
  const n = Number(value);

  if (!Number.isSafeInteger(n) || n < minimum) {
    throw new Error(`Invalid ${name}`);
  }

  return n;
}

const url = process.env.RPC_URL || "ws://eth:8545";
const protocol = new URL(url).protocol;

if (!["http:", "https:", "ws:", "wss:"].includes(protocol)) {
  throw new Error("Unsupported RPC_URL");
}

const checkpointFile = process.env.CHECKPOINT_FILE || "./pool-discover-checkpoint.json";
const confirmations = integer(process.env.CONFIRMATIONS || "6", "CONFIRMATIONS");
const pollMs = integer(process.env.POLL_INTERVAL_MS || "12000", "POLL_INTERVAL_MS");
const once = process.env.ONCE === "1";
const chunkSize = 100;

const sources = [
  {
    version: "v2",
    address: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
    event: "PairCreated",
    abi: "event PairCreated(address indexed token0, address indexed token1, address pair, uint256)",
  },
  {
    version: "v3",
    address: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
    event: "PoolCreated",
    abi: "event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)",
  },
  {
    version: "v4",
    address: "0x000000000004444c5dc75cB358380D2e3dE08A90",
    event: "Initialize",
    abi: "event Initialize(bytes32 indexed id, address indexed currency0, address indexed currency1, uint24 fee, int24 tickSpacing, address hooks, uint160 sqrtPriceX96, int24 tick)",
  },
].map((source) => {
  const iface = new Interface([source.abi]);

  return {
    ...source,
    iface,
    topic: iface.getEvent(source.event).topicHash,
  };
});

// Preserve uint256 values when serializing decoded event arguments.
const stringify = (value) =>
  JSON.stringify(value, (_, v) => typeof v === "bigint" ? v.toString() : v);

const provider = protocol.startsWith("ws")
  ? new WebSocketProvider(url)
  : new JsonRpcProvider(url, undefined, { batchMaxCount: 1, cacheTimeout: -1 });

async function blockAt(height) {
  const block = await provider.send("eth_getBlockByNumber", [
    toQuantity(height),
    false,
  ]);

  if (!block?.hash) {
    throw new Error(`Block ${height} unavailable`);
  }

  return block;
}

async function verifyCheckpoint(cp) {
  if ((await blockAt(cp.height)).hash !== cp.hash) {
    throw new Error(
      `Checkpoint reorg at ${cp.height}; reconcile orphaned output and rewind before restarting`
    );
  }
}

async function output(record) {
  await new Promise((resolve, reject) => {
    process.stdout.write(
      stringify(record) + "\n",
      (error) => error ? reject(error) : resolve()
    );
  });
}

function decode(source, log) {
  if (log.removed) {
    throw new Error("Removed log in canonical range query");
  }

  const parsed = source.iface.parseLog(log);

  if (!parsed) {
    throw new Error("Unexpected event");
  }

  const a = parsed.args;

  // V4 identifies a pool by its key hash, not by a separate pool contract.
  const metadata = source.version === "v4"
    ? {
        poolId: a.id,
        currency0: a.currency0,
        currency1: a.currency1,
        fee: a.fee,
        feeMode: a.fee === 0x800000n ? "dynamic" : "fixed",
        tickSpacing: a.tickSpacing,
        hooks: a.hooks,
        initialSqrtPriceX96: a.sqrtPriceX96,
        initialTick: a.tick,
      }
    : {
        pool: source.version === "v2" ? a.pair : a.pool,
        token0: a.token0,
        token1: a.token1,
        ...(source.version === "v3"
          ? { fee: a.fee, tickSpacing: a.tickSpacing }
          : {}),
      };

  return {
    id: `1:${log.blockHash}:${log.transactionHash}:${log.logIndex}`,
    protocol: source.version,
    emitter: source.address,
    blockNumber: Number(BigInt(log.blockNumber)),
    blockHash: log.blockHash,
    transactionHash: log.transactionHash,
    logIndex: Number(BigInt(log.logIndex)),
    ...metadata,
  };
}

try {
  if (await provider.send("eth_chainId", []) !== "0x1") {
    throw new Error("Expected Ethereum mainnet");
  }

  let cp;

  try {
    cp = JSON.parse(await readFile(checkpointFile, "utf8"));
  } catch (error) {
    if (error.code !== "ENOENT") {
      throw error;
    }

    const start = integer(
      process.env.START_BLOCK,
      "START_BLOCK (required on first run)"
    );

    // The next query starts after the checkpoint, so anchor one block earlier.
    cp = {
      chainId: "0x1",
      height: start - 1,
      hash: (await blockAt(start - 1)).hash,
    };
  }

  integer(cp.height, "checkpoint height", 0);

  if (cp.chainId !== "0x1" || !/^0x[0-9a-fA-F]{64}$/.test(cp.hash)) {
    throw new Error("Invalid checkpoint");
  }

  await verifyCheckpoint(cp);
  console.error(`Scanning after checkpoint ${cp.height}`);

  do {
    const head = Number(BigInt(await provider.send("eth_blockNumber", [])));
    const target = head - confirmations;

    await verifyCheckpoint(cp);

    while (cp.height < target) {
      const end = Math.min(cp.height + chunkSize, target);
      const endBefore = await blockAt(end);

      const batches = await Promise.all(
        sources.map(async (source) => {
          const logs = await provider.send("eth_getLogs", [
            {
              address: source.address,
              topics: [source.topic],
              fromBlock: toQuantity(cp.height + 1),
              toBlock: toQuantity(end),
            },
          ]);

          return logs.map((log) => decode(source, log));
        })
      );

      // Stop if either boundary changed while the range queries were in flight.
      await verifyCheckpoint(cp);

      if ((await blockAt(end)).hash !== endBefore.hash) {
        throw new Error("Chunk reorg; restart after checking chain state");
      }

      const records = batches.flat().sort(
        (a, b) => a.blockNumber - b.blockNumber || a.logIndex - b.logIndex
      );

      for (const record of records) {
        await output(record);
      }

      // Advance only after output succeeds. A crash before rename may replay
      // records, so consumers still need to deduplicate observation IDs.
      const next = {
        chainId: "0x1",
        height: end,
        hash: endBefore.hash,
      };

      await writeFile(`${checkpointFile}.tmp`, JSON.stringify(next));
      await rename(`${checkpointFile}.tmp`, checkpointFile);

      cp = next;
      console.error(`Checkpoint ${cp.height}; ${records.length} pool events`);
    }

    if (!once) {
      await new Promise((resolve) => setTimeout(resolve, pollMs));
    }
  } while (!once);
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
} finally {
  await provider.destroy();
}

On the first run, set START_BLOCK to the first block you want included. The program anchors at the preceding block, so the selected start block is not skipped. For example, this scans forward from block 25,000,000 and exits after reaching the confirmed head captured for that run:

START_BLOCK=25000000 ONCE=1 node pool-discover.mjs

For a short diagnostic, choose a recent block instead of replaying a long history. Set RPC_URL to your endpoint when outside BLAZED.sh. The default local WebSocket address works inside a BLAZED.sh runtime; it is not a public URL for a laptop.

Omit ONCE=1 to keep polling. Existing checkpoints take precedence over START_BLOCK. Use a distinct CHECKPOINT_FILE for an independent replay, and run only one writer per checkpoint file.

What the checkpoint does and does not guarantee

A checkpoint stores {chainId, height, hash} for the last completed chunk. The file replacement is atomic on the same filesystem, but the JSON output and checkpoint are not one database transaction. A process crash can cause the last chunk to be printed again. A downstream consumer should deduplicate the observation ID and commit its own durable state.

The program checks for a changed canonical anchor; it does not maintain an undo journal. If it stops on a checkpoint reorg, find the common ancestor, remove or mark orphaned observations in your destination, and rewind the checkpoint to that ancestor’s number and canonical hash. Do not advance past the fork, which would skip replacement-chain events.

If you no longer have a trustworthy ancestor, rebuild from an earlier known checkpoint or rescan from an earlier START_BLOCK using a new checkpoint file. Keep the old output available for reconciliation. Confirmation depth does not remove this requirement for deeper reorgs.

RPC errors stop the scanner and leave the last completed checkpoint in place. Bounded ranges reduce the risk of provider limits, but they do not override them. If an endpoint rejects even a 100-block range, reduce the chunk size or use an endpoint whose log-query policy supports the workload. See handling eth_getLogs range limits.

Lower-latency discovery uses a different acceptance policy

A new-pool sniper usually cannot wait several descendant blocks. A log subscription can notify it as soon as its node imports the creation block, while the confirmed scanner maintains a slower durable view.

Treat that live notification as provisional. A node can emit a log again with removed: true after a reorg. On a disconnected subscription, use a bounded backfill to recover the gap; the new subscription does not replay it automatically. See Geth’s subscription documentation.

A confirmed creation log is also not a pending-transaction signal. Detecting a creation before inclusion requires observing and interpreting pending execution, and transactions sent privately may not be visible at all. The missing-pending-transactions guide covers that boundary.

What to do with the discovered pool

Keep token metadata enrichment outside the discovery cursor. Token contracts may revert, return nonstandard metadata or use misleading symbols. In v4, handle native ETH before attempting ERC-20 calls. A failed symbol() call should not make the scanner forget an otherwise valid creation event.

Next, load the state needed for the intended trade and evaluate its actual amount. For v4, include the hook in that analysis. The hooks simulation guide explains why a quote, a callback permission mask and a full executor simulation answer different questions.

BLAZED.sh runs code beside an Ethereum mainnet node, so the scanner’s RPC traffic can stay local. The six-block acceptance delay in this example remains six blocks regardless of transport. Change that policy deliberately for a live trading signal; do not confuse a faster RPC round trip with stronger confirmation.