diff --git a/internal/signermsgs/en_field_descriptions.go b/internal/signermsgs/en_field_descriptions.go index b41f5a33..ca669035 100644 --- a/internal/signermsgs/en_field_descriptions.go +++ b/internal/signermsgs/en_field_descriptions.go @@ -48,6 +48,10 @@ var ( EIP712ResultR = ffm("EIP712Result.r", "The R value of the ECDSA signature as a 32byte hex encoded array") EIP712ResultS = ffm("EIP712Result.s", "The S value of the ECDSA signature as a 32byte hex encoded array") + RevertErrorErrorEntry = ffm("RevertError.errorEntry", "The matched ABI error entry at this level of the error chain") + RevertErrorPrefix = ffm("RevertError.prefix", "The readable text prefix before the nested inner error, extracted from the outer Error(string) value") + RevertErrorInnerError = ffm("RevertError.innerError", "The recursively decoded inner error, forming the next level of the error chain, or nil if this is the leaf") + TypedDataDomain = ffm("TypedData.domain", "The data to encode into the EIP712Domain as part fo signing the transaction") TypedDataMessage = ffm("TypedData.message", "The data to encode into primaryType structure, with nested values for any sub-structures") TypedDataTypes = ffm("TypedData.types", "Array of types to use when encoding, which must include the primaryType and the EIP712Domain (noting the primary type can be EIP712Domain if the message is empty)") diff --git a/pkg/abi/abi.go b/pkg/abi/abi.go index 9aaff0fb..db184b44 100644 --- a/pkg/abi/abi.go +++ b/pkg/abi/abi.go @@ -315,19 +315,53 @@ func (a ABI) Errors() map[string]*Entry { return m } +// FilterType returns a new ABI containing only the entries whose Type matches t, +// preserving their original order. Returns nil if no entries match. +// Use this to narrow an ABI before passing it to SelectorMap or other helpers, +// e.g. a.FilterType(Error) to obtain only error definitions. +func (a ABI) FilterType(t EntryType) ABI { + var out ABI + for _, e := range a { + if e.Type == t { + out = append(out, e) + } + } + return out +} + +// SelectorMap builds a map from 4-byte ABI selectors to their corresponding +// entries. When two entries in the ABI produce the same 4-byte selector, the +// first one wins. Entries for which selector generation fails are silently +// skipped. +// +// SelectorMap operates on all entry types — combine with FilterType to restrict +// to a specific type, e.g. a.FilterType(Error).SelectorMap(). +func (a ABI) SelectorMap() map[[4]byte]*Entry { + m := make(map[[4]byte]*Entry) + for _, e := range a { + sel := e.FunctionSelectorBytes() + if len(sel) >= 4 { + var key [4]byte + copy(key[:], sel[:4]) + if _, exists := m[key]; !exists { + m[key] = e + } + } + } + return m +} + // Returns the components value from the parsed error func (a ABI) ParseError(revertData []byte) (*Entry, *ComponentValue, bool) { return a.ParseErrorCtx(context.Background(), revertData) } -// Returns the components value from the parsed error +// ParseErrorCtx returns the matched Entry and decoded ComponentValue from the +// given revert data. The ABI's error entries are tried first, followed by the +// built-in Error(string) and Panic(uint256). func (a ABI) ParseErrorCtx(ctx context.Context, revertData []byte) (*Entry, *ComponentValue, bool) { - // Always include the default error - a = append(ABI{ - {Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}}, - }, a...) - for _, e := range a { - if e.Type == Error { + for _, source := range []ABI{a.FilterType(Error), defaultErrorEntries} { + for _, e := range source { if cv, err := e.DecodeCallDataCtx(ctx, revertData); err == nil { return e, cv, true } @@ -336,20 +370,65 @@ func (a ABI) ParseErrorCtx(ctx context.Context, revertData []byte) (*Entry, *Com return nil, nil, false } -func (a ABI) ErrorString(revertData []byte) (string, bool) { - return a.ErrorStringCtx(context.Background(), revertData) +// ErrorFormatOption configures the behaviour of ErrorString and ErrorStringCtx. +type ErrorFormatOption struct { + // There is a pattern used in some smart contracts, where a string error is used + // to embed the raw bytes of an ABI encoded sub-error. + // While uncommon, this pattern is supported by the library if you set this switch. + // When set, every standard revert `Error(string)` error will be traversed to look for + // eyecatcher (4 byte signatures) of other errors binary encoded within the string. + SearchForWrappedBinaryErrors bool +} + +// ErrorString formats raw EVM revert data as a human-readable string. +// Pass ErrorFormatOption{SearchForWrappedBinaryErrors: true} to decode inner errors +// that are binary-encoded within an Error(string) value. +func (a ABI) ErrorString(revertData []byte, options ...ErrorFormatOption) (string, bool) { + return a.ErrorStringCtx(context.Background(), revertData, options...) +} + +// ErrorStringCtx formats raw EVM revert data as a human-readable string. +// The ABI's own error entries are tried first, followed by the built-in +// Error(string) and Panic(uint256). +// Pass ErrorFormatOption{SearchForWrappedBinaryErrors: true} to decode inner errors +// that are binary-encoded within an Error(string) value. +func (a ABI) ErrorStringCtx(ctx context.Context, revertData []byte, options ...ErrorFormatOption) (string, bool) { + searchBinary := false + for _, o := range options { + searchBinary = searchBinary || o.SearchForWrappedBinaryErrors + } + if searchBinary { + r := a.DecodeRevertErrorCtx(ctx, revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + if r == nil { + return "", false + } + s := r.String() + return s, s != "" + } + e, cv, ok := a.ParseErrorCtx(ctx, revertData) + if !ok { + return "", false + } + s := FormatErrorStringCtx(ctx, e, cv) + return s, s != "" } -func (a ABI) ErrorStringCtx(ctx context.Context, revertData []byte) (strError string, ok bool) { - e, cv, ok := a.ParseErrorCtx(ctx, revertData) - if ok { - strError = FormatErrorStringCtx(ctx, e, cv) - ok = strError != "" +// SanitizeBinaryString returns the input as a text string if it is entirely +// printable ASCII, or hex-encodes the entire input otherwise. This ensures the +// output is always safe for database TEXT columns and human-readable logging. +func SanitizeBinaryString(raw []byte) string { + for _, b := range raw { + if b < 32 || b >= 127 { + return "0x" + hex.EncodeToString(raw) + } } - return strError, ok + return string(raw) } func FormatErrorStringCtx(ctx context.Context, e *Entry, cv *ComponentValue) string { + if e == nil || cv == nil { + return "" + } var ok bool var parsed []interface{} if res, err := NewSerializer(). diff --git a/pkg/abi/abi_test.go b/pkg/abi/abi_test.go index 9cb5bb80..cc793bee 100644 --- a/pkg/abi/abi_test.go +++ b/pkg/abi/abi_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "math/big" + "strings" "testing" "github.com/hyperledger/firefly-common/pkg/ffapi" @@ -1074,6 +1075,389 @@ func TestErrorString(t *testing.T) { } +func TestFilterType(t *testing.T) { + abi := ABI{ + {Type: Function, Name: "transfer"}, + {Type: Error, Name: "MyError", Inputs: ParameterArray{{Type: "string"}}}, + {Type: Event, Name: "Transfer"}, + {Type: Error, Name: "AnotherError", Inputs: ParameterArray{{Type: "uint256"}}}, + } + + errors := abi.FilterType(Error) + require.Len(t, errors, 2) + assert.Equal(t, "MyError", errors[0].Name) + assert.Equal(t, "AnotherError", errors[1].Name) + + functions := abi.FilterType(Function) + require.Len(t, functions, 1) + assert.Equal(t, "transfer", functions[0].Name) + + // No constructors present — result should be nil + assert.Nil(t, abi.FilterType(Constructor)) + + // Empty ABI + assert.Nil(t, ABI{}.FilterType(Error)) +} + +func TestSelectorMap(t *testing.T) { + errorEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + panicEntry := &Entry{Type: Error, Name: "Panic", Inputs: ParameterArray{{Name: "code", Type: "uint256"}}} + abi := ABI{errorEntry, panicEntry} + + m := abi.SelectorMap() + assert.Len(t, m, 2) + + var errorKey [4]byte + copy(errorKey[:], errorEntry.FunctionSelectorBytes()) + assert.Equal(t, errorEntry, m[errorKey]) + + var panicKey [4]byte + copy(panicKey[:], panicEntry.FunctionSelectorBytes()) + assert.Equal(t, panicEntry, m[panicKey]) +} + +func TestSelectorMapFirstWins(t *testing.T) { + // Two entries with identical signatures produce the same 4-byte selector; + // the first one should win as a deterministic tiebreaker. + first := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + second := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + m := ABI{first, second}.SelectorMap() + + var key [4]byte + copy(key[:], first.FunctionSelectorBytes()) + assert.Equal(t, first, m[key], "first entry should win on selector collision") +} + +func TestSelectorMapEmpty(t *testing.T) { + assert.Empty(t, ABI{}.SelectorMap()) +} + +func TestSelectorMapAllEntryTypes(t *testing.T) { + // SelectorMap works on any entry type, not just errors. + // Callers can use FilterType first to restrict the scope. + fnEntry := &Entry{Type: Function, Name: "transfer", Inputs: ParameterArray{{Type: "address"}, {Type: "uint256"}}} + errEntry := &Entry{Type: Error, Name: "InsufficientBalance", Inputs: ParameterArray{{Type: "uint256"}}} + abi := ABI{fnEntry, errEntry} + + m := abi.SelectorMap() + assert.Len(t, m, 2) + + // Restricting to errors only via FilterType gives a smaller map. + errOnly := abi.FilterType(Error).SelectorMap() + assert.Len(t, errOnly, 1) + var key [4]byte + copy(key[:], errEntry.FunctionSelectorBytes()) + assert.Equal(t, errEntry, errOnly[key]) +} + +// buildErrorStringABI encodes msgBytes as the reason argument of Error(string) +// using the ABI pipeline, producing call data prefixed with the 4-byte selector. +// msgBytes may contain arbitrary binary content (e.g. embedded ABI-encoded errors). +func buildErrorStringABI(msgBytes []byte) []byte { + entry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + data, err := entry.EncodeCallDataValues([]interface{}{string(msgBytes)}) + if err != nil { + panic(fmt.Sprintf("buildErrorStringABI: %s", err)) + } + return data +} + +func TestErrorStringBinaryWrappedPlainError(t *testing.T) { + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000001a" + + "4e6f7420656e6f7567682045746865722070726f76696465642e000000000000") + + result, ok := ABI{}.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `Error("Not enough Ether provided.")`, result) +} + +func TestErrorStringBinaryWrappedSingleNested(t *testing.T) { + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a00000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000006b" + + "6f757465723a20" + + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000013" + + "696e6e6572206572726f72206d65737361676500000000000000000000000000" + + "000000000000000000000000000000000000000000") + + result, ok := ABI{}.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `outer: Error("inner error message")`, result) +} + +func TestErrorStringBinaryWrappedDoubleNested(t *testing.T) { + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "00000000000000000000000000000000000000000000000000000000000000cc" + + "6c6576656c313a20" + + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000006c" + + "6c6576656c323a20" + + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000000d" + + "64656570657374206572726f720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + + result, ok := ABI{}.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `level1: level2: Error("deepest error")`, result) +} + +func TestErrorStringBinaryWrappedNestedCustomError(t *testing.T) { + customABI := ABI{ + {Type: Error, Name: "MyCustomError", Inputs: ParameterArray{{Type: "bytes"}}}, + } + customSelector := hex.EncodeToString(customABI[0].FunctionSelectorBytes()) + + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000007c" + + "5b3430345d303164202d206361756768742062797465733a" + + customSelector + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000004" + + "deadbeef00000000000000000000000000000000000000000000000000000000" + + "00000000") + + // Without the custom ABI the inner error can't be decoded — the + // outer Error(string) is formatted directly (binary content included) + result, ok := ABI{}.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(result, `Error("[404]01d`)) + + // With the custom ABI the inner error is decoded + result, ok = customABI.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `[404]01d - caught bytes:MyCustomError("0xdeadbeef")`, result) +} + +func TestErrorStringBinaryWrappedUnknownSelector(t *testing.T) { + // Unknown top-level selector + _, ok := ABI{}.ErrorString([]byte{0x11, 0x22, 0x33, 0x44}, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.False(t, ok) +} + +func TestErrorStringBinaryWrappedMalformedInner(t *testing.T) { + defaultErr := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + sel := defaultErr.FunctionSelectorBytes() + + badData := "prefix:" + string(sel) + "truncated" + outerABI := buildErrorStringABI([]byte(badData)) + + // Malformed inner data can't be decoded, so the outer Error(string) + // is formatted directly with the raw string content + result, ok := ABI{}.ErrorString(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(result, "Error(")) +} + +func TestErrorStringBinaryWrappedDepthLimit(t *testing.T) { + // Build a chain deeper than maxRevertErrorDepth (10) + data := []byte("leaf") + for i := 0; i < maxRevertErrorDepth+2; i++ { + data = buildErrorStringABI(append([]byte("L:"), data...)) + } + + result, ok := ABI{}.ErrorString(data, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + + // The chain should be capped — the leaf should not be fully unwrapped + // through all levels, so the result won't contain a cleanly decoded "leaf" + assert.NotEmpty(t, result) +} + +func TestErrorStringBinaryWrappedCustomBeforeDefault(t *testing.T) { + customABI := ABI{ + {Type: Error, Name: "EarlyErr", Inputs: ParameterArray{{Type: "uint256"}}}, + } + customEncoded, err := customABI[0].EncodeCallDataValues([]interface{}{42}) + require.NoError(t, err) + + innerErrorABI := buildErrorStringABI([]byte("late-error")) + // Custom selector appears before the Error(string) selector + s := "head:" + string(customEncoded) + "middle:" + string(innerErrorABI) + outerABI := buildErrorStringABI([]byte(s)) + + result, ok := customABI.ErrorString(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `head:EarlyErr("42")`, result) +} + +// TestErrorStringAssemblyBubbleUp demonstrates that the assembly bubble-up +// pattern produces legible output from ErrorString — with or without SearchForWrappedBinaryErrors. +// +// The assembly bubble-up pattern: +// +// (bool success, bytes memory result) = target.call(data); +// if (!success) { assembly { revert(add(32, result), mload(result)) } } +// +// passes the raw error bytes through unchanged (no outer wrapper), so the +// error is decoded directly and is legible either way. SearchForWrappedBinaryErrors +// is not required but is harmless. +// TestAssemblyBubbleUpRealPayloads uses revert bytes captured from a live +// Solidity deployment on Kaleido (contract AssemblyRevertTest) where each +// bubbleXxx() function catches its inner revert via `catch (bytes memory data)` +// and re-reverts with: +// +// assembly { revert(add(32, data), mload(data)) } +// +// These payloads confirm that the assembly pattern passes bytes through +// unchanged and that DecodeRevertError handles each without a new option. +func TestAssemblyBubbleUpRealPayloads(t *testing.T) { + noParamsEntry := &Entry{Type: Error, Name: "NoParams", Inputs: ParameterArray{}} + insufficientEntry := &Entry{Type: Error, Name: "InsufficientBalance", Inputs: ParameterArray{ + {Name: "available", Type: "uint256"}, + {Name: "required", Type: "uint256"}, + }} + unauthorizedEntry := &Entry{Type: Error, Name: "Unauthorized", Inputs: ParameterArray{ + {Name: "caller", Type: "address"}, + }} + withStringEntry := &Entry{Type: Error, Name: "WithString", Inputs: ParameterArray{ + {Name: "message", Type: "string"}, + }} + customABI := ABI{noParamsEntry, insufficientEntry, unauthorizedEntry, withStringEntry} + + tests := []struct { + name string + hex string + abi ABI + wantStr string + wantSig string + }{ + { + name: "Error(string)", + hex: "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a4e6f7420656e6f7567682045746865722070726f76696465642e000000000000", + abi: ABI{}, + wantStr: `Error("Not enough Ether provided.")`, + wantSig: "Error(string)", + }, + { + name: "Panic(uint256)", + hex: "0x4e487b710000000000000000000000000000000000000000000000000000000000000001", + abi: ABI{}, + wantStr: `Panic("1")`, + wantSig: "Panic(uint256)", + }, + { + name: "NoParams()", + hex: "0xa28f5fc7", + abi: customABI, + wantStr: `NoParams()`, + wantSig: "NoParams()", + }, + { + name: "InsufficientBalance(uint256,uint256)", + hex: "0xcf479181000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000c8", + abi: customABI, + wantStr: `InsufficientBalance("100","200")`, + wantSig: "InsufficientBalance(uint256,uint256)", + }, + { + name: "WithString(string)", + hex: "0x64ed940e0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000962616420696e7075740000000000000000000000000000000000000000000000", + abi: customABI, + wantStr: `WithString("bad input")`, + wantSig: "WithString(string)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + revertData := ethtypes.MustNewHexBytes0xPrefix(tt.hex) + + r := tt.abi.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Nil(t, r.GetInnerError(), "assembly bubble-up should produce no nesting") + assert.Equal(t, tt.wantStr, r.String()) + + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, tt.wantSig, sig) + + // SearchForWrappedBinaryErrors must not change the result for non-nested payloads + result, ok := tt.abi.ErrorString(revertData, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, tt.wantStr, result) + }) + } +} + +func TestAssemblyBubbleUpUnauthorizedAddress(t *testing.T) { + // Separate test: address formatting needs its own assertion since the + // exact hex representation depends on the serializer's address format. + unauthorizedEntry := &Entry{Type: Error, Name: "Unauthorized", Inputs: ParameterArray{ + {Name: "caller", Type: "address"}, + }} + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x8e4a23d6000000000000000000000000000000000000000000000000000000000000dead") + + r := ABI{unauthorizedEntry}.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Nil(t, r.GetInnerError()) + assert.Equal(t, "Unauthorized", r.ErrorEntry.Name) + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "Unauthorized(address)", sig) + // Confirm the address value is present in the formatted string + assert.Contains(t, r.String(), "dead") +} + +func TestErrorStringAssemblyBubbleUp(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "InsufficientBalance", Inputs: ParameterArray{ + {Name: "available", Type: "uint256"}, + {Name: "required", Type: "uint256"}, + }} + customABI := ABI{customEntry} + + // Real bytes from eth_call on AssemblyRevertTest.bubbleCustomWithUints() + // deployed on Kaleido — identical to a direct InsufficientBalance(100,200) revert. + rawErrorBytes := ethtypes.MustNewHexBytes0xPrefix( + "0xcf479181" + + "0000000000000000000000000000000000000000000000000000000000000064" + + "00000000000000000000000000000000000000000000000000000000000000c8") + + // Without Unwrap: legible because the raw bytes ARE the error. + result, ok := customABI.ErrorString(rawErrorBytes) + assert.True(t, ok) + assert.Equal(t, `InsufficientBalance("100","200")`, result) + + // With SearchForWrappedBinaryErrors: same output — no nesting, so result is unchanged. + resultUnwrap, ok := customABI.ErrorString(rawErrorBytes, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, result, resultUnwrap) + + // Contrast: wrapping the same error inside Error(string) embeds binary in the string. + // Without SearchForWrappedBinaryErrors the string contains raw binary and is not legible. + wrappedBytes := buildErrorStringABI(append([]byte("outer: "), rawErrorBytes...)) + resultWrappedNoUnwrap, ok := customABI.ErrorString(wrappedBytes) + assert.True(t, ok) + assert.False(t, strings.HasPrefix(resultWrappedNoUnwrap, "InsufficientBalance"), + "binary-wrapped error without SearchForWrappedBinaryErrors is not legible") + + // With SearchForWrappedBinaryErrors the inner error is decoded and the result is legible. + resultWrappedUnwrap, ok := customABI.ErrorString(wrappedBytes, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + assert.True(t, ok) + assert.Equal(t, `outer: InsufficientBalance("100","200")`, resultWrappedUnwrap) +} + +func TestSanitizeBinaryString(t *testing.T) { + assert.Equal(t, "", SanitizeBinaryString(nil)) + assert.Equal(t, "", SanitizeBinaryString([]byte{})) + assert.Equal(t, "hello world", SanitizeBinaryString([]byte("hello world"))) + assert.Equal(t, "0xdeadbeef", SanitizeBinaryString([]byte{0xde, 0xad, 0xbe, 0xef})) + assert.Equal(t, "0x000000", SanitizeBinaryString([]byte{0x00, 0x00, 0x00})) + assert.Equal(t, "0x736f6d65206572726f72000000", SanitizeBinaryString([]byte("some error\x00\x00\x00"))) + assert.Equal(t, "0x0168656c6c6f", SanitizeBinaryString([]byte{0x01, 'h', 'e', 'l', 'l', 'o'})) +} + func TestUnnamedInputOutput(t *testing.T) { sampleABI := ABI{ diff --git a/pkg/abi/reverterror.go b/pkg/abi/reverterror.go new file mode 100644 index 00000000..2da3a000 --- /dev/null +++ b/pkg/abi/reverterror.go @@ -0,0 +1,238 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package abi + +import ( + "context" + "strings" + + "github.com/hyperledger/firefly-common/pkg/log" +) + +// defaultErrorEntries are the built-in Solidity error types that are always +// tried when decoding revert data, even if the caller's ABI is empty. +var defaultErrorEntries = ABI{ + {Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}}, + {Type: Error, Name: "Panic", Inputs: ParameterArray{{Name: "code", Type: "uint256"}}}, +} + +const maxRevertErrorDepth = 10 + +// maxInnerErrorScanBytes caps how far into a string value we search for an +// embedded ABI selector. Revert strings are measured in tens of bytes in +// practice, so 1024 is already far beyond any realistic prefix length. +const maxInnerErrorScanBytes = 1024 + +// minABIEncodedLen is the minimum byte length for a decodable ABI-encoded +// error: 4 bytes for the selector plus at least one 32-byte word for the +// first parameter. A candidate selector with fewer bytes remaining is +// guaranteed to fail decoding, so it is skipped. +const minABIEncodedLen = 4 + 32 + +// RevertError represents a decoded Solidity revert error. For nested errors +// (where a contract catches a revert and re-throws with the original error +// embedded in the string), the InnerError field links to the inner decoded +// error, forming a recursive chain. +type RevertError struct { + ErrorEntry *Entry `ffstruct:"RevertError" json:"errorEntry,omitempty"` // the matched ABI error entry at this level + cv *ComponentValue // decoded ABI data for this level (unexported) + Prefix string `ffstruct:"RevertError" json:"prefix,omitempty"` // readable text before the inner error + InnerError *RevertError `ffstruct:"RevertError" json:"innerError,omitempty"` // recursively decoded inner error, nil if none +} + +// DecodeRevertError decodes raw EVM revert data into a RevertError. +// Pass ErrorFormatOption{SearchForWrappedBinaryErrors: true} to scan for inner errors +// binary-encoded within an Error(string) value. +// Returns nil if the data does not match any known error selector. +func (a ABI) DecodeRevertError(revertData []byte, options ...ErrorFormatOption) *RevertError { + return a.DecodeRevertErrorCtx(context.Background(), revertData, options...) +} + +// DecodeRevertErrorCtx decodes raw EVM revert data into a RevertError. +// The ABI's error entries are tried first, followed by the built-in +// Error(string) and Panic(uint256). Returns nil if no selector matches. +// Pass ErrorFormatOption{SearchForWrappedBinaryErrors: true} to scan for inner errors +// binary-encoded within an Error(string) value. +func (a ABI) DecodeRevertErrorCtx(ctx context.Context, revertData []byte, options ...ErrorFormatOption) *RevertError { + searchBinary := false + for _, o := range options { + searchBinary = searchBinary || o.SearchForWrappedBinaryErrors + } + abiErrors := a.FilterType(Error) + for _, source := range []ABI{abiErrors, defaultErrorEntries} { + for _, e := range source { + if cv, err := e.DecodeCallDataCtx(ctx, revertData); err == nil { + r := &RevertError{ErrorEntry: e, cv: cv} + // Only Error(string) is scanned for binary-wrapped inner errors. + // Custom errors with string/bytes params are not scanned since there + // is a high likelihood that they are not intended to carry error data. + if searchBinary && e.Name == "Error" && len(cv.Children) == 1 { + if strVal, ok := cv.Children[0].Value.(string); ok { + // Build a selector map covering both the caller's error entries + // and the built-in defaults, so inner errors of either kind can + // be recognised during recursive unwrapping. + selectors := append(abiErrors, defaultErrorEntries...).SelectorMap() + r.scanForBinaryWrappedError(ctx, selectors, strVal, 0) + } + } + return r + } + } + } + return nil +} + +// scanForBinaryWrappedError scans a decoded string value for an embedded ABI error selector. +// If found, it populates r.Prefix and r.InnerError to form the recursive chain. +func (r *RevertError) scanForBinaryWrappedError(ctx context.Context, selectors map[[4]byte]*Entry, s string, depth int) { + if depth >= maxRevertErrorDepth { + return + } + + raw := []byte(s) + idx, entry := findSelector(raw, selectors) + if idx < 0 { + return + } + + cv, err := entry.DecodeCallDataCtx(ctx, raw[idx:]) + if err != nil { + log.L(ctx).Debugf("Could not decode inner error at depth %d: %s", depth, err) + return + } + + inner := &RevertError{ErrorEntry: entry, cv: cv} + r.Prefix = SanitizeBinaryString(raw[:idx]) + r.InnerError = inner + + // If the inner error is also Error(string), keep scanning recursively + if entry.Name == "Error" && len(cv.Children) == 1 { + if strVal, ok := cv.Children[0].Value.(string); ok { + inner.scanForBinaryWrappedError(ctx, selectors, strVal, depth+1) + } + } +} + +// findSelector scans raw bytes for the first occurrence of a known 4-byte +// error selector. Two constraints are folded into the loop bound: +// - We stop scanning after maxInnerErrorScanBytes, bounding performance on +// large payloads (revert strings are tiny in practice). +// - We only consider positions where at least minABIEncodedLen bytes remain, +// since a selector with fewer bytes after it cannot decode successfully. +func findSelector(raw []byte, selectors map[[4]byte]*Entry) (int, *Entry) { + scanLimit := len(raw) + if scanLimit > maxInnerErrorScanBytes { + scanLimit = maxInnerErrorScanBytes + } + // limit is the highest start index that satisfies both constraints. + limit := scanLimit - 4 + if remainingLimit := len(raw) - minABIEncodedLen; remainingLimit < limit { + limit = remainingLimit + } + var key [4]byte + for i := 0; i <= limit; i++ { + copy(key[:], raw[i:i+4]) + if e, ok := selectors[key]; ok { + return i, e + } + } + return -1, nil +} + +// String returns a human-readable representation of the full error chain. +// It concatenates the Prefix at each level, with the leaf error formatted +// as ErrorName(arg1,arg2,...). +func (r *RevertError) String() string { + if r == nil { + return "" + } + var b strings.Builder + b.WriteString(r.Prefix) + if r.InnerError != nil { + b.WriteString(r.InnerError.String()) + } else { + b.WriteString(FormatErrorStringCtx(context.Background(), r.ErrorEntry, r.cv)) + } + return b.String() +} + +// ErrorString returns the formatted error at this level only, e.g. +// Error("not enough funds") or MyCustomError("0x1234","-100"). +// Unlike String(), it does not walk the chain — use it when +// you need the single-level description. +func (r *RevertError) ErrorString() string { + if r == nil { + return "" + } + return FormatErrorStringCtx(context.Background(), r.ErrorEntry, r.cv) +} + +// Signature returns the ABI signature of the error at this level, +// e.g. "Error(string)" or "AnError(string,uint256)". +func (r *RevertError) Signature() (string, error) { + if r == nil || r.ErrorEntry == nil { + return "", nil + } + return r.ErrorEntry.SignatureCtx(context.Background()) +} + +// SerializeJSON serializes the decoded error data at this level using +// the provided Serializer. +func (r *RevertError) SerializeJSON(ctx context.Context, s *Serializer) ([]byte, error) { + if r == nil || r.cv == nil { + return nil, nil + } + if s == nil { + s = NewSerializer() + } + return s.SerializeJSONCtx(ctx, r.cv) +} + +// GetInnerError returns the next error in the chain (one level deeper), or nil +// at the leaf. +func (r *RevertError) GetInnerError() *RevertError { + if r == nil { + return nil + } + return r.InnerError +} + +// Innermost walks the chain to return the deepest RevertError — the +// innermost binary-wrapped error. +func (r *RevertError) Innermost() *RevertError { + if r == nil { + return nil + } + cur := r + for cur.InnerError != nil { + cur = cur.InnerError + } + return cur +} + +// Errors returns a flattened slice of all RevertError entries in the chain, +// from outermost to innermost. +func (r *RevertError) Errors() []*RevertError { + if r == nil { + return nil + } + var result []*RevertError + for cur := r; cur != nil; cur = cur.InnerError { + result = append(result, cur) + } + return result +} diff --git a/pkg/abi/reverterror_test.go b/pkg/abi/reverterror_test.go new file mode 100644 index 00000000..f989961f --- /dev/null +++ b/pkg/abi/reverterror_test.go @@ -0,0 +1,633 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package abi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodeTestError builds a properly typed ComponentValue by encoding and then +// decoding through the ABI pipeline. +func decodeTestError(t *testing.T, entry *Entry, jsonArgs string) *ComponentValue { + t.Helper() + encoded, err := entry.EncodeCallDataJSON([]byte(jsonArgs)) + require.NoError(t, err) + cv, err := entry.DecodeCallDataCtx(context.Background(), encoded) + require.NoError(t, err) + return cv +} + +// --- Nil receiver safety --- + +func TestRevertErrorNilString(t *testing.T) { + var r *RevertError + assert.Equal(t, "", r.String()) +} + +func TestRevertErrorNilSignature(t *testing.T) { + var r *RevertError + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "", sig) +} + +func TestRevertErrorNilSerializeJSON(t *testing.T) { + var r *RevertError + b, err := r.SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Nil(t, b) +} + +func TestRevertErrorNilInnerError(t *testing.T) { + var r *RevertError + assert.Nil(t, r.GetInnerError()) +} + +func TestRevertErrorNilInnermost(t *testing.T) { + var r *RevertError + assert.Nil(t, r.Innermost()) +} + +func TestRevertErrorNilErrors(t *testing.T) { + var r *RevertError + assert.Nil(t, r.Errors()) +} + +// --- Single (non-nested) error --- + +func TestRevertErrorSingleString(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + assert.Equal(t, `AnError("something went wrong")`, r.String()) +} + +func TestRevertErrorSingleSignature(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "AnError(string)", sig) +} + +func TestRevertErrorSingleSerializeJSON(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + b, err := r.SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Contains(t, string(b), "something went wrong") +} + +func TestRevertErrorSingleSerializeJSONNilCV(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + r := &RevertError{ErrorEntry: entry} + b, err := r.SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Nil(t, b) +} + +func TestRevertErrorSingleInnerError(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + assert.Nil(t, r.GetInnerError()) +} + +func TestRevertErrorSingleInnermost(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + assert.Equal(t, r, r.Innermost()) +} + +func TestRevertErrorSingleErrors(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"something went wrong"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + errs := r.Errors() + require.Len(t, errs, 1) + assert.Equal(t, r, errs[0]) +} + +func TestRevertErrorSingleWithPrefix(t *testing.T) { + entry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + cv := decodeTestError(t, entry, `{"reason":"plain error"}`) + r := &RevertError{ErrorEntry: entry, cv: cv, Prefix: "context: "} + assert.Equal(t, `context: Error("plain error")`, r.String()) +} + +func TestRevertErrorMultipleParams(t *testing.T) { + entry := &Entry{Type: Error, Name: "ExampleError", Inputs: ParameterArray{ + {Name: "param1", Type: "string"}, + {Name: "param2", Type: "uint256"}, + }} + cv := decodeTestError(t, entry, `{"param1":"test1","param2":12345}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + assert.Equal(t, `ExampleError("test1","12345")`, r.String()) +} + +// --- Two-level InnerError --- + +func TestRevertErrorNestedTwoLevelString(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outerCV := decodeTestError(t, outerEntry, `{"reason":"raw outer value"}`) + outer := &RevertError{ + ErrorEntry: outerEntry, + cv: outerCV, + Prefix: "[404]caught bytes", + InnerError: inner, + } + assert.Equal(t, `[404]caught bytesAnError("I am an error")`, outer.String()) +} + +func TestRevertErrorNestedTwoLevelSignatures(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outer := &RevertError{ + ErrorEntry: outerEntry, + Prefix: "[404]caught bytes", + InnerError: inner, + } + + outerSig, err := outer.Signature() + assert.NoError(t, err) + assert.Equal(t, "Error(string)", outerSig) + + innerSig, err := inner.Signature() + assert.NoError(t, err) + assert.Equal(t, "AnError(string)", innerSig) +} + +func TestRevertErrorNestedTwoLevelInnerError(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outer := &RevertError{ErrorEntry: outerEntry, Prefix: "[404]caught bytes", InnerError: inner} + + assert.Equal(t, inner, outer.GetInnerError()) + assert.Nil(t, inner.GetInnerError()) +} + +func TestRevertErrorNestedTwoLevelInnermost(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outer := &RevertError{ErrorEntry: outerEntry, Prefix: "[404]caught bytes", InnerError: inner} + + assert.Equal(t, inner, outer.Innermost()) + assert.Equal(t, inner, inner.Innermost()) +} + +func TestRevertErrorNestedTwoLevelErrors(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outer := &RevertError{ErrorEntry: outerEntry, Prefix: "[404]caught bytes", InnerError: inner} + + errs := outer.Errors() + require.Len(t, errs, 2) + assert.Equal(t, outer, errs[0]) + assert.Equal(t, inner, errs[1]) +} + +func TestRevertErrorNestedTwoLevelSerializeJSONInnermost(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"I am an error"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outerCV := decodeTestError(t, outerEntry, `{"reason":"raw bytes here"}`) + outer := &RevertError{ErrorEntry: outerEntry, cv: outerCV, Prefix: "[404]caught bytes", InnerError: inner} + + b, err := outer.Innermost().SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Contains(t, string(b), "I am an error") +} + +// --- Three-level InnerError --- + +func TestRevertErrorNestedThreeLevelString(t *testing.T) { + leafEntry := &Entry{Type: Error, Name: "RootCause", Inputs: ParameterArray{{Name: "detail", Type: "string"}}} + leafCV := decodeTestError(t, leafEntry, `{"detail":"the real problem"}`) + leaf := &RevertError{ErrorEntry: leafEntry, cv: leafCV} + + middleEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + middle := &RevertError{ErrorEntry: middleEntry, Prefix: "middleware: ", InnerError: leaf} + + outerEntry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + outer := &RevertError{ErrorEntry: outerEntry, Prefix: "gateway: ", InnerError: middle} + + assert.Equal(t, `gateway: middleware: RootCause("the real problem")`, outer.String()) +} + +func TestRevertErrorNestedThreeLevelInnermost(t *testing.T) { + leafEntry := &Entry{Type: Error, Name: "RootCause", Inputs: ParameterArray{{Name: "detail", Type: "string"}}} + leafCV := decodeTestError(t, leafEntry, `{"detail":"the real problem"}`) + leaf := &RevertError{ErrorEntry: leafEntry, cv: leafCV} + + middle := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, Prefix: "middleware: ", InnerError: leaf} + outer := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, Prefix: "gateway: ", InnerError: middle} + + assert.Equal(t, leaf, outer.Innermost()) +} + +func TestRevertErrorNestedThreeLevelErrors(t *testing.T) { + leafEntry := &Entry{Type: Error, Name: "RootCause", Inputs: ParameterArray{{Name: "detail", Type: "string"}}} + leafCV := decodeTestError(t, leafEntry, `{"detail":"the real problem"}`) + leaf := &RevertError{ErrorEntry: leafEntry, cv: leafCV} + + middle := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, Prefix: "middleware: ", InnerError: leaf} + outer := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, Prefix: "gateway: ", InnerError: middle} + + errs := outer.Errors() + require.Len(t, errs, 3) + assert.Equal(t, outer, errs[0]) + assert.Equal(t, middle, errs[1]) + assert.Equal(t, leaf, errs[2]) +} + +func TestRevertErrorNestedThreeLevelInnerErrorChain(t *testing.T) { + leafEntry := &Entry{Type: Error, Name: "RootCause", Inputs: ParameterArray{{Name: "detail", Type: "string"}}} + leafCV := decodeTestError(t, leafEntry, `{"detail":"the real problem"}`) + leaf := &RevertError{ErrorEntry: leafEntry, cv: leafCV} + + middle := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, InnerError: leaf} + outer := &RevertError{ErrorEntry: &Entry{Type: Error, Name: "Error"}, InnerError: middle} + + assert.Equal(t, middle, outer.GetInnerError()) + assert.Equal(t, leaf, outer.GetInnerError().GetInnerError()) + assert.Nil(t, outer.GetInnerError().GetInnerError().GetInnerError()) +} + +// --- SerializeJSON with custom serializer --- + +func TestRevertErrorSerializeJSONCustomSerializer(t *testing.T) { + entry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + cv := decodeTestError(t, entry, `{"message":"test value"}`) + r := &RevertError{ErrorEntry: entry, cv: cv} + s := NewSerializer().SetPretty(true) + b, err := r.SerializeJSON(context.Background(), s) + assert.NoError(t, err) + assert.Contains(t, string(b), "\n") + assert.Contains(t, string(b), "test value") +} + +// --- Edge cases --- + +func TestRevertErrorEmptyPrefix(t *testing.T) { + innerEntry := &Entry{Type: Error, Name: "AnError", Inputs: ParameterArray{{Name: "message", Type: "string"}}} + innerCV := decodeTestError(t, innerEntry, `{"message":"direct"}`) + inner := &RevertError{ErrorEntry: innerEntry, cv: innerCV} + + outer := &RevertError{ + ErrorEntry: &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}}, + Prefix: "", + InnerError: inner, + } + assert.Equal(t, `AnError("direct")`, outer.String()) +} + +func TestRevertErrorSignatureNilEntry(t *testing.T) { + r := &RevertError{} + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "", sig) +} + +// --- DecodeRevertError / DecodeRevertErrorCtx --- + +func TestDecodeRevertErrorDefaultErrorString(t *testing.T) { + revertData := testEncodeError(t, + &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}}, + `{"reason":"Not enough Ether provided."}`, + ) + r := ABI{}.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Equal(t, "Error", r.ErrorEntry.Name) + assert.Equal(t, `Error("Not enough Ether provided.")`, r.String()) + assert.Nil(t, r.GetInnerError()) +} + +func TestDecodeRevertErrorDefaultPanic(t *testing.T) { + revertData := testEncodeError(t, + &Entry{Type: Error, Name: "Panic", Inputs: ParameterArray{{Name: "code", Type: "uint256"}}}, + `{"code":1}`, + ) + r := ABI{}.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Equal(t, "Panic", r.ErrorEntry.Name) + assert.Equal(t, `Panic("1")`, r.String()) + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "Panic(uint256)", sig) +} + +func TestDecodeRevertErrorCustomError(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "InsufficientBalance", Inputs: ParameterArray{ + {Name: "available", Type: "uint256"}, + {Name: "required", Type: "uint256"}, + }} + customABI := ABI{customEntry} + revertData := testEncodeError(t, customEntry, `{"available":100,"required":200}`) + + r := customABI.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Equal(t, "InsufficientBalance", r.ErrorEntry.Name) + assert.Equal(t, `InsufficientBalance("100","200")`, r.String()) + sig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "InsufficientBalance(uint256,uint256)", sig) +} + +func TestDecodeRevertErrorCustomBeforeBuiltin(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "MyError", Inputs: ParameterArray{{Name: "msg", Type: "string"}}} + customABI := ABI{customEntry} + revertData := testEncodeError(t, customEntry, `{"msg":"custom message"}`) + + r := customABI.DecodeRevertError(revertData) + require.NotNil(t, r) + assert.Equal(t, "MyError", r.ErrorEntry.Name, "custom ABI entries should be tried before builtins") +} + +func TestDecodeRevertErrorNoMatch(t *testing.T) { + r := ABI{}.DecodeRevertError([]byte{0x11, 0x22, 0x33, 0x44}) + assert.Nil(t, r) +} + +func TestDecodeRevertErrorTooShort(t *testing.T) { + r := ABI{}.DecodeRevertError([]byte{0x08}) + assert.Nil(t, r) +} + +func TestDecodeRevertErrorNilData(t *testing.T) { + r := ABI{}.DecodeRevertError(nil) + assert.Nil(t, r) +} + +func TestDecodeRevertErrorEmptyData(t *testing.T) { + r := ABI{}.DecodeRevertError([]byte{}) + assert.Nil(t, r) +} + +func TestDecodeRevertErrorCtxPassesContext(t *testing.T) { + revertData := testEncodeError(t, + &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}}, + `{"reason":"with context"}`, + ) + ctx := context.Background() + r := ABI{}.DecodeRevertErrorCtx(ctx, revertData) + require.NotNil(t, r) + assert.Equal(t, `Error("with context")`, r.String()) +} + +func TestDecodeRevertErrorSerializeJSON(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "ExampleError", Inputs: ParameterArray{ + {Name: "param1", Type: "string"}, + {Name: "param2", Type: "uint256"}, + }} + revertData := testEncodeError(t, customEntry, `{"param1":"test1","param2":12345}`) + r := ABI{customEntry}.DecodeRevertError(revertData) + require.NotNil(t, r) + + b, err := r.SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Contains(t, string(b), "test1") + assert.Contains(t, string(b), "12345") +} + +func TestDecodeRevertErrorNonErrorEntriesIgnored(t *testing.T) { + fnEntry := &Entry{Type: Function, Name: "transfer", Inputs: ParameterArray{ + {Name: "to", Type: "address"}, + {Name: "amount", Type: "uint256"}, + }} + r := ABI{fnEntry}.DecodeRevertError([]byte{0x11, 0x22, 0x33, 0x44}) + assert.Nil(t, r, "function entries should not be tried for error decoding") +} + +// testEncodeError is a helper that ABI-encodes error data for a given entry and JSON args. +func testEncodeError(t *testing.T, entry *Entry, jsonArgs string) []byte { + t.Helper() + encoded, err := entry.EncodeCallDataJSON([]byte(jsonArgs)) + require.NoError(t, err) + return encoded +} + +// --- Binary-wrapped inner errors (DecodeRevertError with SearchForWrappedBinaryErrors) --- + +func TestDecodeRevertErrorSingleNested(t *testing.T) { + innerABI := buildErrorStringABI([]byte("inner error message")) + outerABI := buildErrorStringABI(append([]byte("outer: "), innerABI...)) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Equal(t, "Error", r.ErrorEntry.Name) + assert.Equal(t, "outer: ", r.Prefix) + + require.NotNil(t, r.GetInnerError()) + assert.Equal(t, "Error", r.GetInnerError().ErrorEntry.Name) + assert.Nil(t, r.GetInnerError().GetInnerError()) + + assert.Equal(t, `outer: Error("inner error message")`, r.String()) +} + +func TestDecodeRevertErrorDoubleNested(t *testing.T) { + deepABI := buildErrorStringABI([]byte("deepest error")) + midABI := buildErrorStringABI(append([]byte("level2: "), deepABI...)) + outerABI := buildErrorStringABI(append([]byte("level1: "), midABI...)) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Equal(t, "level1: ", r.Prefix) + + mid := r.GetInnerError() + require.NotNil(t, mid) + assert.Equal(t, "level2: ", mid.Prefix) + + leaf := mid.GetInnerError() + require.NotNil(t, leaf) + assert.Nil(t, leaf.GetInnerError()) + + assert.Equal(t, `level1: level2: Error("deepest error")`, r.String()) + assert.Equal(t, leaf, r.Innermost()) + + errs := r.Errors() + require.Len(t, errs, 3) +} + +func TestDecodeRevertErrorNestedCustomError(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "MyCustomError", Inputs: ParameterArray{{Type: "bytes"}}} + customEncoded := testEncodeError(t, customEntry, `{"0":"0xdeadbeef"}`) + + outerABI := buildErrorStringABI(append([]byte("[404]01d - caught bytes:"), customEncoded...)) + + r := ABI{customEntry}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Equal(t, "Error", r.ErrorEntry.Name) + assert.Equal(t, "[404]01d - caught bytes:", r.Prefix) + + inner := r.GetInnerError() + require.NotNil(t, inner) + assert.Equal(t, "MyCustomError", inner.ErrorEntry.Name) + assert.Nil(t, inner.GetInnerError()) + + sig, err := inner.Signature() + assert.NoError(t, err) + assert.Equal(t, "MyCustomError(bytes)", sig) + + assert.Equal(t, `[404]01d - caught bytes:MyCustomError("0xdeadbeef")`, r.String()) +} + +func TestDecodeRevertErrorCustomBeforeDefaultNested(t *testing.T) { + customEntry := &Entry{Type: Error, Name: "EarlyErr", Inputs: ParameterArray{{Type: "uint256"}}} + customEncoded := testEncodeError(t, customEntry, `{"0":42}`) + + innerErrorABI := buildErrorStringABI([]byte("late-error")) + // Custom selector appears before the Error(string) selector + payload := append([]byte("head:"), customEncoded...) + payload = append(payload, []byte("middle:")...) + payload = append(payload, innerErrorABI...) + outerABI := buildErrorStringABI(payload) + + r := ABI{customEntry}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Equal(t, "head:", r.Prefix) + + inner := r.GetInnerError() + require.NotNil(t, inner) + assert.Equal(t, "EarlyErr", inner.ErrorEntry.Name, "first matching selector wins") +} + +func TestDecodeRevertErrorNoNesting(t *testing.T) { + outerABI := buildErrorStringABI([]byte("plain error with no nesting")) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Equal(t, "", r.Prefix) + assert.Nil(t, r.GetInnerError()) + assert.Equal(t, `Error("plain error with no nesting")`, r.String()) +} + +func TestDecodeRevertErrorMalformedNested(t *testing.T) { + defaultErr := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + sel := defaultErr.FunctionSelectorBytes() + + badData := append([]byte("prefix:"), sel...) + badData = append(badData, []byte("truncated")...) + outerABI := buildErrorStringABI(badData) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Nil(t, r.GetInnerError(), "malformed inner data should not produce an inner error") + assert.Equal(t, "", r.Prefix) +} + +func TestDecodeRevertErrorDepthLimit(t *testing.T) { + // Build a chain deeper than maxRevertErrorDepth (10) + data := []byte("leaf") + for i := 0; i < maxRevertErrorDepth+2; i++ { + data = buildErrorStringABI(append([]byte("L:"), data...)) + } + + r := ABI{}.DecodeRevertError(data, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + + depth := 0 + for cur := r; cur != nil; cur = cur.GetInnerError() { + depth++ + } + assert.LessOrEqual(t, depth, maxRevertErrorDepth+1, "chain should be capped by depth limit") +} + +func TestDecodeRevertErrorInnermostSerializeJSON(t *testing.T) { + innerABI := buildErrorStringABI([]byte("inner value")) + outerABI := buildErrorStringABI(append([]byte("prefix:"), innerABI...)) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + + leaf := r.Innermost() + require.NotNil(t, leaf) + b, err := leaf.SerializeJSON(context.Background(), nil) + assert.NoError(t, err) + assert.Contains(t, string(b), "inner value") +} + +func TestDecodeRevertErrorNestedSignatures(t *testing.T) { + innerABI := buildErrorStringABI([]byte("inner")) + outerABI := buildErrorStringABI(append([]byte("prefix:"), innerABI...)) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + + outerSig, err := r.Signature() + assert.NoError(t, err) + assert.Equal(t, "Error(string)", outerSig) + + innerSig, err := r.GetInnerError().Signature() + assert.NoError(t, err) + assert.Equal(t, "Error(string)", innerSig) +} + +// --- findSelector constraints --- + +func TestFindSelectorScanCapExceeded(t *testing.T) { + // Place a valid inner error beyond the maxInnerErrorScanBytes boundary. + // It should not be found. + innerABI := buildErrorStringABI([]byte("inner")) + prefix := make([]byte, maxInnerErrorScanBytes) // pushes selector past the cap + outerABI := buildErrorStringABI(append(prefix, innerABI...)) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Nil(t, r.GetInnerError(), "selector beyond scan cap should not be found") +} + +func TestFindSelectorInsufficientBytesAfterSelector(t *testing.T) { + // Build a payload where the selector appears near the end of the string + // with fewer than minABIEncodedLen bytes remaining after it — too short + // to hold a valid ABI encoding. + entry := &Entry{Type: Error, Name: "Error", Inputs: ParameterArray{{Name: "reason", Type: "string"}}} + sel := entry.FunctionSelectorBytes() + + // Append only 3 bytes after the selector — less than the 32-byte minimum word. + truncated := append([]byte(nil), sel...) + truncated = append(truncated, 0x00, 0x00, 0x00) + outerABI := buildErrorStringABI(truncated) + + r := ABI{}.DecodeRevertError(outerABI, ErrorFormatOption{SearchForWrappedBinaryErrors: true}) + require.NotNil(t, r) + assert.Nil(t, r.GetInnerError(), "selector with insufficient trailing bytes should be skipped") +}