You build a payment history for an Ethereum address. A contract forwards ETH to that address, but your event scan misses it. Ethereum does not require an event for a plain ETH payment.
Execution traces reveal those contract calls. They also include attempted payments that a later failure cancels. Count every visible payment, and your history can show ETH the address never received.
Use trace_filter to find relevant calls. Use complete transaction traces to decide which payments survived. Your importer must verify and store the results before it moves on to the next block.
Choose discovery or complete block processing
An internal ETH transfer is a value movement inside a transaction's execution. It is not a separately signed transaction. A trace records an execution step, called a frame, including its addresses, value, and failure status.
Choose the request by the work you need to complete:
| Your task | Start with | What you still need |
|---|---|---|
| Find activity for selected addresses | trace_filter with address filters | Complete traces for each matching transaction before counting payments |
| Index every transaction in a block | Unfiltered trace_block | Validate transaction coverage, tree structure, and block identity |
| Use a Geth endpoint or inspect nested calls | debug_traceBlockByHash with callTracer | Validate each transaction result and interpret the nested tree |
Reth, Erigon, and Nethermind expose the Parity-style trace methods. Geth provides the debug namespace instead. A hosted endpoint can disable either namespace or restrict historical access.
Probe your exact endpoint with one known block and your oldest required block. A successful recent call does not prove historical coverage. Tracing may require retained historical state or stored traces, depending on the client.
For nested output, callTracer includes the transaction root and its child calls. Do not enable onlyTopCall when you need internal transfers. For individual transaction diagnosis, see debug_traceTransaction in production.
A matching call is not a settled payment
Suppose a router calls another contract. That contract sends 1 ETH, then reverts. The router catches the failure and continues with a separate 2 ETH payment.
The transaction succeeds, but the first payment does not survive. A revert cancels state changes in that call and its child calls. Checking only the transaction receipt's success status misses this distinction.
Flat traces describe ancestry with traceAddress. The root has []; [0,0] is the first child of [0]. These paths identify frames within that trace format.
An address filter can return [0,0] while omitting [0], because the parent's addresses do not match. The child's own frame can look successful. Without its parent, you cannot establish that the payment survived.

The transaction root succeeds. Its first child at traceAddress [0] reverts, so the 1 ETH call at [0,0] rolls back. A separate call at [1] transfers 2 ETH and survives. An address filter may omit the reverted parent. Fetch the complete transaction tree before counting the matching payment.
This is a concrete implementation issue. Reth filters individual frames after tracing each transaction. It does not automatically add the parents of each matching frame.
Use filtered results as transaction discovery. Deduplicate their transactionHash values, then fetch unfiltered trace_transaction results. Alternatively, fetch trace_block once when many matches share a block.
Apply your address selection after excluding failed frames and their descendants. A receipt check remains useful for transaction identity and top-level status, but it cannot replace this ancestor check.
Extract CALL payments from a complete tree
The example below extracts internal CALL payments from one complete, localized transaction trace. It rejects missing parents, missing children, duplicate paths, and mixed transaction or block identities.
It excludes the transaction root because the transaction's own value belongs in your transaction records. It also excludes zero-value calls and calls that send value back to the same account.
DELEGATECALL and STATICCALL are not separate ETH payments. CALLCODE does not transfer value to the code address. Do not count a frame's value without checking its call type.
Save this JavaScript as transfers.mjs. It uses BigInt to keep amounts in wei, Ethereum's smallest ETH unit.
export function callTransfers(traces) {
if (!Array.isArray(traces) || traces.length === 0) {
throw new Error('Expected a complete transaction trace');
}
const byPath = new Map();
const tx = traces[0].transactionHash;
const block = traces[0].blockHash;
for (const t of traces) {
if (
!Array.isArray(t.traceAddress) ||
!t.traceAddress.every((i) => Number.isSafeInteger(i) && i >= 0) ||
!Number.isSafeInteger(t.subtraces) ||
t.subtraces < 0 ||
!tx ||
!block ||
t.transactionHash !== tx ||
t.blockHash !== block
) {
throw new Error('Invalid trace identity or path');
}
const key = t.traceAddress.join('.');
if (byPath.has(key)) throw new Error('Duplicate trace path');
byPath.set(key, t);
}
if (!byPath.has('')) throw new Error('Missing transaction root');
validateChildren(traces, byPath);
const reverted = (path) => {
for (let n = 0; n <= path.length; n++) {
if (byPath.get(path.slice(0, n).join('.')).error) return true;
}
return false;
};
return traces
.filter(
(t) =>
t.traceAddress.length > 0 &&
!reverted(t.traceAddress) &&
t.type === 'call' &&
t.action.callType === 'call' &&
BigInt(t.action.value) > 0n &&
t.action.from.toLowerCase() !== t.action.to.toLowerCase(),
)
.map((t) => ({
blockHash: block,
transactionHash: tx,
traceAddress: t.traceAddress,
from: t.action.from,
to: t.action.to,
wei: BigInt(t.action.value),
}));
}
function validateChildren(traces, byPath) {
for (const t of traces) {
const path = t.traceAddress;
if (path.length) {
const parent = byPath.get(path.slice(0, -1).join('.'));
if (!parent || path.at(-1) >= parent.subtraces) {
throw new Error('Missing parent or invalid child index');
}
}
for (let i = 0; i < t.subtraces; i++) {
if (!byPath.has([...path, i].join('.'))) {
throw new Error('Incomplete transaction tree');
}
}
}
}Call it with the array returned by trace_transaction. For trace_block, group frames by transactionHash first and exclude reward entries without a transaction hash.
The distinction is testable. A fixture with a reverted [0], a successful [0,0] payment, and a successful sibling [1] must return only the sibling. Passing the matching child alone must throw.
This function covers CALL payments only. A full ETH movement ledger must also handle contract creation endowments and self-destruct balance transfers. Apply the same ancestor checks to those frames.
Keep transaction fees and protocol-level balance changes separate. A list of internal calls is not a complete account balance reconciliation.
Flat paths also do not give you portable identifiers across tracer formats. Geth's callTracer includes precompile calls. Never assume a flat traceAddress indexes the same child in another client's nested output.
Page fixed ranges without losing context
trace_filter accepts numeric block bounds, address filters, after, and count. after skips matching frames; count limits the returned page. Neither is a transaction boundary.
For example, this read-only request searches one block for calls involving a recipient:
{
"jsonrpc": "2.0",
"id": 1,
"method": "trace_filter",
"params": [{
"fromBlock": "0x18eb73c",
"toBlock": "0x18eb73c",
"toAddress": ["0x0000000000000000000000000000000000000001"],
"after": 0,
"count": 128
}]
}Replace the recipient and block with your own scope. A precompile address can match calls without identifying an ETH payment.
Keep both numeric bounds and the filter unchanged across pages. Accumulate transaction hashes until the range is complete, then fetch complete transaction trees. A page can split a transaction's frames.
With an endpoint that honors the requested page size, request another page when it returns exactly count entries. Increase after by the number returned. Stop on a short page, including an empty page.
Confirm the endpoint's page policy first. A provider that silently lowers count can make a short page look like completion. Use a smaller documented page size or trace_block when completeness is uncertain.
Avoid latest for a multi-page historical scan. New or replaced blocks can change which frame occupies an offset. Resolve a finalized header to a numeric upper bound when your endpoint supports it.
Filters reduce matching output, but they do not guarantee cheap node work. Reth's implementation replays blocks before retaining matching frames. Its pagination then skips matches, so a deep offset still requires processing earlier results.
Size ranges by observed response bytes and failures on your own endpoint. Do not infer a universal block cap from another provider or one client deployment.
Fail the block before you skip data
JSON-RPC is a protocol for remote procedure calls. Validate the transport response and the JSON-RPC response before reading traces. Require the expected request ID, a result array, and no error. A successful HTTP status alone does not prove a valid result.
Use a timeout that covers reading the body, plus a response byte budget. Reject malformed JSON, null, unexpected objects, and entries with the wrong block identity. Never convert those failures into an empty trace list.
Choose recovery from the actual failure:
| Failure | Recovery |
|---|---|
| Explicit block-range or response-size limit | Reduce the range and retry without changing the checkpoint |
| HTTP 429 or a documented rate-limit response | Back off with bounded retries and honor Retry-After |
| Authentication, plan restriction, or unavailable method | Correct access or route to a verified alternative |
| Timeout, connection failure, or HTTP 5xx | Retry within a fixed budget; reducing the range is an experiment |
Generic -32603 internal error | Inspect the message and provider policy; it does not prove the range was too large |
| Historical state or trace unavailable | Use an endpoint with verified historical coverage; retain the failed block |
-32602 means invalid parameters in JSON-RPC. Some implementations also use it for range limits. Match a documented range-limit message before deciding to shrink.
If one block still fails, stop and retain its number and error. Moving on would turn an infrastructure failure into a permanent gap in the payment history.
Commit results and progress together
For each trace_block response, fetch the header and its transaction hashes. Require a root for every transaction and reject unexpected transaction hashes. Validate every frame's block number and block hash.
An empty array is not automatically a completed block. Compare it with the header's transaction list. Validate the complete tree for each transaction before extracting payments.
Read the same header again before committing. A changed hash invalidates the batch. This check catches a replacement during processing; it does not prevent a later chain reorganization.
Store payments and the processed-block checkpoint in one database transaction. Keep the chain ID, block number, block hash, transaction hash, and trace path. Use the chain ID, block hash, transaction hash, and path as the row identity.
For unfinalized data, compare each new block's parent hash with your stored previous hash. On mismatch, find the last common ancestor. Remove replaced rows and roll back derived totals and progress before processing replacement blocks.
Ponder implements both parts of this recovery. It walks parent hashes to a shared block and reverses tracked inserts, updates, and deletes.
Deleting payment rows alone would leave an updated account total wrong. Restore derived database state and progress at the verified ancestor, then process replacement blocks. Stop if the fork exceeds your retained recovery history. Chain reorganizations explain why block numbers alone cannot identify persisted data.
Use Dwellir's trace_filter reference and trace_block reference to prepare your requests. Test historical coverage on your chosen endpoint before starting the backfill.


