From f0ae9c50eb3efb5d33b65dfd3ff1b8e47f0ccfc5 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg <519948+ARR4N@users.noreply.github.com> Date: Wed, 2 Oct 2024 16:00:13 +0100 Subject: [PATCH] feat: `types.StateAccount` pseudo-generic payload (#44) Some of the changes in the full commit history were merged into `libevm` as part of #43 in `336a289` and then merged back into this branch as `5b15698`. Cherry-picking commits was not possible as some touched both halves of the changes; the squash-merges will, however, make this convoluted history irrelevant. * feat: `types.StateAccount` pseudo-generic payload * feat: registration of `StateAccount` payload type * chore: mark `eth/tracers/logger` flaky * chore: copyright header + `gci` * test: lock default `types.SlimAccount` RLP encoding * feat: `vm.SlimAccount.Extra` from `StateAccount` equiv * chore: placate the linter * test: `pseudo.Type.EncodeRLP()` * test: `pseudo.Type.DecodeRLP()` * fix: `pseudo.Type.DecodeRLP()` with non-pointer type * feat: `pseudo.Type.IsZero()` and `Type.Equal(*Type)` * feat: `types.StateAccountExtra.DecodeRLP()` * fix: remove unnecessary `StateAccountExtra.clone()` * refactor: readability * feat: `pseudo.Type.Format()` implements `fmt.Formatter` --- core/state/statedb.go | 1 + core/types/gen_account_rlp.go | 3 + core/types/gen_slim_account_rlp.libevm.go | 24 +++ core/types/rlp_payload.libevm.go | 175 +++++++++++++++++ core/types/state_account.go | 10 +- core/types/state_account.libevm_test.go | 225 ++++++++++++++++++++++ libevm/pseudo/fmt.go | 56 ++++++ libevm/pseudo/fmt_test.go | 82 ++++++++ libevm/pseudo/type.go | 1 + 9 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 core/types/gen_slim_account_rlp.libevm.go create mode 100644 core/types/rlp_payload.libevm.go create mode 100644 core/types/state_account.libevm_test.go create mode 100644 libevm/pseudo/fmt.go create mode 100644 libevm/pseudo/fmt_test.go diff --git a/core/state/statedb.go b/core/state/statedb.go index a4b8cf93e2d2..8092155ce595 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -579,6 +579,7 @@ func (s *StateDB) getDeletedStateObject(addr common.Address) *stateObject { Balance: acc.Balance, CodeHash: acc.CodeHash, Root: common.BytesToHash(acc.Root), + Extra: acc.Extra, // no need to deep-copy as `acc` is short-lived } if len(data.CodeHash) == 0 { data.CodeHash = types.EmptyCodeHash.Bytes() diff --git a/core/types/gen_account_rlp.go b/core/types/gen_account_rlp.go index 8b424493afb8..9205318512eb 100644 --- a/core/types/gen_account_rlp.go +++ b/core/types/gen_account_rlp.go @@ -16,6 +16,9 @@ func (obj *StateAccount) EncodeRLP(_w io.Writer) error { } w.WriteBytes(obj.Root[:]) w.WriteBytes(obj.CodeHash) + if err := obj.Extra.EncodeRLP(w); err != nil { + return err + } w.ListEnd(_tmp0) return w.Flush() } diff --git a/core/types/gen_slim_account_rlp.libevm.go b/core/types/gen_slim_account_rlp.libevm.go new file mode 100644 index 000000000000..e5d76069a750 --- /dev/null +++ b/core/types/gen_slim_account_rlp.libevm.go @@ -0,0 +1,24 @@ +// Code generated by rlpgen. DO NOT EDIT. + +package types + +import "github.com/ethereum/go-ethereum/rlp" +import "io" + +func (obj *SlimAccount) EncodeRLP(_w io.Writer) error { + w := rlp.NewEncoderBuffer(_w) + _tmp0 := w.List() + w.WriteUint64(obj.Nonce) + if obj.Balance == nil { + w.Write(rlp.EmptyString) + } else { + w.WriteUint256(obj.Balance) + } + w.WriteBytes(obj.Root) + w.WriteBytes(obj.CodeHash) + if err := obj.Extra.EncodeRLP(w); err != nil { + return err + } + w.ListEnd(_tmp0) + return w.Flush() +} diff --git a/core/types/rlp_payload.libevm.go b/core/types/rlp_payload.libevm.go new file mode 100644 index 000000000000..04b10d295cfd --- /dev/null +++ b/core/types/rlp_payload.libevm.go @@ -0,0 +1,175 @@ +// Copyright 2024 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package types + +import ( + "fmt" + "io" + + "github.com/ethereum/go-ethereum/libevm/pseudo" + "github.com/ethereum/go-ethereum/rlp" +) + +// RegisterExtras registers the type `SA` to be carried as an extra payload in +// [StateAccount] structs. It is expected to be called in an `init()` function +// and MUST NOT be called more than once. +// +// The payload will be treated as an extra struct field for the purposes of RLP +// encoding and decoding. RLP handling is plumbed through to the `SA` via the +// [StateAccountExtra] that holds it such that it acts as if there were a field +// of type `SA` in all StateAccount structs. +// +// The payload can be acced via the [ExtraPayloads.FromStateAccount] method of +// the accessor returned by RegisterExtras. +func RegisterExtras[SA any]() ExtraPayloads[SA] { + if registeredExtras != nil { + panic("re-registration of Extras") + } + var extra ExtraPayloads[SA] + registeredExtras = &extraConstructors{ + stateAccountType: func() string { + var x SA + return fmt.Sprintf("%T", x) + }(), + newStateAccount: pseudo.NewConstructor[SA]().Zero, + cloneStateAccount: extra.cloneStateAccount, + } + return extra +} + +var registeredExtras *extraConstructors + +type extraConstructors struct { + stateAccountType string + newStateAccount func() *pseudo.Type + cloneStateAccount func(*StateAccountExtra) *StateAccountExtra +} + +func (e *StateAccountExtra) clone() *StateAccountExtra { + switch r := registeredExtras; { + case r == nil, e == nil: + return nil + default: + return r.cloneStateAccount(e) + } +} + +// ExtraPayloads provides strongly typed access to the extra payload carried by +// [StateAccount] structs. The only valid way to construct an instance is by a +// call to [RegisterExtras]. +type ExtraPayloads[SA any] struct { + _ struct{} // make godoc show unexported fields so nobody tries to make their own instance ;) +} + +func (ExtraPayloads[SA]) cloneStateAccount(s *StateAccountExtra) *StateAccountExtra { + v := pseudo.MustNewValue[SA](s.t) + return &StateAccountExtra{ + t: pseudo.From(v.Get()).Type, + } +} + +// FromStateAccount returns the StateAccount's payload. +func (ExtraPayloads[SA]) FromStateAccount(a *StateAccount) SA { + return pseudo.MustNewValue[SA](a.extra().payload()).Get() +} + +// PointerFromStateAccount returns a pointer to the StateAccounts's extra +// payload. This is guaranteed to be non-nil. +// +// Note that copying a StateAccount by dereferencing a pointer will result in a +// shallow copy and that the *SA returned here will therefore be shared by all +// copies. If this is not the desired behaviour, use +// [StateAccount.Copy] or [ExtraPayloads.SetOnStateAccount]. +func (ExtraPayloads[SA]) PointerFromStateAccount(a *StateAccount) *SA { + return pseudo.MustPointerTo[SA](a.extra().payload()).Value.Get() +} + +// SetOnStateAccount sets the StateAccount's payload. +func (ExtraPayloads[SA]) SetOnStateAccount(a *StateAccount, val SA) { + a.extra().t = pseudo.From(val).Type +} + +// A StateAccountExtra carries the extra payload, if any, registered with +// [RegisterExtras]. It SHOULD NOT be used directly; instead use the +// [ExtraPayloads] accessor returned by RegisterExtras. +type StateAccountExtra struct { + t *pseudo.Type +} + +func (a *StateAccount) extra() *StateAccountExtra { + if a.Extra == nil { + a.Extra = &StateAccountExtra{ + t: registeredExtras.newStateAccount(), + } + } + return a.Extra +} + +func (e *StateAccountExtra) payload() *pseudo.Type { + if e.t == nil { + e.t = registeredExtras.newStateAccount() + } + return e.t +} + +var _ interface { + rlp.Encoder + rlp.Decoder + fmt.Formatter +} = (*StateAccountExtra)(nil) + +// EncodeRLP implements the [rlp.Encoder] interface. +func (e *StateAccountExtra) EncodeRLP(w io.Writer) error { + switch r := registeredExtras; { + case r == nil: + return nil + case e == nil: + e = &StateAccountExtra{} + fallthrough + case e.t == nil: + e.t = r.newStateAccount() + } + return e.t.EncodeRLP(w) +} + +// DecodeRLP implements the [rlp.Decoder] interface. +func (e *StateAccountExtra) DecodeRLP(s *rlp.Stream) error { + switch r := registeredExtras; { + case r == nil: + return nil + case e.t == nil: + e.t = r.newStateAccount() + fallthrough + default: + return s.Decode(e.t) + } +} + +// Format implements the [fmt.Formatter] interface. +func (e *StateAccountExtra) Format(s fmt.State, verb rune) { + var out string + switch r := registeredExtras; { + case r == nil: + out = "" + case e == nil, e.t == nil: + out = fmt.Sprintf("[*StateAccountExtra[%s]]", r.stateAccountType) + default: + e.t.Format(s, verb) + return + } + _, _ = s.Write([]byte(out)) +} diff --git a/core/types/state_account.go b/core/types/state_account.go index 52ef843b3527..3de3d4f64ee0 100644 --- a/core/types/state_account.go +++ b/core/types/state_account.go @@ -25,6 +25,7 @@ import ( ) //go:generate go run ../../rlp/rlpgen -type StateAccount -out gen_account_rlp.go +//go:generate go run ../../rlp/rlpgen -type SlimAccount -out gen_slim_account_rlp.libevm.go // StateAccount is the Ethereum consensus representation of accounts. // These objects are stored in the main account trie. @@ -33,6 +34,8 @@ type StateAccount struct { Balance *uint256.Int Root common.Hash // merkle root of the storage trie CodeHash []byte + + Extra *StateAccountExtra } // NewEmptyStateAccount constructs an empty state account. @@ -55,6 +58,7 @@ func (acct *StateAccount) Copy() *StateAccount { Balance: balance, Root: acct.Root, CodeHash: common.CopyBytes(acct.CodeHash), + Extra: acct.Extra.clone(), } } @@ -66,6 +70,8 @@ type SlimAccount struct { Balance *uint256.Int Root []byte // Nil if root equals to types.EmptyRootHash CodeHash []byte // Nil if hash equals to types.EmptyCodeHash + + Extra *StateAccountExtra } // SlimAccountRLP encodes the state account in 'slim RLP' format. @@ -73,6 +79,7 @@ func SlimAccountRLP(account StateAccount) []byte { slim := SlimAccount{ Nonce: account.Nonce, Balance: account.Balance, + Extra: account.Extra, } if account.Root != EmptyRootHash { slim.Root = account.Root[:] @@ -80,7 +87,7 @@ func SlimAccountRLP(account StateAccount) []byte { if !bytes.Equal(account.CodeHash, EmptyCodeHash[:]) { slim.CodeHash = account.CodeHash } - data, err := rlp.EncodeToBytes(slim) + data, err := rlp.EncodeToBytes(&slim) if err != nil { panic(err) } @@ -96,6 +103,7 @@ func FullAccount(data []byte) (*StateAccount, error) { } var account StateAccount account.Nonce, account.Balance = slim.Nonce, slim.Balance + account.Extra = slim.Extra // Interpret the storage root and code hash in slim format. if len(slim.Root) == 0 { diff --git a/core/types/state_account.libevm_test.go b/core/types/state_account.libevm_test.go new file mode 100644 index 000000000000..fa5810ed84eb --- /dev/null +++ b/core/types/state_account.libevm_test.go @@ -0,0 +1,225 @@ +// Copyright 2024 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package types + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/libevm/pseudo" + "github.com/ethereum/go-ethereum/rlp" +) + +func (e *StateAccountExtra) Equal(f *StateAccountExtra) bool { + eNil := e == nil || e.t == nil + fNil := f == nil || f.t == nil + if eNil && fNil || eNil && f.t.IsZero() || fNil && e.t.IsZero() { + return true + } + return e.t.Equal(f.t) +} + +func TestStateAccountRLP(t *testing.T) { + // RLP encodings that don't involve extra payloads were generated on raw + // geth StateAccounts *before* any libevm modifications, thus locking in + // default behaviour. Encodings that involve a boolean payload were + // generated on ava-labs/coreth StateAccounts to guarantee equivalence. + + type test struct { + name string + register func() + acc *StateAccount + wantHex string + } + + explicitFalseBoolean := test{ + name: "explicit false-boolean extra", + register: func() { + RegisterExtras[bool]() + }, + acc: &StateAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x666666), + Root: common.Hash{}, + CodeHash: []byte{0xbb, 0xbb, 0xbb}, + Extra: &StateAccountExtra{ + t: pseudo.From(false).Type, + }, + }, + wantHex: `0xee8344444483666666a0000000000000000000000000000000000000000000000000000000000000000083bbbbbb80`, + } + + // The vanilla geth code won't set payloads so we need to ensure that the + // zero-value encoding is used instead of the null-value default as when + // no type is registered. + implicitFalseBoolean := explicitFalseBoolean + implicitFalseBoolean.name = "implicit false-boolean extra as zero-value of registered type" + // Clearing the Extra makes the `false` value implicit and due only to the + // fact that we register `bool`. Most importantly, note that `wantHex` + // remains identical. + implicitFalseBoolean.acc.Extra = nil + + tests := []test{ + explicitFalseBoolean, + implicitFalseBoolean, + { + name: "true-boolean extra", + register: func() { + RegisterExtras[bool]() + }, + acc: &StateAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x666666), + Root: common.Hash{}, + CodeHash: []byte{0xbb, 0xbb, 0xbb}, + Extra: &StateAccountExtra{ + t: pseudo.From(true).Type, + }, + }, + wantHex: `0xee8344444483666666a0000000000000000000000000000000000000000000000000000000000000000083bbbbbb01`, + }, + { + name: "vanilla geth account", + acc: &StateAccount{ + Nonce: 0xcccccc, + Balance: uint256.NewInt(0x555555), + Root: common.MaxHash, + CodeHash: []byte{0x77, 0x77, 0x77}, + }, + wantHex: `0xed83cccccc83555555a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff83777777`, + }, + { + name: "vanilla geth account", + acc: &StateAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x666666), + Root: common.Hash{}, + CodeHash: []byte{0xbb, 0xbb, 0xbb}, + }, + wantHex: `0xed8344444483666666a0000000000000000000000000000000000000000000000000000000000000000083bbbbbb`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.register != nil { + registeredExtras = nil + tt.register() + t.Cleanup(func() { + registeredExtras = nil + }) + } + assertRLPEncodingAndReturn(t, tt.acc, tt.wantHex) + + t.Run("RLP round trip via SlimAccount", func(t *testing.T) { + got, err := FullAccount(SlimAccountRLP(*tt.acc)) + require.NoError(t, err) + + if diff := cmp.Diff(tt.acc, got); diff != "" { + t.Errorf("FullAccount(SlimAccountRLP(x)) != x; diff (-want +got):\n%s", diff) + } + }) + }) + } +} + +func assertRLPEncodingAndReturn(t *testing.T, val any, wantHex string) []byte { + t.Helper() + got, err := rlp.EncodeToBytes(val) + require.NoError(t, err, "rlp.EncodeToBytes()") + + t.Logf("got RLP: %#x", got) + wantHex = strings.TrimPrefix(wantHex, "0x") + require.Equalf(t, common.Hex2Bytes(wantHex), got, "RLP encoding of %T", val) + + return got +} + +func TestSlimAccountRLP(t *testing.T) { + // All RLP encodings were generated on geth SlimAccounts *before* libevm + // modifications, to lock in default behaviour. + tests := []struct { + name string + acc *SlimAccount + wantHex string + }{ + { + acc: &SlimAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x777777), + }, + wantHex: `0xca83444444837777778080`, + }, + { + acc: &SlimAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x777777), + Root: common.MaxHash[:], + }, + wantHex: `0xea8344444483777777a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80`, + }, + { + acc: &SlimAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x777777), + CodeHash: common.MaxHash[:], + }, + wantHex: `0xea834444448377777780a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff`, + }, + { + acc: &SlimAccount{ + Nonce: 0x444444, + Balance: uint256.NewInt(0x777777), + Root: common.MaxHash[:], + CodeHash: repeatAsHash(0xee).Bytes(), + }, + wantHex: `0xf84a8344444483777777a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := assertRLPEncodingAndReturn(t, tt.acc, tt.wantHex) + + got := new(SlimAccount) + require.NoError(t, rlp.DecodeBytes(buf, got), "rlp.DecodeBytes()") + + opts := []cmp.Option{ + // The require package differentiates between empty and nil + // slices and doesn't have a configuration mechanism. + cmpopts.EquateEmpty(), + } + + if diff := cmp.Diff(tt.acc, got, opts...); diff != "" { + t.Errorf("rlp.DecodeBytes(rlp.EncodeToBytes(%T), ...) round trip; diff (-want +got):\n%s", tt.acc, diff) + } + }) + } +} + +func repeatAsHash(x byte) (h common.Hash) { + for i := range h { + h[i] = x + } + return h +} diff --git a/libevm/pseudo/fmt.go b/libevm/pseudo/fmt.go new file mode 100644 index 000000000000..e10234e6783e --- /dev/null +++ b/libevm/pseudo/fmt.go @@ -0,0 +1,56 @@ +// Copyright 2024 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package pseudo + +import ( + "fmt" +) + +var _ = []fmt.Formatter{ + (*Type)(nil), + (*Value[struct{}])(nil), + (*concrete[struct{}])(nil), +} + +// Format implements the [fmt.Formatter] interface. +func (t *Type) Format(s fmt.State, verb rune) { + switch { + case t == nil, t.val == nil: + writeToFmtState(s, "[pseudo.Type[unknown]]") + default: + t.val.Format(s, verb) + } +} + +// Format implements the [fmt.Formatter] interface. +func (v *Value[T]) Format(s fmt.State, verb rune) { v.t.Format(s, verb) } + +func (c *concrete[T]) Format(s fmt.State, verb rune) { + switch { + case c == nil: + writeToFmtState(s, "[pseudo.Type[%T]]", concrete[T]{}.val) + default: + // Respects the original formatting directive. fmt all the way down! + format := fmt.Sprintf("pseudo.Type[%%T]{%s}", fmt.FormatString(s, verb)) + writeToFmtState(s, format, c.val, c.val) + } +} + +func writeToFmtState(s fmt.State, format string, a ...any) { + // There is no way to bubble errors out from a `fmt.Formatter`. + _, _ = s.Write([]byte(fmt.Sprintf(format, a...))) +} diff --git a/libevm/pseudo/fmt_test.go b/libevm/pseudo/fmt_test.go new file mode 100644 index 000000000000..e29ecab0565f --- /dev/null +++ b/libevm/pseudo/fmt_test.go @@ -0,0 +1,82 @@ +// Copyright 2024 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package pseudo + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormat(t *testing.T) { + tests := []struct { + name string + from any + format string + wantContains []string + }{ + { + name: "width", + from: 42, + format: "%04d", + wantContains: []string{"int", "0042"}, + }, + { + name: "precision", + from: float64(2), + format: "%.5f", + wantContains: []string{"float64", "2.00000"}, + }, + { + name: "flag", + from: 42, + format: "%+d", + wantContains: []string{"int", "+42"}, + }, + { + name: "verb", + from: 42, + format: "%x", + wantContains: []string{"int", "2a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fmt.Sprintf(tt.format, fromAny(t, tt.from)) + for _, want := range tt.wantContains { + assert.Containsf(t, got, want, "fmt.Sprintf(%q, From(%T[%[2]v]))", tt.format, tt.from) + } + }) + } +} + +func fromAny(t *testing.T, x any) *Type { + t.Helper() + + // Without this, the function will be From[any](). + switch x := x.(type) { + case int: + return From(x).Type + case float64: + return From(x).Type + default: + t.Fatalf("Bad test setup: add type case for %T", x) + return nil + } +} diff --git a/libevm/pseudo/type.go b/libevm/pseudo/type.go index 3f5e4f26677a..6b80e18a9674 100644 --- a/libevm/pseudo/type.go +++ b/libevm/pseudo/type.go @@ -193,6 +193,7 @@ type value interface { json.Unmarshaler rlp.Encoder rlp.Decoder + fmt.Formatter } type concrete[T any] struct {