Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting13 min read

BSC Parity Traces at Scale: trace_filter, trace_block, and Block-Range Limits

A production playbook for paging the Parity-style trace namespace on BNB Smart Chain, covering trace_filter, trace_block, capability probing, cursor persistence, and reorg repair.

TL;DR

The Parity-style trace namespace on BNB Smart Chain exposes trace_filter, trace_block, trace_transaction, and trace_get, each answering a different question about internal calls, value transfers, creates, and selfdestructs. Because a single BSC block can contain thousands of trace objects, a wide trace_filter range is materially heavier than the equivalent eth_getLogs range and is commonly capped or timed out by providers. The namespace is optional, so many public endpoints return JSON-RPC method-not-found (-32601) until you probe capability. This article shows how to detect trace support, page fixed block windows with a persisted cursor, dedupe on (blockNumber, transactionHash, traceAddress), back off on range-too-large errors, and repair the last few blocks after a reorg. It also separates documented OpenEthereum trace behaviour from provider-specific limits and gives you a results table to fill in against your own endpoint.

The trace namespace method set on BNB Smart Chain

BNB Smart Chain inherits the Parity/OpenEthereum trace namespace, a family of methods that report execution-level events rather than receipt-level logs. The four methods you will use most are trace_filter, trace_block, trace_transaction, and trace_get. Each answers a different question, and choosing the wrong one produces a silently incomplete answer rather than an error.

trace_filter accepts a block range plus optional fromAddress and toAddress filters and returns the traces that match. trace_block returns every trace in a single block. trace_transaction returns every trace for one transaction, and trace_get returns a single trace by its transaction hash and traceAddress path. The historical origin of these methods and their filter and output fields is the OpenEthereum trace module documentation (openethereum.github.io).

The Ethereum JSON-RPC specification (ethereum.org/en/developers/docs/apis/json-rpc) defines the standard eth_* methods that sit alongside this namespace, while the geth debug namespace (geth.ethereum.org) covers per-transaction call trees. If you are deciding between trace_transaction and debug_traceTransaction, see trace_transaction vs debug_traceTransaction and trace_call.

The trace namespace itself is not part of the base Ethereum JSON-RPC method set; it originated with the OpenEthereum client, whose trace module documentation remains the reference for trace_filter, trace_block and their output fields. The ordinary methods it sits beside are defined by the Ethereum JSON-RPC specification. Read them together, because the specification explains the standard surface while the trace module documents the optional one.

  • trace_filter — block range plus optional fromAddress/toAddress; returns matching external-facing traces.
  • trace_block — all traces in one block; useful for block-oriented indexing.
  • trace_transaction — all traces for one transaction hash.
  • trace_get — one trace by transaction hash and traceAddress path.

How traces differ from logs and why BSC blocks are heavy

A log is emitted by a contract and stored in the receipt; a trace records every internal call, value transfer, create, and selfdestruct, each with a call type and a traceAddress path that describes its position in the call tree. That means a single BSC block can yield thousands of trace objects even when the block contains only a few hundred transactions, because each transaction may fan out into many internal calls.

The practical consequence is that a wide trace_filter range is materially heavier than the equivalent eth_getLogs range. The same block window that returns a manageable log payload can return an order of magnitude more trace objects, so a range that succeeds for logs may be capped or timed out for traces. The paging strategy for logs still applies, but with smaller windows; the log-specific version is covered in Scanning BSC logs at scale: eth_getLogs range limits.

Because trace_filter only covers external-facing traces, internal-call detail requires debug_traceTransaction. If your question is 'what did this address do at the top level', trace_filter is the right tool; if it is 'what happened inside this transaction', you need the debug family.

Why trace support varies by endpoint and how to probe it

The trace namespace is optional. Many public endpoints do not enable it, and a call to trace_filter on such an endpoint returns the JSON-RPC method-not-found error, code -32601, as defined by the JSON-RPC 2.0 Specification (jsonrpc.org/specification). This is not a transient failure; retrying will not help. A client must probe capability before relying on the namespace.

The probe is a cheap trace_block call against a recent block, or a trace_filter with a one-block range. If the response is a result array, the namespace is enabled. If it is an error object with code -32601, the endpoint does not expose traces and you should fail over to another endpoint. The general pattern for namespace errors is documented in RPC method not found (-32601) and endpoint namespaces.

Documented OpenEthereum behaviour defines the method semantics; whether a given provider enables the namespace, and what range it caps, varies by provider. Treat capability and caps as provider-specific and measure them rather than assuming.

async function probeTraceSupport(url) {
  const body = {
    jsonrpc: '2.0',
    id: 1,
    method: 'trace_block',
    params: ['latest']
  };
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  if (json.error && json.error.code === -32601) {
    return { supported: false, reason: 'trace namespace not enabled' };
  }
  if (json.error) {
    return { supported: false, reason: json.error.message };
  }
  return { supported: true, sample: json.result.length };
}

A paging strategy for large block ranges

The safe pattern is fixed block windows with a persisted cursor. Choose a window size, request trace_filter for [cursor, cursor + window - 1], process the results, then advance the cursor to cursor + window. Persist the cursor after each successful window so an interruption resumes from the last completed block rather than from the start.

Dedupe on the tuple (blockNumber, transactionHash, traceAddress). Because traceAddress is a path array, two traces in the same transaction can share a transaction hash but differ in path; the tuple is the stable identity. If you retry a window after a timeout, dedupe prevents double-counting.

On a range-too-large or timeout error, halve the window and retry the same cursor. On repeated failure, back off exponentially and consider switching to a provider with a larger cap. This mirrors the eth_getLogs approach but with smaller windows to account for the larger per-block trace payload. For reconciliation against block headers, see Block-by-block EVM indexer reconciliation.

  • Fixed window, persisted cursor, advance only after a successful window.
  • Dedupe key: (blockNumber, transactionHash, traceAddress).
  • On range-too-large: halve the window, retry the same cursor.
  • On repeated failure: exponential backoff, then fail over.

Address-oriented scans with trace_filter

When the question is 'what did this address do', trace_filter with fromAddress or toAddress answers it without downloading every trace in the chain. The filter is applied server-side, so the response contains only traces where the address appears as the sender or recipient of an external-facing call or transfer.

Addresses are case-insensitive hex. A checksummed or truncated string silently matches nothing, returning an empty result rather than an error. Normalize to lowercase before sending, and validate length and hex characters client-side so a malformed filter fails loudly in your code rather than quietly at the endpoint.

Because trace_filter covers external traces only, an address that appears solely as an internal call target will not show up. If you need internal appearances, you must fall back to debug_traceTransaction per transaction, which is far more expensive and should be reserved for targeted lookups.

function normalizeAddress(addr) {
  if (typeof addr !== 'string') throw new Error('address must be a string');
  const hex = addr.toLowerCase();
  if (!/^0x[0-9a-f]{40}$/.test(hex)) {
    throw new Error('malformed address: ' + addr);
  }
  return hex;
}

async function scanAddress(url, address, fromBlock, toBlock) {
  const body = {
    jsonrpc: '2.0',
    id: 1,
    method: 'trace_filter',
    params: [{
      fromBlock: '0x' + fromBlock.toString(16),
      toBlock: '0x' + toBlock.toString(16),
      fromAddress: [normalizeAddress(address)]
    }]
  };
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  if (json.error) throw new Error(json.error.message);
  return json.result;
}

A runnable Node.js pager with cursor persistence

The pager below detects trace-namespace support, pages trace_filter windows, persists a cursor to a JSON file, and resumes after an interruption. It halves the window on a range-too-large error and backs off on repeated failures. Run it against your own endpoint and adjust WINDOW to the largest value your provider accepts.

The cursor file stores the next block to request. On restart, the pager reads it and continues. Dedupe is handled by a Set keyed on the identity tuple, so a retried window does not double-count. This is a minimal reference; production code should add structured logging and a dead-letter queue for windows that never succeed.

const fs = require('fs');
const ENDPOINT = process.env.BSC_RPC_URL;
const CURSOR_FILE = './trace-cursor.json';
const START = Number(process.env.START_BLOCK || 0);
const END = Number(process.env.END_BLOCK || 0);
let WINDOW = Number(process.env.WINDOW || 50);

function loadCursor() {
  if (fs.existsSync(CURSOR_FILE)) {
    return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')).next;
  }
  return START;
}

function saveCursor(next) {
  fs.writeFileSync(CURSOR_FILE, JSON.stringify({ next }));
}

async function rpc(method, params) {
  const res = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  return res.json();
}

async function traceWindow(from, to) {
  return rpc('trace_filter', [{
    fromBlock: '0x' + from.toString(16),
    toBlock: '0x' + to.toString(16)
  }]);
}

async function main() {
  const probe = await rpc('trace_block', ['latest']);
  if (probe.error && probe.error.code === -32601) {
    throw new Error('trace namespace not enabled on this endpoint');
  }
  const seen = new Set();
  let cursor = loadCursor();
  while (cursor <= END) {
    const to = Math.min(cursor + WINDOW - 1, END);
    const json = await traceWindow(cursor, to);
    if (json.error) {
      if (/range|too large|timeout/i.test(json.error.message)) {
        WINDOW = Math.max(1, Math.floor(WINDOW / 2));
        console.warn('shrinking window to', WINDOW);
        continue;
      }
      throw new Error(json.error.message);
    }
    for (const t of json.result) {
      const key = t.blockNumber + ':' + t.transactionHash + ':' + JSON.stringify(t.traceAddress);
      if (seen.has(key)) continue;
      seen.add(key);
      // process(t)
    }
    cursor = to + 1;
    saveCursor(cursor);
    console.log('processed through block', to, 'traces', json.result.length);
  }
}

main().catch((e) => { console.error(e); process.exit(1); });

Results table to fill in against your own endpoint

Provider caps and trace availability vary, so measure them against the endpoint you actually use. Run the probe and pager above, then record the values below. Do not assume another provider's numbers apply to yours.

Start with a small window and increase it until you hit a range-too-large or timeout error, then record the last successful value. Repeat at a busy block height and a quiet one, because traces per block vary with network activity.

  • Trace support present: yes / no (from the -32601 probe).
  • Largest successful trace_filter range: ___ blocks.
  • Traces per block (busy height): ___.
  • Traces per block (quiet height): ___.
  • Retries before success: ___.
  • Window size after backoff: ___ blocks.

Troubleshooting trace_filter and trace_block failures

A -32601 error means the namespace is not enabled on that endpoint. It is not transient; fail over to an endpoint that exposes traces. Confirm the error code rather than the message, since messages vary by provider.

A range-too-large or timeout error means the window exceeds the provider cap or the response took too long to build. Halve the window and retry the same cursor. If the error persists at a window of one block, the block itself may be too heavy; consider trace_block for that height or a provider with a larger cap.

An empty result from trace_filter usually means a malformed address filter. Addresses are case-insensitive hex, so a checksummed or truncated string silently matches nothing. Normalize to lowercase and validate the 0x-prefixed 40-hex-character form before sending.

For the last few blocks, a reorg can invalidate traces you already processed. Track the block hash alongside the number, and on a hash mismatch re-request that block and any later ones. The reconciliation pattern is described in Block-by-block EVM indexer reconciliation.

  • -32601: namespace not enabled; fail over, do not retry.
  • Range-too-large: halve the window, retry the same cursor.
  • Empty result: normalize and validate the address filter.
  • Reorg: compare block hashes, re-request from the fork point.

Limitations and tradeoffs of the trace namespace

Trace storage is heavier than receipt storage, so providers may prune traces on archive nodes or disable the namespace entirely on public endpoints. Documented OpenEthereum behaviour defines the method semantics, but whether a given provider retains traces, and for how long, varies by provider.

trace_filter only covers external-facing traces. Internal-call detail requires debug_traceTransaction, which is more expensive and typically rate-limited more aggressively. If your question needs the full call tree, budget for the debug family rather than trying to reconstruct it from trace_filter output.

Wide ranges are the main operational risk. Even on an endpoint that accepts them, a large trace_filter response can be slow to build and large to transfer, so smaller windows with a persisted cursor are more reliable than one big request. For endpoint reliability and timeout behaviour, see BNB Smart Chain RPC reliability and timeouts.

Next steps for production trace indexing

Start by probing trace support on the endpoints you intend to use, then fill in the results table with your own measurements. Pick a window size that succeeds reliably at busy heights, not just quiet ones, and persist the cursor so restarts are cheap.

If you need managed BSC endpoints with the trace namespace available, review BNB Smart Chain RPC endpoints (RPC Assistant) and the BNB Smart Chain network page. For capacity planning and cost, see RPC pricing and the API service. More RPC troubleshooting guides are collected in the OnFinality Learn hub.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started