Skip to main content

Matching call traces with events in EVM logs

· 3 min read

When analyzing Ethereum transactions, we often need to answer: which call emitted which event?

This post outlines a practical approach to deterministically match trace nodes (from debug_traceTransaction or similar) with log events (from eth_getTransactionReceipt).

TL;DR

  • Use the receipt's logs array as the canonical sequence.
  • Walk the call tree in execution order and assign logs to the deepest active call at each log emission.
  • Maintain a stack of active calls using (from, to, input, depth, index) as identity.
  • Edge cases: reverts (logs discarded), delegatecall/staticcall (emitter address differs), precompiles (no logs).

Inputs

  • Call trace: flattened steps or a structured call tree with CALL, DELEGATECALL, STATICCALL, CREATE*, and return/revert boundaries.
  • Receipt logs: ordered array of {address, topics, data, logIndex}.

Algorithm sketch

  1. Build a DFS pre-order traversal of the call tree with enter/exit events.
  2. Stream through execution; whenever a LOG* opcode occurs, consume the next log from the receipt.
  3. Attribute the consumed log to the top of the call stack (special-case DELEGATECALL to use the caller address).
  4. On revert, discard any logs attributed within the reverted subtree (receipts already exclude them for post-Byzantium chains).

This relies on the fact that receipts preserve the emission order of successful LOG* opcodes.

Notes on delegatecall/staticcall

  • DELEGATECALL emits logs under the caller's address, not the callee's. Your call stack should carry the effective msg.sender/address(this) for attribution.
  • STATICCALL can emit logs; treat it like CALL but note no state change side-effects.

Minimal TypeScript snippet

type Log = {address: string; topics: string[]; data: string; logIndex: number};
type Call = {type: 'CALL'|'DELEGATECALL'|'STATICCALL'|'CREATE'|'ROOT'; to?: string; from?: string; children: Call[]};

function assignLogsToCalls(root: Call, logs: Log[]) {
const result = new Map<Call, Log[]>();
const stack: Call[] = [];
let i = 0;

function enter(call: Call) { stack.push(call); }
function exit() { stack.pop(); }
function onLog() {
if (i >= logs.length || stack.length === 0) return;
const current = stack[stack.length - 1];
const arr = result.get(current) ?? [];
arr.push(logs[i++]);
result.set(current, arr);
}

// Pseudocode: traverse calls executing children in order,
// invoking onLog() at each LOG* encountered in the execution stream.
// Implementation depends on your tracer output.

return result;
}

Validation

  • Cross-check by recomputing topic hashes from ABIs for known events and ensuring addresses match expected contracts at each call node.
  • For transactions with reverts, ensure your tracer excludes reverted logs or prune them via the call tree.

If you want the full implementation for a specific client tracer (Geth/Erigon/Nethermind), tell me which one and I’ll tailor it.