Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
Confirm Ethereum payments without false failures

Confirm Ethereum payments without false failures

By Elias Faltin11min read

Your service sends a payment, then waits for proof that it completed. The first lookup returns nothing. Treating that answer as failure can trigger a second payment. Accepting the first visible result can also be wrong if Ethereum replaces its block.

A transaction receipt records execution in a particular block. eth_getTransactionReceipt returns that receipt, or null when the node has none for the hash. Keep three questions separate. Does this node know the transaction? Did execution succeed? Has its block reached your settlement requirement?

This guide builds a bounded polling loop for those decisions. For submission and saved transaction bytes, use the safe submission guide.

Treat null as unresolved

The execution API specification defines a receipt or null as the result. It also lists error 4444 for unavailable pruned history. Other failures remain possible. Geth can return an indexing-in-progress error instead of a receipt or transaction.

Geth's receipt lookup uses canonical transactions, meaning transactions in the node's current chosen chain. A receipt can disappear when a chain reorganization replaces its block. null therefore does not prove that a transaction failed, was cancelled, or never reached the network.

When the receipt is null, ask eth_getTransactionByHash through the same endpoint:

ReceiptTransactionDecision
ObjectNot neededValidate its identity, execution status, and block
nullMatching hash with null block fieldsPending on the answering node. Keep waiting.
nullMatching hash with valid block fieldsReads disagree or receipt data lags. Retry.
nullnullUnknown to the answering node. Reconcile without declaring failure.
Error or malformed dataAny answerLookup failed. Do not infer a transaction outcome.
A null receipt leads to a transaction lookup on the same endpoint, which separates four states: pending, receipt lagging, unknown, and lookup failed.

eth_getTransactionReceipt returns null, so ask eth_getTransactionByHash on the same endpoint. Pending: the hash matches and no block is set yet. Keep polling. Receipt lagging: the hash matches and a block is set. Retry the receipt. Unknown: the transaction lookup is null too. Reconcile and keep the payment open. Lookup failed: an error or malformed response. Retry and infer nothing. Never sign a second payment because a receipt is null.

An endpoint URL can route requests to different backends. These reads are not an atomic snapshot. Temporary disagreement needs another check, not a new payment.

Validate a 32-byte transaction hash before polling. Reject missing fields and mismatched hashes in responses. A malformed transaction object must not become a pending state.

Two nulls cannot distinguish a dropped transaction from a propagation delay or a replacement using the same sender and nonce. Follow the submission guide's nonce reconciliation procedure. Keep the payment unresolved while that investigation runs.

Separate execution from settlement depth

Under Ethereum Improvement Proposal 658, status: "0x1" means the top-level execution succeeded. status: "0x0" means it failed. Both are receipts for included transactions. A receipt's existence alone never establishes successful payment.

Successful execution also needs application checks. Match the expected chain, destination, amount, and contract operation against the saved payment intent. For token payments, validate the expected transfer event. The loop below checks transaction outcome and block depth. It does not verify those payment details.

Choose the target before polling:

TargetWhat the endpoint reportsApplication decision
includedThe receipt's hash matches the canonical block at its heightShow an execution result that can still change
safeThe matching receipt block is at or below the safe blockAccept only if your policy accepts the safe tag's assumptions
finalizedThe matching receipt block is at or below the finalized blockApply your final settlement policy after payment checks

The specification defines safe and finalized through different security assumptions. Safe assumes an honest majority and certain synchrony conditions. Reorganizing a finalized block requires manual intervention through community coordination.

These tags identify blocks. They do not promise a fixed delay. Check your endpoint's behavior before choosing a timeout. The safe-block guide explains how client configuration can affect safe.

Compare the block hash as well as the height. A receipt below the safe boundary can still belong to a replaced block. An unavailable tag must leave settlement unresolved.

Poll with a deadline and backoff

Start with a one-second delay. Increase it by half after each attempt, with jitter and a twelve-second cap. These values are example settings. Choose them from your response-time requirement and request budget.

Shorter intervals can discover new data sooner, but they increase requests. A fixed two-second interval allows about 90 receipt checks over 180 seconds. Twelve seconds allows about 15. Transaction and block lookups add requests. A shared worker should also limit total concurrency.

Honor an HTTP Retry-After header before another attempt. A rate limit, timeout, or JSON-RPC error describes the lookup, not transaction execution.

Viem's receipt waiter demonstrates why lookup retries matter. It retries transaction reads because providers can lag behind mined transactions. Its replacement path searches a block for the same sender and nonce. A cancellation or replacement receipt must not settle the original payment without checking its intent.

The Node.js 22 example below sends read-only requests. Save it as track.mjs. It waits for finalized by default and stops after five consecutive failed attempts. It validates the fields needed for its decisions, rather than the full receipt schema.

JavaScript
const isHash = (v) => typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v);
const isQuantity = (v) => typeof v === 'string' && /^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(v);
const same = (a, b) => isHash(a) && isHash(b) && a.toLowerCase() === b.toLowerCase();
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
let requestId = 0;

async function rpc(url, method, params, signal) {
  const id = ++requestId;
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
    body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
  });
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status}`);
    const header = response.headers.get('retry-after');
    const seconds = header === null ? NaN : Number(header);
    const retryAt = Number.isFinite(seconds) ? Date.now() + seconds * 1_000 : Date.parse(header);
    error.retryAfterMs = Math.max(0, retryAt - Date.now()) || 0;
    throw error;
  }
  const body = await response.json();
  if (!isObject(body) || body.jsonrpc !== '2.0' || body.id !== id) {
    throw new Error('Invalid RPC envelope');
  }
  if (Object.hasOwn(body, 'error')) throw new Error(`RPC error: ${JSON.stringify(body.error)}`);
  if (!Object.hasOwn(body, 'result')) throw new Error('Missing RPC result');
  return body.result;
}

function checkReceipt(receipt, hash) {
  if (!isObject(receipt) || !same(receipt.transactionHash, hash) ||
      !isHash(receipt.blockHash) || !isQuantity(receipt.blockNumber) ||
      !isQuantity(receipt.transactionIndex) || !['0x0', '0x1'].includes(receipt.status)) {
    throw new Error('Incomplete or mismatched receipt');
  }
}

async function lookup(url, hash, signal) {
  const receipt = await rpc(url, 'eth_getTransactionReceipt', [hash], signal);
  if (receipt !== null) {
    checkReceipt(receipt, hash);
    return { state: 'included', receipt };
  }
  const tx = await rpc(url, 'eth_getTransactionByHash', [hash], signal);
  if (tx === null) return { state: 'unknown' };
  if (!isObject(tx) || !same(tx.hash, hash)) throw new Error('Mismatched transaction');
  if (tx.blockHash === null && tx.blockNumber === null) return { state: 'pending' };
  if (!isHash(tx.blockHash) || !isQuantity(tx.blockNumber)) throw new Error('Incomplete transaction');
  return { state: 'receipt-lagging' };
}

function checkBlock(block) {
  if (!isObject(block) || !isHash(block.hash) || !isQuantity(block.number)) {
    throw new Error('Incomplete block');
  }
}

async function depth(url, receipt, until, signal) {
  const boundary = until === 'included' ? null :
    await rpc(url, 'eth_getBlockByNumber', [until, false], signal);
  if (until !== 'included' && boundary === null) return 'tag-unavailable';
  if (boundary !== null) checkBlock(boundary);
  // Read canonical identity after the tag, so an earlier receipt is rechecked.
  const block = await rpc(url, 'eth_getBlockByNumber', [receipt.blockNumber, false], signal);
  if (block === null) return 'block-unavailable';
  checkBlock(block);
  if (BigInt(block.number) !== BigInt(receipt.blockNumber)) throw new Error('Wrong block height');
  if (!same(block.hash, receipt.blockHash)) return 'replaced';
  if (boundary && BigInt(receipt.blockNumber) <= BigInt(boundary.number)) {
    if (BigInt(receipt.blockNumber) === BigInt(boundary.number) &&
        !same(receipt.blockHash, boundary.hash)) throw new Error('Inconsistent tag');
    return until;
  }
  return 'included';
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function track(url, hash, {
  chainId = '0x1', until = 'finalized', deadlineMs = 180_000,
} = {}) {
  if (!isHash(hash)) throw new Error('Not a 32-byte transaction hash');
  if (!isQuantity(chainId)) throw new Error('Invalid chain ID');
  if (!['included', 'safe', 'finalized'].includes(until)) throw new Error('Invalid target');
  if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 2_147_483_647) {
    throw new Error('Invalid deadline');
  }
  const deadline = Date.now() + deadlineMs;
  const signal = AbortSignal.timeout(deadlineMs);
  let delay = 1_000;
  let failures = 0;
  let last = { state: 'unknown' };
  try {
    const actualChain = await rpc(url, 'eth_chainId', [], signal);
    if (!isQuantity(actualChain) || BigInt(actualChain) !== BigInt(chainId)) {
      throw new Error('Wrong chain');
    }
  } catch (error) {
    return { state: 'lookup-failed', error: error.message };
  }
  while (Date.now() < deadline) {
    let retryAfterMs = 0;
    try {
      last = await lookup(url, hash, signal);
      if (last.receipt) {
        last.depth = await depth(url, last.receipt, until, signal);
        if (last.depth === until) {
          return { ...last, state: last.receipt.status === '0x1' ? 'succeeded' : 'reverted' };
        }
      }
      failures = 0;
    } catch (error) {
      failures += 1;
      last = { state: 'lookup-failed', error: error.message };
      if (failures >= 5) return last;
      retryAfterMs = error.retryAfterMs || 0;
    }
    const wait = Math.max(Math.min(delay * (0.8 + Math.random() * 0.4), 12_000), retryAfterMs);
    await sleep(Math.min(wait, Math.max(0, deadline - Date.now())));
    delay = Math.min(delay * 1.5, 12_000);
  }
  return { state: 'unresolved', last, timedOut: true };
}

Call it from your worker:

JavaScript
import { track } from './track.mjs';

const result = await track(process.env.RPC_URL, process.env.TX_HASH, {
  chainId: '0x1',
  until: 'finalized',
  deadlineMs: 180_000,
});
console.log(result);

Only succeeded means successful execution reached the requested target. reverted means failed execution reached that target. lookup-failed and unresolved leave the payment open. A timeout is never permission to sign another payment.

A matching chain ID catches configuration mistakes. It does not authenticate the provider. The block checks assume an honest endpoint with a coherent chain view. Separate remote calls cannot prevent every race or a later reorganization.

Recheck and recover replaced receipts

Persist the transaction hash, receipt block number, receipt block hash, execution status, and required settlement target. Before settlement, repeat the checks when a new block arrives. A different canonical hash invalidates the earlier receipt. A missing block leaves the check inconclusive.

Block A forks into replaced blocks B and C and canonical blocks D and E. Below, three steps: stored, read again, recheck.

Both chain views share block A at height 100. The receipt was seen in block B at height 101, followed by block C. Both were replaced. The canonical chain now has block D at height 101, followed by block E. Stored: the receipt's height 101 and the hash of block B. Read again: height 101 now returns block D. Recheck: the hashes differ, so the stored receipt is stale. Settle only while the stored block hash is still canonical.

The transaction may return in a different block. Its execution can then produce a different status or different logs. Store each observation with its block hash so replacement remains visible.

A polling loop cannot undo application writes. If an early receipt changed a balance, restore the previous balance before processing replacement data. Make the database update and its recovery record part of one transaction.

Ponder's recovery code follows parent hashes until it finds a block shared by both chain views. HyperIndex's recovery code compares stored block hashes to locate its rollback point. These mechanisms explain why a payment worker needs retained block identity and recoverable state.

For database history and progress recovery, follow the block-receipt recovery procedure. Database rollback cannot retract an external payment or notification. Delay irreversible actions until your settlement policy permits them.

Use new heads as a polling trigger

For many outstanding hashes, subscribe to newHeads and check them when a header arrives. Keep a timer as a fallback after connection loss. Geth's subscription documentation says notifications cover current events and disappear with the connection.

A new header does not prove that another HTTP backend already serves its block or receipts. Retry inconclusive reads. On reorganizations, newHeads can emit another header at the same height. If several blocks arrive together, Geth emits only the last header.

Check all outstanding hashes when triggered. For contract logs, process removed: true notifications as reversals. After reconnecting, recover from persisted progress through HTTP reads. The WebSocket recovery guide covers missed events and slow consumers.

When you need most receipts in a block, consider eth_getBlockReceipts instead of one request per transaction. Keep its completeness checks before moving on to the next block.

Connect the loop to your payment records

Keep one persisted record per payment. Restart polling from unresolved records after a worker restarts. Make settlement idempotent so two workers cannot credit the same payment twice.

Use Dwellir's eth_getTransactionReceipt reference to connect your endpoint. Before settlement, confirm the field checks with an included transaction, an unknown hash, and deliberately inconsistent fixture responses.

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 170+ networks. Upgrade when you need more.