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 toawaiting.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/0x23b872ddselectors to identifytransferandtransferFromcalls 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_expiredevents 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
txHashto cover propagation gaps between providers.