Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pkg/ethereum/execution/embedded_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,21 @@ func (n *EmbeddedNode) TransactionReceipt(ctx context.Context, hash string) (Rec
}

// DebugTraceTransaction delegates to the DataSource.
//
// OPTIMIZATION: In embedded mode, the tracer extracts CallToAddress directly
// for CALL-family opcodes instead of capturing the full stack. We explicitly
// set DisableStack: true to signal this intent, even though the tracer ignores
// this setting (it always uses the optimized path).
func (n *EmbeddedNode) DebugTraceTransaction(
ctx context.Context,
hash string,
blockNumber *big.Int,
opts TraceOptions,
) (*TraceTransaction, error) {
// Override DisableStack for embedded mode optimization.
// The tracer extracts CallToAddress directly, so full stack capture is unnecessary.
opts.DisableStack = true

return n.source.DebugTraceTransaction(ctx, hash, blockNumber, opts)
}

Expand Down
55 changes: 46 additions & 9 deletions pkg/ethereum/execution/structlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,51 @@ type TraceTransaction struct {
Structlogs []StructLog
}

// StructLog represents a single EVM opcode execution trace entry.
//
// This struct supports two operation modes:
// - RPC mode: Stack is populated for CALL opcodes, CallToAddress/GasUsed computed post-hoc
// - Embedded mode: CallToAddress/GasUsed pre-computed by tracer, Stack remains nil
//
// The embedded mode optimizations eliminate ~99% of stack-related allocations
// and remove the post-processing GasUsed computation pass.
type StructLog struct {
PC uint32 `json:"pc"`
Op string `json:"op"`
Gas uint64 `json:"gas"`
GasCost uint64 `json:"gasCost"`
Depth uint64 `json:"depth"`
ReturnData *string `json:"returnData"`
Refund *uint64 `json:"refund,omitempty"`
Error *string `json:"error,omitempty"`
Stack *[]string `json:"stack,omitempty"`
// PC is the program counter. Kept for RPC backward compatibility but not
// populated in embedded mode (always 0).
PC uint32 `json:"pc"`

// Op is the opcode name (e.g., "PUSH1", "CALL", "SSTORE").
Op string `json:"op"`

// Gas is the remaining gas before this opcode executes.
Gas uint64 `json:"gas"`

// GasCost is the static gas cost of the opcode (may differ from actual GasUsed).
GasCost uint64 `json:"gasCost"`

// GasUsed is the actual gas consumed by this opcode.
// In embedded mode: pre-computed by tracer using gas difference to next opcode.
// In RPC mode: computed post-hoc by ComputeGasUsed(), this field will be 0.
GasUsed uint64 `json:"gasUsed,omitempty"`

// Depth is the call stack depth (1 = top-level, increases with CALL/CREATE).
Depth uint64 `json:"depth"`

// ReturnData contains the return data from the last CALL/STATICCALL/etc.
ReturnData *string `json:"returnData"`

// Refund is the gas refund counter value.
Refund *uint64 `json:"refund,omitempty"`

// Error contains any error message if the opcode failed.
Error *string `json:"error,omitempty"`

// Stack contains the EVM stack state (RPC mode only).
// In embedded mode this is nil - use CallToAddress instead.
Stack *[]string `json:"stack,omitempty"`

// CallToAddress is the target address for CALL/STATICCALL/DELEGATECALL/CALLCODE.
// In embedded mode: pre-extracted by tracer from stack[len-2].
// In RPC mode: nil, extracted post-hoc from Stack by extractCallAddress().
CallToAddress *string `json:"callToAddress,omitempty"`
}
41 changes: 41 additions & 0 deletions pkg/processor/transaction/structlog/gas_cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ import (
"github.com/ethpandaops/execution-processor/pkg/ethereum/execution"
)

// EVM opcode name constants used for opcode identification and benchmarking.
const (
opCALL = "CALL"
opSTATICCALL = "STATICCALL"
opDELEGATECALL = "DELEGATECALL"
opCALLCODE = "CALLCODE"
opPUSH1 = "PUSH1"
opRETURN = "RETURN"
opSTOP = "STOP"
)

// isCallOpcode checks if the opcode is a call-family opcode that has a target address.
//
// Call-family opcodes are:
// - CALL: Standard external call
// - STATICCALL: Read-only external call (no state modifications)
// - DELEGATECALL: Call using caller's context (msg.sender, msg.value preserved)
// - CALLCODE: Deprecated, similar to DELEGATECALL but uses callee's msg.value
//
// For these opcodes, the target address is at stack position len-2 (second from top).
func isCallOpcode(op string) bool {
return op == opCALL || op == opSTATICCALL || op == opDELEGATECALL || op == opCALLCODE
}

// hasPrecomputedGasUsed detects whether GasUsed values are pre-computed by the tracer.
//
// In embedded mode, the tracer computes GasUsed inline during trace capture,
// populating this field with non-zero values. In RPC mode, GasUsed is always 0
// and must be computed post-hoc using ComputeGasUsed().
//
// This enables backward compatibility: execution-processor works with both
// embedded mode (optimized, pre-computed) and RPC mode (legacy, post-computed).
func hasPrecomputedGasUsed(structlogs []execution.StructLog) bool {
if len(structlogs) == 0 {
return false
}

// Check first structlog - if GasUsed > 0, tracer pre-computed values.
return structlogs[0].GasUsed > 0
}

// ComputeGasUsed calculates the actual gas consumed for each structlog using
// the difference between consecutive gas values at the same depth level.
//
Expand Down
64 changes: 50 additions & 14 deletions pkg/processor/transaction/structlog/transaction_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,15 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc

totalCount := len(trace.Structlogs)

// Compute actual gas used for each structlog
gasUsed := ComputeGasUsed(trace.Structlogs)
// Check if GasUsed is pre-computed by the tracer (embedded mode).
// In embedded mode, skip the post-processing computation.
// In RPC mode, compute GasUsed from gas differences.
precomputedGasUsed := hasPrecomputedGasUsed(trace.Structlogs)

var gasUsed []uint64
if !precomputedGasUsed {
gasUsed = ComputeGasUsed(trace.Structlogs)
}

// Check if this is a big transaction and register if needed
if totalCount >= p.bigTxManager.GetThreshold() {
Expand Down Expand Up @@ -136,7 +143,16 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc

// Producer - convert and send batches
batch := make([]Structlog, 0, chunkSize)

for i := 0; i < totalCount; i++ {
// Get GasUsed: use pre-computed value from tracer (embedded) or computed value (RPC).
var gasUsedValue uint64
if precomputedGasUsed {
gasUsedValue = trace.Structlogs[i].GasUsed
} else {
gasUsedValue = gasUsed[i]
}

// Convert structlog
batch = append(batch, Structlog{
UpdatedDateTime: NewClickHouseTime(time.Now()),
Expand All @@ -151,7 +167,7 @@ func (p *Processor) ProcessTransaction(ctx context.Context, block execution.Bloc
Operation: trace.Structlogs[i].Op,
Gas: trace.Structlogs[i].Gas,
GasCost: trace.Structlogs[i].GasCost,
GasUsed: gasUsed[i],
GasUsed: gasUsedValue,
Depth: trace.Structlogs[i].Depth,
ReturnData: trace.Structlogs[i].ReturnData,
Refund: trace.Structlogs[i].Refund,
Expand Down Expand Up @@ -226,9 +242,21 @@ func (p *Processor) getTransactionTrace(ctx context.Context, tx execution.Transa
return trace, nil
}

// extractCallAddress extracts the call address from a structlog if it's a CALL operation.
// extractCallAddress extracts the call address from a structlog for CALL-family opcodes.
//
// Supports two modes for backward compatibility:
// - Embedded mode: CallToAddress is pre-populated by the tracer, use directly.
// - RPC mode: CallToAddress is nil, extract from Stack[len-2] for CALL-family opcodes.
//
// CALL-family opcodes: CALL, STATICCALL, DELEGATECALL, CALLCODE.
func (p *Processor) extractCallAddress(structLog *execution.StructLog) *string {
if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 {
// Embedded mode: use pre-extracted CallToAddress
if structLog.CallToAddress != nil {
return structLog.CallToAddress
}

// RPC mode fallback: extract from Stack for CALL-family opcodes
if isCallOpcode(structLog.Op) && structLog.Stack != nil && len(*structLog.Stack) > 1 {
stackValue := (*structLog.Stack)[len(*structLog.Stack)-2]

return &stackValue
Expand Down Expand Up @@ -268,18 +296,26 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block execution.Block
uIndex := uint32(index) //nolint:gosec // index is bounded by block.Transactions() length

if trace != nil {
// Compute actual gas used for each structlog
gasUsed := ComputeGasUsed(trace.Structlogs)
// Check if GasUsed is pre-computed by the tracer (embedded mode).
// In embedded mode, skip the post-processing computation.
// In RPC mode, compute GasUsed from gas differences.
precomputedGasUsed := hasPrecomputedGasUsed(trace.Structlogs)

var gasUsed []uint64
if !precomputedGasUsed {
gasUsed = ComputeGasUsed(trace.Structlogs)
}

// Pre-allocate slice for better memory efficiency
structlogs = make([]Structlog, 0, len(trace.Structlogs))

for i, structLog := range trace.Structlogs {
var callToAddress *string

if structLog.Op == "CALL" && structLog.Stack != nil && len(*structLog.Stack) > 1 {
stackValue := (*structLog.Stack)[len(*structLog.Stack)-2]
callToAddress = &stackValue
// Get GasUsed: use pre-computed value from tracer (embedded) or computed value (RPC).
var gasUsedValue uint64
if precomputedGasUsed {
gasUsedValue = structLog.GasUsed
} else {
gasUsedValue = gasUsed[i]
}

row := Structlog{
Expand All @@ -295,12 +331,12 @@ func (p *Processor) ExtractStructlogs(ctx context.Context, block execution.Block
Operation: structLog.Op,
Gas: structLog.Gas,
GasCost: structLog.GasCost,
GasUsed: gasUsed[i],
GasUsed: gasUsedValue,
Depth: structLog.Depth,
ReturnData: structLog.ReturnData,
Refund: structLog.Refund,
Error: structLog.Error,
CallToAddress: callToAddress,
CallToAddress: p.extractCallAddress(&structLog),
MetaNetworkID: p.network.ID,
MetaNetworkName: p.network.Name,
}
Expand Down