feat: pseudo-generic extra payloads in params.ChainConfig and params.Rules

This commit is contained in:
Arran Schlosberg 2024-08-22 16:10:28 +01:00
parent 941ae33d7e
commit bc6da7c964
No known key found for this signature in database
GPG key ID: 8A30F7E4344B4EF3
8 changed files with 674 additions and 1 deletions

View file

@ -0,0 +1,36 @@
package extraparams
import (
"math/big"
"github.com/ethereum/go-ethereum/libevm/pseudo"
"github.com/ethereum/go-ethereum/params"
)
func init() {
params.RegisterExtras(params.Extras[ChainConfigExtra, RulesExtra]{
NewForRules: constructRulesExtra,
})
}
type ChainConfigExtra struct {
MyFeatureTime *uint64
}
type RulesExtra struct {
IsMyFeature bool
}
func constructRulesExtra(c *params.ChainConfig, r *params.Rules, cEx *ChainConfigExtra, blockNum *big.Int, isMerge bool, timestamp uint64) *RulesExtra {
return &RulesExtra{
IsMyFeature: isMerge && cEx.MyFeatureTime != nil && *cEx.MyFeatureTime < timestamp,
}
}
func FromChainConfig(c *params.ChainConfig) *ChainConfigExtra {
return pseudo.NewValueUnsafe[*ChainConfigExtra](c.ExtraPayload()).Get()
}
func FromRules(r *params.Rules) *RulesExtra {
return pseudo.NewValueUnsafe[*RulesExtra](r.ExtraPayload()).Get()
}

13
libevm/examples/go.mod Normal file
View file

@ -0,0 +1,13 @@
module libevm/examples
go 1.22.4
replace github.com/ethereum/go-ethereum => ../../
require github.com/ethereum/go-ethereum v0.0.0-00010101000000-000000000000
require (
github.com/holiman/uint256 v1.3.1 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/sys v0.20.0 // indirect
)

14
libevm/examples/go.sum Normal file
View file

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs=
github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

121
libevm/pseudo/type.go Normal file
View file

@ -0,0 +1,121 @@
// Package pseudo ...
package pseudo
import (
"encoding/json"
"fmt"
)
// Zero ...
func Zero[T any]() (*Type, *Value[T]) {
var x T
return From[T](x)
}
// From ...
func From[T any](x T) (*Type, *Value[T]) {
t := &Type{
val: &concrete[T]{
val: x,
},
}
return t, NewValueUnsafe[T](t)
}
// OnlyType ...
func OnlyType[T any](t *Type, _ *Value[T]) *Type {
return t
}
// A Type ...
type Type struct {
val value
}
func (t *Type) Interface() any { return t.val.get() }
// func (t *Type) Set(v any) error { return t.val.Set(v) }
// func (t *Type) MustSet(v any) { t.val.MustSet(v) }
func (t *Type) MarshalJSON() ([]byte, error) { return t.val.MarshalJSON() }
func (t *Type) UnmarshalJSON(b []byte) error { return t.val.UnmarshalJSON(b) }
var (
_ json.Marshaler = (*Type)(nil)
_ json.Unmarshaler = (*Type)(nil)
)
func NewValueUnsafe[T any](t *Type) *Value[T] {
return &Value[T]{t: t}
}
func NewValue[T any](t *Type) (*Value[T], error) {
var x T
if !t.val.canSetTo(x) {
return nil, fmt.Errorf("cannot create *Accessor[%T] with *Type carrying %T", x, t.val.get())
}
return NewValueUnsafe[T](t), nil
}
type Value[T any] struct {
t *Type
}
func (a *Value[T]) Get() T { return a.t.val.get().(T) }
func (a *Value[T]) Set(v T) { a.t.val.mustSet(v) }
type value interface {
get() any
canSetTo(any) bool
set(any) error
mustSet(any)
json.Marshaler
json.Unmarshaler
}
type concrete[T any] struct {
val T
}
func (c *concrete[T]) get() any { return c.val }
func (c *concrete[T]) canSetTo(v any) bool {
_, ok := v.(T)
return ok
}
type InvalidTypeError[T any] struct {
SetTo any
}
func (e *InvalidTypeError[T]) Error() string {
var t T
return fmt.Sprintf("cannot set %T to %T", t, e.SetTo)
}
func (c *concrete[T]) set(v any) error {
vv, ok := v.(T)
if !ok {
return &InvalidTypeError[T]{SetTo: v}
}
c.val = vv
return nil
}
func (c *concrete[T]) mustSet(v any) {
if err := c.set(v); err != nil {
panic(err)
}
_ = 0 // for happy-path coverage inspection
}
func (c *concrete[T]) MarshalJSON() ([]byte, error) { return json.Marshal(c.val) }
func (c *concrete[T]) UnmarshalJSON(b []byte) error {
var v T
if err := json.Unmarshal(b, &v); err != nil {
return err
}
c.val = v
return nil
}

View file

@ -0,0 +1,68 @@
package pseudo
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestType(t *testing.T) {
testType(t, "Zero[int]", Zero[int], 0, 42, "I'm not an int")
testType(t, "Zero[string]", Zero[string], "", "hello, world", 99)
testType(
t, "From[uint](314159)",
func() (*Type, *Value[uint]) {
return From[uint](314159)
},
314159, 0, struct{}{},
)
testType(t, "nil pointer", Zero[*float64], (*float64)(nil), new(float64), 0)
}
func testType[T any](t *testing.T, name string, ctor func() (*Type, *Value[T]), init T, setTo T, invalid any) {
t.Run(name, func(t *testing.T) {
typ, val := ctor()
assert.Equal(t, init, val.Get())
val.Set(setTo)
assert.Equal(t, setTo, val.Get())
t.Run("set to invalid type", func(t *testing.T) {
wantErr := &InvalidTypeError[T]{SetTo: invalid}
assertError := func(t *testing.T, err any) {
t.Helper()
switch err := err.(type) {
case *InvalidTypeError[T]:
assert.Equal(t, wantErr, err)
default:
t.Errorf("got error %v; want %v", err, wantErr)
}
}
t.Run(fmt.Sprintf("Set(%T{%v})", invalid, invalid), func(t *testing.T) {
assertError(t, typ.val.set(invalid))
})
t.Run(fmt.Sprintf("MustSet(%T{%v})", invalid, invalid), func(t *testing.T) {
defer func() {
assertError(t, recover())
}()
typ.val.mustSet(invalid)
})
})
t.Run("JSON round trip", func(t *testing.T) {
buf, err := json.Marshal(typ)
require.NoError(t, err)
got, gotVal := Zero[T]()
require.NoError(t, json.Unmarshal(buf, &got))
assert.Equal(t, val.Get(), gotVal.Get())
})
})
}

View file

@ -21,6 +21,7 @@ import (
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/libevm/pseudo"
"github.com/ethereum/go-ethereum/params/forks"
)
@ -340,6 +341,8 @@ type ChainConfig struct {
// Various consensus engines
Ethash *EthashConfig `json:"ethash,omitempty"`
Clique *CliqueConfig `json:"clique,omitempty"`
extra *pseudo.Type // See RegisterExtras()
}
// EthashConfig is the consensus engine configs for proof-of-work based sealing.
@ -891,6 +894,8 @@ type Rules struct {
IsBerlin, IsLondon bool
IsMerge, IsShanghai, IsCancun, IsPrague bool
IsVerkle bool
extra *pseudo.Type // See RegisterExtras()
}
// Rules ensures c's ChainID is not nil.
@ -902,7 +907,7 @@ func (c *ChainConfig) Rules(num *big.Int, isMerge bool, timestamp uint64) Rules
// disallow setting Merge out of order
isMerge = isMerge && c.IsLondon(num)
isVerkle := isMerge && c.IsVerkle(num, timestamp)
return Rules{
r := Rules{
ChainID: new(big.Int).Set(chainID),
IsHomestead: c.IsHomestead(num),
IsEIP150: c.IsEIP150(num),
@ -922,4 +927,6 @@ func (c *ChainConfig) Rules(num *big.Int, isMerge bool, timestamp uint64) Rules
IsVerkle: isVerkle,
IsEIP4762: isVerkle,
}
c.addRulesExtra(&r, num, isMerge, timestamp)
return r
}

164
params/config.libevm.go Normal file
View file

@ -0,0 +1,164 @@
package params
import (
"encoding/json"
"fmt"
"math/big"
"reflect"
"github.com/ethereum/go-ethereum/libevm/pseudo"
)
// Extras are arbitrary payloads to be added as extra fields in [ChainConfig]
// and [Rules] structs. See [RegisterExtras].
type Extras[C any, R any] struct {
// NewForRules, if non-nil is called at the end of [ChainConfig.Rules] with
// the newly created [Rules] and the [ChainConfig] extra payload. Its
// returned value will be the extra payload of the [Rules]. If NewForRules
// is nil then so too will the [Rules] extra payload be a nil `*R`.
//
// NewForRules MAY modify the [Rules] but MUST NOT modify the [ChainConfig].
NewForRules func(_ *ChainConfig, _ *Rules, _ *C, blockNum *big.Int, isMerge bool, timestamp uint64) *R
}
// RegisterExtras registers the types `C` and `R` such that they are carried as
// extra payloads in [ChainConfig] and [Rules] structs, respectively. It is
// expected to be called in an `init()` function and MUST NOT be called more
// than once. Both `C` and `R` MUST be structs.
//
// After registration, JSON unmarshalling of a [ChainConfig] will create a new
// `*C` and unmarshal the JSON key "extra" into it. Conversely, JSON marshalling
// will populate the "extra" key with the contents of the `*C`. Calls to
// [ChainConfig.Rules] will call the `NewForRules` function of the registered
// [Extras] to create a new `*R`.
//
// The payloads can be accessed via the [ChainConfig.ExtraPayload] and
// [Rules.ExtraPayload] methods, which will always return a `*C` or `*R`
// respectively however these pointers may themselves be nil.
//
// As the `ExtraPayload()` methods are not generic and return `any`, their
// values MUST be type-asserted to the returned type; failure to do so may
// result in a typed-nil bug. This pattern most-closely resembles a fully
// generic implementation and users SHOULD wrap the type assertions in a shared
// package.
func RegisterExtras[C any, R any](e Extras[C, R]) {
if registeredExtras != nil {
panic("re-registration of Extras")
}
mustBeStruct[C]()
mustBeStruct[R]()
registeredExtras = &e
}
func mustBeStruct[T any]() {
var x T
if k := reflect.TypeOf(x).Kind(); k != reflect.Struct {
panic(notStructMessage[T]())
}
}
func notStructMessage[T any]() string {
var x T
return fmt.Sprintf("%T is not a struct", x)
}
var registeredExtras interface {
nilForChainConfig() *pseudo.Type
nilForRules() *pseudo.Type
newForChainConfig() *pseudo.Type
newForRules(_ *ChainConfig, _ *Rules, blockNum *big.Int, isMerge bool, timestamp uint64) *pseudo.Type
}
var (
_ json.Unmarshaler = (*ChainConfig)(nil)
_ json.Marshaler = (*ChainConfig)(nil)
)
// UnmarshalJSON ... TODO
func (c *ChainConfig) UnmarshalJSON(data []byte) error {
// We need to bypass this UnmarshalJSON() method when we again call
// json.Unmarshal(). The `raw` type won't inherit the method.
type raw ChainConfig
cc := &struct {
*raw
Extra json.RawMessage `json:"extra"`
}{raw: (*raw)(c)}
if err := json.Unmarshal(data, cc); err != nil {
return err
}
if registeredExtras == nil || len(cc.Extra) == 0 {
return nil
}
extra := registeredExtras.newForChainConfig()
if err := json.Unmarshal(cc.Extra, extra); err != nil {
return err
}
c.extra = extra
return nil
}
// MarshalJSON ... TODO
func (c *ChainConfig) MarshalJSON() ([]byte, error) {
type raw ChainConfig
cc := &struct {
*raw
Extra any `json:"extra"`
}{raw: (*raw)(c), Extra: c.extra}
return json.Marshal(cc)
}
func (c *ChainConfig) addRulesExtra(r *Rules, blockNum *big.Int, isMerge bool, timestamp uint64) {
r.extra = nil
if registeredExtras != nil {
r.extra = registeredExtras.newForRules(c, r, blockNum, isMerge, timestamp)
}
}
// ExtraPayload returns the extra payload carried by the ChainConfig and can
// only be called if [RegisterExtras] was called. The returned value is always
// of type `*C` as registered, but may be nil. Callers MUST immediately
// type-assert the returned value to `*C` to avoid typed-nil bugs. See the
// example for the intended usage pattern.
func (c *ChainConfig) ExtraPayload() *pseudo.Type {
if registeredExtras == nil {
panic(fmt.Sprintf("%T.ExtraPayload() called before RegisterExtras()", c))
}
if c.extra == nil {
c.extra = registeredExtras.nilForChainConfig()
}
return c.extra
}
// ExtraPayload returns the extra payload carried by the Rules and can only be
// called if [RegisterExtras] was called. The returned value is always of type
// `*R` as registered, but may be nil. Callers MUST immediately type-assert the
// returned value to `*R` to avoid typed-nil bugs. See the example on
// [ChainConfig.ExtraPayload] for the intended usage pattern.
func (r *Rules) ExtraPayload() *pseudo.Type {
if registeredExtras == nil {
panic(fmt.Sprintf("%T.ExtraPayload() called before RegisterExtras()", r))
}
if r.extra == nil {
r.extra = registeredExtras.nilForRules()
}
return r.extra
}
func (Extras[C, R]) nilForChainConfig() *pseudo.Type { return pseudo.OnlyType(pseudo.Zero[*C]()) }
func (Extras[C, R]) nilForRules() *pseudo.Type { return pseudo.OnlyType(pseudo.Zero[*R]()) }
func (*Extras[C, R]) newForChainConfig() *pseudo.Type {
var x C
return pseudo.OnlyType(pseudo.From(&x))
}
func (e *Extras[C, R]) newForRules(c *ChainConfig, r *Rules, blockNum *big.Int, isMerge bool, timestamp uint64) *pseudo.Type {
if e.NewForRules == nil {
return e.nilForRules()
}
return pseudo.OnlyType(
pseudo.From(e.NewForRules(c, r, c.extra.Interface().(*C), blockNum, isMerge, timestamp)),
)
}

View file

@ -0,0 +1,250 @@
package params
import (
"encoding/json"
"fmt"
"log"
"math/big"
"testing"
"github.com/ethereum/go-ethereum/libevm/pseudo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testOnlyClearRegisteredExtras() {
registeredExtras = nil
}
func ExampleRegisterExtras() {
type (
chainConfigExtra struct {
Foo string `json:"foo"`
}
rulesExtra struct {
FooCopy string
}
)
// In practice, this would be called inside an init() func.
RegisterExtras(Extras[chainConfigExtra, rulesExtra]{
NewForRules: func(cc *ChainConfig, r *Rules, cEx *chainConfigExtra, blockNum *big.Int, isMerge bool, timestamp uint64) *rulesExtra {
// This function is called at the end of ChainConfig.Rules(),
// receiving a pointer to the Rules that will be returned. It MAY
// modify the Rules but MUST NOT modify the ChainConfig. The value
// that it returns will be available via Rules.ExtraPayload().
return &rulesExtra{
FooCopy: fmt.Sprintf("copy of: %q", cEx.Foo),
}
},
})
defer testOnlyClearRegisteredExtras()
// ChainConfig now unmarshals any JSON field named "extra" into a pointer to
// the registered type, which is available via the ExtraPayload() method.
buf := []byte(`{
"chainId": 1234,
"extra": {
"foo": "hello, world"
}
}`)
var config ChainConfig
if err := json.Unmarshal(buf, &config); err != nil {
log.Fatal(err)
}
fmt.Println(config.ChainID)
// The values returned by ExtraPayload() are guaranteed to be pointers to
// the registered types. They MAY, however, be nil pointers. In practice,
// callers SHOULD abstract the type assertion in a reusable function to
// provide a seamless devex.
ccExtra := config.ExtraPayload().Interface().(*chainConfigExtra)
rules := config.Rules(nil, false, 0)
rExtra := rules.ExtraPayload().Interface().(*rulesExtra)
if ccExtra != nil {
fmt.Println(ccExtra.Foo)
}
if rExtra != nil {
fmt.Println(rExtra.FooCopy)
}
// Output:
// 1234
// hello, world
// copy of: "hello, world"
}
func ExampleChainConfig_ExtraPayload() {
type (
chainConfigExtra struct{}
rulesExtra struct{}
)
// Typically called in an `init()` function.
RegisterExtras(Extras[chainConfigExtra, rulesExtra]{ /*...*/ })
defer testOnlyClearRegisteredExtras()
var c ChainConfig // Sourced from elsewhere, typically unmarshalled from JSON.
// Both ChainConfig.ExtraPayload() and Rules.ExtraPayload() return `any`
// that are guaranteed to be pointers to the registered types.
extra := c.ExtraPayload().Interface().(*chainConfigExtra)
// Act on the extra payload...
if extra != nil {
// ...
}
}
type rawJSON struct {
json.RawMessage
}
var (
_ json.Unmarshaler = (*rawJSON)(nil)
_ json.Marshaler = (*rawJSON)(nil)
)
func TestRegisterExtras(t *testing.T) {
type (
ccExtraA struct {
A string `json:"a"`
}
rulesExtraA struct {
A string
}
ccExtraB struct {
B string `json:"b"`
}
rulesExtraB struct {
B string
}
)
tests := []struct {
name string
register func()
ccExtra *pseudo.Type
wantRulesExtra any
}{
{
name: "Rules payload copied from ChainConfig payload",
register: func() {
RegisterExtras(Extras[ccExtraA, rulesExtraA]{
NewForRules: func(cc *ChainConfig, r *Rules, ex *ccExtraA, _ *big.Int, _ bool, _ uint64) *rulesExtraA {
return &rulesExtraA{
A: ex.A,
}
},
})
},
ccExtra: pseudo.OnlyType(pseudo.From(&ccExtraA{
A: "hello",
})),
wantRulesExtra: &rulesExtraA{
A: "hello",
},
},
{
name: "no NewForRules() function results in typed but nil pointer",
register: func() {
RegisterExtras(Extras[ccExtraB, rulesExtraB]{})
},
ccExtra: pseudo.OnlyType(pseudo.From(&ccExtraB{
B: "world",
})),
wantRulesExtra: (*rulesExtraB)(nil),
},
{
name: "custom JSON handling honoured",
register: func() {
RegisterExtras(Extras[rawJSON, struct{}]{})
},
ccExtra: pseudo.OnlyType(pseudo.From(&rawJSON{
RawMessage: []byte(`"hello, world"`),
})),
wantRulesExtra: (*struct{})(nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.register()
defer testOnlyClearRegisteredExtras()
in := &ChainConfig{
ChainID: big.NewInt(142857),
extra: tt.ccExtra,
}
buf, err := json.Marshal(in)
require.NoError(t, err)
got := new(ChainConfig)
require.NoError(t, json.Unmarshal(buf, got))
assert.Equal(t, tt.ccExtra.Interface(), got.ExtraPayload().Interface())
assert.Equal(t, in, got)
// TODO: do we need an explicit test of the JSON output, or is a
// Marshal-Unmarshal round trip sufficient?
gotRules := got.Rules(nil, false, 0)
assert.Equal(t, tt.wantRulesExtra, gotRules.ExtraPayload().Interface())
})
}
}
func TestExtrasPanic(t *testing.T) {
assertPanics(
t, func() {
RegisterExtras(Extras[int, struct{}]{})
},
notStructMessage[int](),
)
assertPanics(
t, func() {
RegisterExtras(Extras[struct{}, bool]{})
},
notStructMessage[bool](),
)
assertPanics(
t, func() {
new(ChainConfig).ExtraPayload()
},
"before RegisterExtras",
)
assertPanics(
t, func() {
new(Rules).ExtraPayload()
},
"before RegisterExtras",
)
RegisterExtras(Extras[struct{}, struct{}]{})
defer testOnlyClearRegisteredExtras()
assertPanics(
t, func() {
RegisterExtras(Extras[struct{}, struct{}]{})
},
"re-registration",
)
}
func assertPanics(t *testing.T, fn func(), wantContains string) {
t.Helper()
defer func() {
switch r := recover().(type) {
case nil:
t.Error("function did not panic as expected")
case string:
assert.Contains(t, r, wantContains)
default:
t.Fatalf("BAD TEST SETUP: recover() got unsupported type %T", r)
}
}()
fn()
}