core/state, eth/tracers: add access list to struct logger

Add AccessListMode option to the struct logger configuration.
When set to "full", each StructLog entry includes a snapshot of
the current access list at that execution step.

Ref #25278
This commit is contained in:
agwab 2026-04-04 17:30:45 +09:00
parent 00da4f51ff
commit 3d8e0076d4
8 changed files with 184 additions and 6 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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.

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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