diff --git a/core/state/access_list.go b/core/state/access_list.go index 0b830e7222..5020dd7618 100644 --- a/core/state/access_list.go +++ b/core/state/access_list.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" ) type accessList struct { @@ -143,6 +144,26 @@ func (al *accessList) Equal(other *accessList) bool { return slices.EqualFunc(al.slots, other.slots, maps.Equal) } +// export converts the internal access list into a types.AccessList. +// The result is sorted by address and storage keys for deterministic output. +func (al *accessList) export() types.AccessList { + result := make(types.AccessList, 0, len(al.addresses)) + sortedAddrs := slices.Collect(maps.Keys(al.addresses)) + slices.SortFunc(sortedAddrs, common.Address.Cmp) + for _, addr := range sortedAddrs { + idx := al.addresses[addr] + tuple := types.AccessTuple{Address: addr, StorageKeys: []common.Hash{}} + if idx >= 0 { + keys := slices.SortedFunc(maps.Keys(al.slots[idx]), common.Hash.Cmp) + if keys != nil { + tuple.StorageKeys = keys + } + } + result = append(result, tuple) + } + return result +} + // PrettyPrint prints the contents of the access list in a human-readable form func (al *accessList) PrettyPrint() string { out := new(strings.Builder) diff --git a/core/state/statedb.go b/core/state/statedb.go index 854aaf6109..342a7f2cac 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -1437,6 +1437,11 @@ func (s *StateDB) SlotInAccessList(addr common.Address, slot common.Hash) (addre return s.accessList.Contains(addr, slot) } +// AccessList returns the current access list as a types.AccessList snapshot. +func (s *StateDB) AccessList() types.AccessList { + return s.accessList.export() +} + // markDelete is invoked when an account is deleted but the deletion is // not yet committed. The pending mutation is cached and will be applied // all together diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 8c217fba48..6b6bbebe55 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -90,6 +90,10 @@ func (s *hookedStateDB) GetRefund() uint64 { return s.inner.GetRefund() } +func (s *hookedStateDB) AccessList() types.AccessList { + return s.inner.AccessList() +} + func (s *hookedStateDB) GetStateAndCommittedState(addr common.Address, hash common.Hash) (common.Hash, common.Hash) { return s.inner.GetStateAndCommittedState(addr, hash) } diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go index de63689bc5..1c6d864bd4 100644 --- a/core/tracing/hooks.go +++ b/core/tracing/hooks.go @@ -56,6 +56,7 @@ type StateDB interface { GetTransientState(common.Address, common.Hash) common.Hash Exist(common.Address) bool GetRefund() uint64 + AccessList() types.AccessList } // VMContext provides the context for the EVM execution. diff --git a/core/vm/interface.go b/core/vm/interface.go index 6a93846ac5..300fbdffba 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -80,6 +80,8 @@ type StateDB interface { // AddSlotToAccessList adds the given (address,slot) to the access list. This operation is safe to perform // even if the feature/fork is not active yet AddSlotToAccessList(addr common.Address, slot common.Hash) + // AccessList returns the current access list as a snapshot. + AccessList() types.AccessList Prepare(rules params.Rules, sender, coinbase common.Address, dest *common.Address, precompiles []common.Address, txAccesses types.AccessList) diff --git a/eth/tracers/logger/gen_structlog.go b/eth/tracers/logger/gen_structlog.go index b406cb3445..8efffe188c 100644 --- a/eth/tracers/logger/gen_structlog.go +++ b/eth/tracers/logger/gen_structlog.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" "github.com/holiman/uint256" ) @@ -26,6 +27,7 @@ func (s StructLog) MarshalJSON() ([]byte, error) { Stack []hexutil.U256 `json:"stack"` ReturnData hexutil.Bytes `json:"returnData,omitempty"` Storage map[common.Hash]common.Hash `json:"-"` + AccessList types.AccessList `json:"accessList,omitempty"` Depth int `json:"depth"` RefundCounter uint64 `json:"refund"` Err error `json:"-"` @@ -47,6 +49,7 @@ func (s StructLog) MarshalJSON() ([]byte, error) { } enc.ReturnData = s.ReturnData enc.Storage = s.Storage + enc.AccessList = s.AccessList enc.Depth = s.Depth enc.RefundCounter = s.RefundCounter enc.Err = s.Err @@ -67,6 +70,7 @@ func (s *StructLog) UnmarshalJSON(input []byte) error { Stack []hexutil.U256 `json:"stack"` ReturnData *hexutil.Bytes `json:"returnData,omitempty"` Storage map[common.Hash]common.Hash `json:"-"` + AccessList *types.AccessList `json:"accessList,omitempty"` Depth *int `json:"depth"` RefundCounter *uint64 `json:"refund"` Err error `json:"-"` @@ -105,6 +109,9 @@ func (s *StructLog) UnmarshalJSON(input []byte) error { if dec.Storage != nil { s.Storage = dec.Storage } + if dec.AccessList != nil { + s.AccessList = *dec.AccessList + } if dec.Depth != nil { s.Depth = *dec.Depth } diff --git a/eth/tracers/logger/logger.go b/eth/tracers/logger/logger.go index 7f2b2aecf2..87aaa9f66e 100644 --- a/eth/tracers/logger/logger.go +++ b/eth/tracers/logger/logger.go @@ -40,13 +40,39 @@ import ( // Storage represents a contract's storage. type Storage map[common.Hash]common.Hash +// AccessListMode represents the capture mode for access lists in tracing. +type AccessListMode string + +const ( + AccessListModeDisabled AccessListMode = "" // default, no access list capture + AccessListModeFull AccessListMode = "full" // capture full access list at each step +) + +// UnmarshalJSON parses the access list mode from JSON input. +func (m *AccessListMode) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch s { + case "", "disabled": + *m = AccessListModeDisabled + case "full": + *m = AccessListModeFull + default: + return fmt.Errorf("unknown access list mode %q, want \"disabled\" or \"full\"", s) + } + return nil +} + // 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 + AccessListMode AccessListMode // access list capture mode (default: disabled) + 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"` } @@ -65,6 +91,7 @@ type StructLog struct { Stack []uint256.Int `json:"stack"` ReturnData []byte `json:"returnData,omitempty"` Storage map[common.Hash]common.Hash `json:"-"` + AccessList types.AccessList `json:"accessList,omitempty"` Depth int `json:"depth"` RefundCounter uint64 `json:"refund"` Err error `json:"-"` @@ -153,6 +180,7 @@ type structLogLegacy struct { ReturnData string `json:"returnData,omitempty"` Memory *[]string `json:"memory,omitempty"` Storage *map[string]string `json:"storage,omitempty"` + AccessList types.AccessList `json:"accessList,omitempty"` RefundCounter uint64 `json:"refund,omitempty"` } @@ -204,6 +232,9 @@ func (s *StructLog) toLegacyJSON() json.RawMessage { } msg.Storage = &storage } + if len(s.AccessList) > 0 { + msg.AccessList = s.AccessList + } element, _ := json.Marshal(msg) return element } @@ -287,7 +318,16 @@ 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} + log := StructLog{ + Pc: pc, + Op: op, + Gas: gas, + GasCost: cost, + MemorySize: len(memory), + Depth: depth, + RefundCounter: l.env.StateDB.GetRefund(), + Err: err, + } if l.cfg.EnableMemory { log.Memory = memory } @@ -297,6 +337,11 @@ func (l *StructLogger) OnOpcode(pc uint64, opcode byte, gas, cost uint64, scope if l.cfg.EnableReturnData { log.ReturnData = rData } + if l.cfg.AccessListMode == AccessListModeFull { + // TODO: export() sorts and allocates on every opcode step, even when the + // access list hasn't changed. This may cause GC pressure on long traces. + log.AccessList = l.env.StateDB.AccessList() + } // Copy a snapshot of the current storage to a new container var storage Storage diff --git a/eth/tracers/logger/logger_test.go b/eth/tracers/logger/logger_test.go index 554a37aff1..69cf8432fe 100644 --- a/eth/tracers/logger/logger_test.go +++ b/eth/tracers/logger/logger_test.go @@ -24,6 +24,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" @@ -43,6 +44,15 @@ func (*dummyStatedb) GetStateAndCommittedState(common.Address, common.Hash) (com return common.Hash{}, common.Hash{} } +func (*dummyStatedb) AccessList() types.AccessList { + return types.AccessList{ + { + Address: common.HexToAddress("0xaaaa"), + StorageKeys: []common.Hash{common.HexToHash("0x01")}, + }, + } +} + func TestStoreCapture(t *testing.T) { var ( logger = NewStructLogger(nil) @@ -97,6 +107,89 @@ func TestStructLogMarshalingOmitEmpty(t *testing.T) { } } +func TestAccessListCapture(t *testing.T) { + type resultLog struct { + AccessList types.AccessList `json:"accessList,omitempty"` + } + type execResult struct { + StructLogs []resultLog `json:"structLogs"` + } + + tests := []struct { + name string + mode AccessListMode + wantCapture bool + }{ + {"disabled by default", AccessListModeDisabled, false}, + {"full mode", AccessListModeFull, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := NewStructLogger(&Config{AccessListMode: tt.mode}) + 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), 0x0, byte(vm.SLOAD)} + + logger.OnTxStart(evm.GetVMContext(), nil, common.Address{}) + _, err := evm.Run(contract, []byte{}, false) + if err != nil { + t.Fatal(err) + } + blob, err := logger.GetResult() + if err != nil { + t.Fatal(err) + } + var result execResult + if err := json.Unmarshal(blob, &result); err != nil { + t.Fatal(err) + } + if len(result.StructLogs) == 0 { + t.Fatal("expected at least one struct log") + } + for _, log := range result.StructLogs { + if tt.wantCapture && log.AccessList == nil { + t.Fatal("expected access list to be captured, got nil") + } + if !tt.wantCapture && log.AccessList != nil { + t.Fatal("expected no access list capture, got non-nil") + } + } + }) + } +} + +func TestAccessListModeUnmarshalJSON(t *testing.T) { + tests := []struct { + input string + want AccessListMode + wantErr bool + }{ + {`""`, AccessListModeDisabled, false}, + {`"disabled"`, AccessListModeDisabled, false}, + {`"full"`, AccessListModeFull, false}, + {`"invalid"`, "", true}, + {`"Full"`, "", true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var mode AccessListMode + err := json.Unmarshal([]byte(tt.input), &mode) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mode != tt.want { + t.Fatalf("got %q, want %q", mode, tt.want) + } + }) + } +} + func TestStructLogLegacyJSONSpecFormatting(t *testing.T) { tests := []struct { name string