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
- Build a DFS pre-order traversal of the call tree with enter/exit events.
- Stream through execution; whenever a
LOG*
opcode occurs, consume the next log from the receipt. - Attribute the consumed log to the top of the call stack (special-case
DELEGATECALL
to use the caller address). - 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 effectivemsg.sender
/address(this)
for attribution.STATICCALL
can emit logs; treat it likeCALL
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.