FORENSIAWeb3 forensicsOpen workspace

Reading a transaction as a tree of contract calls

See which contract call happened inside which, then jump from any step to its activity record and source response. It is the structure a provider reported, not an independent replay.

· Forensia · 7 min read · Transactions

When you send a transaction to a smart contract, that contract often calls other contracts, which call others again. A single swap or bridge deposit can involve dozens of these inner calls. Most tools list them as a flat table, in order, and leave you to guess which call was made from inside which.

That guess matters. Whether a step belongs to a branch that later failed, or was made on behalf of another contract, depends entirely on where it sits in the nesting.

For EVM transactions where the data source provides a call trace, Forensia records the exact position of every retained call and shows the nesting directly. From any step you can open the matching activity record or the source response that reported it, and come back to the same step.

#Plain languageAn outline with numbered headings, not a flat list

Picture a document outline. Section 1 contains 1.1 and 1.2. Section 1.2 contains 1.2.1. The numbering alone tells you what sits inside what, even if someone hands you the headings shuffled.

A transaction works the same way. The outer transaction is the title. Each call it makes is a section, and each call made from inside that one is a subsection. Forensia stores that outline number for every call, taken from the trace, and uses only that number to decide what is inside what. It never works the nesting out from which rows are next to each other, from matching addresses or from matching amounts.

Token transfer events are kept apart from this outline. They come from the receipt, a separate record, and Forensia does not guess which call emitted them.

#How it worksWhere the call hierarchy lives and how to move through it

  1. Search an EVM transaction hash. The call hierarchy applies to transactions, not to addresses.
  2. On Overview, open Inspect transaction steps. The line shows how many steps were recorded.
  3. Check the header. It gives the number of token events, the number of nested calls, and the source: geth-callTracer when a call trace was captured, receipt-only when it was not.
  4. Open the disclosure titled Which calls happened inside other calls? Each entry is labeled Transaction entry or Inside step N with its depth, followed by a button such as Step 7 · DELEGATECALL.
  5. Choose a step button. The list scrolls to that numbered step and moves keyboard focus to its heading.
  6. Read the step: its call type, call depth, From and To addresses, an execution label, an amount label and the function selector.
  7. Choose View activity record to open the same call in the Activity reader, or open Source references to reach the exact entries under Sources, listed by purpose such as Execution trace and Execution result and events.
  8. Choose Back to transaction step to return. It restores the same step in the same captured transaction and makes no new lookup.

The review transaction used in the engineering notes is 0x22fc4a1bba0e2a711a5852f5c835eea4cb764f989895b1b48ec73d84dab8ed95 on Ethereum. One saved capture of it held 95 interpreted steps, 64 of them nested call frames. A fresh lookup can differ, because it depends on what the sources return at that moment.

#Technical detailHow the tree is requested, bounded and checked

The requests

A transaction lookup first calls eth_getTransactionByHash. It then requests eth_getTransactionReceipt, eth_getBlockByNumber for the latest block, and the trace in parallel, followed by eth_getBlockByNumber for the containing block. The trace request is optional: if it fails, the lookup continues on the receipt alone.

The trace request (client-side timeout 7,000 ms)
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "debug_traceTransaction",
  "params": [
    "<transaction hash>",
    {
      "tracer": "callTracer",
      "timeout": "5s",
      "tracerConfig": { "onlyTopCall": false, "withLog": false }
    }
  ]
}

The trace response carries no transaction hash, so its root frame is matched against the captured transaction before any frame is read: same sender, same recipient with type CALL (or type CREATE or CREATE2 when the transaction has no recipient), identical input bytes, equal value, and an error state that agrees with the receipt status. If the root does not match, the trace is not used.

Flattening the tree

Frames are walked depth-first, parent before children. Each retained call stores its path, which is the list of child indexes from the root (the root path is empty), its depth, and its order. Accepted frame types are CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2 and SELFDESTRUCT.

A frame is rejected when its type is not one of those, its from address is invalid, its to address is invalid (except a failed creation, which may have none), its value is not hex of at most 64 digits, its input is not whole bytes, or it is a STATICCALL with nonzero value. A rejected frame removes its whole branch, descendants included, because context cannot be inherited through a broken parent. The number of excluded branches is reported under the heading Internal call data is incomplete.

LimitValue in code
Maximum depth interpreted6
Maximum nested calls retained64
Maximum frames inspected257 (1 plus 64 times 4)
Return bytes kept per callAt most 64 bytes
Transfer items retained from the receipt96
Interpreted steps shown in ActivityFirst 32

Reaching a limit adds the notice The call inspection limit was reached. The raw trace response stays linked as a source record.

Identifiers

RecordStep IDActivity ID
Outer transactionevm-flow:root:{hash}native:{hash}
Nested call number nevm-flow:call:{hash}:{n}internal:{hash}:{n}
Token eventevm-flow:log:{hash}:{log index}token:{hash}:{log index}

When the hierarchy is refused

The hierarchy is built from recorded paths only. It is withheld, with the individual steps still listed, when any of these hold:

  • the source is not geth-callTracer, there are more than 256 steps, or two steps share an ID;
  • a receipt event carries a call path, or there is not exactly one root;
  • a path is longer than 12, differs in length from the recorded depth, repeats another path, or has an index outside 0 to 1,000,000;
  • a call has no recorded parent at the path one level up;
  • a parent is failed and its child is not, or a parent is unknown and its child claims success.

Entries are ordered by comparing paths index by index; where one path is a prefix of another, the shorter comes first. Captures made before paths were recorded show an explanation instead of a hierarchy. They are not backfilled.

Step to activity, step to source

View activity record appears only when the step ID maps to exactly one activity ID and the two agree on transaction hash, record kind, from, to, raw value, status and selector, with every source reference of the step also present on the activity row. Otherwise the step states why no link is offered. Because Activity keeps the first 32 interpreted steps, later calls can lack an activity record.

A source reference supports a call only when its recorded request is debug_traceTransaction for this hash with tracer set to callTracer. A request with no recorded tracer is treated as unknown. A different tracer, such as prestateTracer, is a conflict: it uses the same RPC method but returns account state, not call frames.

Checking against saved responses

Under Sources, Capture integrity & local evidence, the Check local evidence action re-runs the same bounded interpreter over three saved, hash-verified responses (transaction, receipt, call trace, up to 8 MiB each) and compares every retained path, order, type, participant, raw quantity, selector and failure status. It contacts no provider. It needs the response bodies to be held in the browser vault; where they are missing or expired, the result is unavailable.

#LimitsWhat the call hierarchy does not establish

  • It is the structure a provider reported through callTracer. It is not an independent replay of the transaction and not an opcode-level trace.
  • If the configured source does not expose debug_traceTransaction, only the receipt view exists. Missing call data never means no internal calls happened.
  • The view is bounded to depth 6 and 64 calls. A matching bounded prefix never means the trace is complete.
  • Nesting is not a money trail. A value on a call is not a verified balance change, and nested values must not be added together.
  • Token events are not attached to calls. No call is said to have emitted a given event.
  • It does not show which code bytes ran at a target. The optional code observation covers up to four distinct targets at the end of the containing block, which is not the state at the time of the call.
  • Step, activity and source links show that records correspond. They do not authenticate the provider.
  • Nothing in the hierarchy identifies an owner or shows intent.