Skip to content

Cross-Contract Call Tracing

Source: crates/vm-runtime/src/context.rs, crates/vm-runtime/src/trace_*.rs

The VM captures hierarchical call traces during contract execution. Each trace is a tree of TraceFrame nodes --- one per cross-contract call --- with storage operations, events, gas usage, and timing attached to the originating frame. Tracing has zero overhead when disabled.

The core node type representing a single call in the trace tree:

FieldTypeDescription
call_typeTraceCallTypeKind of call
fromVec<u8>Caller address
toVec<u8>Callee address
valueu128Value transferred
gas_limitGasGas allocated to this call
gas_usedGasGas consumed
inputVec<u8>Calldata (selector + args)
outputVec<u8>Return data
storage_opsVec<TraceStorageOp>Storage reads/writes/deletes
eventsVec<TraceLogEntry>Emitted events
childrenVec<TraceFrame>Nested calls (recursive tree)
errorOption<TraceError>Error if call reverted
duration_nsu64Wall-clock execution time
Call | StaticCall | DelegateCall | Create | Create2
FieldTypeDescription
op_typeTraceStorageOpTypeRead, Write, or Delete
keyVec<u8>Storage key
value_beforeOption<Vec<u8>>Value before the operation
value_afterOption<Vec<u8>>Value after the operation
gas_costGasGas cost for this operation
FieldTypeDescription
contractVec<u8>Event emitter address
topicsVec<[u8; 32]>Indexed topics
dataVec<u8>Event data
FieldDefaultDescription
max_depth64Maximum call depth to trace

Frames beyond max_depth are suppressed automatically to prevent DoS from deeply nested calls.

Tracing is driven by the TxContext during execution:

  1. Enable: ctx.enable_tracing(TracingConfig { max_depth: 64 })
  2. Enter frame: ctx.enter_call_frame(call_type, from, to, value, gas, input)
  3. During execution: ctx.trace_storage_op(op) and ctx.trace_event(event)
  4. Exit frame: ctx.exit_call_frame(output, gas_used, error)
  5. Extract: ctx.take_root_trace() returns the complete call tree

The internal frame stack (TraceFrameState) maintains the current nesting. On exit, each frame is attached to its parent’s children vector or saved as the root frame.

trace_format::to_json(&frame) // Compact JSON
trace_format::to_json_pretty(&frame) // Pretty-printed
trace_format::to_json_with_metadata( // With tx hash and block number
&frame, Some(&tx_hash), Some(block_number)
)

Addresses are hex-encoded with 0x prefix. Byte arrays are hex-encoded.

trace_format::to_chrome_trace(&frame)

Compatible with chrome://tracing and Perfetto. Gas used is mapped to duration for visual sizing. Call depth maps to thread ID for visual stacking.

trace_format::to_tree(&frame)
trace_format::to_tree_with_config(&frame, &config)

Example output:

|-- CALL 0xabc123...(0xdeadbeef) [1,000,000 gas]
| |-- SLOAD 0x0102... [2,100 gas]
| |-- SSTORE 0x0304... [5,000 gas]
| |-- STATICCALL 0xdef456...(0x12345678) [50,000 gas]
| | '-- RETURN: 32 bytes
| |-- EVENT 0xdddd...
| '-- RETURN: 4 bytes [45,230/1,000,000 gas, 4.5%]

TreeConfig presets:

PresetShows
minimal()Call structure only
default()Balanced (gas, values, errors)
verbose()All details (storage, events, timing)

Source: crates/vm-runtime/src/trace_compress.rs

Batch operations (airdrops, bulk transfers) produce massive traces with repeated subtrees. The TraceCompressor performs exact subtree deduplication:

SettingDefaultDescription
min_repetitions3Minimum repeats to trigger compression
sample_count3Number of samples preserved in detail

Repeated subtrees are collapsed into a Repeated node with a template frame, repetition count, and sampled instances.

Typical compression ratios:

WorkloadRatio
1000-address airdrop~1000x
100 identical swaps~100x
Mixed batch (50% unique)~2x

The compressed trace can be expand()-ed back to the full tree.

Source: crates/vm-runtime/src/trace_filter.rs

For production use, TraceFilter reduces overhead by selectively tracing:

FilterDescription
contracts(addresses)Only trace calls to/from specific contracts
failures_only()Only trace reverted calls
high_gas(threshold)Only trace calls above a gas threshold
max_depth(depth)Cap trace depth
depth_range(min, max)Trace only a range of call depths
Method selectorsFilter by function selector
min_value(amount)Only trace calls with value transfer

Filters apply at two points:

  • should_trace(ctx) --- pre-execution (skip tracing entirely)
  • should_keep(frame) --- post-execution (prune from output)

Source: crates/vm-runtime/src/trace_diff.rs

diff_traces(old, new) compares two traces for regression testing:

pub struct TraceDiff {
pub added_frames: Vec<FramePath>,
pub removed_frames: Vec<FramePath>,
pub changed_frames: Vec<FrameChange>,
pub gas_delta: i64,
}

Changes detected: callee, gas used, call type, outcome, value, input/output length, storage ops count, events count, and child count.

Source: crates/vm-runtime/src/trace_summary.rs

TraceSummary::from_trace(&root) collapses a trace into net effects:

FieldDescription
storage_deltasNet storage changes per (contract, key)
contracts_touchedAll contracts involved
total_gas_usedCumulative gas
eventsAll emitted events (flattened)
storage_reads/writes/deletesOperation counts
max_depthDeepest call nesting
total_framesTotal call frames
successWhether the root call succeeded

Tracing is invoked via execute_entrypoint_with_trace() in the execution layer. The resulting TraceFrame is attached to ExecMetadata::vm_traces during block execution and can be retrieved per-transaction.