From bc6da7c9645b21838c66dfa873665e9a14a784f8 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 22 Aug 2024 16:10:28 +0100 Subject: [PATCH] feat: pseudo-generic extra payloads in `params.ChainConfig` and `params.Rules` --- libevm/examples/extraparams/extraparams.go | 36 +++ libevm/examples/go.mod | 13 ++ libevm/examples/go.sum | 14 ++ libevm/pseudo/type.go | 121 ++++++++++ libevm/pseudo/type_test.go | 68 ++++++ params/config.go | 9 +- params/config.libevm.go | 164 ++++++++++++++ params/config.libevm_test.go | 250 +++++++++++++++++++++ 8 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 libevm/examples/extraparams/extraparams.go create mode 100644 libevm/examples/go.mod create mode 100644 libevm/examples/go.sum create mode 100644 libevm/pseudo/type.go create mode 100644 libevm/pseudo/type_test.go create mode 100644 params/config.libevm.go create mode 100644 params/config.libevm_test.go diff --git a/libevm/examples/extraparams/extraparams.go b/libevm/examples/extraparams/extraparams.go new file mode 100644 index 0000000000..fd240a0d40 --- /dev/null +++ b/libevm/examples/extraparams/extraparams.go @@ -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() +} diff --git a/libevm/examples/go.mod b/libevm/examples/go.mod new file mode 100644 index 0000000000..22735c18a9 --- /dev/null +++ b/libevm/examples/go.mod @@ -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 +) diff --git a/libevm/examples/go.sum b/libevm/examples/go.sum new file mode 100644 index 0000000000..29f549c59f --- /dev/null +++ b/libevm/examples/go.sum @@ -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= diff --git a/libevm/pseudo/type.go b/libevm/pseudo/type.go new file mode 100644 index 0000000000..4cd1beec7a --- /dev/null +++ b/libevm/pseudo/type.go @@ -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 +} diff --git a/libevm/pseudo/type_test.go b/libevm/pseudo/type_test.go new file mode 100644 index 0000000000..964969500f --- /dev/null +++ b/libevm/pseudo/type_test.go @@ -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()) + }) + }) +} diff --git a/params/config.go b/params/config.go index c4e6ba6948..fbb721a9ef 100644 --- a/params/config.go +++ b/params/config.go @@ -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 } diff --git a/params/config.libevm.go b/params/config.libevm.go new file mode 100644 index 0000000000..884a5f2574 --- /dev/null +++ b/params/config.libevm.go @@ -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)), + ) +} diff --git a/params/config.libevm_test.go b/params/config.libevm_test.go new file mode 100644 index 0000000000..18824f1457 --- /dev/null +++ b/params/config.libevm_test.go @@ -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() +}