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, ""))
})
}
}