feat(params): UnmarshalChainJSONConfig and MarshalChainConfigJSON (#92)

- Allow wallets and other clients to parse blocks from both coreth and subnet-evm without registering extra types
- Allow wallets and other clients to make their genesis/chain configurations without registering extra types
This commit is contained in:
Quentin McGaw 2025-01-12 18:35:14 +01:00 committed by GitHub
parent c996175b46
commit d08d0f0510
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 208 additions and 68 deletions

View file

@ -19,8 +19,6 @@ package params
import (
"encoding/json"
"fmt"
"github.com/ava-labs/libevm/libevm/pseudo"
)
var _ interface {
@ -28,99 +26,117 @@ var _ interface {
json.Unmarshaler
} = (*ChainConfig)(nil)
// chainConfigWithoutMethods avoids infinite recurion into
// chainConfigWithoutMethods avoids infinite recursion into
// [ChainConfig.UnmarshalJSON].
type chainConfigWithoutMethods ChainConfig
// chainConfigWithExportedExtra supports JSON (un)marshalling of a [ChainConfig]
// while exposing the `extra` field as the "extra" JSON key.
type chainConfigWithExportedExtra struct {
*chainConfigWithoutMethods // embedded to achieve regular JSON unmarshalling
Extra *pseudo.Type `json:"extra"` // `c.extra` is otherwise unexported
}
// UnmarshalJSON implements the [json.Unmarshaler] interface.
func (c *ChainConfig) UnmarshalJSON(data []byte) error {
switch reg := registeredExtras; {
case reg.Registered() && !reg.Get().reuseJSONRoot:
return c.unmarshalJSONWithExtra(data)
case reg.Registered() && reg.Get().reuseJSONRoot: // although the latter is redundant, it's clearer
c.extra = reg.Get().newChainConfig()
if err := json.Unmarshal(data, c.extra); err != nil {
c.extra = nil
return err
}
fallthrough // Important! We've only unmarshalled the extra field.
default: // reg == nil
// UnmarshalJSON implements the [json.Unmarshaler] interface. If extra payloads
// were registered, UnmarshalJSON decodes data as described by [Extras] and
// [RegisterExtras] otherwise it unmarshals directly into c as if ChainConfig
// didn't implement json.Unmarshaler.
func (c *ChainConfig) UnmarshalJSON(data []byte) (err error) {
if !registeredExtras.Registered() {
return json.Unmarshal(data, (*chainConfigWithoutMethods)(c))
}
ec := registeredExtras.Get()
c.extra = ec.newChainConfig()
return UnmarshalChainConfigJSON(data, c, c.extra, ec.reuseJSONRoot)
}
// unmarshalJSONWithExtra unmarshals JSON under the assumption that the
// registered [Extras] payload is in the JSON "extra" key. All other
// unmarshalling is performed as if no [Extras] were registered.
func (c *ChainConfig) unmarshalJSONWithExtra(data []byte) error {
cc := &chainConfigWithExportedExtra{
chainConfigWithoutMethods: (*chainConfigWithoutMethods)(c),
Extra: registeredExtras.Get().newChainConfig(),
// UnmarshalChainConfigJSON is equivalent to [ChainConfig.UnmarshalJSON]
// had [Extras] with `C` been registered, but without the need to call
// [RegisterExtras]. The `extra` argument MUST NOT be nil.
func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C, reuseJSONRoot bool) (err error) {
if extra == nil {
return fmt.Errorf("%T argument is nil; use %T.UnmarshalJSON() directly", extra, config)
}
if err := json.Unmarshal(data, cc); err != nil {
return err
if reuseJSONRoot {
if err := json.Unmarshal(data, (*chainConfigWithoutMethods)(config)); err != nil {
return fmt.Errorf("decoding JSON into %T: %s", config, err)
}
if err := json.Unmarshal(data, extra); err != nil {
return fmt.Errorf("decoding JSON into %T: %s", extra, err)
}
return nil
}
combined := struct {
*chainConfigWithoutMethods
Extra *C `json:"extra"`
}{
(*chainConfigWithoutMethods)(config),
extra,
}
if err := json.Unmarshal(data, &combined); err != nil {
return fmt.Errorf(`decoding JSON into combination of %T and %T (as "extra" key): %s`, config, extra, err)
}
c.extra = cc.Extra
return nil
}
// MarshalJSON implements the [json.Marshaler] interface.
// If extra payloads were registered, MarshalJSON encodes JSON as
// described by [Extras] and [RegisterExtras] otherwise it marshals
// `c` as if ChainConfig didn't implement json.Marshaler.
func (c *ChainConfig) MarshalJSON() ([]byte, error) {
switch reg := registeredExtras; {
case !reg.Registered():
if !registeredExtras.Registered() {
return json.Marshal((*chainConfigWithoutMethods)(c))
case !reg.Get().reuseJSONRoot:
return c.marshalJSONWithExtra()
default: // reg.reuseJSONRoot == true
// The inverse of reusing the JSON root is merging two JSON buffers,
// which isn't supported by the native package. So we use
// map[string]json.RawMessage intermediates.
geth, err := toJSONRawMessages((*chainConfigWithoutMethods)(c))
if err != nil {
return nil, err
}
extra, err := toJSONRawMessages(c.extra)
if err != nil {
return nil, err
}
for k, v := range extra {
if _, ok := geth[k]; ok {
return nil, fmt.Errorf("duplicate JSON key %q in both %T and registered extra", k, c)
}
geth[k] = v
}
return json.Marshal(geth)
}
ec := registeredExtras.Get()
return MarshalChainConfigJSON(*c, c.extra, ec.reuseJSONRoot)
}
// marshalJSONWithExtra is the inverse of unmarshalJSONWithExtra().
func (c *ChainConfig) marshalJSONWithExtra() ([]byte, error) {
cc := &chainConfigWithExportedExtra{
chainConfigWithoutMethods: (*chainConfigWithoutMethods)(c),
Extra: c.extra,
// MarshalChainConfigJSON is equivalent to [ChainConfig.MarshalJSON]
// had [Extras] with `C` been registered, but without the need to
// call [RegisterExtras].
func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bool) (data []byte, err error) {
if !reuseJSONRoot {
jsonExtra := struct {
ChainConfig
Extra C `json:"extra,omitempty"`
}{
config,
extra,
}
data, err = json.Marshal(jsonExtra)
if err != nil {
return nil, fmt.Errorf(`encoding combination of %T and %T (as "extra" key) to JSON: %s`, config, extra, err)
}
return data, nil
}
return json.Marshal(cc)
// The inverse of reusing the JSON root is merging two JSON buffers,
// which isn't supported by the native package. So we use
// map[string]json.RawMessage intermediates.
// Note we cannot encode a combined struct directly because of the extra
// type generic nature which cannot be embedded in such a combined struct.
configJSONRaw, err := toJSONRawMessages((chainConfigWithoutMethods)(config))
if err != nil {
return nil, fmt.Errorf("converting config to JSON raw messages: %s", err)
}
extraJSONRaw, err := toJSONRawMessages(extra)
if err != nil {
return nil, fmt.Errorf("converting extra config to JSON raw messages: %s", err)
}
for k, v := range extraJSONRaw {
_, ok := configJSONRaw[k]
if ok {
return nil, fmt.Errorf("duplicate JSON key %q in ChainConfig and extra %T", k, extra)
}
configJSONRaw[k] = v
}
return json.Marshal(configJSONRaw)
}
func toJSONRawMessages(v any) (map[string]json.RawMessage, error) {
buf, err := json.Marshal(v)
if err != nil {
return nil, err
return nil, fmt.Errorf("encoding %T: %s", v, err)
}
msgs := make(map[string]json.RawMessage)
if err := json.Unmarshal(buf, &msgs); err != nil {
return nil, err
return nil, fmt.Errorf("decoding JSON encoding of %T into %T: %s", v, msgs, err)
}
return msgs, nil
}

View file

@ -21,6 +21,7 @@ import (
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ava-labs/libevm/libevm/pseudo"
@ -144,3 +145,126 @@ func TestChainConfigJSONRoundTrip(t *testing.T) {
})
}
}
func TestUnmarshalChainConfigJSON_Errors(t *testing.T) {
t.Parallel()
type testExtra struct {
Field string `json:"field"`
}
testCases := map[string]struct {
jsonData string // string for convenience
extra *testExtra
reuseJSONRoot bool
wantConfig ChainConfig
wantExtra any
wantErrRegex string
}{
"invalid_json": {
extra: &testExtra{},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into combination of \*.+\.ChainConfig and \*.+\.testExtra \(as "extra" key\): .+$`,
},
"nil_extra_at_root_depth": {
jsonData: `{"chainId": 1}`,
extra: nil,
reuseJSONRoot: true,
wantExtra: (*testExtra)(nil),
wantErrRegex: `^\*.+.testExtra argument is nil; use \*.+\.ChainConfig\.UnmarshalJSON\(\) directly$`,
},
"nil_extra_at_extra_key": {
jsonData: `{"chainId": 1}`,
extra: nil,
wantExtra: (*testExtra)(nil),
wantErrRegex: `^\*.+\.testExtra argument is nil; use \*.+\.ChainConfig.UnmarshalJSON\(\) directly$`,
},
"wrong_extra_type_at_extra_key": {
jsonData: `{"chainId": 1, "extra": 1}`,
extra: &testExtra{},
wantConfig: ChainConfig{ChainID: big.NewInt(1)},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into combination of \*.+\.ChainConfig and \*.+\.testExtra \(as "extra" key\): .+$`,
},
"wrong_extra_type_at_root_depth": {
jsonData: `{"chainId": 1, "field": 1}`,
extra: &testExtra{},
reuseJSONRoot: true,
wantConfig: ChainConfig{ChainID: big.NewInt(1)},
wantExtra: &testExtra{},
wantErrRegex: `^decoding JSON into \*.+\.testExtra: .+`,
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
data := []byte(testCase.jsonData)
config := ChainConfig{}
err := UnmarshalChainConfigJSON(data, &config, testCase.extra, testCase.reuseJSONRoot)
if testCase.wantErrRegex == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Regexp(t, testCase.wantErrRegex, err.Error())
}
assert.Equal(t, testCase.wantConfig, config)
assert.Equal(t, testCase.wantExtra, testCase.extra)
})
}
}
func TestMarshalChainConfigJSON_Errors(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
config ChainConfig
extra any
reuseJSONRoot bool
wantJSONData string // string for convenience
wantErrRegex string
}{
"invalid_extra_at_extra_key": {
extra: struct {
Field chan struct{} `json:"field"`
}{},
wantErrRegex: `^encoding combination of .+\.ChainConfig and .+ to JSON: .+$`,
},
"nil_extra_at_extra_key": {
wantJSONData: `{"chainId":null}`,
},
"invalid_extra_at_root_depth": {
extra: struct {
Field chan struct{} `json:"field"`
}{},
reuseJSONRoot: true,
wantErrRegex: "^converting extra config to JSON raw messages: .+$",
},
"duplicate_key": {
extra: struct {
Field string `json:"chainId"`
}{},
reuseJSONRoot: true,
wantErrRegex: `^duplicate JSON key "chainId" in ChainConfig and extra struct .+$`,
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
config := ChainConfig{}
data, err := MarshalChainConfigJSON(config, testCase.extra, testCase.reuseJSONRoot)
if testCase.wantErrRegex == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Regexp(t, testCase.wantErrRegex, err.Error())
}
assert.Equal(t, testCase.wantJSONData, string(data))
})
}
}