Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whole-symbols-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

add error decode command
5 changes: 4 additions & 1 deletion chain/evm/provider/rpcclient/multiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,10 @@ func (mc *MultiClient) retryWithBackups(ctx context.Context, opName string, op f

err = op(timeoutCtx, client)
if err != nil {
mc.lggr.Warnf("traceID %q: chain %q: op: %q: client index %d: failed execution - retryable error '%s'", traceID.String(), mc.chainName, opName, rpcIndex, maybeDataErr(err))
detailedErr := maybeDataErr(err)
mc.lggr.Warnf("traceID %q: chain %q: op: %q: client index %d: failed execution - retryable error '%s'", traceID.String(), mc.chainName, opName, rpcIndex, detailedErr)
err = errors.Join(err, detailedErr)

return err
}

Expand Down
166 changes: 161 additions & 5 deletions engine/cld/legacy/cli/mcmsv2/err_decode_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (

const noRevertData = "(no revert data)"

type errorSelector [4]byte

type traceConfig struct {
DisableStorage bool `json:"disableStorage,omitempty"`
DisableMemory bool `json:"disableMemory,omitempty"`
Expand Down Expand Up @@ -57,25 +59,25 @@ type ErrSig struct {
TypeVer string
Name string
Inputs abi.Arguments
id [4]byte
id errorSelector
}

// ErrDecoder indexes custom-error selectors across many ABIs.
type ErrDecoder struct {
bySelector map[[4]byte][]ErrSig
bySelector map[errorSelector][]ErrSig
registry analyzer.EVMABIRegistry
}

// NewErrDecoder builds an index from EVM ABI registry.
func NewErrDecoder(registry analyzer.EVMABIRegistry) (*ErrDecoder, error) {
idx := make(map[[4]byte][]ErrSig)
idx := make(map[errorSelector][]ErrSig)
for tv, jsonABI := range registry.GetAllABIs() {
a, err := abi.JSON(strings.NewReader(jsonABI))
if err != nil {
return nil, fmt.Errorf("parse ABI for %s: %w", tv, err)
}
for name, e := range a.Errors {
var key [4]byte
var key errorSelector
copy(key[:], e.ID[:4]) // selector is first 4 bytes of the keccak(sig)
idx[key] = append(idx[key], ErrSig{
TypeVer: tv,
Expand Down Expand Up @@ -181,7 +183,7 @@ func (d *ErrDecoder) decodeRecursive(revertData []byte, preferredABIJSON string)
}

// --- B) Registry lookup
var key [4]byte
var key errorSelector
copy(key[:], sel)
cands, ok := d.bySelector[key]
if !ok {
Expand Down Expand Up @@ -619,6 +621,160 @@ func prettyRevertFromError(err error, preferredABIJSON string, dec *ErrDecoder)
return "", false
}

// DecodedExecutionError contains the decoded revert reasons from an ExecutionError.
type DecodedExecutionError struct {
RevertReason string
RevertReasonDecoded bool
UnderlyingReason string
UnderlyingReasonDecoded bool
}

// tryDecodeExecutionError decodes an evm.ExecutionError into human-readable strings.
// It first checks for RevertReasonDecoded and UnderlyingReasonDecoded fields.
// If those are not available, it extracts RevertReasonRaw and UnderlyingReasonRaw from the struct
// and decodes them using the provided ErrDecoder to match error selectors against the ABI registry.
func tryDecodeExecutionError(execError *evm.ExecutionError, dec *ErrDecoder) DecodedExecutionError {
if execError == nil {
return DecodedExecutionError{}
}

revertReason, revertDecoded := decodeRevertReasonWithStatus(execError, dec)
underlyingReason, underlyingDecoded := decodeUnderlyingReasonWithStatus(execError, dec)

return DecodedExecutionError{
RevertReason: revertReason,
RevertReasonDecoded: revertDecoded,
UnderlyingReason: underlyingReason,
UnderlyingReasonDecoded: underlyingDecoded,
}
}

// decodeRevertReasonWithStatus decodes the revert reason and returns both the reason and decoded status.
func decodeRevertReasonWithStatus(execError *evm.ExecutionError, dec *ErrDecoder) (string, bool) {
if execError.RevertReasonDecoded != "" {
return execError.RevertReasonDecoded, true
}

if execError.RevertReasonRaw == nil {
return "", false
}

hasData := len(execError.RevertReasonRaw.Data) > 0
hasSelector := execError.RevertReasonRaw.Selector != errorSelector{}

if hasData {
if reason, decoded := tryDecodeFromData(execError.RevertReasonRaw, dec); decoded {
return reason, true
}
}

if hasSelector && !hasData {
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic checks hasSelector && !hasData on line 673, but when hasData is true and decoding fails (lines 668-670), it doesn't try the selector. Consider attempting selector-based decoding as a fallback when data-based decoding fails, by changing line 673 to just if hasSelector to cover both cases where data decoding failed and where there's no data.

Suggested change
if hasSelector && !hasData {
if hasSelector {

Copilot uses AI. Check for mistakes.
reason := decodeSelectorOnly(execError.RevertReasonRaw.Selector, dec)
return reason, reason != ""
}

return "", false
}

// tryDecodeFromData attempts to decode revert data from the CustomErrorData.
func tryDecodeFromData(raw *evm.CustomErrorData, dec *ErrDecoder) (string, bool) {
if len(raw.Data) >= 4 {
if reason, decoded := decodeRevertDataFromBytes(raw.Data, dec, ""); decoded {
return reason, true
}
}

if raw.Selector != errorSelector{} {
if combined := raw.Combined(); len(combined) > 4 {
return decodeRevertDataFromBytes(combined, dec, "")
}
}

return "", false
}

// decodeSelectorOnly decodes an error when only the selector is available.
func decodeSelectorOnly(selector errorSelector, dec *ErrDecoder) string {
if dec == nil {
return formatSelectorHex(selector)
}

if matched, ok := dec.matchErrorSelector(selector); ok {
return matched
}

return formatSelectorHex(selector)
}

// formatSelectorHex formats a selector as a hex string.
func formatSelectorHex(selector errorSelector) string {
return "custom error 0x" + hex.EncodeToString(selector[:])
}

// decodeUnderlyingReasonWithStatus decodes the underlying reason and returns both the reason and decoded status.
func decodeUnderlyingReasonWithStatus(execError *evm.ExecutionError, dec *ErrDecoder) (string, bool) {
if execError.UnderlyingReasonDecoded != "" {
return execError.UnderlyingReasonDecoded, true
}

if execError.UnderlyingReasonRaw == "" {
return "", false
}

reason, decoded := decodeRevertData(execError.UnderlyingReasonRaw, dec, "")

return reason, decoded
}

// decodeRevertData decodes a hex string containing revert data into a human-readable error message.
func decodeRevertData(hexStr string, dec *ErrDecoder, preferredABIJSON string) (string, bool) {
if hexStr == "" {
return "", false
}

data, err := hexutil.Decode(hexStr)
if err != nil || len(data) == 0 {
return "", false
}

return decodeRevertDataFromBytes(data, dec, preferredABIJSON)
}

// matchErrorSelector tries to resolve a 4-byte selector to an error name.
// Returns "ErrorName(...) @Type@Version" if found in registry, or empty string if not found.
func (d *ErrDecoder) matchErrorSelector(sel4 errorSelector) (string, bool) {
if d == nil || d.bySelector == nil {
return "", false
}

cands, ok := d.bySelector[sel4]
if !ok || len(cands) == 0 {
return "", false
}

// If multiple ABIs define the same selector, pick the first.
c := cands[0]

return fmt.Sprintf("%s(...) @%s", c.Name, c.TypeVer), true
}

// decodeRevertDataFromBytes decodes revert data bytes into a human-readable error message.
func decodeRevertDataFromBytes(data []byte, dec *ErrDecoder, preferredABIJSON string) (string, bool) {
if len(data) == 0 {
return "", false
}

if dec == nil {
if len(data) >= 4 {
return "custom error 0x" + hex.EncodeToString(data[:4]), true
}

return "", false
}

return prettyFromBytes(data, preferredABIJSON, dec)
}

type callContractClient interface {
CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error)
}
Expand Down
157 changes: 157 additions & 0 deletions engine/cld/legacy/cli/mcmsv2/err_decode_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/ethereum/go-ethereum/crypto"
mcmsbindings "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers"
timelockbindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_0/timelock"
"github.com/smartcontractkit/mcms/sdk/evm"
"github.com/smartcontractkit/mcms/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -466,3 +467,159 @@ func Test_DiagnoseTimelockRevert(t *testing.T) {
assert.Contains(t, es, "first revert")
assert.Contains(t, es, "second revert")
}

func Test_tryDecodeExecutionError(t *testing.T) {
t.Parallel()

// Create test ABI with custom error
abiJSON := `[
{"type":"error","name":"InsufficientBalance","inputs":[
{"name":"required","type":"uint256"},
{"name":"available","type":"uint256"}]}
]`

// Build custom error revert data
u256Ty := mustType(t, "uint256")
errArgs := abi.Arguments{
{Name: "required", Type: u256Ty},
{Name: "available", Type: u256Ty},
}
required := big.NewInt(100)
available := big.NewInt(50)
customRevert := buildCustomErrorRevert(t, "InsufficientBalance", errArgs, required, available)

// Create registry with the ABI
ds := cldfds.NewMemoryDataStore()
reg, err := analyzer.NewEnvironmentEVMRegistry(cldf.Environment{
ExistingAddresses: cldf.NewMemoryAddressBook(),
DataStore: ds.Seal(),
}, map[string]string{
"TestContract@1.0.0": abiJSON,
})
require.NoError(t, err)

dec, err := NewErrDecoder(reg)
require.NoError(t, err)

t.Run("nil error returns empty result", func(t *testing.T) {
t.Parallel()

result := tryDecodeExecutionError(nil, dec)
assert.False(t, result.RevertReasonDecoded)
assert.False(t, result.UnderlyingReasonDecoded)
assert.Empty(t, result.RevertReason)
assert.Empty(t, result.UnderlyingReason)
})

t.Run("decodes revert reason from Data field", func(t *testing.T) {
t.Parallel()

execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{
Data: customRevert,
},
}

result := tryDecodeExecutionError(execErr, dec)
require.True(t, result.RevertReasonDecoded, "should decode revert reason")
assert.Contains(t, result.RevertReason, "InsufficientBalance")
assert.Contains(t, result.RevertReason, "@TestContract@1.0.0")
assert.Contains(t, result.RevertReason, required.String())
assert.Contains(t, result.RevertReason, available.String())
})

t.Run("decodes revert reason from Selector field", func(t *testing.T) {
t.Parallel()

var selector [4]byte
copy(selector[:], customRevert[:4])

execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{
Selector: selector,
},
}

result := tryDecodeExecutionError(execErr, dec)
require.True(t, result.RevertReasonDecoded, "should decode revert reason from selector")
assert.Contains(t, result.RevertReason, "InsufficientBalance")
})

t.Run("decodes standard Error(string)", func(t *testing.T) {
t.Parallel()

stdRevert := buildStdErrorRevert(t, "test error message")
execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{
Data: stdRevert,
},
}

result := tryDecodeExecutionError(execErr, dec)
require.True(t, result.RevertReasonDecoded, "should decode standard error")
assert.Equal(t, "test error message", result.RevertReason)
})

t.Run("decodes underlying reason", func(t *testing.T) {
t.Parallel()

underlyingRevert := buildStdErrorRevert(t, "underlying error")
underlyingHex := "0x" + hex.EncodeToString(underlyingRevert)

execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{
Data: customRevert,
},
UnderlyingReasonRaw: underlyingHex,
}

result := tryDecodeExecutionError(execErr, dec)
require.True(t, result.RevertReasonDecoded)
require.True(t, result.UnderlyingReasonDecoded, "should decode underlying reason")
assert.Equal(t, "underlying error", result.UnderlyingReason)
})

t.Run("handles nil decoder", func(t *testing.T) {
t.Parallel()

var selector [4]byte
copy(selector[:], customRevert[:4])

execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{
Selector: selector,
},
}

result := tryDecodeExecutionError(execErr, nil)
require.True(t, result.RevertReasonDecoded, "should return selector even without decoder")
assert.Contains(t, result.RevertReason, "custom error 0x")
assert.Contains(t, result.RevertReason, hex.EncodeToString(selector[:]))
})

t.Run("handles empty RawRevertReason", func(t *testing.T) {
t.Parallel()

execErr := &evm.ExecutionError{
RevertReasonRaw: &evm.CustomErrorData{},
}

result := tryDecodeExecutionError(execErr, dec)
assert.False(t, result.RevertReasonDecoded)
assert.Empty(t, result.RevertReason)
})

t.Run("handles nil RawRevertReason", func(t *testing.T) {
t.Parallel()

execErr := &evm.ExecutionError{
RevertReasonRaw: nil,
UnderlyingReasonRaw: "0x12345678",
}

result := tryDecodeExecutionError(execErr, dec)
assert.False(t, result.RevertReasonDecoded)
// Underlying reason should still be attempted
assert.NotEmpty(t, result.UnderlyingReason)
})
}
Loading
Loading