EVM Debugging and Transaction Tracing: A Developer's Guide
Learn EVM debugging and transaction tracing with debug_traceTransaction, Foundry, Tenderly, and opcode-level trace analysis. Includes step-by-step examples.
TL;DR: EVM debugging and transaction tracing let you replay any Ethereum transaction and inspect every opcode, stack value, memory change, and gas consumption step by step. The primary tools are debug_traceTransaction (JSON-RPC), Foundry’s built-in debugger, and hosted platforms like Tenderly. This guide covers how execution traces work at the opcode level, the different tracer types (callTracer, prestateTracer, opcodeLogger), practical debugging patterns for finding reverts and gas spikes, and a step-by-step walkthrough of tracing a failed transaction.
What Is an Execution Trace?
When the EVM processes a transaction, it executes bytecode one opcode at a time. An execution trace is a recording of that process — a sequential log of every instruction executed, along with the machine state at each step.
A single trace entry typically contains:
- pc (program counter): The byte offset of the current opcode in the contract’s bytecode
- op: The opcode name (PUSH1, ADD, SSTORE, CALL, etc.)
- gas: Gas remaining before this opcode executes
- gasCost: Gas consumed by this specific opcode
- depth: Call depth (1 for the top-level call, 2 for a subcall, etc.)
- stack: The full contents of the EVM stack
- memory: The contents of EVM memory (optional, can be large)
- storage: Storage changes made by this opcode (optional)
Here is what a single trace step looks like:
{
"pc": 0,
"op": "PUSH1",
"gas": 78124,
"gasCost": 3,
"depth": 1,
"stack": [],
"memory": []
}
A complete trace for even a simple transaction can contain thousands of these entries. A complex DeFi transaction involving multiple contract calls might produce hundreds of thousands of steps.
Trace Types and When to Use Each
Ethereum nodes support several built-in tracers, each optimized for different debugging scenarios:
structLogger (opcodeLogger)
The most detailed tracer. Records every single opcode with full machine state. Use this when you need to understand exactly what happened at the bytecode level.
Best for: Low-level bytecode debugging, understanding why a specific opcode failed, analyzing exact gas consumption per instruction.
Drawback: Produces enormous output. A moderately complex transaction can generate hundreds of megabytes of trace data.
callTracer
Captures only CALL-level interactions between contracts. Instead of recording every opcode, it builds a tree of calls showing which contract called which, with what input data, and what was returned.
Best for: Understanding the flow of a multi-contract transaction, identifying which subcall reverted, mapping out protocol interactions.
{
"type": "CALL",
"from": "0xSender",
"to": "0xRouter",
"value": "0x0",
"gas": "0x4c4b40",
"input": "0x38ed1739...",
"output": "0x...",
"calls": [
{
"type": "STATICCALL",
"from": "0xRouter",
"to": "0xFactory",
"input": "0xe6a43905...",
"output": "0x..."
},
{
"type": "CALL",
"from": "0xRouter",
"to": "0xPair",
"input": "0x022c0d9f...",
"calls": [...]
}
]
}
prestateTracer
Records the state of all accounts and storage slots that the transaction reads or writes, capturing the state before execution. This is useful for understanding what conditions existed when the transaction ran.
Best for: Reproducing transaction failures, understanding the pre-conditions, building transaction simulations.
Comparison of Tracer Types
| Tracer | Granularity | Output Size | Primary Use Case |
|---|---|---|---|
| structLogger | Every opcode | Very large | Bytecode-level debugging |
| callTracer | Contract calls only | Small | Interaction flow analysis |
| prestateTracer | Pre-execution state | Medium | State reproduction |
| 4byteTracer | Function selectors only | Tiny | Quick function identification |
Using debug_traceTransaction
The debug_traceTransaction JSON-RPC method is the standard interface for obtaining execution traces. It takes a transaction hash and replays the transaction against historical state, recording the trace.
Prerequisites
- An archive node (or a node with state for the relevant block)
- Debug APIs enabled (most public RPC providers disable these)
- Providers that support debug APIs: Alchemy (Growth plan), QuickNode (add-on), self-hosted geth/reth with
--http.api debug
Basic Usage
# Get a full opcode trace
curl -X POST \
-H "Content-Type: application/json" \
--data '{
"jsonrpc": "2.0",
"method": "debug_traceTransaction",
"params": [
"0xTRANSACTION_HASH",
{"tracer": "callTracer"}
],
"id": 1
}' \
http://localhost:8545
Using debug_traceCall for Simulations
debug_traceCall traces a call without actually submitting a transaction. This is useful for debugging calls that would revert:
# Simulate and trace a call
curl -X POST \
-H "Content-Type: application/json" \
--data '{
"jsonrpc": "2.0",
"method": "debug_traceCall",
"params": [
{
"from": "0xSender",
"to": "0xContract",
"data": "0xFunctionSelector..."
},
"latest",
{"tracer": "callTracer"}
],
"id": 1
}' \
http://localhost:8545
Foundry’s Debugger
Foundry provides the most developer-friendly EVM debugging experience. Its interactive debugger lets you step through execution with a terminal UI.
Debugging Tests
# Run a specific test with the debugger
forge test --match-test testSwap --debug
# The debugger opens a TUI showing:
# - Current opcode and program counter
# - Stack contents
# - Memory contents
# - Source code mapping (if available)
Debugging Transactions on a Fork
# Fork mainnet and debug a specific transaction
cast run 0xTRANSACTION_HASH --debug --rpc-url https://eth.llamarpc.com
This replays the transaction in a local fork and opens the interactive debugger. You can step forward and backward through opcodes, inspect the stack at any point, and see exactly which opcode caused a revert.
Trace Output with cast
For non-interactive analysis, cast can produce trace output:
# Get a call trace of a transaction
cast run 0xTRANSACTION_HASH --trace --rpc-url https://eth.llamarpc.com
# Example output:
# Traces:
# [245929] Router::swapExactTokensForTokens(1000000, 990000, [...], 0xUser, 1680000000)
# [2516] Factory::getPair(USDC, WETH) [staticcall]
# <- 0xPairAddress
# [120539] Pair::swap(0, 5532423, 0xUser, 0x)
# [34521] WETH::transfer(0xUser, 5532423)
# <- true
# <- ()
# <- [990000]
Common Debugging Patterns
Pattern 1: Finding Why a Transaction Reverted
This is the most common debugging task. Here is a systematic approach:
Step 1: Get the call trace to identify which subcall reverted.
cast run 0xFAILED_TX_HASH --trace --rpc-url $RPC_URL
Look for the call that has a revert indicator. The output shows which contract and function reverted.
Step 2: If the revert reason is not clear from the call trace, get the full opcode trace for just the reverted subcall.
Step 3: Look for the REVERT opcode in the trace. Walk backward to find the condition check — typically a pattern like:
ISZERO -> Checks if the comparison result is zero
JUMPI -> Conditional jump based on the check
REVERT -> Execution ends here if the condition failed
Step 4: The stack values at the comparison instruction reveal the actual values that failed the check. Decode these against the contract’s source code to understand the failed require statement.
Pattern 2: Identifying Gas Spikes
When a transaction consumes unexpectedly high gas, trace it and look for:
- Large gasCost values: Sort trace entries by gasCost to find expensive operations
- Repeated SLOAD/SSTORE: Storage operations dominate gas costs. Look for patterns where the same slot is accessed repeatedly without caching
- Excessive memory expansion: MSTORE to high offsets triggers quadratic memory cost
- Deep call chains: Each CALL carries a base cost plus the 63/64 gas forwarding rule
# Python script to analyze gas consumption from a trace
import json
trace = json.load(open("trace.json"))
gas_by_opcode = {}
for step in trace["structLogs"]:
op = step["op"]
cost = step["gasCost"]
gas_by_opcode[op] = gas_by_opcode.get(op, 0) + cost
# Sort by total gas consumption
for op, gas in sorted(gas_by_opcode.items(), key=lambda x: -x[1]):
print(f"{op:15s} {gas:>10,} gas")
Pattern 3: Detecting Reentrancy
Reentrancy shows up clearly in call traces. Look for:
- Contract A calls Contract B (CALL)
- Contract B calls back into Contract A (CALL at increased depth)
- Contract A’s state has not been updated yet
In a callTracer output, this appears as a nested call back to the original contract before the first call completes:
A.withdraw()
-> B.receive() depth 2
-> A.withdraw() depth 3 <-- REENTRANCY
Pattern 4: Understanding Delegate Call Chains
In proxy patterns, execution flows through delegatecall, which can be confusing because the code executes in the caller’s storage context:
Proxy.fallback() storage: Proxy
-> DELEGATECALL Implementation.swap() storage: Proxy (!)
-> CALL ExternalToken.transfer() storage: ExternalToken
The trace’s depth and call type fields make this clear. Every DELEGATECALL entry means “executing this code but using the parent’s storage.”
Tools Comparison
| Tool | Type | Cost | Best For |
|---|---|---|---|
| Foundry (forge/cast) | Local CLI | Free | Development debugging, test debugging, fork tracing |
| Tenderly | Hosted platform | Free tier available | Visual transaction decoding, alerts, simulations |
| Remix Debugger | Browser IDE | Free | Quick debugging during development |
| Etherscan Tracer | Block explorer | Free | Quick call trace visualization |
| Zig EVM Playground | Educational tool | Free | Learning opcode execution, stepping through bytecode |
| Hardhat Network | Local node | Free | Console.log-style debugging in Solidity |
Tenderly
Tenderly deserves special mention for its visual transaction decoder. Given any transaction hash on supported networks, it shows:
- Decoded function calls with parameter names and values
- State changes (before/after for every storage slot modified)
- Event logs with decoded parameters
- Gas breakdown by function call
- The exact line of Solidity source code that reverted (if verified)
For production incident debugging, Tenderly is often the fastest path to understanding what happened.
The Zig EVM Playground
For learning and experimentation, the Zig EVM Playground provides a controlled environment where you can:
- Write raw bytecode and step through execution one opcode at a time
- Observe the stack, memory, and storage after each instruction
- Understand gas consumption at the individual opcode level
- Experiment with opcodes without deploying to any network
This is particularly useful for understanding the low-level mechanics that higher-level tools abstract away. The Zig EVM project implements the full opcode set with accurate gas metering, making it suitable for educational trace analysis.
Step-by-Step: Tracing a Failed Transaction
Let us walk through a concrete example of debugging a failed Uniswap V2 swap.
Step 1: Identify the Failure
You submitted a swap transaction and it reverted. The transaction hash is available on Etherscan, which shows “Fail” with a generic “Reverted” message.
Step 2: Get the Call Trace
cast run 0xFAILED_TX --trace --rpc-url $RPC_URL
Output:
Traces:
[128493] Router::swapExactTokensForTokens(
1000000000000000000, // amountIn: 1 ETH worth
995000000000000000, // amountOutMin: 0.995 ETH worth
[...], // path
0xUser,
1680000000 // deadline
)
[2516] Factory::getPair(TokenA, TokenB) [staticcall]
<- 0xPairAddress
[5200] Pair::getReserves() [staticcall]
<- (reserve0: 50000e18, reserve1: 100e18, blockTimestamp: 1679999990)
<- REVERT: "UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT"
Step 3: Interpret the Trace
The trace tells us:
- The router called
getPairsuccessfully - It called
getReservessuccessfully - It then computed the expected output amount locally and found it was below our
amountOutMin - The router itself reverted — no swap was even attempted
Step 4: Calculate What Happened
Using the reserves (50000e18 and 100e18), we can calculate the expected output:
# Uniswap V2 constant product formula
amount_in = 1e18
reserve_in = 50000e18
reserve_out = 100e18
fee = 0.003
amount_in_with_fee = amount_in * (1 - fee)
output = (amount_in_with_fee * reserve_out) / (reserve_in + amount_in_with_fee)
# output approximately equals 0.00199 ETH worth
# Our minimum was 0.995 -- way too high for this pool's liquidity
Step 5: Root Cause
The slippage tolerance was set too tight for the pool’s current reserves. The solution is either to increase the slippage tolerance or to route through a more liquid pool.
EVM debugging and transaction tracing is the single most important skill for diagnosing smart contract failures in production. Without traces, you are guessing. With traces, you have a complete record of every computation the EVM performed.
Key Takeaways
- Execution traces record every opcode, stack state, gas cost, and memory change that occurs during a transaction, providing complete visibility into EVM execution.
- Use callTracer for high-level flow analysis (which contracts called which) and structLogger/opcodeLogger for bytecode-level debugging (why a specific opcode failed).
- debug_traceTransaction requires an archive node with debug APIs enabled. Hosted alternatives like Tenderly provide the same data without running your own infrastructure.
- Foundry’s debugger (forge test —debug, cast run —debug) offers the best interactive debugging experience for local development and mainnet fork analysis.
- Common debugging patterns include tracing reverts backward from the REVERT opcode, sorting opcodes by gas cost to find spikes, and detecting reentrancy through nested call patterns.
- The Zig EVM Playground at /playground/ lets you step through raw bytecode execution for educational purposes, complementing production debugging tools.
- Nothing replaces reading the actual trace. Tools can decode and visualize, but understanding how the EVM stack machine operates at the opcode level makes you a fundamentally better debugger.
Frequently Asked Questions
- What is EVM transaction tracing?
- EVM transaction tracing is the process of replaying a transaction and recording every opcode executed, including the program counter, gas consumed, stack state, and memory contents at each step. This produces a complete execution trace that developers use to diagnose reverts, identify gas inefficiencies, and understand contract interactions.
- How do I use debug_traceTransaction?
- Call debug_traceTransaction via JSON-RPC with the transaction hash and an optional tracer configuration. For example: curl -X POST --data '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["0xTxHash",{"tracer":"callTracer"}],"id":1}' http://localhost:8545. The node replays the transaction and returns the trace. Note that this requires an archive node with debug APIs enabled.
- What is the difference between callTracer and opcodeLogger?
- callTracer captures high-level information about CALL, DELEGATECALL, STATICCALL, and CREATE operations, showing the tree of contract interactions. opcodeLogger (also called structLogger) records every single opcode execution with full stack and memory dumps. callTracer is useful for understanding contract interaction flow, while opcodeLogger is needed for low-level bytecode debugging.
- How do I debug a reverted transaction?
- First, obtain the transaction trace using debug_traceTransaction with the structLogger tracer. Look for the REVERT opcode in the trace output. Walk backward through the trace to find the condition that triggered the revert -- typically a failing ISZERO check after a comparison. The stack values at that point reveal what condition failed. Tools like Tenderly can decode this automatically.
- Can I debug EVM transactions without an archive node?
- Yes. Tools like Tenderly provide hosted trace infrastructure that does not require your own archive node. Foundry's forge test --debug flag lets you debug local transactions interactively. You can also use the Zig EVM Playground to step through bytecode execution in a controlled environment without connecting to any network.
Try Zig EVM
Explore the interactive playground or dive into the source code.