Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
debug_traceCall in production: inspect before you send

debug_traceCall in production: inspect before you send

By Elias Faltin 9min read

Your wallet is about to send a transaction that asks a contract to perform several operations. One operation fails, but the contract catches the failure and returns normally. A check that only asks whether the whole call failed can miss the incomplete operation. Your user can still pay a transaction fee without getting the result they wanted.

Before asking the user to sign, inspect the calls inside that operation. debug_traceCall simulates a contract call without broadcasting a transaction. Its callTracer option shows the outer call and the calls it makes, including failures that the contract catches.

The trace answers what happened in that simulation. It cannot guarantee what happens when your transaction reaches a later block. This guide shows how to inspect those failures, record the simulated state, and reject missing evidence.

Choose the output you need

Both eth_call and debug_traceCall execute a hypothetical call against the selected block's state. Neither commits those changes to the chain.

Your questionRequestWhat to inspect
What does the contract return?eth_callDecode the return data using the contract's interface.
Which internal calls fail?debug_traceCall with callTracerRead the outer outcome and failed child calls separately.
Which events survive execution?Add tracerConfig: { withLog: true }Geth removes logs from reverted calls and their descendants.
Which accounts and storage change?prestateTracer with diffMode: trueCompare pre and post; add disableCode: true if bytecode is unnecessary.

An application binary interface, or ABI, describes how to encode inputs and decode outputs. A successful execution can still return a failure flag in its ABI-encoded output. Check that output against the user's intended operation.

Specify a tracer on every request. Without one, Geth uses the opcode logger, which records execution instructions. Use it for instruction-level debugging when a call tree cannot answer your question. Its output can be large.

The Geth tracer reference defines these output formats. Trace options and endpoint access depend on the client and provider. Probe each endpoint with the tracer you intend to use.

A caught failure is not the outer outcome

Multicall3 is a contract that groups several calls into one operation. Its aggregate3 function accepts an allowFailure flag for each child call. When that flag is true, a failed child does not make the outer call revert. Multicall3 returns the child's success flag and return data instead.

Consider a grouped call that asks the WETH token contract to transfer more tokens than the caller holds. WETH is wrapped Ether, an ERC-20 token. The transfer fails. With allowFailure: true, the outer operation still finishes. With allowFailure: false, Multicall3 reverts with Multicall3: call failed.

A failed WETH transfer leaves Multicall3's outer call executed when allowFailure is true, and reverted when false.

Multicall3 aggregate3 calls WETH, whose transfer reverts. The allowFailure flag determines the outer outcome. With true, the outer call executes and returns a failed child result. With false, the outer call reverts.

This distinction changes your decision. If the child represents an optional lookup, the application can accept its failure. If it represents the user's required transfer, an outer execution success is insufficient.

Keep the outer frame's error, revertReason, and output. Report failed child frames as separate evidence. A contract can catch a child failure and later revert for another reason. The last failed child does not prove the cause of the outer revert.

revertReason covers Solidity's standard Error(string) format. Decode custom errors from output against the target contract's ABI. Empty revert data gives you no reason string.

Record the state, then match the transaction

Fetch the latest block and pass its hash to debug_traceCall. Record that hash with the transaction fields and trace options. Another endpoint can then attempt the same simulation against the same block.

A hash identifies a block; it does not make that block immune to a chain reorganization. Check that the block number still maps to the recorded hash before using the result. Discard the result if that mapping changes.

Geth traces against the selected block's end state unless you set its txIndex option. It rejects pending tracing. A simulation against block N does not include transactions that will execute ahead of yours in block N + 1.

A trace executes against block N, but another trade can change pool state before your transaction executes in block N plus one.

Block N provides the recorded state for your trace. The trace executes without broadcasting a transaction. Another trade can change the pool before your trade executes in block N plus one. Your trade can then revert despite the earlier trace.

Send the from, to, data, value, gas limit, and fee fields you intend to sign. For an EIP-1559 transaction, include maxFeePerGas and maxPriorityFeePerGas. Include accessList or authorizationList when they apply; these fields affect gas use or the code that runs.

Geth's call path skips the sender nonce check and other transaction admission checks. When fee fields are omitted or zero, it can also skip fee checks and set the simulated base fee to zero. A trace can therefore execute even when the real transaction would fail admission. Check the nonce, signer balance, and transaction validity separately.

Do not replace missing gas or fee fields with provider defaults. The node's gas cap can exceed your intended transaction limit. The pinned Geth implementation and call argument conversion show these differences.

Keep hypothetical state explicit

For debug_traceCall, put state changes under config.stateOverrides and block changes under config.blockOverrides. These belong inside the third parameter, alongside tracer. They are not extra positional parameters copied from eth_call.

An override changes the simulation's starting conditions. It does not prove that those conditions exist on chain. Run the same call without the override as a negative control. If your expected outcome does not change, investigate the request and contract behavior.

For example, a token contract can require the user to grant another contract permission to spend their tokens. That permission is called an allowance. A separate transaction grants it before a later transaction uses it.

Independent debug_traceCall requests do not carry that permission change into the next request. For a sequence that must share simulated changes, use eth_simulateV1's shared state model where your endpoint supports it. For one-call override mechanics, see the state override guide.

Reject missing evidence before reading success

The example below uses Node.js 22 or later and its built-in fetch. It accepts a JSON-RPC URL and an already prepared Ethereum mainnet call object. JSON-RPC is the request format used by Ethereum's remote procedure call API.

preflight pins a block hash, validates the returned call tree, and checks the block's canonical mapping afterward. It returns executed or reverted with the outer frame and failed children. HTTP errors, RPC errors, timeouts, and missing or malformed results throw instead.

JAVASCRIPT
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(8_000),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const body = await response.json();
  if (body?.jsonrpc !== '2.0' || body.id !== 1) throw new Error('Invalid RPC envelope');
  if (body.error) throw new Error(`RPC ${body.error.code}: ${body.error.message}`);
  if (body.result == null) throw new Error('Missing RPC result');
  return body.result;
}

function inspectTrace(root) {
  const failedCalls = [];
  const queue = [{ frame: root, path: '0' }];
  for (const { frame, path } of queue) {
    if (!frame || typeof frame !== 'object' || Array.isArray(frame)) {
      throw new Error('Invalid call frame');
    }
    if (typeof frame.type !== 'string' || typeof frame.gasUsed !== 'string' ||
        !/^0x[0-9a-f]+$/i.test(frame.gasUsed)) {
      throw new Error('Incomplete call frame');
    }
    if (frame.error !== undefined && typeof frame.error !== 'string') {
      throw new Error('Invalid execution error');
    }
    if (frame.calls !== undefined && !Array.isArray(frame.calls)) {
      throw new Error('Invalid child calls');
    }
    if (path !== '0' && frame.error) failedCalls.push({ path, frame });
    for (const [index, child] of (frame.calls ?? []).entries()) {
      queue.push({ frame: child, path: `${path}.${index}` });
    }
  }
  return { status: root.error ? 'reverted' : 'executed', root, failedCalls };
}

async function preflight(url, tx) {
  if (await rpc(url, 'eth_chainId', []) !== '0x1') throw new Error('Wrong chain');
  const block = await rpc(url, 'eth_getBlockByNumber', ['latest', false]);
  if (!/^0x[0-9a-f]{64}$/i.test(block.hash) || !/^0x[0-9a-f]+$/i.test(block.number)) {
    throw new Error('Invalid block identity');
  }
  const trace = await rpc(url, 'debug_traceCall', [
    tx, block.hash, { tracer: 'callTracer', timeout: '5s' },
  ]);
  const result = inspectTrace(trace);
  const canonical = await rpc(url, 'eth_getBlockByNumber', [block.number, false]);
  if (canonical.hash !== block.hash) throw new Error('Block changed; rerun preflight');
  return { ...result, blockHash: block.hash };
}

Call preflight(RPC_URL, tx) with hexadecimal transaction fields and the exact gas and fees you intend to use. Obtain the URL from configuration; keep credentials out of logs. Store the returned block hash alongside tx and your trace settings.

executed means the outer call completed in the simulation. It does not approve the transaction for broadcast. Decode root.output and inspect failedCalls against application requirements before asking the user to sign.

The failed-child list uses paths such as 0.0 for the first child of the root. It preserves evidence without replacing the outer outcome. The example's validation checks the fields it consumes; it is not a complete schema for every client's extensions.

A live read-only run against eth.drpc.org reproduced both Multicall3 outcomes described above. The executable example also ran against that endpoint. Local fixtures exercised malformed frames, missing results, RPC errors, timeouts, wrong-chain responses, and a changed block hash.

Treat unavailable traces as a separate decision

Client support does not establish endpoint support. A JSON-RPC -32601 means that endpoint does not serve the requested method. Keep it out of a failover route that requires traces until a capability probe succeeds.

A timeout or missing historical state gives you no execution verdict. Use a bounded retry within your signing deadline. Keep the same block hash when comparing endpoints. If an endpoint cannot supply that state, use an endpoint with the required history or stop the check.

An eth_call fallback answers a narrower question. It cannot replace a required inspection of child calls. If that inspection is mandatory for the user's operation, keep signing blocked until you have it.

The example requests a 5-second trace timeout and an 8-second HTTP deadline. These are example budgets, not a latency promise. Set both deadlines to fit your application, and check provider limits before adding traces to every request.

After inclusion, use the transaction receipt for the actual outcome. If it differs from the simulation, debug_traceTransaction can explain the included execution. Keep the earlier block hash and call object for comparison.

For a Dwellir integration, start with the Ethereum debug_traceCall request reference. Probe your selected endpoint with your tracer and block requirements before routing application traffic to it.

read another blog post