diff --git a/eth/tracers/api_test.go b/eth/tracers/api_test.go index 1d5024ad08..3a2308f1c9 100644 --- a/eth/tracers/api_test.go +++ b/eth/tracers/api_test.go @@ -202,6 +202,18 @@ type stateTracer struct { Storage map[common.Address]map[common.Hash]common.Hash } +type tracedOpcodeLog struct { + Op string `json:"op"` + Refund *uint64 `json:"refund,omitempty"` + Storage map[string]string `json:"storage,omitempty"` +} + +type tracedOpcodeResult struct { + Failed bool `json:"failed"` + ReturnValue string `json:"returnValue"` + StructLogs []tracedOpcodeLog `json:"structLogs"` +} + func newStateTracer(ctx *Context, cfg json.RawMessage, chainCfg *params.ChainConfig) (*Tracer, error) { t := &stateTracer{ Balance: make(map[common.Address]*hexutil.Big), @@ -1058,6 +1070,176 @@ func TestTracingWithOverrides(t *testing.T) { } } +func TestTraceTransactionRefundAndStorageSnapshots(t *testing.T) { + t.Parallel() + + accounts := newAccounts(1) + contract := common.HexToAddress("0x00000000000000000000000000000000deadbeef") + slot0 := common.BigToHash(big.NewInt(0)) + txSigner := types.HomesteadSigner{} + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + contract: { + Nonce: 1, + Code: []byte{ + byte(vm.PUSH1), 0x00, + byte(vm.SLOAD), + byte(vm.POP), + byte(vm.PUSH1), 0x00, + byte(vm.PUSH1), 0x00, + byte(vm.SSTORE), + byte(vm.STOP), + }, + Storage: map[common.Hash]common.Hash{ + slot0: common.BigToHash(big.NewInt(1)), + }, + }, + }, + } + var target common.Hash + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: 0, + To: &contract, + Value: big.NewInt(0), + Gas: 100000, + GasPrice: b.BaseFee(), + }), txSigner, accounts[0].key) + b.AddTx(tx) + target = tx.Hash() + }) + defer backend.teardown() + + api := NewAPI(backend) + result, err := api.TraceTransaction(context.Background(), target, nil) + if err != nil { + t.Fatalf("failed to trace refunding transaction: %v", err) + } + var traced tracedOpcodeResult + if err := json.Unmarshal(result.(json.RawMessage), &traced); err != nil { + t.Fatalf("failed to unmarshal trace result: %v", err) + } + if traced.Failed { + t.Fatal("expected refunding transaction to succeed") + } + if traced.ReturnValue != "0x" { + t.Fatalf("unexpected return value: have %s want 0x", traced.ReturnValue) + } + slotHex := slot0.Hex() + oneHex := common.BigToHash(big.NewInt(1)).Hex() + zeroHex := common.Hash{}.Hex() + var ( + foundSloadSnapshot bool + foundSstoreSnapshot bool + foundRefund bool + ) + for _, log := range traced.StructLogs { + switch log.Op { + case "SLOAD": + if got := log.Storage[slotHex]; got == oneHex { + foundSloadSnapshot = true + } + case "SSTORE": + if got := log.Storage[slotHex]; got == zeroHex { + foundSstoreSnapshot = true + } + } + if log.Refund != nil && *log.Refund > 0 { + foundRefund = true + } + } + if !foundSloadSnapshot { + t.Fatal("expected SLOAD snapshot to include the pre-existing non-zero storage value") + } + if !foundSstoreSnapshot { + t.Fatal("expected SSTORE snapshot to include the post-write zeroed storage value") + } + if !foundRefund { + t.Fatal("expected at least one structLog entry with a non-zero refund field") + } +} + +func TestTraceTransactionFailureReturnValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code []byte + wantReturnValue string + }{ + { + name: "revert preserves return data", + code: []byte{ + byte(vm.PUSH1), 0x2a, + byte(vm.PUSH1), 0x00, + byte(vm.MSTORE), + byte(vm.PUSH1), 0x20, + byte(vm.PUSH1), 0x00, + byte(vm.REVERT), + }, + wantReturnValue: "0x000000000000000000000000000000000000000000000000000000000000002a", + }, + { + name: "hard failure clears return data", + code: []byte{ + byte(vm.INVALID), + }, + wantReturnValue: "0x", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + accounts := newAccounts(1) + contract := common.HexToAddress("0x00000000000000000000000000000000deadbeef") + txSigner := types.HomesteadSigner{} + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + contract: { + Nonce: 1, + Code: tc.code, + }, + }, + } + var target common.Hash + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: 0, + To: &contract, + Value: big.NewInt(0), + Gas: 100000, + GasPrice: b.BaseFee(), + }), txSigner, accounts[0].key) + b.AddTx(tx) + target = tx.Hash() + }) + defer backend.teardown() + + api := NewAPI(backend) + result, err := api.TraceTransaction(context.Background(), target, nil) + if err != nil { + t.Fatalf("failed to trace transaction: %v", err) + } + var traced tracedOpcodeResult + if err := json.Unmarshal(result.(json.RawMessage), &traced); err != nil { + t.Fatalf("failed to unmarshal trace result: %v", err) + } + if !traced.Failed { + t.Fatal("expected traced transaction to fail") + } + if traced.ReturnValue != tc.wantReturnValue { + t.Fatalf("unexpected returnValue: have %s want %s", traced.ReturnValue, tc.wantReturnValue) + } + if len(traced.StructLogs) == 0 { + t.Fatal("expected failing trace to still include structLogs") + } + }) + } +} + type Account struct { key *ecdsa.PrivateKey addr common.Address diff --git a/eth/tracers/logger/logger.go b/eth/tracers/logger/logger.go index 67e07f78d0..3294baaaac 100644 --- a/eth/tracers/logger/logger.go +++ b/eth/tracers/logger/logger.go @@ -148,7 +148,7 @@ type structLogLegacy struct { Gas uint64 `json:"gas"` GasCost uint64 `json:"gasCost"` Depth int `json:"depth"` - Error string `json:"error,omitempty"` + Error *string `json:"error,omitempty"` Stack *[]string `json:"stack,omitempty"` ReturnData string `json:"returnData,omitempty"` Memory *[]string `json:"memory,omitempty"` @@ -156,6 +156,12 @@ type structLogLegacy struct { RefundCounter uint64 `json:"refund,omitempty"` } +func formatMemoryWord(chunk []byte) string { + var word [32]byte + copy(word[:], chunk) + return hexutil.Encode(word[:]) +} + // toLegacyJSON converts the structLog to legacy json-encoded legacy form. func (s *StructLog) toLegacyJSON() json.RawMessage { msg := structLogLegacy{ @@ -164,9 +170,11 @@ func (s *StructLog) toLegacyJSON() json.RawMessage { Gas: s.Gas, GasCost: s.GasCost, Depth: s.Depth, - Error: s.ErrorString(), RefundCounter: s.RefundCounter, } + if err := s.ErrorString(); err != "" { + msg.Error = &err + } if s.Stack != nil { stack := make([]string, len(s.Stack)) for i, stackValue := range s.Stack { @@ -175,7 +183,7 @@ func (s *StructLog) toLegacyJSON() json.RawMessage { msg.Stack = &stack } if len(s.ReturnData) > 0 { - msg.ReturnData = hexutil.Bytes(s.ReturnData).String() + msg.ReturnData = hexutil.Encode(s.ReturnData) } if len(s.Memory) > 0 { memory := make([]string, 0, (len(s.Memory)+31)/32) @@ -184,14 +192,14 @@ func (s *StructLog) toLegacyJSON() json.RawMessage { if end > len(s.Memory) { end = len(s.Memory) } - memory = append(memory, fmt.Sprintf("%x", s.Memory[i:end])) + memory = append(memory, formatMemoryWord(s.Memory[i:end])) } msg.Memory = &memory } if len(s.Storage) > 0 { storage := make(map[string]string) for i, storageValue := range s.Storage { - storage[fmt.Sprintf("%x", i)] = fmt.Sprintf("%x", storageValue) + storage[i.Hex()] = storageValue.Hex() } msg.Storage = &storage } diff --git a/eth/tracers/logger/logger_test.go b/eth/tracers/logger/logger_test.go index acc3069e70..554a37aff1 100644 --- a/eth/tracers/logger/logger_test.go +++ b/eth/tracers/logger/logger_test.go @@ -96,3 +96,46 @@ func TestStructLogMarshalingOmitEmpty(t *testing.T) { }) } } + +func TestStructLogLegacyJSONSpecFormatting(t *testing.T) { + tests := []struct { + name string + log *StructLog + want string + }{ + { + name: "omits empty error and pads memory/storage", + log: &StructLog{ + Pc: 7, + Op: vm.SSTORE, + Gas: 100, + GasCost: 20, + Memory: []byte{0xaa, 0xbb}, + Storage: map[common.Hash]common.Hash{common.BigToHash(big.NewInt(1)): common.BigToHash(big.NewInt(2))}, + Depth: 1, + ReturnData: []byte{0x12, 0x34}, + }, + want: `{"pc":7,"op":"SSTORE","gas":100,"gasCost":20,"depth":1,"returnData":"0x1234","memory":["0xaabb000000000000000000000000000000000000000000000000000000000000"],"storage":{"0x0000000000000000000000000000000000000000000000000000000000000001":"0x0000000000000000000000000000000000000000000000000000000000000002"}}`, + }, + { + name: "includes error only when present", + log: &StructLog{ + Pc: 1, + Op: vm.STOP, + Gas: 2, + GasCost: 3, + Depth: 1, + Err: errors.New("boom"), + }, + want: `{"pc":1,"op":"STOP","gas":2,"gasCost":3,"depth":1,"error":"boom"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + have := string(tt.log.toLegacyJSON()) + if have != tt.want { + t.Fatalf("mismatched results\n\thave: %v\n\twant: %v", have, tt.want) + } + }) + } +}