Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
eth_getBlockReceipts in production

eth_getBlockReceipts in production

By Elias Faltin 11min read

A block indexer needs every receipt before it moves on to the next block. Each receipt records a transaction's result and event logs. A missing receipt can leave events out of your database.

eth_getBlockReceipts replaces one receipt lookup per transaction with one block-level remote procedure call, or RPC. Use it when you ingest whole blocks. Keep per-transaction lookups for individual transactions or an explicitly approved fallback.

The method reduces receipt lookups from N to 1 for a block with N transactions. That does not guarantee lower latency or a lower invoice. Method weights, response bytes, retries, and your plan determine those results.

The operator's rule is simple. Return a complete, internally consistent block, or stop before moving on to the next block.

Choose the receipt path for your workload

If you index all transactions, request block receipts. If you need one transaction's status, use eth_getTransactionReceipt. If you only need selected events, start with a filtered eth_getLogs query.

The receipt specification accepts a hexadecimal block number, a block tag, or a block hash string. It returns an array or null. Use strings such as "0x18e43db", rather than JSON numbers.

JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_getBlockReceipts",
  "params": ["0x18e43db"]
}

Pass the hash as a string. An object such as { blockHash: ... } is outside this method's published schema. A client's extra parameter forms do not establish provider support.

Client implementation and endpoint policy are separate facts. For example, Geth's implementation constructs receipts in transaction order and checks their count against the block. Your provider still controls method access and retained history.

Use the same credentials and endpoint configuration as your worker. Verify the expected chain with eth_chainId, then request a known block and its receipts. A successful recent request does not establish historical coverage.

Pin the block and verify its identity

Resolve a block number or a supported safe or finalized tag to a block hash. Request receipts by that hash. Two calls using latest can refer to different blocks.

Before returning data, check each receipt against the block's transaction list:

  • Receipt count equals transaction count.
  • Each transactionHash matches the transaction at that array position.
  • Each transactionIndex equals that position.
  • Each blockHash and blockNumber matches the resolved block.

These checks also apply to the per-transaction fallback. null is missing data. An empty receipt array is valid only when the block's transaction list is empty.

Resolve a block hash, request its receipts, verify every receipt, and recheck the canonical hash before returning data.

Resolve the block number to hash H. Request eth_getBlockReceipts using H. Verify receipt count, transaction order, transaction index, block hash, and block number. Recheck the canonical hash at that height before returning data. A matching hash does not prevent a later reorg.

A hash identifies a block; it does not prove that block remains canonical. Read the block at the same height again before returning data. Stop if its hash changed.

These are consistency checks against a trusted endpoint. They do not verify the receipt trie against receiptsRoot or detect a dishonest server.

Recover when earlier blocks are replaced

A chain reorganization, or reorg, can replace a block after its receipts pass every check. If you already stored data from that block, your database must undo its changes before processing the replacements.

Ponder follows parent block hashes back to the last block shared by both chains. It uses a database change log to undo inserts, updates, and deletes after that block. Then it fetches the replacement data and runs the indexing functions again. Ponder's reorg documentation describes this recovery sequence.

Envio HyperIndex compares recent block hashes to find a recovery point. Its PostgreSQL history restores earlier records and removes records created by replaced blocks. It also rewinds saved progress before processing replacements. Deleting recent receipts alone would leave other database changes, such as updated balances, unrepaired.

Recovery depends on retained history. Ponder limits recovery with a time window, while HyperIndex uses a block-depth limit. These settings do not establish Ethereum's consensus finality. Choose a recovery window for your chain and plan for deeper reorgs. Database rollback also cannot undo an external API action. Delay irreversible actions until your application's finality requirement is met.

Separate method absence from failed requests

Fallback should answer one question. Does this endpoint lack the block-receipts method while still serving individual receipts?

The JSON-RPC specification defines -32601 as method not found. That response can justify a configured fallback. Authentication errors and temporary failures require different actions.

ResponseWorker action
-32601Use per-transaction receipts only if fallback is enabled and within its call budget
HTTP 401 or 403Repair credentials or access configuration
Timeout, HTTP 429, or 5xxRetry the same path with backoff and a bounded retry budget
-32602Repair the parameter form; do not infer method absence
Error 4444, null, or incomplete receiptsStop and investigate history coverage or block availability

Provider-specific gating messages need a documented adapter. Do not classify every message containing "not available" as method absence. A successful eth_chainId call does not prove another method's authentication or plan entitlement.

Use individual receipts after -32601 only when fallback is permitted and the transaction count fits the configured call limit. Repair access, retry temporary failures on the same path, and stop for missing history.

Method not found, -32601: first check whether fallback is permitted. Stop if it is disabled. Then check the fallback call budget. Transaction count must fit the configured limit. Stop if the budget is exceeded. Otherwise request one receipt per transaction. HTTP 401 or 403: repair credentials or access configuration. Timeout, HTTP 429, or 5xx: retry the same path with backoff and a bounded retry budget. Stop when that budget is exhausted. Error 4444, null, or partial receipts: stop and check history coverage and block availability. Keep the cursor unchanged.

Do not replace one timed-out block request with hundreds of receipt requests. That can increase both load and usage charges.

Verify receipt history separately from historical state

The execution API specifies error 4444 for pruned history. A node can retain block headers while dropping the bodies or receipts your indexer needs.

Geth's history-pruning documentation describes deleting old block bodies and receipts while preserving headers and state. Historical state retention serves different queries, such as old balances or contract storage.

Ask for retained block bodies and receipts over your target range. An "archive" label alone does not establish both. Probe known nonempty historical blocks before starting a backfill.

If history is unavailable, use an endpoint that retains that range. Per-transaction calls on the same node cannot be assumed to recover missing receipts. Keep the cursor unchanged until you have complete data.

Run a bounded receipt reader

Save this JavaScript as receipts.mjs. It uses Node.js 22's built-in fetch and needs no packages.

The reader checks chain identity, block identity, receipt completeness, and canonicality at return time. It permits fallback only for -32601, with explicit approval and a transaction-call budget.

A cursor records where indexing should resume after a restart. Your worker must update that saved position only after storing the complete block.

It makes one attempt per request. Your worker must classify failures and apply bounded retries. The fallback runs sequentially to keep the example's request count and failure behavior clear.

JAVASCRIPT
class RpcError extends Error {
  constructor(message, code) {
    super(message);
    this.code = code;
  }
}

const hashPattern = /^0x[0-9a-f]{64}$/i;
const quantityPattern = /^0x(0|[1-9a-f][0-9a-f]*)$/i;
const isHash = (value) => typeof value === 'string' && hashPattern.test(value);
const isQuantity = (value) => typeof value === 'string' && quantityPattern.test(value);
const sameHash = (a, b) => isHash(a) && isHash(b) && a.toLowerCase() === b.toLowerCase();

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

function checkBlock(block, selector) {
  if (!block || !isHash(block.hash) || !isQuantity(block.number)) {
    throw new Error('Block unavailable or malformed');
  }
  if (!Array.isArray(block.transactions) || !block.transactions.every(isHash)) {
    throw new Error('Invalid block transaction list');
  }
  if (new Set(block.transactions.map((hash) => hash.toLowerCase())).size !== block.transactions.length) {
    throw new Error('Duplicate block transaction');
  }
  if (isQuantity(selector) && BigInt(block.number) !== BigInt(selector)) {
    throw new Error('Wrong block number');
  }
}

function checkReceipts(block, receipts) {
  if (!Array.isArray(receipts) || receipts.length !== block.transactions.length) {
    throw new Error('Missing receipts or wrong count');
  }
  for (const [index, receipt] of receipts.entries()) {
    if (!receipt || !sameHash(receipt.transactionHash, block.transactions[index])) {
      throw new Error(`Wrong transaction at receipt ${index}`);
    }
    if (!isQuantity(receipt.transactionIndex) || BigInt(receipt.transactionIndex) !== BigInt(index)) {
      throw new Error(`Wrong transaction index at receipt ${index}`);
    }
    if (!sameHash(receipt.blockHash, block.hash) || !isQuantity(receipt.blockNumber)) {
      throw new Error(`Wrong block identity at receipt ${index}`);
    }
    if (BigInt(receipt.blockNumber) !== BigInt(block.number) || !Array.isArray(receipt.logs)) {
      throw new Error(`Wrong block number or malformed logs at receipt ${index}`);
    }
  }
}

export async function getCompleteBlockReceipts(
  url,
  expectedChainId,
  selector = 'finalized',
  { allowFallback = false, maxFallback = 32 } = {},
) {
  if (!['safe', 'finalized'].includes(selector) && !isQuantity(selector)) {
    throw new Error('Use safe, finalized, or a hex block number');
  }
  if (!Number.isSafeInteger(maxFallback) || maxFallback < 0) {
    throw new Error('maxFallback must be a nonnegative safe integer');
  }
  const chainId = await rpc(url, 'eth_chainId', []);
  if (!isQuantity(chainId) || BigInt(chainId) !== expectedChainId) {
    throw new Error('Wrong chain');
  }
  const block = await rpc(url, 'eth_getBlockByNumber', [selector, false]);
  checkBlock(block, selector);
  let receipts;
  let path = 'block';
  try {
    receipts = await rpc(url, 'eth_getBlockReceipts', [block.hash]);
  } catch (error) {
    if (!(error instanceof RpcError) || error.code !== -32601 || !allowFallback) throw error;
    if (block.transactions.length > maxFallback) throw new Error('Fallback call budget exceeded');
    path = 'per-tx';
    receipts = [];
    for (const hash of block.transactions) {
      const receipt = await rpc(url, 'eth_getTransactionReceipt', [hash]);
      if (receipt === null) throw new Error('Receipt unavailable; keep the cursor unchanged');
      receipts.push(receipt);
    }
  }
  checkReceipts(block, receipts);
  const canonical = await rpc(url, 'eth_getBlockByNumber', [block.number, false]);
  checkBlock(canonical, block.number);
  if (!sameHash(canonical.hash, block.hash)) throw new Error('Block changed; keep the cursor unchanged');
  return { block, receipts, path };
}

Save this runner as run.mjs beside it:

JAVASCRIPT
import { getCompleteBlockReceipts } from './receipts.mjs';

try {
  if (!process.env.RPC_URL) throw new Error('Set RPC_URL');
  const { block, receipts, path } = await getCompleteBlockReceipts(
    process.env.RPC_URL,
    1n,
    process.env.BLOCK ?? 'finalized',
    { allowFallback: false, maxFallback: 32 },
  );
  console.log(`${block.number}: ${receipts.length} receipts via ${path}`);
  // Commit receipts and the cursor together in your database transaction.
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}

Run it against your Ethereum endpoint:

BASH
RPC_URL='https://your-ethereum-endpoint.example' node run.mjs

Enable allowFallback only after checking method access and approving its call budget. The default cap permits at most 32 per-transaction requests. A larger block stops before sending them.

The block path makes 4 RPC calls, including the chain check and final canonicality check. A successful fallback adds N individual receipt calls after the rejected block-receipts request.

For a throughput-oriented worker, replace sequential fallback with bounded concurrency or batches. Keep the same completeness checks. For batches, match responses by id, since response order is not guaranteed.

Persist receipts and the cursor in one database transaction. Never advance the cursor in an error handler. Resume from the same block after repairing the endpoint or changing the approved route.

Size requests before starting a backfill

One block can contain many logs. A block-receipts response includes those logs, so transaction count alone does not determine response size.

Request one block per call before combining blocks into JSON-RPC batches. Check your HTTP client's decompressed response limit, the server's response limit, and the request timeout.

Enable HTTP compression where supported. Measure response bytes and latency from your worker's region. Record the endpoint, block hash, sample count, and compression settings before publishing benchmark results.

Track calls and retries per completed block. Compare the two receipt paths using your plan's method weights. Fewer lookups do not establish a universal cost break-even point.

Put the reader into an indexing worker

Start with a known nonempty block and verify both receipt paths against the same hash. Test empty blocks, missing receipts, unavailable methods, wrong-chain endpoints, and reorgs before advancing a production cursor.

For event-only indexing, plan your eth_getLogs backfill. For concurrent fallback requests, see JSON-RPC batch sizing and partial failures.

Dwellir publishes an Ethereum reference for eth_getBlockReceipts. Create a Dwellir account to evaluate the reader with your endpoint, credentials, and required history range.

read another blog post