BchainPay logoBchainPay
EngineeringEVMEthereumInfrastructureSecurity

EVM Mempool Monitoring: Detect Pending Payments in Real Time

How to subscribe to the EVM mempool, filter pending ERC-20 and ETH transfers to deposit addresses, and safely act on 0-conf signals without accepting reorg risk.

By Cipher · Founding engineer, BchainPay10 min read
Illustration for EVM Mempool Monitoring: Detect Pending Payments in Real Time

Your checkout page shows "waiting for payment." The customer already hit Send in MetaMask. Their transaction is sitting in the mempool, visible to every node on the network, and it will probably confirm in the next block. But your gateway is polling eth_getTransactionReceipt once every 12 seconds and won't notice the payment for up to 24 seconds after the user acts.

That window is a UX liability. It is also avoidable. With a persistent WebSocket subscription to a mempool feed, you can display "payment detected, confirming..." within 200 ms of the transaction hitting the public mempool — before a single block is mined.

This post covers the mechanics of EVM mempool subscriptions, the correct way to filter for relevant transfers, the 0-conf risk framework, and the failure modes that will trip you up in production.

How the EVM mempool works#

Every full node maintains a transaction pool (mempool) of signed transactions it has received but not yet seen included in a block. When your customer submits a transaction, their wallet broadcasts it to one or more nodes, which gossip it to their peers. Within a few hundred milliseconds, it is visible across most of the network.

The mempool is not a reliable ordered queue. Transactions compete by maxFeePerGas, get replaced via EIP-1559 or nonce reuse, can be dropped after sitting too long at a fee too low for current base fee, or can be included in a private relay (MEV searchers, Flashbots) without ever appearing in the public mempool at all. These edge cases matter and we will come back to each of them.

Subscribing with eth_subscribe#

Every EVM-compatible WebSocket RPC endpoint supports eth_subscribe. The pendingTransactions subscription type sends the transaction hash of every transaction the node adds to its local mempool:

// lib/mempool/subscribe.ts
import WebSocket from "ws";
 
type TxHandler = (txHash: string) => void;
 
export function subscribePendingTransactions(
  wsUrl: string,
  handler: TxHandler
): WebSocket {
  const ws = new WebSocket(wsUrl);
 
  ws.on("open", () => {
    ws.send(
      JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "eth_subscribe",
        params: ["pendingTransactions"],
      })
    );
  });
 
  ws.on("message", (raw: string) => {
    const msg = JSON.parse(raw);
    if (msg.method === "eth_subscription" && msg.params?.result) {
      handler(msg.params.result as string);
    }
  });
 
  ws.on("close", () => {
    // reconnect with exponential backoff
    setTimeout(() => subscribeWithRetry(wsUrl, handler), 1000);
  });
 
  return ws;
}

The subscription delivers hashes only. To inspect the transaction you need a second call to eth_getTransactionByHash. For high-volume mempools (Ethereum mainnet sees 50-200 pending transactions per second during busy periods), this is expensive — you will make a lot of network requests for transactions that have nothing to do with your deposit addresses.

Enhanced full-transaction subscriptions#

Alchemy (newPendingTransactions enhanced mode), Infura, and QuickNode all offer subscription variants that return the full transaction object inline, eliminating the second RPC call. The syntax is provider-specific but the pattern is identical:

// Alchemy extended subscription (returns full tx object)
ws.send(JSON.stringify({
  jsonrpc: "2.0",
  id: 1,
  method: "eth_subscribe",
  params: [
    "alchemy_pendingTransactions",
    {
      toAddress: watchedAddresses,  // array of deposit addresses to filter server-side
    },
  ],
}));

Server-side filtering by toAddress cuts the data volume from thousands of hashes per second to only the transactions relevant to your gateway. This is the production pattern — do not subscribe to the raw global feed and filter client-side if you can avoid it.

Filtering for relevant transfers#

ETH direct transfers are trivial to detect: tx.to is the deposit address and tx.value > 0n. ERC-20 transfers are harder.

A standard ERC-20 transfer(address to, uint256 amount) produces calldata beginning with the 4-byte selector 0xa9059cbb. The recipient address is ABI- encoded in bytes 4-36, left-padded to 32 bytes:

// lib/mempool/parsePendingTransfer.ts
import { decodeFunctionData, parseAbi, isAddressEqual } from "viem";
 
const ERC20_ABI = parseAbi([
  "function transfer(address to, uint256 amount)",
  "function transferFrom(address from, address to, uint256 amount)",
]);
 
const TRANSFER_SELECTOR   = "0xa9059cbb";
const TRANSFER_FROM_SEL   = "0x23b872dd";
 
export type PendingTransfer = {
  txHash:    string;
  token:     string;  // ERC-20 contract address, or "ETH"
  from:      string;
  to:        string;
  amount:    bigint;
};
 
export function parsePendingTransfer(
  tx: { hash: string; from: string; to: string | null; value: bigint; input: string }
): PendingTransfer | null {
  // Native ETH
  if (!tx.input || tx.input === "0x") {
    if (tx.to && tx.value > 0n) {
      return { txHash: tx.hash, token: "ETH", from: tx.from, to: tx.to, amount: tx.value };
    }
    return null;
  }
 
  const sel = tx.input.slice(0, 10);
 
  if (sel === TRANSFER_SELECTOR && tx.to) {
    try {
      const { args } = decodeFunctionData({ abi: ERC20_ABI, functionName: "transfer", data: tx.input as `0x${string}` });
      return { txHash: tx.hash, token: tx.to, from: tx.from, to: args[0], amount: args[1] };
    } catch { return null; }
  }
 
  if (sel === TRANSFER_FROM_SEL && tx.to) {
    try {
      const { args } = decodeFunctionData({ abi: ERC20_ABI, functionName: "transferFrom", data: tx.input as `0x${string}` });
      return { txHash: tx.hash, token: tx.to, from: args[0], to: args[1], amount: args[2] };
    } catch { return null; }
  }
 
  return null;
}

Pair this parser with a hash-set lookup of your active deposit addresses to decide whether to act:

// lib/mempool/detector.ts
import { subscribePendingTransactions } from "./subscribe";
import { parsePendingTransfer } from "./parsePendingTransfer";
import { getDepositAddresses } from "@/lib/deposits";
import { emitPendingWebhook } from "@/lib/webhooks";
 
export function startMempoolDetector(wsUrl: string) {
  const depositSet = new Set<string>(); // lowercase addresses
 
  // Refresh deposit address set every 30 s
  setInterval(async () => {
    const addresses = await getDepositAddresses({ status: "awaiting" });
    depositSet.clear();
    addresses.forEach(a => depositSet.add(a.toLowerCase()));
  }, 30_000);
 
  subscribePendingTransactions(wsUrl, async (txHash) => {
    const tx = await rpcClient.getTransaction({ hash: txHash as `0x${string}` });
    if (!tx) return;
 
    const transfer = parsePendingTransfer(tx);
    if (!transfer) return;
    if (!depositSet.has(transfer.to.toLowerCase())) return;
 
    await emitPendingWebhook(transfer);
  });
}

The 0-conf risk framework#

Seeing a transaction in the mempool is not a payment confirmation. The risks fall into four categories:

1. Transaction replacement#

EIP-1559 allows any sender to replace a pending transaction by resubmitting with the same nonce and a fee at least 10% higher. On networks with full-RBF enabled (Ethereum mainnet since mid-2023, all major L2s), any pending transaction can be replaced, even those that did not opt in to RBF.

Implication: never release goods on a pending transaction. Use the pending signal exclusively to update UX ("payment detected") and start a confirmation timer. The actual intent confirmation must wait for inclusion in a finalized or sufficiently confirmed block.

2. Dropped transactions#

Nodes evict transactions that sit unconfirmed past a time-to-live threshold (typically 4-6 hours). A transaction that was pending at 8 Gwei when the base fee spikes to 40 Gwei will never confirm. Track pending transactions with a TTL and emit a payment_intent.pending_expired event if the originating hash disappears from the mempool and never lands on-chain.

// lib/mempool/pendingTracker.ts
const pendingTTL = 4 * 60 * 60 * 1000; // 4 hours
 
const pending = new Map<string, { depositAddress: string; detectedAt: number }>();
 
export function trackPending(txHash: string, depositAddress: string) {
  pending.set(txHash, { depositAddress, detectedAt: Date.now() });
}
 
export async function sweepExpiredPending() {
  const now = Date.now();
  for (const [txHash, meta] of pending.entries()) {
    if (now - meta.detectedAt < pendingTTL) continue;
 
    const receipt = await rpcClient.getTransactionReceipt({ hash: txHash as `0x${string}` });
    if (!receipt) {
      // not confirmed and TTL passed — signal expiry
      await updateIntentStatus(meta.depositAddress, "pending_expired");
      pending.delete(txHash);
    } else {
      // it eventually landed — remove from pending, normal confirmation flow handles it
      pending.delete(txHash);
    }
  }
}

3. Private mempools#

MEV relays (Flashbots, BloXroute, Titan) allow users to submit transactions directly to block builders, bypassing the public mempool entirely. You will never see these transactions in a pendingTransactions subscription. They appear only once included in a block.

This means pending detection has to be layered on top of standard block- confirmation polling, not a replacement for it. Treat the mempool signal as an optimistic fast path; confirmations from block scanning remain the source of truth.

4. Reorgs#

On Ethereum mainnet, 1-block reorgs happen a few times per month. For ERC-20 payments treat a transaction as safe at 2 confirmations (roughly 24 seconds post-inclusion under normal conditions). On L2s the relevant threshold is the L2 finality window, not the L2 block count.

State machine for pending detection#

                        ┌─────────────────────────────────┐
                        │         payment_intent           │
           mempool      │  awaiting → pending_detected     │  block polling
         event fires    │  pending_detected → confirming   │  event fires
              ↓         │  confirming → succeeded          │
        update UX       │  pending_detected → pending_     │  TTL expires,
       "we see it!"     │  expired → awaiting (retry)      │  tx not landed
                        └─────────────────────────────────┘

The key states:

  • awaiting — intent created, no mempool or on-chain signal yet.
  • pending_detected — mempool signal received; UX shows "payment detected". No funds released. If the tx is replaced with a different recipient, revert to awaiting.
  • confirming — transaction included in a block, waiting for required confirmation depth.
  • succeeded — required depth reached; funds credited.
  • pending_expired — no confirmation after TTL; merchant optionally notified.

BchainPay webhook events#

BchainPay emits a payment_intent.pending event the moment our mempool detector matches a pending transaction to an active intent:

{
  "event": "payment_intent.pending",
  "data": {
    "id": "pi_01JVHX...",
    "status": "pending_detected",
    "tx_hash": "0x4a1b2c...",
    "token": "USDC",
    "chain_id": 1,
    "expected_amount": "150.00",
    "pending_amount": "150.00",
    "detected_at": "2026-04-27T16:00:01Z",
    "note": "Mempool signal only — do not release goods until payment_intent.succeeded"
  }
}

The payment_intent.succeeded event fires separately once the on-chain confirmation depth requirement is met. The distinction is intentional and important: do not treat payment_intent.pending as a confirmation.

Use the pending event to:

  • Display "Payment detected — confirming..." on the checkout page.
  • Stop the countdown timer so users do not assume they failed.
  • Pre-warm any fulfillment pipeline (fetch inventory, reserve stock) without committing the order.

Infrastructure considerations#

WebSocket stability#

Long-lived WebSocket connections on managed RPC providers time out after 5-30 minutes of inactivity. Implement ping/pong keepalives and reconnect with exponential backoff:

function startHeartbeat(ws: WebSocket, intervalMs = 20_000) {
  return setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) ws.ping();
  }, intervalMs);
}

Multiple providers#

The public mempool is not globally consistent. Run subscriptions against two independent WebSocket endpoints (e.g., Alchemy + a self-hosted node) and deduplicate by txHash. One provider may receive a transaction seconds before the other, and having two feeds narrows the detection window.

Mainnet vs L2 mempool differences#

On Arbitrum and Optimism, the sequencer is the single entry point for transactions. There is no peer-to-peer mempool gossip — the sequencer processes transactions in arrival order. The WebSocket pendingTransactions subscription on L2 RPC endpoints reflects what the sequencer has accepted, which gives you reliable mempool detection, but also means the pending signal is almost indistinguishable from the confirmation signal (L2 blocks are produced every 0.25 seconds on Arbitrum). On L2s, focus detection resources on block monitoring rather than a separate mempool layer.

Key takeaways#

  • Use eth_subscribe("pendingTransactions") over WebSocket (or provider- specific enhanced variants with server-side address filtering) to detect incoming transfers within 200 ms of broadcast.
  • Parse ERC-20 calldata with the 0xa9059cbb / 0x23b872dd selectors to identify transfer and transferFrom calls targeting your deposit addresses.
  • A mempool signal is an optimistic UX signal — never a payment confirmation. Use it to update the checkout UI and start a confirmation timer, not to release goods.
  • Layer mempool detection on top of block-confirmation polling. Private-relay transactions (Flashbots, BloXroute) bypass the public mempool and will only appear at inclusion time.
  • Track pending transactions with a TTL (4-6 hours). Emit pending_expired events if the transaction never lands on-chain, so merchants can notify customers to resubmit at a higher fee.
  • On L2s with sub-second block times, mempool detection provides minimal additional UX benefit. Spend the infrastructure budget on low-latency block subscriptions instead.
  • Run two independent WebSocket feeds and deduplicate by txHash to cover propagation gaps between providers.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading