Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
debug_traceBlockByHash: pin traces and recover from reorgs

debug_traceBlockByHash: pin traces and recover from reorgs

By Elias Faltin8min read

Your indexer reads a new block, records the contract calls inside it, and moves on to the next block. Then the network replaces that block with another at the same height. Your database still shows calls from the discarded block, so reports and totals can describe activity outside the accepted chain.

A block hash identifies the exact block you read. debug_traceBlockByHash uses that identity to replay its transactions and return execution traces. A trace shows the calls made during transaction execution. Pinning the request prevents a retry from silently selecting the replacement block.

It does not repair your database or prove that the block remains on the accepted chain. You need both hash-pinned reads and recovery for later replacements. This guide connects those two jobs for engineers who index whole-block call trees.

A hash pins identity, not canonicality

The canonical chain is the chain your node currently accepts. A chain reorganization, or reorg, can replace its recent blocks. The old and replacement blocks have different hashes, even when they share a height.

RequestWhat it selectsWhat a later retry can change
debug_traceBlockByNumberThe node's block at that heightA reorg can select another block
debug_traceBlockByHashThe exact block with that hashAvailability can change, but the requested identity stays fixed

Geth's hash lookup does not require the block to be canonical. A discarded block can remain traceable if the node retains its block data and required state. A successful trace therefore proves neither current canonicality nor finality.

An unavailable hash must remain an acquisition failure. Do not fall back to the same height and store the replacement response under the old hash. Another provider may lack the block or the state needed to replay it.

Blocks A and B both build on parent P at height N. A hash-pinned request returns traces for A, while a height request returns traces for B.

Blocks A and B both build on parent P at height N. The node now accepts B as canonical. debug_traceBlockByHash(A) returns traces for block A, or an error if A is unavailable. debug_traceBlockByNumber(N) returns traces for whichever block holds height N. A successful trace proves which block you read, not that it is still canonical.

Fetch the block first with eth_getBlockByHash(hash, false). Keep its number, hash, parentHash, and ordered transaction hashes beside the traces.

Include the chain ID and tracer configuration in your storage key. The same block can produce different datasets under different tracers. Hash pinning also trusts the endpoint's response; it is not a cryptographic proof of correct execution.

Read a complete call-trace envelope

Use callTracer when your index needs nested contract calls. Its onlyTopCall option omits subcalls, so it cannot replace the full tree for internal-call indexing. Geth's built-in tracer reference explains these options.

The following reader targets Geth-style {txHash, result} responses. Save it as trace-block.mjs and run it with Node.js 22 or later. Supply an endpoint that supports the method and the required history.

JavaScript
const HASH = /^0x[0-9a-fA-F]{64}$/;
const QUANTITY = /^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/;
const isHash = (value) => typeof value === 'string' && HASH.test(value);
const isQuantity = (value) => typeof value === 'string' && QUANTITY.test(value);
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const config = { tracer: 'callTracer', timeout: '10s' };

async function rpc(url, method, params) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    signal: AbortSignal.timeout(60_000),
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const body = await response.json();
  if (!isObject(body) || body.jsonrpc !== '2.0' || body.id !== 1) {
    throw new Error('Invalid JSON-RPC envelope');
  }
  if (Object.hasOwn(body, 'error')) {
    throw new Error(`RPC ${body.error?.code}: ${body.error?.message}`);
  }
  if (!Object.hasOwn(body, 'result')) throw new Error('Missing RPC result');
  return body.result;
}

async function checkChain(url, expectedChainId) {
  if (!isQuantity(expectedChainId)) throw new Error('Invalid chain ID');
  const actual = await rpc(url, 'eth_chainId', []);
  if (!isQuantity(actual) || BigInt(actual) !== BigInt(expectedChainId)) {
    throw new Error('Wrong chain');
  }
}

export async function traceBlockByHash(url, hash, chainId) {
  if (!isHash(hash)) throw new Error('Invalid block hash');
  await checkChain(url, chainId);
  const block = await rpc(url, 'eth_getBlockByHash', [hash, false]);
  if (!isObject(block) || !isHash(block.hash) || block.hash.toLowerCase() !== hash.toLowerCase()) {
    throw new Error('Missing or mismatched block');
  }
  if (!isQuantity(block.number) || !isHash(block.parentHash)) {
    throw new Error('Invalid block identity');
  }
  const txs = block.transactions;
  if (!Array.isArray(txs) || !txs.every(isHash)) throw new Error('Invalid transaction list');
  if (new Set(txs.map((tx) => tx.toLowerCase())).size !== txs.length) {
    throw new Error('Duplicate transaction hash');
  }
  const traces = await rpc(url, 'debug_traceBlockByHash', [hash, config]);
  if (!Array.isArray(traces) || traces.length !== txs.length) {
    throw new Error('Incomplete trace list');
  }
  traces.forEach((entry, index) => {
    if (!isObject(entry) || Object.hasOwn(entry, 'error')) {
      throw new Error(`Trace execution failed at ${index}`);
    }
    if (!isHash(entry.txHash) || entry.txHash.toLowerCase() !== txs[index].toLowerCase()) {
      throw new Error(`Transaction mismatch at ${index}`);
    }
    if (!isObject(entry.result) || typeof entry.result.type !== 'string' || !entry.result.type || !isQuantity(entry.result.gasUsed)) {
      throw new Error(`Missing call result at ${index}`);
    }
  });
  return {
    chainId, number: block.number, hash: block.hash,
    parentHash: block.parentHash, config: { ...config }, traces,
  };
}

export async function isCanonical(url, stored) {
  await checkChain(url, stored.chainId);
  const block = await rpc(url, 'eth_getBlockByNumber', [stored.number, false]);
  if (!isObject(block) || block.number !== stored.number || !isHash(block.hash)) {
    throw new Error('Canonical block unavailable');
  }
  return block.hash.toLowerCase() === stored.hash.toLowerCase();
}

This validates the response envelope, block identity, trace count, transaction order, and presence of call results. It accepts an empty trace list only for an empty block. Validate nested call fields against your application's schema before deriving transfers or totals.

An outer entry.error means tracing failed. An error inside entry.result can describe a correctly traced transaction that reverted. Preserve those execution failures rather than treating them as missing data.

Import the reader from another module. Replace the environment variables with your endpoint and a known block hash. Ethereum mainnet uses chain ID 0x1.

JavaScript
import { traceBlockByHash, isCanonical } from './trace-block.mjs';

const url = process.env.RPC_URL;
const record = await traceBlockByHash(url, process.env.BLOCK_HASH, '0x1');
if (!(await isCanonical(url, record))) {
  throw new Error('Block replaced; reconcile before indexing');
}
console.log(record.hash, record.traces.length);

The canonical check can become stale immediately after it returns. Store accepted records as provisional and check their parent links during subsequent processing. The reader performs no database writes, retries, or automatic failover.

Recover the database when the branch changes

A parent mismatch signals that your stored branch and the incoming branch do not connect. Stop applying new blocks. Walk back to a shared ancestor within your retained recovery history.

Undo every affected database change after that ancestor. Restore previous values for updated or deleted records. Remove newly created records and rewind saved progress. Then fetch and validate replacement blocks before applying them in order.

For example, suppose a derived count is 10 at the shared ancestor. Old block A adds 7, producing 17. Replacement B adds 3. Deleting A's trace rows leaves the count at 17; processing B would incorrectly produce 20. Restoring the count to 10 before processing B produces 13.

Restoring the count to 10 before applying replacement B gives 13. Deleting only A's rows gives 20.

A synthetic count is 10 at the shared ancestor. Applying old block A adds 7, giving 17. Undoing A restores 10 and rewinds progress. Applying replacement B adds 3, giving the correct count of 13. Deleting only A's trace rows leaves 17, so applying B gives 20, off by A's 7. Restore every value A changed, not only its trace rows.

Ponder's parent-hash recovery finds a shared block. Its database reversal removes inserted rows and restores updated or deleted rows. HyperIndex's recovery code checks tracked block hashes, rewinds progress, and schedules processing again. Its rollback diff restores earlier entities and removes entities created after the recovery point.

These implementations illustrate recovery mechanisms. They do not establish that either framework acquires data with debug_traceBlockByHash. The receipt indexer's reorg section explains retained history and external side effects in more detail.

Apply the trace-derived changes and saved progress in one database transaction. Keep raw orphaned traces separately if you need an audit trail. Exclude them from canonical queries. If a reorg exceeds retained history, stop and rebuild from a trusted checkpoint.

Separate retries, finality, and missing history

Retry a transient failure with the same hash and tracer configuration. Bound attempts and back off. Recheck chain identity on each failover endpoint. A missing block on one endpoint does not establish that the block never existed.

Do not interpret every -32000 error as a missing hash. Inspect the full error and endpoint policy. Method denial, unavailable replay state, and a tracing timeout require different responses. An HTTP 200 response can still contain a JSON-RPC error.

Geth's block replay starts from the parent state. Retaining a block header therefore does not guarantee trace availability. Probe your oldest required block and tracer before starting a backfill. A successful probe establishes that request's availability, not uninterrupted coverage of an entire range.

Use Ethereum's finalized block tag as a consensus reference, with the documented block selectors. Before promoting stored rows, verify their hashes against that accepted chain. Promote only matching blocks at or below the finalized height. Recovery-history settings are not consensus finality. Delay irreversible external actions until your application's finality requirement is met.

For one transaction, use the debug_traceTransaction guide. For flat traces and address-filtered ranges, see trace_filter and trace_block. Preserve the same identity and recovery checks when changing the acquisition method.

For Dwellir, use the Ethereum debug_traceBlockByHash reference to configure a hash-pinned request. Confirm the required tracer and historical range on your endpoint before integrating it.

Start free.1 response = 1 credit.

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