Skip to content

fix(eth/tracers): fix dropped real frames and failing flat traces of non-EVM transactions - #2584

Open
gzliudan wants to merge 2 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-non-evm-tracer-frames
Open

gzliudan wants to merge 2 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-non-evm-tracer-frames

Conversation

@gzliudan

@gzliudan gzliudan commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Proposed changes

The call tracer decides that a transaction is non-EVM from its to address alone (types.Transaction.IsNonEVMTx(): the system addresses 0x91/0x92/0x93/0x94 and the block signers address 0x89), and returned the synthetic frame it prepares on OnTxStart whenever that flag was set. The flag does not know whether the fork that routes those transactions away from the EVM is active: before TIPXDCXBlock and after TIPXDCXReceiverDisableBlock, block processing executes them as ordinary EVM transactions and the EVM pushes a real top-level frame. OnTxEnd and OnLog followed the flag as well, so the receipt gas was written into the synthetic frame and the real one kept 0, and GetResult shadowed the real frame with the synthetic one.

flatCallTracer builds its answer from that same callstack and failed GetResult with invalid number of calls when the callstack was empty — which is exactly the state of a transaction the EVM never entered: transactions to the system addresses while the receiver fork is active, and every block signer transaction (0x89).

Symptoms

  • debug_traceBlockByNumber / ByHash / traceBlock with flatCallTracer fails the whole call with invalid number of calls for any block carrying a block signer transaction (0x89) — those are not skipped by IsSkipNonceTransaction(), so they do reach the tracer. Measured on mainnet block 37,849,456 (0x2418970) and Apothem block 48,667,667 (0x2e69c13).
  • flatCallTracer reports gasUsed: 0x0 for a transaction to a system address that the EVM did execute (receiver fork window not active), because the receipt gas went into the synthetic frame, which that tracer does not read. Measured on mainnet block 37,849,457 (0x2418971) transaction 0: gasUsed 0x0 against a receipt of 0x6308.
  • callTracer returns the synthetic frame whenever the flag is set, even when the EVM pushed a real top-level frame, so debug_traceTransaction, debug_traceCall and debug_traceBlock* disagree with flatCallTracer on the same transaction. On the live networks the two frames happen to coincide today — the five system addresses hold no code (eth_getCode is 0x for 0x89 and 0x910x94, at block 0x2418971 and at latest), so a transaction they execute produces no output, no error and no sub-calls — and the difference shows as soon as an execution produces any of those. TestCallTracerKeepsRealFrame pins the choice of frame.

Fix

Two commits.

  1. fix(eth/tracers): the call tracer prefers the real top-level frame whenever the EVM pushed one, and keeps the synthetic frame only as the fallback for transactions the EVM never entered. OnTxEnd and OnLog follow the same decision, so the real frame carries the receipt gas, and GetResult stops shadowing it.
  2. fix(eth/tracers): flatCallTracer flattens the synthetic frame the call tracer prepares when no real frame was pushed, instead of failing the whole trace with invalid number of calls; when a real frame exists it keeps that one, with the gas the first commit gives it. An interrupted trace reports its interruption reason rather than an internal error: an unbalanced callstack is flattened from its first frame, as the baseline and upstream do.

The two are one PR because they are the two exits of the same decision: the flat tracer reads the real frame, and only commit 1 makes that frame carry the receipt gas.

Upstream

No upstream fix to port: geth has no non-EVM transaction concept, so neither the synthetic frame nor the unconditional flag has a geth counterpart.

Tests

  • eth/tracers/native/call_test.goTestCallTracerKeepsRealFrame: a system-address transaction the EVM did execute keeps its real top-level frame, with its gas, output and logs; the base implementation returns the synthetic frame, whose output is empty.
  • eth/tracers/native/call_test.goTestCallTracerNonEVMTx: kept, and still covers the synthetic frame of a transaction the EVM never entered.
  • eth/tracers/native/call_flat_test.goTestFlatCallTracerNonEVMTx: the flat tracer flattens the synthetic frame instead of failing on the empty callstack.
  • eth/tracers/native/call_flat_test.goTestFlatCallTracerKeepsRealFrame: the flat tracer uses the real frame, with the receipt gas, when the EVM pushed one; fails without the call tracer commit of this PR.
  • eth/tracers/native/call_flat_test.goTestFlatCallTracerUnbalancedCallstack: a Stop in the middle of a nested call leaves two frames on the callstack; the root frame is still reported, together with the interruption reason, and the frame left open by the interruption is dropped, as upstream does.
  • eth/tracers/internal/tracetest/non_evm_trace_test.goTestNonEVMTxThroughBlockProcessingRouting: runs flatCallTracer and callTracer over both receiver fork settings through the production routing (core.ApplyTransactionWithEVM, hooked state, real EVM) and checks the single frame — type, sub-traces, to and gasUsed — against the receipt.

End-to-end verification

Four binaries were run one after the other on the same node and the same data directory: baseline = dev-upgrade @ cdce8fc5c, commit 1 = 7adea3967, this branch = 5c429f99a, and earlier = 4c5032e5d, the tip this branch had before the interruption fix, kept only for the last table. The first three tables were measured on mainnet (~/xdc_chain/mainnet_2, RPC 8646); the Apothem table was measured on its own data directory (~/xdc_chain/testnet_1, RPC 8745) with 4c5032e5d as the branch tip, because the probes it contains never enter the interruption path that 5c429f99a changes. The RPCs were called with debug exposed (--rpcapi/RPC_API includes debug in the Local_DPoS_Setup configs).

Mainnet block 37,849,456 (0x2418970, 4 transactions; sign transactions at index 0 and 2, no system-address transaction, receiver fork window not active):

probe baseline commit 1 this branch
debug_traceBlockByNumber (flatCallTracer) invalid number of calls, the whole call fails invalid number of calls ok, 4 entries, 0 nulls
debug_traceBlockByNumber (callTracer) ok, 4 entries ok, 4 entries ok, 4 entries

Mainnet block 37,849,457 (0x2418971, 204 transactions; transaction 0 to 0x92, which the EVM executes because the receiver fork window is not active; transaction 95 to 0x90 from the same sender, nonce +1):

probe baseline commit 1 this branch
debug_traceTransaction (transaction 0, flatCallTracer) frame gasUsed 0x0 frame gasUsed 0x6308, the receipt gas same as commit 1
debug_traceTransaction (transaction 0, callTracer, withLog) frame without output, error or calls — the same fields the real frame has for this transaction same same
debug_traceBlockByNumber (flatCallTracer) invalid number of calls, it fails on the first sign transaction invalid number of calls tracing failed: nonce too high: address xdcE3bC38418f89C386d1093aee89d606564012e329, tx: 1547093 state: 1547092 — the trace now walks past the ~100 sign transactions of the block and fails later on the pre-fork skip, which is #2581
debug_traceBlockByNumber (callTracer) tracing failed: nonce too high (the same skip) same same

Apothem block 48,667,667 (0x2e69c13, 8 transactions; transaction 0 to 0x92, transactions 1–7 sign transactions, receiver fork window active):

probe baseline 4c5032e5d
debug_traceBlockByNumber (flatCallTracer) invalid number of calls, the whole call fails ok, 8 entries, 1 null (transaction 0, the system-address transaction is still skipped, that side is #2581)
debug_traceBlockByNumber (callTracer) ok, 8 entries, 1 null ok, 8 entries, 1 null
frame of the sign transaction (index 1) one frame, to 0x89, gasUsed 0x0

Interruption inside a nested call — the fix 5c429f99a carries on top of 4c5032e5d: debug_traceTransaction with flatCallTracer and small timeout values (1, 2, 5 and 20 ms, three runs each) on a deep transaction of mainnet block 0x6626778 (13 frames, 5 levels deep, 160,494 gas):

binary 36 runs
baseline cdce8fc5c reports execution timeout
4c5032e5d invalid number of calls 7 times (1 ms and 5 ms), ok 29
5c429f99a execution timeout 5 times (1 ms), ok 31, never invalid number of calls

flatCallTracer stops hearing from the EVM as soon as Stop fires — its OnEnter/OnExit return early and never reach the inner callTracer, which is the only place that pops the callstack — so an interruption inside a nested call leaves more than one frame behind. 4c5032e5d required exactly one frame and reported that state as invalid number of calls, hiding the reason; 5c429f99a flattens the first frame and returns the reason, which is what the baseline and the upstream implementation do.

Manual test plan: run a node on those data directories with debug exposed and call debug_traceBlockByNumber with flatCallTracer on 0x2418970 and 0x2e69c13, plus debug_traceTransaction with flatCallTracer on transaction 0 of 0x2418971; the baseline binary fails both block traces with invalid number of calls and reports gasUsed 0x0, this branch passes the first and reports the receipt gas. To exercise the interruption path, trace a deep transaction with {"tracer":"flatCallTracer","timeout":"1ms"}: 4c5032e5d fails with invalid number of calls, 5c429f99a reports execution timeout.

Regression: on both networks both binaries imported testnet and mainnet segments normally, with no bad block, no panic and no error attributable to the change.

Types of changes

  • fix: A bug fix
  • refactor: A code change that neither fixes a bug nor adds a feature
  • test: Adding missing tests or correcting existing tests
  • build / ci / chore / docs / feat / perf / revert / style

Impacted Components

  • Geth
  • Not sure (the change is the debug JSON-RPC surface only — the frames callTracer and flatCallTracer report; no state, consensus or API signature changes)
  • Consensus
  • Account
  • Network
  • Smart Contract
  • External components

Checklist

  • This PR has sufficient test coverage (unit/integration test)
  • Provide an end-to-end test plan in the PR description on how to manually test it on the devnet/testnet (see "End-to-end verification")
  • Tested the backwards compatibility — only the tracer output changes: a transaction the EVM never entered used to fail the whole flat trace and now reports the synthetic frame the call tracer reports, and a system-address transaction the EVM did execute now reports the gas of its receipt. An interrupted trace (a timeout that fires inside a nested call) keeps reporting its interruption reason, as the baseline does. No RPC method, parameter or state format changes.
  • Tested on a private network from the genesis block and monitored the chain operating correctly for multiple epochs — not done for this branch on its own.
  • Tested with XDC nodes running this version co-exist with those running the previous version — not done for this branch on its own (the change is confined to the debug tracers, which no other node observes).
  • Relevant documentation has been updated as part of this PR

Relation to other work

This is the tracer half of #2579, split out so the two defects of the debug tracing surface are reviewable on their own. #2579 keeps the replay-routing refactor (core.ApplyTransactionForReplay), the nonce too low fix in stateAtTransaction and the debug_intermediateRoots fix; it no longer carries these two commits. The two branches touch disjoint files.

#2581 removes the unconditional IsSkipNonceTransaction() skip in traceBlock and in the state feeder of the JS tracer path, which is the reason the system-address transaction of a block is still left out of debug_traceBlock* (the null entry above) and the reason a pre-fork block still fails the block trace one step later with nonce too highinvalid number of calls is fixed here, those two there.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9800050f-a019-45a2-b32b-893a50fb76fe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Exact stack-size validation can mask timeout errors and conflicts with the documented unbalanced-stack behavior.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes native tracers to report real EVM frames when available and synthetic frames for routed non-EVM transactions.

Changes:

  • Corrects frame, gas, and log selection.
  • Adds synthetic-frame support to flat traces.
  • Adds unit and routing regression tests.
File summaries
File Description
eth/tracers/native/call.go Selects and updates the correct frame.
eth/tracers/native/call_test.go Tests preservation of real frames.
eth/tracers/native/call_flat.go Adds synthetic flat-frame handling.
eth/tracers/native/call_flat_test.go Tests flat tracer frame selection.
eth/tracers/internal/tracetest/non_evm_trace_test.go Tests production transaction routing.
Review details

Suppressed comments (1)

eth/tracers/internal/tracetest/non_evm_trace_test.go:72

  • Handle the key-generation error before dereferencing key; an entropy-source failure currently turns a clear setup error into a nil-pointer panic.
	key, _ := crypto.GenerateKey()
	from := crypto.PubkeyToAddress(key.PublicKey)
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread eth/tracers/native/call_flat.go Outdated
…address tx

The call tracer flags a transaction to one of the XDCX system addresses as
non-EVM from the to address alone, and used to return the synthetic frame it
prepares on OnTxStart whenever that flag was set. The flag does not know
whether the fork that routes those transactions away from the EVM is active:
before the fork, and after the receiver-disable fork, the EVM executes them
and pushes a real top-level frame.

That frame was silently replaced by the synthetic one, dropping its output,
error and sub-calls, so debug_traceTransaction returned degraded data for
those transactions — while the flat tracer kept using the real frame, so the
two tracers disagreed on the very same transaction.

Prefer the real top-level frame whenever one was pushed and let the synthetic
frame be the fallback for transactions the EVM never entered. OnTxEnd and
OnLog follow the same decision.

Pre-existing and XDPoS-specific: geth has no non-EVM transaction concept, so
there is no upstream fix to port.

Add TestCallTracerKeepsRealFrame, and keep TestCallTracerNonEVMTx covering
the synthetic frame of a transaction the EVM did not execute.
@gzliudan
gzliudan force-pushed the fix-non-evm-tracer-frames branch from 4c5032e to d44a344 Compare September 18, 2026 10:37
flatCallTracer built its answer from the callstack of the call tracer and
failed the whole trace with "invalid number of calls" when that callstack was
empty. It is empty exactly when the EVM did not execute the transaction, which
is what block processing does to the transactions it routes away from the EVM:
those to the XDCX system addresses while the receiver fork is active, and the
block-signing transactions. debug_traceBlock* therefore failed the whole call
on any block carrying one of them.

Flatten the synthetic frame the call tracer prepares for those transactions
when no real frame was pushed, and keep the real frame whenever the EVM did
run, so the flat tracer reports the same top-level frame as the call tracer.

Add TestFlatCallTracerNonEVMTx, TestFlatCallTracerKeepsRealFrame and
TestFlatCallTracerUnbalancedCallstack for the flat tracer itself, and
TestNonEVMTxThroughBlockProcessingRouting, which traces a transaction to a
system address through core.ApplyTransactionWithEVM and checks both tracers
against the receipt.

No corresponding fix exists upstream: geth has no non-EVM transaction concept.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused tracer fix is consistently implemented and covered by unit and routing-level tests.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants