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 ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/ava-labs/libevm/libevm/pseudo"
) )
var _ interface { var _ interface {
@ -28,99 +26,117 @@ var _ interface {
json.Unmarshaler json.Unmarshaler
} = (*ChainConfig)(nil) } = (*ChainConfig)(nil)
// chainConfigWithoutMethods avoids infinite recurion into // chainConfigWithoutMethods avoids infinite recursion into
// [ChainConfig.UnmarshalJSON]. // [ChainConfig.UnmarshalJSON].
type chainConfigWithoutMethods ChainConfig type chainConfigWithoutMethods ChainConfig
// chainConfigWithExportedExtra supports JSON (un)marshalling of a [ChainConfig] // UnmarshalJSON implements the [json.Unmarshaler] interface. If extra payloads
// while exposing the `extra` field as the "extra" JSON key. // were registered, UnmarshalJSON decodes data as described by [Extras] and
type chainConfigWithExportedExtra struct { // [RegisterExtras] otherwise it unmarshals directly into c as if ChainConfig
*chainConfigWithoutMethods // embedded to achieve regular JSON unmarshalling // didn't implement json.Unmarshaler.
Extra *pseudo.Type `json:"extra"` // `c.extra` is otherwise unexported func (c *ChainConfig) UnmarshalJSON(data []byte) (err error) {
} if !registeredExtras.Registered() {
// 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
return json.Unmarshal(data, (*chainConfigWithoutMethods)(c)) 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 // UnmarshalChainConfigJSON is equivalent to [ChainConfig.UnmarshalJSON]
// registered [Extras] payload is in the JSON "extra" key. All other // had [Extras] with `C` been registered, but without the need to call
// unmarshalling is performed as if no [Extras] were registered. // [RegisterExtras]. The `extra` argument MUST NOT be nil.
func (c *ChainConfig) unmarshalJSONWithExtra(data []byte) error { func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C, reuseJSONRoot bool) (err error) {
cc := &chainConfigWithExportedExtra{ if extra == nil {
chainConfigWithoutMethods: (*chainConfigWithoutMethods)(c), return fmt.Errorf("%T argument is nil; use %T.UnmarshalJSON() directly", extra, config)
Extra: registeredExtras.Get().newChainConfig(),
} }
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 return nil
} }
// MarshalJSON implements the [json.Marshaler] interface. // 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) { func (c *ChainConfig) MarshalJSON() ([]byte, error) {
switch reg := registeredExtras; { if !registeredExtras.Registered() {
case !reg.Registered():
return json.Marshal((*chainConfigWithoutMethods)(c)) 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(). // MarshalChainConfigJSON is equivalent to [ChainConfig.MarshalJSON]
func (c *ChainConfig) marshalJSONWithExtra() ([]byte, error) { // had [Extras] with `C` been registered, but without the need to
cc := &chainConfigWithExportedExtra{ // call [RegisterExtras].
chainConfigWithoutMethods: (*chainConfigWithoutMethods)(c), func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bool) (data []byte, err error) {
Extra: c.extra, 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) { func toJSONRawMessages(v any) (map[string]json.RawMessage, error) {
buf, err := json.Marshal(v) buf, err := json.Marshal(v)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("encoding %T: %s", v, err)
} }
msgs := make(map[string]json.RawMessage) msgs := make(map[string]json.RawMessage)
if err := json.Unmarshal(buf, &msgs); err != nil { 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 return msgs, nil
} }

View file

@ -21,6 +21,7 @@ import (
"math/big" "math/big"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ava-labs/libevm/libevm/pseudo" "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))
})
}
}