diff --git a/.gitignore b/.gitignore index 1a0a283..698b465 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ __debug* .leveldb .vscode/*.log *.iml -.idea/ \ No newline at end of file +.idea/ + +# Local Go workspace (not part of the module release) +go.work +go.work.sum \ No newline at end of file diff --git a/go.mod b/go.mod index 11cf7e2..c57bdcb 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/hashicorp/golang-lru v1.0.2 github.com/hyperledger/firefly-common v1.5.9 - github.com/hyperledger/firefly-signer v1.1.21 + github.com/hyperledger/firefly-signer v1.1.23-0.20260422080826-42345c6c6b85 github.com/hyperledger/firefly-transaction-manager v1.4.4 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 @@ -24,7 +24,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 // indirect github.com/aidarkhanov/nanoid v1.0.8 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.5 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect diff --git a/go.sum b/go.sum index f769651..9bba158 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/aidarkhanov/nanoid v1.0.8 h1:yxyJkgsEDFXP7+97vc6JevMcjyb03Zw+/9fqhlVX github.com/aidarkhanov/nanoid v1.0.8/go.mod h1:vadfZHT+m4uDhttg0yY4wW3GKtl2T6i4d2Age+45pYk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= -github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU= +github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -102,8 +102,8 @@ github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hyperledger/firefly-common v1.5.9 h1:Z1+SuKNYJ8hPKQ5CvcsMg6r/E4RyW6wb08nGwtcc8Ww= github.com/hyperledger/firefly-common v1.5.9/go.mod h1:1Xawm5PUhxT7k+CL/Kr3i1LE3cTTzoQwZMLimvlW8rs= -github.com/hyperledger/firefly-signer v1.1.21 h1:r7cTOw6e/6AtiXLf84wZy6Z7zppzlc191HokW2hv4N4= -github.com/hyperledger/firefly-signer v1.1.21/go.mod h1:axrlSQeKrd124UdHF5L3MkTjb5DeTcbJxJNCZ3JmcWM= +github.com/hyperledger/firefly-signer v1.1.23-0.20260422080826-42345c6c6b85 h1:gh3YhxUYYwOfBCsEJXFmWO7SFzFrNuNulXftOam2JRI= +github.com/hyperledger/firefly-signer v1.1.23-0.20260422080826-42345c6c6b85/go.mod h1:cb40Xkm/t2+KH+V1q3/zxZPohBNEA0iOA7mcr9wyfzI= github.com/hyperledger/firefly-transaction-manager v1.4.4 h1:cbG9FkQWriOcc1MMGaMqU7OpOwLloSV+PImOoaN0ckU= github.com/hyperledger/firefly-transaction-manager v1.4.4/go.mod h1:1kbYt8ofDXqfwC02vwV/HoOjmiv0IuT9UkJ//bbrliE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= diff --git a/internal/ethereum/estimate_gas_test.go b/internal/ethereum/estimate_gas_test.go index d0060b1..fe5b413 100644 --- a/internal/ethereum/estimate_gas_test.go +++ b/internal/ethereum/estimate_gas_test.go @@ -17,7 +17,6 @@ package ethereum import ( - "context" "encoding/hex" "encoding/json" "testing" @@ -32,6 +31,12 @@ import ( "github.com/stretchr/testify/mock" ) +var testDefaultError = &abi.Entry{ + Type: abi.Error, + Name: "Error", + Inputs: abi.ParameterArray{{Type: "string"}}, +} + const sampleGasEstimate = `{ "ffcapi": { "version": "v1.0.0", @@ -150,7 +155,7 @@ func TestGasEstimateFailRevertReasonInData(t *testing.T) { ctx, c, mRPC, done := newTestConnector(t) defer done() - errData, err := defaultError.EncodeCallDataValues([]string{"this reason"}) + errData, err := testDefaultError.EncodeCallDataValues([]string{"this reason"}) assert.NoError(t, err) mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_estimateGas", mock.MatchedBy(func(tx *ethsigner.Transaction) bool { @@ -205,7 +210,7 @@ func TestGasEstimateFailThenRevertDataFromCall(t *testing.T) { ctx, c, mRPC, done := newTestConnector(t) defer done() - errData, err := defaultError.EncodeCallDataValues([]string{"this reason"}) + errData, err := testDefaultError.EncodeCallDataValues([]string{"this reason"}) assert.NoError(t, err) mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_estimateGas", mock.MatchedBy(func(tx *ethsigner.Transaction) bool { @@ -261,7 +266,7 @@ func TestGasEstimateFailCustomErrorCannotParse(t *testing.T) { ctx, c, mRPC, done := newTestConnector(t) defer done() - errData, err := defaultError.EncodeCallDataValues([]string{"this reason"}) + errData, err := testDefaultError.EncodeCallDataValues([]string{"this reason"}) assert.NoError(t, err) mRPC.On("CallRPC", mock.Anything, mock.Anything, "eth_estimateGas", mock.MatchedBy(func(tx *ethsigner.Transaction) bool { @@ -281,6 +286,3 @@ func TestGasEstimateFailCustomErrorCannotParse(t *testing.T) { } -func TestFormatErrorComponentBadCV(t *testing.T) { - assert.Equal(t, "?", formatErrorComponent(context.Background(), &abi.ComponentValue{})) -} diff --git a/internal/ethereum/exec_query.go b/internal/ethereum/exec_query.go index 701c20f..e56ce6e 100644 --- a/internal/ethereum/exec_query.go +++ b/internal/ethereum/exec_query.go @@ -17,10 +17,8 @@ package ethereum import ( - "bytes" "context" "encoding/json" - "fmt" "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/hyperledger/firefly-common/pkg/i18n" @@ -34,21 +32,6 @@ import ( "github.com/hyperledger/firefly-transaction-manager/pkg/ffcapi" ) -var ( - // See https://docs.soliditylang.org/en/v0.8.14/control-structures.html#revert - // There default error for `revert("some error")` is a function Error(string) - defaultError = &abi.Entry{ - Type: abi.Error, - Name: "Error", - Inputs: abi.ParameterArray{ - { - Type: "string", - }, - }, - } - defaultErrorID = defaultError.FunctionSelectorBytes() -) - func (c *ethConnector) QueryInvoke(ctx context.Context, req *ffcapi.QueryInvokeRequest) (*ffcapi.QueryInvokeResponse, ffcapi.ErrorReason, error) { // Parse the input JSON data, to build the call data callData, method, err := c.prepareCallData(ctx, &req.TransactionInput) @@ -164,59 +147,15 @@ func processRevertReason(ctx context.Context, outputData ethtypes.HexBytes0xPref // result in a multiple of 32 bytes) and has exactly 4 extra bytes for a function // signature if len(outputData)%32 == 4 { - signature := outputData[0:4] - if bytes.Equal(signature, defaultErrorID) { - errorInfo, err := defaultError.DecodeCallDataCtx(ctx, outputData) - if err == nil && len(errorInfo.Children) == 1 { - if strError, ok := errorInfo.Children[0].Value.(string); ok { - return strError - } - } - log.L(ctx).Warnf("Invalid revert data: %s", outputData) - } else if len(errorAbis) > 0 { - // check if the signature matches any of the declared custom error definitions - for _, e := range errorAbis { - idBytes := e.FunctionSelectorBytes() - if bytes.Equal(signature, idBytes) { - err := formatCustomError(ctx, e, outputData) - if err == "" { - log.L(ctx).Warnf("Invalid revert data: %s", outputData) - break - } - return err - } - } + var errors abi.ABI + for _, e := range errorAbis { + errors = append(errors, e) + } + if result, ok := errors.ErrorStringCtx(ctx, outputData, abi.ErrorFormatOption{SearchForWrappedBinaryErrors: true}); ok { + return result } - // we call this "transient error" because it signals to the caller of the case - // that the raw revert data is returned, then it gets thrown away. so no need to translate log.L(ctx).Debugf("Directly returning revert reason: %s", outputData) return outputData.String() } return "" } - -func formatCustomError(ctx context.Context, e *abi.Entry, outputData ethtypes.HexBytes0xPrefix) string { - errorInfo, err := e.DecodeCallDataCtx(ctx, outputData) - if err == nil { - strError := fmt.Sprintf("%s(", e.Name) - for i, child := range errorInfo.Children { - strError += formatErrorComponent(ctx, child) - if i < len(errorInfo.Children)-1 { - strError += ", " - } - } - strError += ")" - return strError - } - return "" -} - -func formatErrorComponent(ctx context.Context, child *abi.ComponentValue) string { - value, err := child.JSON() - if err != nil { - // if this part of the error structure failed to parse, simply append "?" - log.L(ctx).Warnf("Failed to parse component value in error: %+v", child) - return "?" - } - return string(value) -} diff --git a/internal/ethereum/exec_query_test.go b/internal/ethereum/exec_query_test.go index 0452111..57bdef5 100644 --- a/internal/ethereum/exec_query_test.go +++ b/internal/ethereum/exec_query_test.go @@ -17,12 +17,15 @@ package ethereum import ( + "encoding/hex" "encoding/json" + "strings" "testing" "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-evmconnect/internal/msgs" + "github.com/hyperledger/firefly-signer/pkg/abi" "github.com/hyperledger/firefly-signer/pkg/ethsigner" "github.com/hyperledger/firefly-signer/pkg/ethtypes" "github.com/hyperledger/firefly-signer/pkg/rpcbackend" @@ -164,7 +167,7 @@ func TestExecQueryCustomErrorRevertData(t *testing.T) { assert.NoError(t, err) _, reason, err := c.QueryInvoke(ctx, &req) assert.Equal(t, ffcapi.ErrorReasonTransactionReverted, reason) - expectedError := i18n.NewError(ctx, msgs.MsgReverted, `GreaterThanTen("20", "20")`) + expectedError := i18n.NewError(ctx, msgs.MsgReverted, `GreaterThanTen("20","20")`) assert.Equal(t, expectedError.Error(), err.Error()) } @@ -185,7 +188,7 @@ func TestExecQueryCustomErrorRevertDataExceedsBalance(t *testing.T) { assert.NoError(t, err) _, reason, err := c.QueryInvoke(ctx, &req) assert.Equal(t, ffcapi.ErrorReasonTransactionReverted, reason) - expectedError := i18n.NewError(ctx, msgs.MsgReverted, `ERC20: transfer amount exceeds balance`) + expectedError := i18n.NewError(ctx, msgs.MsgReverted, `Error("ERC20: transfer amount exceeds balance")`) assert.Equal(t, expectedError.Error(), err.Error()) } @@ -206,7 +209,7 @@ func TestExecQueryCustomErrorRevertDataNotEnoughEther(t *testing.T) { assert.NoError(t, err) _, reason, err := c.QueryInvoke(ctx, &req) assert.Equal(t, ffcapi.ErrorReasonTransactionReverted, reason) - expectedError := i18n.NewError(ctx, msgs.MsgReverted, `Not enough Ether provided.`) + expectedError := i18n.NewError(ctx, msgs.MsgReverted, `Error("Not enough Ether provided.")`) assert.Equal(t, expectedError.Error(), err.Error()) } @@ -227,7 +230,7 @@ func TestExecQueryCustomErrorRevertDataTransferFromZeroAddress(t *testing.T) { assert.NoError(t, err) _, reason, err := c.QueryInvoke(ctx, &req) assert.Equal(t, ffcapi.ErrorReasonTransactionReverted, reason) - expectedError := i18n.NewError(ctx, msgs.MsgReverted, `ERC20: transfer from the zero address`) + expectedError := i18n.NewError(ctx, msgs.MsgReverted, `Error("ERC20: transfer from the zero address")`) assert.Equal(t, expectedError.Error(), err.Error()) } @@ -390,6 +393,350 @@ func TestExecQueryFailBadToAddress(t *testing.T) { } +func TestProcessRevertReasonNestedErrorString(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Outer Error(string) wrapping "outer: " + raw inner Error(string) ABI bytes. + // Simulates: catch (bytes memory reason) { revert(string.concat("outer: ", string(reason))); } + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a00000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000006b" + + "6f757465723a20" + + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000013" + + "696e6e6572206572726f72206d65737361676500000000000000000000000000" + + "000000000000000000000000000000000000000000") + + result := processRevertReason(ctx, revertData, nil) + assert.Equal(t, `outer: Error("inner error message")`, result) +} + +func TestProcessRevertReasonDoubleNestedErrorString(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Three levels: Error("level1: " + Error("level2: " + Error("deepest error"))) + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "00000000000000000000000000000000000000000000000000000000000000cc" + + "6c6576656c313a20" + // "level1: " + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000006c" + + "6c6576656c323a20" + // "level2: " + "08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000000d" + + "64656570657374206572726f720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + + result := processRevertReason(ctx, revertData, nil) + assert.Equal(t, `level1: level2: Error("deepest error")`, result) +} + +func TestProcessRevertReasonNestedCustomError(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Define the custom error ABI first so we can use its real selector + customErr := &abi.Entry{ + Type: abi.Error, + Name: "MyCustomError", + Inputs: abi.ParameterArray{ + {Type: "bytes"}, + }, + } + customSelector := hex.EncodeToString(customErr.FunctionSelectorBytes()) + + // Error("[404]01d - caught bytes:" + MyCustomError(0xdeadbeef) raw ABI bytes) + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000007c" + + "5b3430345d303164202d206361756768742062797465733a" + // "[404]01d - caught bytes:" + customSelector + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000004" + + "deadbeef00000000000000000000000000000000000000000000000000000000" + + "00000000") + + // With no error ABIs, the custom error can't be decoded — the outer Error(string) + // is formatted directly (binary content JSON-escaped inside the string). + result := processRevertReason(ctx, revertData, nil) + assert.True(t, strings.HasPrefix(result, `Error("[404]01d`)) + assert.NotContains(t, result, "\x00") + + // Now provide the custom error ABI so it CAN be decoded + result = processRevertReason(ctx, revertData, []*abi.Entry{customErr}) + assert.Equal(t, `[404]01d - caught bytes:MyCustomError("0xdeadbeef")`, result) +} + +func TestProcessRevertReasonUnknownNestedBinaryFallback(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Error("[404]01d - caught bytes:" + unknown error selector + binary payload) + // The embedded selector ac8ae0 is NOT in our error ABIs, so the function + // falls back to readable prefix + hex-encoded binary remainder. + revertData := ethtypes.MustNewHexBytes0xPrefix("0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000007b5b3430345d303164202d2063617567687420627974" + + "65733aac8ae000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000") + + result := processRevertReason(ctx, revertData, nil) + + // Unknown nested selector: outer Error(string) is still decoded; binary tail is JSON-escaped. + assert.True(t, strings.HasPrefix(result, `Error("[404]01d`)) + assert.NotContains(t, result, "\x00") +} + +func TestProcessRevertReasonPlainStringUnchanged(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // A normal Error(string) with no nested binary data should pass through unchanged + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000001a" + + "4e6f7420656e6f7567682045746865722070726f76696465642e000000000000") + + result := processRevertReason(ctx, revertData, nil) + assert.Equal(t, `Error("Not enough Ether provided.")`, result) +} + +// ---- processRevertReason behavioral tests ---- + +func TestProcessRevertReasonNonRevertData(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Data whose length is a multiple of 32 is NOT revert data + data32 := ethtypes.MustNewHexBytes0xPrefix("0x" + strings.Repeat("ab", 32)) + assert.Equal(t, "", processRevertReason(ctx, data32, nil)) + + data64 := ethtypes.MustNewHexBytes0xPrefix("0x" + strings.Repeat("ab", 64)) + assert.Equal(t, "", processRevertReason(ctx, data64, nil)) +} + +func TestProcessRevertReasonEmptyData(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + assert.Equal(t, "", processRevertReason(ctx, ethtypes.HexBytes0xPrefix{}, nil)) + assert.Equal(t, "", processRevertReason(ctx, nil, nil)) +} + +func TestProcessRevertReasonBareSelector(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Just 4 bytes — valid error selector but no params to decode + data := ethtypes.MustNewHexBytes0xPrefix("0x08c379a0") + result := processRevertReason(ctx, data, nil) + assert.Equal(t, "0x08c379a0", result) +} + +func TestProcessRevertReasonCustomErrorStringParam(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + customErr := &abi.Entry{ + Type: abi.Error, + Name: "LessThanOne", + Inputs: abi.ParameterArray{{Name: "x", Type: "string"}}, + } + errData, err := customErr.EncodeCallDataValues([]string{"bad value"}) + assert.NoError(t, err) + + result := processRevertReason(ctx, errData, []*abi.Entry{customErr}) + assert.Equal(t, `LessThanOne("bad value")`, result) +} + +func TestProcessRevertReasonCustomErrorAddressParam(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + customErr := &abi.Entry{ + Type: abi.Error, + Name: "Unauthorized", + Inputs: abi.ParameterArray{{Name: "caller", Type: "address"}}, + } + errData, err := customErr.EncodeCallDataJSON([]byte(`{"caller":"0x03706Ff580119B130E7D26C5e816913123C24d89"}`)) + assert.NoError(t, err) + + result := processRevertReason(ctx, errData, []*abi.Entry{customErr}) + assert.Equal(t, `Unauthorized("0x03706ff580119b130e7d26c5e816913123c24d89")`, result) +} + +func TestProcessRevertReasonMultipleCustomErrorsCorrectMatch(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + errA := &abi.Entry{ + Type: abi.Error, + Name: "ErrAlpha", + Inputs: abi.ParameterArray{{Type: "uint256"}}, + } + errB := &abi.Entry{ + Type: abi.Error, + Name: "ErrBeta", + Inputs: abi.ParameterArray{{Type: "string"}}, + } + + // Encode errB and verify errA doesn't accidentally match + errData, err := errB.EncodeCallDataValues([]string{"beta triggered"}) + assert.NoError(t, err) + result := processRevertReason(ctx, errData, []*abi.Entry{errA, errB}) + assert.Equal(t, `ErrBeta("beta triggered")`, result) + + // Now encode errA and verify it matches + errData, err = errA.EncodeCallDataValues([]string{"42"}) + assert.NoError(t, err) + result = processRevertReason(ctx, errData, []*abi.Entry{errA, errB}) + assert.Equal(t, `ErrAlpha("42")`, result) +} + +func TestProcessRevertReasonUnknownSelectorFallsThrough(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Selector that doesn't match any known error — returns raw hex + data := ethtypes.MustNewHexBytes0xPrefix( + "0xdeadbeef" + strings.Repeat("00", 32)) + + result := processRevertReason(ctx, data, nil) + assert.Equal(t, "0xdeadbeef"+strings.Repeat("00", 32), result) +} + +func TestProcessRevertReasonErrorSelectorMalformedData(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Error(string) selector but ABI data is garbage — should fall through to hex + data := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "00000000000000000000000000000000000000000000000000000000baadf00d") + + result := processRevertReason(ctx, data, nil) + assert.Equal(t, "0x08c379a000000000000000000000000000000000000000000000000000000000baadf00d", result) +} + +func TestProcessRevertReasonCustomErrorTruncatedData(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + customErr := &abi.Entry{ + Type: abi.Error, + Name: "NeedsTwoWords", + Inputs: abi.ParameterArray{{Type: "uint256"}, {Type: "uint256"}}, + } + + // Only 1 word of data — not enough for the error's 2 params + data := ethtypes.MustNewHexBytes0xPrefix( + "0x" + hex.EncodeToString(customErr.FunctionSelectorBytes()) + + strings.Repeat("00", 32)) + + result := processRevertReason(ctx, data, []*abi.Entry{customErr}) + assert.True(t, strings.HasPrefix(result, "0x")) +} + +func TestProcessRevertReasonNilErrorAbis(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // "hello world!" = 12 bytes, padded to 32. + // Total: 4 (sel) + 32 (offset) + 32 (len) + 32 (data) = 100. 100%32 = 4 ✓ + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000000c" + + "68656c6c6f20776f726c64210000000000000000000000000000000000000000") + + assert.Equal(t, `Error("hello world!")`, processRevertReason(ctx, revertData, nil)) +} + +func TestProcessRevertReasonEmptyErrorAbis(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000000c" + + "68656c6c6f20776f726c64210000000000000000000000000000000000000000") + + assert.Equal(t, `Error("hello world!")`, processRevertReason(ctx, revertData, []*abi.Entry{})) +} + +func TestProcessRevertReasonRealWorldNestedData(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // Real revert data from the original bug report (the hex from logs). + // This contains deeply nested Error(string) chains from Solidity catch-and-rethrow + // with string(reason). The outer Error(string) declares string length 0x73=115 bytes, + // which captures the first-level prefix "[OCPE]404/98 - " plus the START of the + // inner Error(string) ABI encoding. The inner encoding declares length 0x212=530 but + // only ~32 bytes fit in the outer string, so the inner decode correctly fails and + // the remainder is represented as JSON-escaped bytes inside Error(string). + // + // The critical requirement: the output must NOT contain null bytes (\x00) which + // was the root cause of the PostgreSQL "invalid byte sequence" bug. + revertData := ethtypes.MustNewHexBytes0xPrefix("0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000735b4f4350455d3430342f3938202d2008c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002125b544d4d5d3430342f3136653a2008c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b45b4c544d4d525d3430342f3236713a2008c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001435b4b44574c5d3430342f313061202d205b4350485d3430342f5b3078616638333233336638626462323834333235386235653234663261326464636133356666323738625d3339613a205b4c4f43435d3430342f3137613a205b4350485d3430342f5b3078393638343634366535383033313539623061396338623163396538646237316361373062643236615d3239633a5b4c4f43535d3430342f32333a08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000555b44574c5d3430342f3737633a205b4b4841415d3430342f303161202d205b4350485d3430332f30343a2030786633363438306137643036356137366666623366366531633939613137313232353464656538353500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + + result := processRevertReason(ctx, revertData, nil) + + // Critical: must not contain null bytes (the original bug) + assert.NotContains(t, result, "\x00") + + // First level is decoded to readable text + assert.Contains(t, result, "[OCPE]404/98") + + // Inner nested fragments appear inside the formatted Error(string) (binary JSON-escaped). + assert.Contains(t, result, "[TMM]404") +} + +func TestProcessRevertReasonCustomErrorWithMultipleParams(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + customErr := &abi.Entry{ + Type: abi.Error, + Name: "DetailedError", + Inputs: abi.ParameterArray{ + {Name: "code", Type: "uint256"}, + {Name: "message", Type: "string"}, + }, + } + errData, err := customErr.EncodeCallDataJSON([]byte(`{"code":404,"message":"not found"}`)) + assert.NoError(t, err) + + result := processRevertReason(ctx, errData, []*abi.Entry{customErr}) + assert.Equal(t, `DetailedError("404","not found")`, result) +} + +func TestProcessRevertReasonDefaultErrorTakesPriorityOverCustom(t *testing.T) { + ctx, _, _, done := newTestConnector(t) + defer done() + + // "default error msg" = 17 bytes, padded to 32. + // Total: 4 + 32 + 32 + 32 = 100. 100%32 = 4 ✓ + revertData := ethtypes.MustNewHexBytes0xPrefix( + "0x08c379a0" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000011" + + "64656661756c74206572726f72206d7367000000000000000000000000000000") + + customErr := &abi.Entry{ + Type: abi.Error, + Name: "SomeOtherError", + Inputs: abi.ParameterArray{{Type: "uint256"}}, + } + result := processRevertReason(ctx, revertData, []*abi.Entry{customErr}) + assert.Equal(t, `Error("default error msg")`, result) +} + func TestExecQueryFailBadToParams(t *testing.T) { ctx, c, _, done := newTestConnector(t) diff --git a/internal/ethereum/get_receipt.go b/internal/ethereum/get_receipt.go index 1247a07..fd812ad 100644 --- a/internal/ethereum/get_receipt.go +++ b/internal/ethereum/get_receipt.go @@ -17,7 +17,6 @@ package ethereum import ( - "bytes" "context" "encoding/hex" "encoding/json" @@ -132,17 +131,13 @@ func (c *ethConnector) getErrorInfo(ctx context.Context, transactionHash string, revertReason = revertFromReceipt.String() } - // See if the return value is using the default error you get from "revert" var errorMessage string returnDataBytes, _ := hex.DecodeString(padHexData(revertReason)) - if len(returnDataBytes) > 4 && bytes.Equal(returnDataBytes[0:4], defaultErrorID) { - value, err := defaultError.DecodeCallDataCtx(ctx, returnDataBytes) - if err == nil { - errorMessage = value.Children[0].Value.(string) - } + var errors abi.ABI + if decoded, ok := errors.ErrorStringCtx(ctx, returnDataBytes, abi.ErrorFormatOption{SearchForWrappedBinaryErrors: true}); ok { + errorMessage = decoded } - // Otherwise we can't decode it, so put it directly in the error if errorMessage == "" { if len(returnDataBytes) > 0 { errorMessage = i18n.NewError(ctx, msgs.MsgReturnValueNotDecoded, revertReason).Error()