diff --git a/eth/tracers/logger/logger.go b/eth/tracers/logger/logger.go index 67e07f78d0..f2eba1a7ae 100644 --- a/eth/tracers/logger/logger.go +++ b/eth/tracers/logger/logger.go @@ -33,6 +33,7 @@ import ( "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" ) @@ -42,11 +43,12 @@ type Storage map[common.Hash]common.Hash // Config are the configuration options for structured logger the EVM type Config struct { - EnableMemory bool // enable memory capture - DisableStack bool // disable stack capture - DisableStorage bool // disable storage capture - EnableReturnData bool // enable return data capture - Limit int // maximum size of output, but zero means unlimited + EnableMemory bool // enable memory capture + DisableStack bool // disable stack capture + DisableStorage bool // disable storage capture + EnableReturnData bool // enable return data capture + EnableActualGasCost bool // enable actual gas cost computation from consecutive gas values (non-streaming mode only) + Limit int // maximum size of output, but zero means unlimited // Chain overrides, can be used to execute a trace using future fork rules Overrides *params.ChainConfig `json:"overrides,omitempty"` } @@ -216,9 +218,11 @@ type StructLogger struct { err error usedGas uint64 - writer io.Writer // If set, the logger will stream instead of store logs - logs []json.RawMessage // buffer of json-encoded logs - resultSize int + writer io.Writer // If set, the logger will stream instead of store logs + jsonLogs []json.RawMessage // buffer of json-encoded logs (when EnableActualGasCost is false) + logs []*StructLog // buffer of logs for deferred JSON encoding (when EnableActualGasCost is true) + pendingGasIdx []int // index into logs for pending gas update at each depth + resultSize int interrupt atomic.Bool // Atomic flag to signal execution interruption reason error // Textual reason for the interruption @@ -229,17 +233,26 @@ type StructLogger struct { func NewStreamingStructLogger(cfg *Config, writer io.Writer) *StructLogger { l := NewStructLogger(cfg) l.writer = writer + if cfg != nil && cfg.EnableActualGasCost { + log.Warn("EnableActualGasCost is not supported in streaming mode, ignoring") + l.cfg.EnableActualGasCost = false + l.pendingGasIdx = nil + } return l } // NewStructLogger construct a new (non-streaming) struct logger. func NewStructLogger(cfg *Config) *StructLogger { logger := &StructLogger{ - storage: make(map[common.Address]Storage), - logs: make([]json.RawMessage, 0), + storage: make(map[common.Address]Storage), + jsonLogs: make([]json.RawMessage, 0), } if cfg != nil { logger.cfg = *cfg + if cfg.EnableActualGasCost { + logger.logs = make([]*StructLog, 0) + logger.pendingGasIdx = make([]int, 0, 16) + } } return logger } @@ -278,15 +291,15 @@ func (l *StructLogger) OnOpcode(pc uint64, opcode byte, gas, cost uint64, scope stack = scope.StackData() stackLen = len(stack) ) - log := StructLog{pc, op, gas, cost, nil, len(memory), nil, nil, nil, depth, l.env.StateDB.GetRefund(), err} + slog := StructLog{pc, op, gas, cost, nil, len(memory), nil, nil, nil, depth, l.env.StateDB.GetRefund(), err} if l.cfg.EnableMemory { - log.Memory = memory + slog.Memory = memory } if !l.cfg.DisableStack { - log.Stack = scope.StackData() + slog.Stack = scope.StackData() } if l.cfg.EnableReturnData { - log.ReturnData = rData + slog.ReturnData = rData } // Copy a snapshot of the current storage to a new container @@ -315,16 +328,44 @@ func (l *StructLogger) OnOpcode(pc uint64, opcode byte, gas, cost uint64, scope storage = maps.Clone(l.storage[contractAddr]) } } - log.Storage = storage + slog.Storage = storage // create a log if l.writer == nil { - entry := log.toLegacyJSON() - l.resultSize += len(entry) - l.logs = append(l.logs, entry) + if l.cfg.EnableActualGasCost { + // Store struct for later GasCost modification and deferred JSON encoding + l.logs = append(l.logs, &slog) + // Approximate size for limit checking. This intentionally overestimates + // since exact JSON size is only known after final encoding with updated + // gas costs. Constants: ~100 bytes base, 66 per stack item (hex u256), + // 2 per memory byte (hex), 132 per storage entry (two hex hashes). + l.resultSize += 100 + len(slog.Stack)*66 + len(slog.Memory)*2 + len(slog.ReturnData)*2 + len(slog.Storage)*132 + + // Ensure pendingGasIdx slice is large enough for this depth + for len(l.pendingGasIdx) <= depth { + l.pendingGasIdx = append(l.pendingGasIdx, -1) + } + // Clear pending indices from deeper levels (we've returned from calls). + // These are the last opcodes in child call contexts - we cannot compute + // actual gas cost across call boundaries, so keep the pre-calculated cost. + for d := len(l.pendingGasIdx) - 1; d > depth; d-- { + l.pendingGasIdx[d] = -1 + } + // Update gas cost for pending log at current depth + if l.pendingGasIdx[depth] >= 0 { + l.logs[l.pendingGasIdx[depth]].GasCost = l.logs[l.pendingGasIdx[depth]].Gas - gas + } + // Store current log's index as pending at this depth + l.pendingGasIdx[depth] = len(l.logs) - 1 + } else { + // Original behavior: encode to JSON immediately with exact size tracking + entry := slog.toLegacyJSON() + l.resultSize += len(entry) + l.jsonLogs = append(l.jsonLogs, entry) + } return } - log.Write(l.writer) + slog.Write(l.writer) } // OnExit is called a call frame finishes processing. @@ -357,11 +398,25 @@ func (l *StructLogger) GetResult() (json.RawMessage, error) { if failed && !errors.Is(l.err, vm.ErrExecutionReverted) { returnData = []byte{} } + + // Get the appropriate logs based on mode + var structLogs []json.RawMessage + if l.cfg.EnableActualGasCost { + // Convert deferred logs to JSON + structLogs = make([]json.RawMessage, 0, len(l.logs)) + for _, slog := range l.logs { + structLogs = append(structLogs, slog.toLegacyJSON()) + } + } else { + // Use pre-encoded JSON logs + structLogs = l.jsonLogs + } + return json.Marshal(&ExecutionResult{ Gas: l.usedGas, Failed: failed, ReturnValue: returnData, - StructLogs: l.logs, + StructLogs: structLogs, }) } diff --git a/eth/tracers/logger/logger_test.go b/eth/tracers/logger/logger_test.go index acc3069e70..562538fa8e 100644 --- a/eth/tracers/logger/logger_test.go +++ b/eth/tracers/logger/logger_test.go @@ -17,6 +17,7 @@ package logger import ( + "bytes" "encoding/json" "errors" "math/big" @@ -24,6 +25,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" @@ -43,6 +45,28 @@ func (*dummyStatedb) GetStateAndCommittedState(common.Address, common.Hash) (com return common.Hash{}, common.Hash{} } +// mockOpContext implements tracing.OpContext for manual OnOpcode testing. +type mockOpContext struct{} + +var _ tracing.OpContext = (*mockOpContext)(nil) + +func (m *mockOpContext) MemoryData() []byte { return nil } +func (m *mockOpContext) StackData() []uint256.Int { return nil } +func (m *mockOpContext) Caller() common.Address { return common.Address{} } +func (m *mockOpContext) Address() common.Address { return common.Address{} } +func (m *mockOpContext) CallValue() *uint256.Int { return new(uint256.Int) } +func (m *mockOpContext) CallInput() []byte { return nil } +func (m *mockOpContext) ContractCode() []byte { return nil } + +// testStructLog is used to parse JSON structLogs in tests. +type testStructLog struct { + Pc uint64 `json:"pc"` + Op string `json:"op"` + Gas uint64 `json:"gas"` + GasCost uint64 `json:"gasCost"` + Depth int `json:"depth"` +} + func TestStoreCapture(t *testing.T) { var ( logger = NewStructLogger(nil) @@ -66,6 +90,669 @@ func TestStoreCapture(t *testing.T) { } } +// TestEnableActualGasCost verifies that when EnableActualGasCost is true, gasCost is +// computed as the difference between consecutive gas values rather than the +// pre-calculated opcode cost. For opcodes that are the last in a call context +// (before returning to parent), the pre-calculated cost is kept since we cannot +// compute actual cost across call boundaries. +func TestEnableActualGasCost(t *testing.T) { + t.Run("same_depth", func(t *testing.T) { + // Create logger with EnableActualGasCost enabled + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + evm := vm.NewEVM(vm.BlockContext{}, &dummyStatedb{}, params.TestChainConfig, vm.Config{Tracer: logger.Hooks()}) + contract := vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), 100000, nil) + + // Simple bytecode: PUSH1 0x1, PUSH1 0x0, ADD + contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x0, byte(vm.ADD)} + + logger.OnTxStart(evm.GetVMContext(), nil, common.Address{}) + _, err := evm.Run(contract, []byte{}, false) + if err != nil { + t.Fatal(err) + } + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + // Verify we have logs + if len(execResult.StructLogs) < 2 { + t.Fatalf("expected at least 2 logs, got %d", len(execResult.StructLogs)) + } + + // Parse logs and verify gas costs are computed as diffs + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // For all but the last log, gasCost should equal gas[n] - gas[n+1] + for i := 0; i < len(logs)-1; i++ { + expectedCost := logs[i].Gas - logs[i+1].Gas + if logs[i].GasCost != expectedCost { + t.Errorf("log[%d] (%s): expected gasCost %d, got %d", + i, logs[i].Op, expectedCost, logs[i].GasCost) + } + } + }) + + t.Run("depth_change", func(t *testing.T) { + // Manually simulate OnOpcode calls with depth changes + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate: CALL at depth 1, child execution at depth 2, return to depth 1 + // depth=1, gas=100000: CALL opcode + // depth=2, gas=63000: child starts (PUSH) + // depth=2, gas=60000: child continues (STOP) + // depth=1, gas=98000: back to parent (PUSH) + logger.OnOpcode(0, byte(vm.CALL), 100000, 100, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 63000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.STOP), 60000, 0, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.PUSH1), 98000, 3, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + if len(execResult.StructLogs) != 4 { + t.Fatalf("expected 4 logs, got %d", len(execResult.StructLogs)) + } + + // Parse all logs - order must match execution order + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Verify execution order is preserved: CALL, PUSH, STOP, PUSH + expectedOrder := []string{"CALL", "PUSH1", "STOP", "PUSH1"} + for i, expected := range expectedOrder { + if logs[i].Op != expected { + t.Errorf("log[%d]: expected op %s, got %s", i, expected, logs[i].Op) + } + } + + // CALL at depth 1: gasCost should be 100000 - 98000 = 2000 + // (includes all child execution gas) + if logs[0].GasCost != 2000 { + t.Errorf("CALL gasCost: expected 2000, got %d", logs[0].GasCost) + } + + // Find child opcodes at depth 2 and verify gas costs + var childPushFound bool + var childStopLog *testStructLog + for i, log := range logs { + if log.Depth == 2 && log.Op == "PUSH1" && log.GasCost == 3000 { + childPushFound = true + } + if log.Depth == 2 && log.Op == "STOP" { + childStopLog = &logs[i] + } + } + if !childPushFound { + t.Error("child PUSH with gasCost 3000 not found") + } + + // STOP is the last opcode in the child call - it should keep pre-calculated + // cost (0) since we cannot compute actual cost across call boundaries. + if childStopLog == nil { + t.Fatal("child STOP log not found") + } + if childStopLog.GasCost != 0 { + t.Errorf("child STOP gasCost: expected 0 (pre-calculated), got %d", childStopLog.GasCost) + } + }) + + t.Run("nested_calls", func(t *testing.T) { + // Test 3 levels deep: depth 1 -> depth 2 -> depth 3 -> back + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate nested calls: + // depth=1, gas=100000: CALL (outer) + // depth=2, gas=80000: PUSH in first child + // depth=2, gas=79000: CALL (inner call) + // depth=3, gas=50000: PUSH in innermost child + // depth=3, gas=49000: STOP in innermost child + // depth=2, gas=75000: back to first child + // depth=2, gas=74000: STOP in first child + // depth=1, gas=90000: back to outer + logger.OnOpcode(0, byte(vm.CALL), 100000, 100, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 80000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.CALL), 79000, 100, scope, nil, 2, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 50000, 3, scope, nil, 3, nil) + logger.OnOpcode(1, byte(vm.STOP), 49000, 0, scope, nil, 3, nil) + logger.OnOpcode(2, byte(vm.PUSH1), 75000, 3, scope, nil, 2, nil) + logger.OnOpcode(3, byte(vm.STOP), 74000, 0, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.PUSH1), 90000, 3, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + if len(execResult.StructLogs) != 8 { + t.Fatalf("expected 8 logs, got %d", len(execResult.StructLogs)) + } + + // Parse all logs + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find and verify outer CALL at depth 1 + // gasCost should be 100000 - 90000 = 10000 (includes all nested execution) + var outerCall *testStructLog + for i := range logs { + if logs[i].Op == "CALL" && logs[i].Depth == 1 { + outerCall = &logs[i] + break + } + } + if outerCall == nil { + t.Fatal("outer CALL log not found") + } + if outerCall.GasCost != 10000 { + t.Errorf("outer CALL gasCost: expected 10000, got %d", outerCall.GasCost) + } + + // Find and verify inner CALL at depth 2 + // gasCost should be 79000 - 75000 = 4000 (includes innermost execution) + var innerCall *testStructLog + for i := range logs { + if logs[i].Op == "CALL" && logs[i].Depth == 2 { + innerCall = &logs[i] + break + } + } + if innerCall == nil { + t.Fatal("inner CALL log not found") + } + if innerCall.GasCost != 4000 { + t.Errorf("inner CALL gasCost: expected 4000, got %d", innerCall.GasCost) + } + + // Verify innermost PUSH at depth 3 has correct gas diff + // gasCost should be 50000 - 49000 = 1000 + var innermostPush *testStructLog + for i := range logs { + if logs[i].Op == "PUSH1" && logs[i].Depth == 3 { + innermostPush = &logs[i] + break + } + } + if innermostPush == nil { + t.Fatal("innermost PUSH log not found") + } + if innermostPush.GasCost != 1000 { + t.Errorf("innermost PUSH gasCost: expected 1000, got %d", innermostPush.GasCost) + } + + // Verify STOP opcodes at depth 2 and 3 keep pre-calculated cost (0) + // since they are the last opcodes in their respective call contexts. + for _, log := range logs { + if log.Op == "STOP" && log.GasCost != 0 { + t.Errorf("STOP at depth %d: expected gasCost 0 (pre-calculated), got %d", + log.Depth, log.GasCost) + } + } + }) + + t.Run("disabled", func(t *testing.T) { + // Default behavior - EnableActualGasCost is false + logger := NewStructLogger(nil) + evm := vm.NewEVM(vm.BlockContext{}, &dummyStatedb{}, params.TestChainConfig, vm.Config{Tracer: logger.Hooks()}) + contract := vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), 100000, nil) + + contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x0, byte(vm.ADD)} + + logger.OnTxStart(evm.GetVMContext(), nil, common.Address{}) + _, err := evm.Run(contract, []byte{}, false) + if err != nil { + t.Fatal(err) + } + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + // Parse logs + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // With disabled flag, gasCost should be the pre-calculated cost, not the diff + // For PUSH1, the pre-calculated cost is 3 + for _, log := range logs { + if log.Op == "PUSH1" && log.GasCost != 3 { + t.Errorf("PUSH1 with disabled flag: expected pre-calculated gasCost 3, got %d", log.GasCost) + } + } + }) + + t.Run("empty_logs", func(t *testing.T) { + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + // No opcodes executed, just get result + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + if len(execResult.StructLogs) != 0 { + t.Errorf("expected 0 logs, got %d", len(execResult.StructLogs)) + } + }) + + t.Run("return_opcode", func(t *testing.T) { + // RETURN has dynamic gas cost based on memory expansion + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate: CALL at depth 1, child prepares return data, RETURN + // depth=1, gas=100000: CALL opcode + // depth=2, gas=63000: PUSH (prepare return offset) + // depth=2, gas=62000: PUSH (prepare return size) + // depth=2, gas=61000: RETURN with memory cost (dynamic gas) + // depth=1, gas=95000: back to parent + logger.OnOpcode(0, byte(vm.CALL), 100000, 100, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 63000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.PUSH1), 62000, 3, scope, nil, 2, nil) + logger.OnOpcode(2, byte(vm.RETURN), 61000, 10, scope, nil, 2, nil) // 10 = pre-calculated cost with mem + logger.OnOpcode(1, byte(vm.POP), 95000, 2, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + if len(execResult.StructLogs) != 5 { + t.Fatalf("expected 5 logs, got %d", len(execResult.StructLogs)) + } + + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find RETURN and verify it keeps pre-calculated cost (last opcode in child) + var returnLog *testStructLog + for i := range logs { + if logs[i].Op == "RETURN" { + returnLog = &logs[i] + break + } + } + if returnLog == nil { + t.Fatal("RETURN log not found") + } + // RETURN is last opcode in child, should keep pre-calculated cost (10) + if returnLog.GasCost != 10 { + t.Errorf("RETURN gasCost: expected 10 (pre-calculated), got %d", returnLog.GasCost) + } + + // Verify CALL has actual cost including child execution + var callLog *testStructLog + for i := range logs { + if logs[i].Op == "CALL" { + callLog = &logs[i] + break + } + } + if callLog == nil { + t.Fatal("CALL log not found") + } + // CALL: 100000 - 95000 = 5000 + if callLog.GasCost != 5000 { + t.Errorf("CALL gasCost: expected 5000, got %d", callLog.GasCost) + } + }) + + t.Run("revert_opcode", func(t *testing.T) { + // REVERT is similar to RETURN but indicates failure + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate: CALL at depth 1, child reverts + // depth=1, gas=100000: CALL opcode + // depth=2, gas=63000: PUSH (error offset) + // depth=2, gas=62000: PUSH (error size) + // depth=2, gas=61000: REVERT + // depth=1, gas=96000: back to parent (more gas refunded on revert) + logger.OnOpcode(0, byte(vm.CALL), 100000, 100, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 63000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.PUSH1), 62000, 3, scope, nil, 2, nil) + logger.OnOpcode(2, byte(vm.REVERT), 61000, 5, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.ISZERO), 96000, 3, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find REVERT and verify it keeps pre-calculated cost + var revertLog *testStructLog + for i := range logs { + if logs[i].Op == "REVERT" { + revertLog = &logs[i] + break + } + } + if revertLog == nil { + t.Fatal("REVERT log not found") + } + if revertLog.GasCost != 5 { + t.Errorf("REVERT gasCost: expected 5 (pre-calculated), got %d", revertLog.GasCost) + } + + // Verify CALL actual cost + var callLog *testStructLog + for i := range logs { + if logs[i].Op == "CALL" { + callLog = &logs[i] + break + } + } + if callLog == nil { + t.Fatal("CALL log not found") + } + // CALL: 100000 - 96000 = 4000 + if callLog.GasCost != 4000 { + t.Errorf("CALL gasCost: expected 4000, got %d", callLog.GasCost) + } + }) + + t.Run("create_opcode", func(t *testing.T) { + // CREATE starts a new contract creation context + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate: CREATE at depth 1, constructor runs at depth 2, returns + // depth=1, gas=100000: CREATE opcode + // depth=2, gas=70000: constructor PUSH + // depth=2, gas=69000: constructor RETURN (returns deployed code) + // depth=1, gas=80000: back to parent + logger.OnOpcode(0, byte(vm.CREATE), 100000, 32000, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 70000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.RETURN), 69000, 0, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.POP), 80000, 2, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find CREATE and verify actual cost + var createLog *testStructLog + for i := range logs { + if logs[i].Op == "CREATE" { + createLog = &logs[i] + break + } + } + if createLog == nil { + t.Fatal("CREATE log not found") + } + // CREATE: 100000 - 80000 = 20000 + if createLog.GasCost != 20000 { + t.Errorf("CREATE gasCost: expected 20000, got %d", createLog.GasCost) + } + + // Verify RETURN in constructor keeps pre-calculated cost + var returnLog *testStructLog + for i := range logs { + if logs[i].Op == "RETURN" { + returnLog = &logs[i] + break + } + } + if returnLog == nil { + t.Fatal("RETURN log not found") + } + if returnLog.GasCost != 0 { + t.Errorf("constructor RETURN gasCost: expected 0 (pre-calculated), got %d", returnLog.GasCost) + } + }) + + t.Run("create2_opcode", func(t *testing.T) { + // CREATE2 is similar to CREATE but with deterministic address + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // Simulate: CREATE2 at depth 1, constructor at depth 2 + logger.OnOpcode(0, byte(vm.CREATE2), 100000, 32000, scope, nil, 1, nil) + logger.OnOpcode(0, byte(vm.PUSH1), 65000, 3, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.STOP), 64000, 0, scope, nil, 2, nil) + logger.OnOpcode(1, byte(vm.DUP1), 75000, 3, scope, nil, 1, nil) + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find CREATE2 and verify actual cost + var create2Log *testStructLog + for i := range logs { + if logs[i].Op == "CREATE2" { + create2Log = &logs[i] + break + } + } + if create2Log == nil { + t.Fatal("CREATE2 log not found") + } + // CREATE2: 100000 - 75000 = 25000 + if create2Log.GasCost != 25000 { + t.Errorf("CREATE2 gasCost: expected 25000, got %d", create2Log.GasCost) + } + }) + + t.Run("multiple_siblings", func(t *testing.T) { + // Test: depth 1 -> depth 2 (call A) -> depth 1 -> depth 2 (call B) -> depth 1 + // This tests that pendingLogs is properly cleared between sibling calls + logger := NewStructLogger(&Config{EnableActualGasCost: true}) + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + + scope := &mockOpContext{} + + // First call to depth 2 + logger.OnOpcode(0, byte(vm.CALL), 100000, 100, scope, nil, 1, nil) // CALL A + logger.OnOpcode(0, byte(vm.PUSH1), 60000, 3, scope, nil, 2, nil) // in child A + logger.OnOpcode(1, byte(vm.STOP), 59000, 0, scope, nil, 2, nil) // child A ends + logger.OnOpcode(1, byte(vm.PUSH1), 90000, 3, scope, nil, 1, nil) // back to parent + + // Second call to depth 2 (sibling) + logger.OnOpcode(2, byte(vm.CALL), 89000, 100, scope, nil, 1, nil) // CALL B + logger.OnOpcode(0, byte(vm.PUSH1), 50000, 3, scope, nil, 2, nil) // in child B + logger.OnOpcode(1, byte(vm.STOP), 49000, 0, scope, nil, 2, nil) // child B ends + logger.OnOpcode(1, byte(vm.PUSH1), 80000, 3, scope, nil, 1, nil) // back to parent + + result, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + + var execResult ExecutionResult + if err := json.Unmarshal(result, &execResult); err != nil { + t.Fatal(err) + } + + if len(execResult.StructLogs) != 8 { + t.Fatalf("expected 8 logs, got %d", len(execResult.StructLogs)) + } + + var logs []testStructLog + for _, raw := range execResult.StructLogs { + var log testStructLog + if err := json.Unmarshal(raw, &log); err != nil { + t.Fatal(err) + } + logs = append(logs, log) + } + + // Find both CALLs and verify their costs + var calls []*testStructLog + for i := range logs { + if logs[i].Op == "CALL" { + calls = append(calls, &logs[i]) + } + } + if len(calls) != 2 { + t.Fatalf("expected 2 CALL logs, got %d", len(calls)) + } + + // First CALL: 100000 - 90000 = 10000 + if calls[0].GasCost != 10000 { + t.Errorf("first CALL gasCost: expected 10000, got %d", calls[0].GasCost) + } + // Second CALL: 89000 - 80000 = 9000 + if calls[1].GasCost != 9000 { + t.Errorf("second CALL gasCost: expected 9000, got %d", calls[1].GasCost) + } + + // All STOPs should keep pre-calculated cost (0) + for _, log := range logs { + if log.Op == "STOP" && log.GasCost != 0 { + t.Errorf("STOP at depth %d: expected gasCost 0, got %d", log.Depth, log.GasCost) + } + } + }) + + t.Run("streaming_mode_ignores_flag", func(t *testing.T) { + // Verify that EnableActualGasCost is ignored in streaming mode + var buf bytes.Buffer + logger := NewStreamingStructLogger(&Config{EnableActualGasCost: true}, &buf) + + // pendingGasIdx should be nil since feature is disabled in streaming mode + if logger.pendingGasIdx != nil { + t.Error("pendingGasIdx should be nil in streaming mode") + } + + // Verify streaming still works + logger.env = &tracing.VMContext{StateDB: &dummyStatedb{}} + scope := &mockOpContext{} + + logger.OnOpcode(0, byte(vm.PUSH1), 100000, 3, scope, nil, 1, nil) + logger.OnOpcode(1, byte(vm.STOP), 99997, 0, scope, nil, 1, nil) + + // Output should have been written to buffer + if buf.Len() == 0 { + t.Error("streaming logger should have written output") + } + }) +} + // Tests that blank fields don't appear in logs when JSON marshalled, to reduce // logs bloat and confusion. See https://github.com/ethereum/go-ethereum/issues/24487 func TestStructLogMarshalingOmitEmpty(t *testing.T) {