diff --git a/internal/libevm/errs/errs.go b/internal/libevm/errs/errs.go new file mode 100644 index 000000000000..0254537c255b --- /dev/null +++ b/internal/libevm/errs/errs.go @@ -0,0 +1,93 @@ +// Copyright 2025 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 errs provides a mechanism for [testing error semantics] through +// unique identifiers, instead of depending on error messages that may result in +// change-detector tests. +// +// [testing error semantics]: https://google.github.io/styleguide/go/decisions#test-error-semantics +package errs + +import ( + "errors" + "fmt" +) + +// An ID is a distinct numeric identifier for an error. Any two errors with the +// same ID will result in [errors.Is] returning true, regardless of their +// messages. +type ID int + +// An identifier performs ID comparison, for embedding in all error types to +// provide their Is() method. +type identifier struct { + id ID +} + +func (id identifier) errorID() ID { return id.id } + +func (id identifier) Is(target error) bool { + t, ok := target.(interface{ errorID() ID }) + if !ok { + return false + } + return t.errorID() == id.errorID() +} + +func (id ID) asIdentifier() identifier { return identifier{id} } + +// WithID returns a new error with the ID and message. +func WithID(id ID, msg string) error { + return noWrap{errors.New(msg), id.asIdentifier()} +} + +type noWrap struct { + error + identifier +} + +// WithIDf is the formatted equivalent of [WithID], supporting the same +// wrapping semantics as [fmt.Errorf]. +func WithIDf(id ID, format string, a ...any) error { + switch err := fmt.Errorf(format, a...).(type) { + case singleWrapper: + return single{err, id.asIdentifier()} + case multiWrapper: + return multi{err, id.asIdentifier()} + default: + return noWrap{err, id.asIdentifier()} + } +} + +type singleWrapper interface { + error + Unwrap() error +} + +type single struct { + singleWrapper + identifier +} + +type multiWrapper interface { + error + Unwrap() []error +} + +type multi struct { + multiWrapper + identifier +} diff --git a/internal/libevm/errs/errs_test.go b/internal/libevm/errs/errs_test.go new file mode 100644 index 000000000000..bfa6597475dd --- /dev/null +++ b/internal/libevm/errs/errs_test.go @@ -0,0 +1,71 @@ +// Copyright 2025 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 errs + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIs(t *testing.T) { + ids := []ID{0, 42} + errsByID := make(map[ID][]error) + for _, id := range ids { + errsByID[id] = []error{ + WithID(id, "WithID()"), + WithIDf(id, "WithIDf() no wrapping"), + WithIDf(id, "WithIDf() wrap one %w", errors.New("x")), + WithIDf(id, "WithIDf() wrap multi %w + %w", errors.New("x"), errors.New("y")), + } + } + + unidentified := []error{ + errors.New("errors.New()"), + fmt.Errorf("fmt.Errorf()"), + } + + for id, errs := range errsByID { + for _, err := range errs { + for targetID, targets := range errsByID { + want := id == targetID + for _, target := range targets { + assert.Equalf(t, want, errors.Is(err, target), "errors.Is(%v [ID %d], %v [ID %d])", err, id, target, targetID) + } + } + + for _, target := range unidentified { + assert.NotErrorIsf(t, err, target, "error ID %d", id) + } + } + } +} + +func Example() { + id42 := WithID(42, "hello") + alsoWithID42 := WithIDf(42, "%s", "world") + unidentified := errors.New("hello") + + fmt.Println(errors.Is(id42, alsoWithID42)) + fmt.Println(errors.Is(id42, unidentified)) + + // Output: + // true + // false +} diff --git a/params/json.libevm.go b/params/json.libevm.go index 639bfe18f67e..376581b338f2 100644 --- a/params/json.libevm.go +++ b/params/json.libevm.go @@ -19,6 +19,8 @@ package params import ( "encoding/json" "fmt" + + "github.com/ava-labs/libevm/internal/libevm/errs" ) var _ interface { @@ -43,12 +45,22 @@ func (c *ChainConfig) UnmarshalJSON(data []byte) (err error) { return UnmarshalChainConfigJSON(data, c, c.extra, ec.reuseJSONRoot) } +// Internal error identifiers for precise testing. +const ( + errIDDecodeJSONIntoCombination errs.ID = iota + errIDDecodeJSONIntoExtra + errIDEncodeJSONCombination + errIDEncodeExtraToRawJSON + errIDEncodeDuplicateJSONKey + errIDNilExtra +) + // 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) + return errs.WithIDf(errIDNilExtra, "%T argument is nil; use %T.UnmarshalJSON() directly", extra, config) } if reuseJSONRoot { @@ -56,7 +68,7 @@ func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C, 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 errs.WithIDf(errIDDecodeJSONIntoExtra, "decoding JSON into %T: %s", extra, err) } return nil } @@ -69,7 +81,7 @@ func UnmarshalChainConfigJSON[C any](data []byte, config *ChainConfig, extra *C, 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) + return errs.WithIDf(errIDDecodeJSONIntoCombination, `decoding JSON into combination of %T and %T (as "extra" key): %s`, config, extra, err) } return nil } @@ -100,7 +112,7 @@ func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bo } 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 nil, errs.WithIDf(errIDEncodeJSONCombination, `encoding combination of %T and %T (as "extra" key) to JSON: %s`, config, extra, err) } return data, nil } @@ -116,13 +128,13 @@ func MarshalChainConfigJSON[C any](config ChainConfig, extra C, reuseJSONRoot bo } extraJSONRaw, err := toJSONRawMessages(extra) if err != nil { - return nil, fmt.Errorf("converting extra config to JSON raw messages: %s", err) + return nil, errs.WithIDf(errIDEncodeExtraToRawJSON, "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) + return nil, errs.WithIDf(errIDEncodeDuplicateJSONKey, "duplicate JSON key %q in ChainConfig and extra %T", k, extra) } configJSONRaw[k] = v } diff --git a/params/json.libevm_test.go b/params/json.libevm_test.go index 84b04b354278..5fa21318bd4d 100644 --- a/params/json.libevm_test.go +++ b/params/json.libevm_test.go @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Lesser General Public License // along with the go-ethereum library. If not, see // . + package params import ( @@ -24,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ava-labs/libevm/internal/libevm/errs" "github.com/ava-labs/libevm/libevm/pseudo" ) @@ -157,42 +159,33 @@ func TestUnmarshalChainConfigJSON_Errors(t *testing.T) { jsonData string // string for convenience extra *testExtra reuseJSONRoot bool - wantConfig ChainConfig - wantExtra any - wantErrRegex string + wantErrID errs.ID }{ "invalid_json": { - extra: &testExtra{}, - wantExtra: &testExtra{}, - wantErrRegex: `^decoding JSON into combination of \*.+\.ChainConfig and \*.+\.testExtra \(as "extra" key\): .+$`, + extra: &testExtra{}, + wantErrID: errIDDecodeJSONIntoCombination, }, "nil_extra_at_root_depth": { jsonData: `{"chainId": 1}`, extra: nil, reuseJSONRoot: true, - wantExtra: (*testExtra)(nil), - wantErrRegex: `^\*.+.testExtra argument is nil; use \*.+\.ChainConfig\.UnmarshalJSON\(\) directly$`, + wantErrID: errIDNilExtra, }, "nil_extra_at_extra_key": { - jsonData: `{"chainId": 1}`, - extra: nil, - wantExtra: (*testExtra)(nil), - wantErrRegex: `^\*.+\.testExtra argument is nil; use \*.+\.ChainConfig.UnmarshalJSON\(\) directly$`, + jsonData: `{"chainId": 1}`, + extra: nil, + wantErrID: errIDNilExtra, }, "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\): .+$`, + jsonData: `{"chainId": 1, "extra": 1}`, + extra: &testExtra{}, + wantErrID: errIDDecodeJSONIntoCombination, }, "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: .+`, + wantErrID: errIDDecodeJSONIntoExtra, }, } @@ -204,14 +197,7 @@ func TestUnmarshalChainConfigJSON_Errors(t *testing.T) { 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) + assert.ErrorIs(t, err, errs.WithID(testCase.wantErrID, "")) }) } } @@ -223,31 +209,28 @@ func TestMarshalChainConfigJSON_Errors(t *testing.T) { config ChainConfig extra any reuseJSONRoot bool - wantJSONData string // string for convenience wantErrRegex string + wantErrID errs.ID }{ "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}`, + wantErrID: errIDEncodeJSONCombination, }, "invalid_extra_at_root_depth": { extra: struct { Field chan struct{} `json:"field"` }{}, reuseJSONRoot: true, - wantErrRegex: "^converting extra config to JSON raw messages: .+$", + wantErrID: errIDEncodeExtraToRawJSON, }, "duplicate_key": { extra: struct { Field string `json:"chainId"` }{}, reuseJSONRoot: true, - wantErrRegex: `^duplicate JSON key "chainId" in ChainConfig and extra struct .+$`, + wantErrID: errIDEncodeDuplicateJSONKey, }, } @@ -257,14 +240,8 @@ func TestMarshalChainConfigJSON_Errors(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)) + _, err := MarshalChainConfigJSON(config, testCase.extra, testCase.reuseJSONRoot) + assert.ErrorIs(t, err, errs.WithID(testCase.wantErrID, "")) }) } }