Skip to content

Commit 7da8a8e

Browse files
ecPabloCopilot
andauthored
fix: replace sender to timelock address during error decoding (#775)
Timelock execution failures through a CallProxy were decoded with the wrong underlying revert. CI reported `OnlyCallableByOwner()` on WorkflowRegistry, but the real failure was `NodeDoesNotSupportCapability(...)`. #### Root cause When `mcms.WithCallProxy` is used, the EVM executor sends the tx to the `CallProxy,` not the `RBACTimelock`. On failure, `BuildExecutionError` replayed the inner timelock call with `From` set to that execution address (the proxy). That synthetic path is not what happens on chain: CallProxy forwards to the timelock, and the target sees `msg.sender` as the timelock. Replaying from the proxy made Ownable targets revert with `OnlyCallableByOwner()` even when the real execution had already passed ownership and failed for a different reason. #### Fix EVM-only change in `sdk/evm/execution_error.go`: before underlying-call replay, resolve the real sender. If the execution address is a generated CallProxy (runtime bytecode fingerprint from bindings.CallProxy), extract the embedded timelock target and use it as From for eth_call replay. Otherwise behavior is unchanged. ### AI Summary This pull request enhances the handling of execution errors for EVM timelock transactions, especially when transactions are routed through a CallProxy contract. The main improvement is the ability to correctly identify and replay the underlying call from the true sender (the embedded timelock) even when a proxy is involved, providing more accurate revert reasons. The changes also add robust testing for CallProxy bytecode fingerprinting and sender resolution logic. **Enhancements to CallProxy handling and error replay:** * Added logic to detect when an address is a CallProxy contract by matching its runtime bytecode against known prefix/suffix fingerprints, and to extract the embedded target (timelock) address from the bytecode. This allows the system to replay the underlying transaction as the correct sender, improving error diagnosis. (`sdk/evm/execution_error.go`) [[1]](diffhunk://#diff-9877f8386f0be068752fac0fe1b707306afd2dca253b8b10b494f145c3b08131R35-R40) [[2]](diffhunk://#diff-9877f8386f0be068752fac0fe1b707306afd2dca253b8b10b494f145c3b08131L298-R351) * Introduced the `resolveUnderlyingCallSender` function, which determines the true sender for replaying failed calls, handling both proxy and non-proxy cases. (`sdk/evm/execution_error.go`) **Testing and validation improvements:** * Added comprehensive unit tests for CallProxy bytecode detection, target extraction, and sender resolution, including edge cases for incorrect bytecode and error fallbacks. (`sdk/evm/execution_error_test.go`) * Added integration-style tests to ensure that when a timelock operation fails through a CallProxy, the error replay uses the correct sender and the underlying revert reason is surfaced accurately. (`sdk/evm/timelock_executor_test.go`) [[1]](diffhunk://#diff-2f3502ced6798d1ac2675c1ae7d2e1beae121201c834d810ca2b00dbf6ab6accR102-R135) [[2]](diffhunk://#diff-2f3502ced6798d1ac2675c1ae7d2e1beae121201c834d810ca2b00dbf6ab6accR78) [[3]](diffhunk://#diff-2f3502ced6798d1ac2675c1ae7d2e1beae121201c834d810ca2b00dbf6ab6accR175-R178) **Other changes:** * Updated imports to support new logic and tests. (`sdk/evm/execution_error.go`, `sdk/evm/execution_error_test.go`, `sdk/evm/timelock_executor_test.go`) [[1]](diffhunk://#diff-9877f8386f0be068752fac0fe1b707306afd2dca253b8b10b494f145c3b08131R4) [[2]](diffhunk://#diff-809122f496465d2c75e2526e781484db6146c7e09f05750872260c7a19a8a0e0R4-R16) [[3]](diffhunk://#diff-2f3502ced6798d1ac2675c1ae7d2e1beae121201c834d810ca2b00dbf6ab6accR10) These changes significantly improve the reliability and transparency of error handling for timelock operations, especially in the presence of proxy contracts. --------- Signed-off-by: Pablo <pablo.estrada@smartcontract.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d1e8d62 commit 7da8a8e

3 files changed

Lines changed: 323 additions & 6 deletions

File tree

sdk/evm/execution_error.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package evm
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
@@ -31,6 +32,12 @@ var (
3132
// customErrorPattern matches "custom error 0x<8-hex-chars>: <hex-data>"
3233
// Captures: group 1 = selector (8 hex chars), group 2 = data (hex chars with optional spaces)
3334
customErrorPattern = regexp.MustCompile(`custom error 0x([0-9a-fA-F]{8}):\s*([0-9a-fA-F\s]+)`)
35+
36+
// callProxyRuntimePrefix and callProxyRuntimeSuffix fingerprint the deployed
37+
// runtime of bindings.CallProxy (immutable target embedded between them).
38+
// If CallProxy is recompiled, update these and TestCallProxyBytecodeFingerprintMatchesBinding.
39+
callProxyRuntimePrefix = common.FromHex("0x60806040527f")
40+
callProxyRuntimeSuffix = common.FromHex("0x366000803760008036600034855af13d6000803e80156043573d6000f35b503d6000fd")
3441
)
3542

3643
const (
@@ -296,14 +303,45 @@ func BuildExecutionError(
296303
if strings.Contains(errStr, "RBACTimelock: underlying transaction reverted") ||
297304
strings.Contains(execErr.RevertReasonDecoded, "RBACTimelock: underlying transaction reverted") ||
298305
(isCallReverted && timelockAddr != (common.Address{}) && len(timelockCallData) > 0) {
299-
rawUnderlyingReason, decodedUnderlyingReason := getUnderlyingRevertReason(ctx, timelockAddr, timelockCallData, opts, client)
306+
underlyingCallSender := resolveUnderlyingCallSender(ctx, timelockAddr, client)
307+
rawUnderlyingReason, decodedUnderlyingReason := getUnderlyingRevertReason(ctx, underlyingCallSender, timelockCallData, opts, client)
300308
execErr.UnderlyingReasonRaw = rawUnderlyingReason
301309
execErr.UnderlyingReasonDecoded = decodedUnderlyingReason
302310
}
303311

304312
return execErr
305313
}
306314

315+
func resolveUnderlyingCallSender(ctx context.Context, executionAddr common.Address, client ContractDeployBackend) common.Address {
316+
if executionAddr != (common.Address{}) && client != nil {
317+
if code, err := client.CodeAt(ctx, executionAddr, nil); err == nil {
318+
if targetAddress, ok := callProxyTargetFromRuntime(code); ok {
319+
return targetAddress
320+
}
321+
}
322+
}
323+
324+
return executionAddr
325+
}
326+
327+
// callProxyTargetFromRuntime checks whether code matches bindings.CallProxy runtime
328+
// (fixed prefix + suffix with a 32-byte immutable target between them) and returns that target.
329+
func callProxyTargetFromRuntime(code []byte) (common.Address, bool) {
330+
targetOffset := len(callProxyRuntimePrefix)
331+
suffixOffset := targetOffset + common.HashLength
332+
if len(code) < suffixOffset+len(callProxyRuntimeSuffix) {
333+
return common.Address{}, false
334+
}
335+
if !bytes.Equal(code[:targetOffset], callProxyRuntimePrefix) {
336+
return common.Address{}, false
337+
}
338+
if !bytes.Equal(code[suffixOffset:suffixOffset+len(callProxyRuntimeSuffix)], callProxyRuntimeSuffix) {
339+
return common.Address{}, false
340+
}
341+
342+
return common.BytesToAddress(code[targetOffset:suffixOffset]), true
343+
}
344+
307345
// extractHexEncodedRevertData extracts hex-encoded revert data (0x...) from an error string.
308346
// Returns the extracted bytes if found, nil otherwise.
309347
func extractHexEncodedRevertData(errStr string) []byte {
@@ -449,14 +487,14 @@ func parseBytesFromString(s string) []byte {
449487
if len(parts) == 0 {
450488
return nil
451489
}
452-
bytes := make([]byte, 0, len(parts))
490+
parsed := make([]byte, 0, len(parts))
453491
for _, part := range parts {
454492
if val, err := strconv.ParseUint(part, 10, 8); err == nil {
455-
bytes = append(bytes, byte(val))
493+
parsed = append(parsed, byte(val))
456494
}
457495
}
458496

459-
return bytes
497+
return parsed
460498
}
461499

462500
// decodeRevertReasonFromCustomError decodes the revert reason using the error selector to find
@@ -627,8 +665,8 @@ func getUnderlyingRevertReason(
627665
return "", ""
628666
}
629667

630-
// Simulate the underlying transaction using CallContract (best-effort)
631-
// Use timelock address as From since that's what msg.sender will be when RBACTimelock executes
668+
// Simulate the underlying transaction using CallContract (best-effort).
669+
// timelockAddr is the underlying-call sender (RBACTimelock), including after CallProxy resolution.
632670
_, err := client.CallContract(ctx, ethereum.CallMsg{
633671
From: timelockAddr,
634672
To: &underlyingCall.Target,

sdk/evm/execution_error_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package evm
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/binary"
67
"encoding/json"
@@ -11,9 +12,11 @@ import (
1112
"strings"
1213
"testing"
1314

15+
"github.com/ethereum/go-ethereum"
1416
"github.com/ethereum/go-ethereum/accounts/abi"
1517
"github.com/ethereum/go-ethereum/accounts/abi/bind"
1618
"github.com/ethereum/go-ethereum/common"
19+
gethtypes "github.com/ethereum/go-ethereum/core/types"
1720
"github.com/stretchr/testify/assert"
1821
"github.com/stretchr/testify/mock"
1922
"github.com/stretchr/testify/require"
@@ -1423,6 +1426,242 @@ func TestGetUnderlyingRevertReason(t *testing.T) {
14231426
}
14241427
}
14251428

1429+
const (
1430+
onlyCallableByOwnerSelector = "0x2b5c74de"
1431+
realUnderlyingErrorSelector = "0x4b5786e7"
1432+
)
1433+
1434+
func TestCallProxyBytecodeFingerprintMatchesBinding(t *testing.T) {
1435+
t.Parallel()
1436+
1437+
runtime := callProxyRuntimeFromBinding(t)
1438+
require.GreaterOrEqual(t, len(runtime), len(callProxyRuntimePrefix)+common.HashLength+len(callProxyRuntimeSuffix))
1439+
assert.Equal(t, callProxyRuntimePrefix, runtime[:len(callProxyRuntimePrefix)])
1440+
suffixStart := len(callProxyRuntimePrefix) + common.HashLength
1441+
assert.Equal(t, callProxyRuntimeSuffix, runtime[suffixStart:suffixStart+len(callProxyRuntimeSuffix)])
1442+
1443+
target := common.HexToAddress("0x1111111111111111111111111111111111111111")
1444+
patched := callProxyRuntime(target)
1445+
got, ok := callProxyTargetFromRuntime(patched)
1446+
require.True(t, ok)
1447+
assert.Equal(t, target, got)
1448+
}
1449+
1450+
func TestCallProxyTargetFromRuntime(t *testing.T) {
1451+
t.Parallel()
1452+
1453+
target := common.HexToAddress("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
1454+
1455+
tests := []struct {
1456+
name string
1457+
code []byte
1458+
wantAddr common.Address
1459+
wantOK bool
1460+
}{
1461+
{
1462+
name: "valid call proxy runtime",
1463+
code: callProxyRuntime(target),
1464+
wantAddr: target,
1465+
wantOK: true,
1466+
},
1467+
{
1468+
name: "too short",
1469+
code: []byte{0x60},
1470+
wantAddr: common.Address{},
1471+
wantOK: false,
1472+
},
1473+
{
1474+
name: "wrong prefix",
1475+
code: append([]byte{0xff}, callProxyRuntime(target)[1:]...),
1476+
wantAddr: common.Address{},
1477+
wantOK: false,
1478+
},
1479+
{
1480+
name: "wrong suffix",
1481+
code: append(callProxyRuntime(target)[:len(callProxyRuntime(target))-1], 0xff),
1482+
wantAddr: common.Address{},
1483+
wantOK: false,
1484+
},
1485+
}
1486+
1487+
for _, tt := range tests {
1488+
t.Run(tt.name, func(t *testing.T) {
1489+
t.Parallel()
1490+
1491+
got, ok := callProxyTargetFromRuntime(tt.code)
1492+
assert.Equal(t, tt.wantOK, ok)
1493+
assert.Equal(t, tt.wantAddr, got)
1494+
})
1495+
}
1496+
}
1497+
1498+
func TestResolveUnderlyingCallSender(t *testing.T) {
1499+
t.Parallel()
1500+
1501+
executionAddr := common.HexToAddress("0x0B665A6d3ec78BF99e17Ca81c071bb9Dfdf081Fc")
1502+
realTimelock := common.HexToAddress("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
1503+
1504+
tests := []struct {
1505+
name string
1506+
executionAddr common.Address
1507+
setupMock func(*mocks.ContractDeployBackend)
1508+
wantSender common.Address
1509+
}{
1510+
{
1511+
name: "call proxy resolves to embedded timelock",
1512+
executionAddr: executionAddr,
1513+
setupMock: func(m *mocks.ContractDeployBackend) {
1514+
m.On("CodeAt", mock.Anything, executionAddr, mock.Anything).
1515+
Return(callProxyRuntime(realTimelock), nil).Once()
1516+
},
1517+
wantSender: realTimelock,
1518+
},
1519+
{
1520+
name: "non-proxy bytecode falls back to execution address",
1521+
executionAddr: executionAddr,
1522+
setupMock: func(m *mocks.ContractDeployBackend) {
1523+
m.On("CodeAt", mock.Anything, executionAddr, mock.Anything).
1524+
Return([]byte("not a call proxy"), nil).Once()
1525+
},
1526+
wantSender: executionAddr,
1527+
},
1528+
{
1529+
name: "code lookup error falls back to execution address",
1530+
executionAddr: executionAddr,
1531+
setupMock: func(m *mocks.ContractDeployBackend) {
1532+
m.On("CodeAt", mock.Anything, executionAddr, mock.Anything).
1533+
Return(nil, errors.New("rpc error")).Once()
1534+
},
1535+
wantSender: executionAddr,
1536+
},
1537+
{
1538+
name: "nil client falls back to execution address",
1539+
executionAddr: executionAddr,
1540+
setupMock: nil,
1541+
wantSender: executionAddr,
1542+
},
1543+
}
1544+
1545+
for _, tt := range tests {
1546+
t.Run(tt.name, func(t *testing.T) {
1547+
t.Parallel()
1548+
1549+
var client ContractDeployBackend
1550+
if tt.setupMock != nil {
1551+
mockClient := mocks.NewContractDeployBackend(t)
1552+
tt.setupMock(mockClient)
1553+
client = mockClient
1554+
}
1555+
1556+
got := resolveUnderlyingCallSender(context.Background(), tt.executionAddr, client)
1557+
assert.Equal(t, tt.wantSender, got)
1558+
})
1559+
}
1560+
}
1561+
1562+
func TestBuildExecutionError_callProxyUnderlyingReplay(t *testing.T) {
1563+
t.Parallel()
1564+
1565+
timelockABI, err := bindings.RBACTimelockMetaData.GetAbi()
1566+
require.NoError(t, err, errMsgFailedTimelockABI)
1567+
1568+
testCall := bindings.RBACTimelockCall{
1569+
Target: common.HexToAddress("0x1234567890123456789012345678901234567890"),
1570+
Value: big.NewInt(0),
1571+
Data: []byte{1, 2, 3},
1572+
}
1573+
callData := packBypasserExecuteBatch(t, timelockABI, []bindings.RBACTimelockCall{testCall})
1574+
1575+
callProxyAddr := common.HexToAddress("0x0B665A6d3ec78BF99e17Ca81c071bb9Dfdf081Fc")
1576+
realTimelock := common.HexToAddress("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
1577+
opts := &bind.TransactOpts{GasLimit: 1_000_000}
1578+
1579+
mockClient := mocks.NewContractDeployBackend(t)
1580+
mockClient.On("CodeAt", mock.Anything, callProxyAddr, mock.Anything).
1581+
Return(callProxyRuntime(realTimelock), nil).Once()
1582+
mockClient.On("CallContract", mock.Anything, mock.MatchedBy(func(msg ethereum.CallMsg) bool {
1583+
return msg.From == realTimelock &&
1584+
msg.To != nil &&
1585+
*msg.To == testCall.Target
1586+
}), mock.Anything).
1587+
Return(nil, errors.New("execution reverted: "+realUnderlyingErrorSelector)).Once()
1588+
1589+
execErr := BuildExecutionError(
1590+
context.Background(),
1591+
errors.New("execution reverted: CallReverted"),
1592+
gethtypes.NewTx(&gethtypes.LegacyTx{}),
1593+
opts,
1594+
callProxyAddr,
1595+
mockClient,
1596+
callProxyAddr,
1597+
callData,
1598+
)
1599+
1600+
require.NotNil(t, execErr)
1601+
assert.Equal(t, realUnderlyingErrorSelector, execErr.UnderlyingReasonRaw)
1602+
assert.NotEqual(t, onlyCallableByOwnerSelector, execErr.UnderlyingReasonRaw)
1603+
}
1604+
1605+
func TestBuildExecutionError_callProxyPreservesRealOwnershipFailure(t *testing.T) {
1606+
t.Parallel()
1607+
1608+
timelockABI, err := bindings.RBACTimelockMetaData.GetAbi()
1609+
require.NoError(t, err, errMsgFailedTimelockABI)
1610+
1611+
testCall := bindings.RBACTimelockCall{
1612+
Target: common.HexToAddress("0x1234567890123456789012345678901234567890"),
1613+
Value: big.NewInt(0),
1614+
Data: []byte{1, 2, 3},
1615+
}
1616+
callData := packBypasserExecuteBatch(t, timelockABI, []bindings.RBACTimelockCall{testCall})
1617+
1618+
callProxyAddr := common.HexToAddress("0x0B665A6d3ec78BF99e17Ca81c071bb9Dfdf081Fc")
1619+
realTimelock := common.HexToAddress("0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0")
1620+
opts := &bind.TransactOpts{GasLimit: 1_000_000}
1621+
1622+
mockClient := mocks.NewContractDeployBackend(t)
1623+
mockClient.On("CodeAt", mock.Anything, callProxyAddr, mock.Anything).
1624+
Return(callProxyRuntime(realTimelock), nil).Once()
1625+
mockClient.On("CallContract", mock.Anything, mock.MatchedBy(func(msg ethereum.CallMsg) bool {
1626+
return msg.From == realTimelock
1627+
}), mock.Anything).
1628+
Return(nil, errors.New("execution reverted: "+onlyCallableByOwnerSelector)).Once()
1629+
1630+
execErr := BuildExecutionError(
1631+
context.Background(),
1632+
errors.New("execution reverted: CallReverted"),
1633+
gethtypes.NewTx(&gethtypes.LegacyTx{}),
1634+
opts,
1635+
callProxyAddr,
1636+
mockClient,
1637+
callProxyAddr,
1638+
callData,
1639+
)
1640+
1641+
require.NotNil(t, execErr)
1642+
assert.Equal(t, onlyCallableByOwnerSelector, execErr.UnderlyingReasonRaw)
1643+
}
1644+
1645+
func callProxyRuntime(target common.Address) []byte {
1646+
code := make([]byte, 0, len(callProxyRuntimePrefix)+common.HashLength+len(callProxyRuntimeSuffix))
1647+
code = append(code, callProxyRuntimePrefix...)
1648+
code = append(code, common.LeftPadBytes(target.Bytes(), common.HashLength)...)
1649+
code = append(code, callProxyRuntimeSuffix...)
1650+
1651+
return code
1652+
}
1653+
1654+
func callProxyRuntimeFromBinding(t *testing.T) []byte {
1655+
t.Helper()
1656+
1657+
bin := common.FromHex(bindings.CallProxyBin)
1658+
marker := []byte{0xf3, 0xfe}
1659+
idx := bytes.Index(bin, marker)
1660+
require.NotEqual(t, -1, idx, "call proxy init/runtime separator not found in CallProxyBin")
1661+
1662+
return bin[idx+len(marker):]
1663+
}
1664+
14261665
// Helper functions for packing timelock method calls
14271666

14281667
// encodeErrorString ABI-encodes a string for Error(string) format.

0 commit comments

Comments
 (0)