Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
trace_filter and trace_block: index ETH payments without false entries

trace_filter and trace_block: index ETH payments without false entries

By Elias Faltin Updated 9min read

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 taskStart withWhat you still need
Find activity for selected addressestrace_filter with address filtersComplete traces for each matching transaction before counting payments
Index every transaction in a blockUnfiltered trace_blockValidate transaction coverage, tree structure, and block identity
Use a Geth endpoint or inspect nested callsdebug_traceBlockByHash with callTracerValidate 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.

A successful transaction has a reverted branch that cancels 1 ETH, while a separate branch keeps 2 ETH.

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.

JAVASCRIPT
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:

JSON
{
  "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:

FailureRecovery
Explicit block-range or response-size limitReduce the range and retry without changing the checkpoint
HTTP 429 or a documented rate-limit responseBack off with bounded retries and honor Retry-After
Authentication, plan restriction, or unavailable methodCorrect access or route to a verified alternative
Timeout, connection failure, or HTTP 5xxRetry within a fixed budget; reducing the range is an experiment
Generic -32603 internal errorInspect the message and provider policy; it does not prove the range was too large
Historical state or trace unavailableUse 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.

read another blog post