You import a block's contract activity into a database. One transaction's execution record is missing, but your importer saves the available records and moves on to the next block. Your database now has a gap that later blocks cannot repair.
A successful web request does not prove that every transaction has a usable execution record. Before saving progress, match the returned records against the block's full transaction list.
debug_traceBlockByNumber requests execution traces for every transaction at one block number. With callTracer, each trace contains the transaction's contract calls as a nested tree. This guide shows how to fetch those trees and reject an incomplete block.
Choose the output your importer needs
A call tree starts with the transaction's outer execution frame. Nested calls contain its child frames. A frame describes one execution scope, including its caller, target, input, gas, and any execution error.
Geth returns an array of {txHash, result} entries in transaction order. Each result has the selected tracer's shape. The Geth implementation loads the block's parent state and executes its transactions in order. A later transaction sees state changes from earlier transactions.
One block request replaces separate trace requests for each transaction. It still requires the node to perform the tracing work. Do not infer a speed or billing advantage from request count alone.
Use the built-in tracer reference to choose your stored records:
| You need | Configuration | What to retain |
|---|---|---|
| Nested calls for every transaction | callTracer, onlyTopCall: false | Full trees and transaction hashes |
| Only outer execution frames | callTracer, onlyTopCall: true | Roots without child calls |
| Changed account state | prestateTracer, diffMode: true | Account differences rather than call trees |
| Function-selector counts | 4byteTracer | Selector and calldata-size counts |
The importer below requires full callTracer output. onlyTopCall removes the child calls your index needs. An absent calls field can also mean that execution made no child calls. It cannot prove which configuration produced the response.
Specify the tracer on every request. Without one, Geth uses the opcode logger, which records individual execution steps. That output answers a different question and can produce large responses.
For one included transaction, use debug_traceTransaction. For an unsigned hypothetical call, use debug_traceCall. Choose trace_block or trace_filter when your importer needs their flat trace format or address filtering.
Check the whole block before saving progress
Match the trace count to the block's transaction count. Then match every entry's txHash to the corresponding transaction hash. A count check alone misses duplicate, reordered, or unrelated records.

Trace block N with callTracer and full call trees. Count: the entries equal the block's transactions. Match: each txHash appears in the same order. Link: parentHash equals the stored block hash. If all 4 checks pass, save the trees and the next block number in one database transaction. If any check fails, save nothing and keep block N pending until a retry returns complete results. A reverted transaction is data. A failed trace job is a gap.
Distinguish two error locations:
entry.errormeans the trace job failed. Reject the block rather than saving the other entries.entry.result.errormeans execution failed inside a completed trace. Keep that trace as an execution record.
A transaction that reverted still belongs in a complete block import. Its failed execution is data. Conversely, a trace timeout does not establish what the transaction did.
Geth can report tracing failures at the request level. Its parallel JavaScript tracer path can report failures within individual entries. Validate both error locations even when your current endpoint only returns request-level errors.
Retaining a tree does not make every visible payment permanent. A reverted call can cancel changes made by its descendants. Apply those rules when deriving payments from the stored trees. The internal ETH payment guide explains ancestor failures and payment extraction.
Fetch and validate full call trees
This example uses Node.js 22 or later. It expects Geth-shaped {txHash, result} entries. It rejects other response formats rather than guessing their transaction identities.
Save both code blocks in one .mjs file. The transport checks HTTP status and the JSON-RPC response envelope. JSON-RPC is the request protocol used by these Ethereum methods.
let requestId = 0;
async function rpc(url, method, params) {
const id = ++requestId;
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: AbortSignal.timeout(60_000),
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = await response.json();
if (!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;
}The importer requests one explicit block number. It checks the chain, block identity, ordered transaction coverage, and call-frame structure. A second block read detects a changed hash during collection.
const quantity = (value) =>
typeof value === 'string' && /^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(value);
const hash = (value) =>
typeof value === 'string' && /^0x[0-9a-f]{64}$/i.test(value);
const address = (value) =>
typeof value === 'string' && /^0x[0-9a-f]{40}$/i.test(value);
const bytes = (value) => typeof value === 'string' && /^0x(?:[0-9a-f]{2})*$/i.test(value);
const frameTypes = new Set(['CALL', 'CALLCODE', 'DELEGATECALL', 'STATICCALL', 'CREATE', 'CREATE2', 'SELFDESTRUCT']);
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const sameHash = (a, b) => hash(a) && hash(b) && a.toLowerCase() === b.toLowerCase();
function checkFrame(frame) {
if (!object(frame) || !frameTypes.has(frame.type) || !address(frame.from)) {
throw new Error('Invalid call frame');
}
if (!bytes(frame.input) || (frame.to !== undefined && !address(frame.to))) {
throw new Error('Invalid frame input or target');
}
if ((frame.output !== undefined && !bytes(frame.output)) ||
(frame.value !== undefined && !quantity(frame.value))) {
throw new Error('Invalid frame output or value');
}
if (!quantity(frame.gas) || !quantity(frame.gasUsed)) {
throw new Error('Invalid frame gas');
}
if (frame.error !== undefined && typeof frame.error !== 'string') {
throw new Error('Invalid execution error');
}
if (frame.calls !== undefined) {
if (!Array.isArray(frame.calls)) throw new Error('Invalid child calls');
frame.calls.forEach(checkFrame);
}
}
function checkBlock(block, number) {
if (!object(block) || block.number !== number || !hash(block.hash) || !hash(block.parentHash)) {
throw new Error('Missing or mismatched block');
}
if (!Array.isArray(block.transactions) || !block.transactions.every(hash)) {
throw new Error('Invalid transaction list');
}
const unique = new Set(block.transactions.map((tx) => tx.toLowerCase()));
if (unique.size !== block.transactions.length) throw new Error('Duplicate transaction');
}
async function readBlockTrees(url, number, expectedChainId = '0x1') {
if (!quantity(number) || BigInt(number) === 0n || !quantity(expectedChainId)) {
throw new Error('Use an explicit non-genesis block and chain ID');
}
const chainId = await rpc(url, 'eth_chainId', []);
if (!quantity(chainId) || BigInt(chainId) !== BigInt(expectedChainId)) {
throw new Error('Wrong chain');
}
const block = await rpc(url, 'eth_getBlockByNumber', [number, false]);
checkBlock(block, number);
const entries = await rpc(url, 'debug_traceBlockByNumber', [number, {
tracer: 'callTracer',
timeout: '10s',
tracerConfig: { onlyTopCall: false, withLog: false },
}]);
if (!Array.isArray(entries) || entries.length !== block.transactions.length) {
throw new Error('Incomplete block trace');
}
entries.forEach((entry, index) => {
if (!object(entry) || Object.hasOwn(entry, 'error')) {
throw new Error(`Trace job failed at transaction ${index}`);
}
if (!sameHash(entry.txHash, block.transactions[index])) {
throw new Error(`Wrong transaction at index ${index}`);
}
checkFrame(entry.result);
});
const after = await rpc(url, 'eth_getBlockByNumber', [number, false]);
checkBlock(after, number);
if (!sameHash(after.hash, block.hash)) throw new Error('Block changed during tracing');
return { chainId, number, blockHash: block.hash, parentHash: block.parentHash, entries };
}
const blockHex = process.env.BLOCK_HEX;
const previousBlockHash = process.env.PREVIOUS_BLOCK_HASH;
const blockTrees = await readBlockTrees(process.env.RPC_URL, blockHex);
if (!sameHash(blockTrees.parentHash, previousBlockHash)) {
throw new Error('Parent mismatch: reconcile stored history before continuing');
}
console.log(JSON.stringify(blockTrees));Set RPC_URL to your tracing endpoint and BLOCK_HEX to the block's hexadecimal number. The default expected chain is Ethereum mainnet, chain ID 0x1.
Set PREVIOUS_BLOCK_HASH to the hash of the block before BLOCK_HEX. After the first import, use the hash of the last block you committed.
An empty database has no previous block yet. Before you import your first block N, fetch block N-1 with eth_getBlockByNumber from the same chain. Store its number and hash as your starting point, and pass that hash. This records where the import starts. It does not mark block N-1 as imported.
The 10-second tracer timeout and 60-second HTTP deadline are example budgets. Set them from your workload and endpoint limits. The Geth timeout implementation starts a separate deadline for each transaction. The HTTP deadline covers the entire request.
This validates transaction coverage and basic frame structure. It cannot detect a server that silently omits nested calls or fabricates trace data. Test your endpoint with known nested execution before using it for accounting.
The hash check detects a replacement observed during this attempt. It does not provide finality or guarantee consistent replies from every backend. Store chainId, block number, and block hash with the trees.
Retry a block without leaving gaps
Near the chain head, a block number can resolve to a different block after a chain reorganization. For unfinalized imports, compare each new header's parentHash with the previously stored block hash, as shown above. Stop on mismatch. Reconcile replaced records, derived values, and progress before continuing. Follow the published recovery procedure.
The example throws on failure and writes nothing. Your caller should save the validated trees and the next block number in one database transaction. Add a unique key using chain ID, block hash, and transaction hash so a retry cannot duplicate records.
Retry temporary transport failures with a bounded delay and attempt count. Respect the endpoint's rate-limit instructions. Stop on missing methods or denied access until the endpoint configuration changes.
viem's request implementation separates retry classification from request decoding. It uses Retry-After when available and an exponential delay otherwise. Apply that separation to your importer, and keep validation failures visible rather than converting them to empty arrays.
A timeout needs investigation before repetition. The node may lack historical state, or one transaction may exceed the tracing budget. Test both a recent block and the oldest block your backfill requires. Available history depends on node configuration and endpoint policy.
An empty transaction list can legitimately match an empty trace array. A nonempty block cannot. Keep the failed block pending until you obtain complete results or record an explicit operational decision to leave a gap.
For Dwellir, start with the Ethereum method reference. Check your endpoint's tracer access and required history with a bounded probe before starting the backfill.




