diff --git a/client/x/evmengine/keeper/evmmsgs.go b/client/x/evmengine/keeper/evmmsgs.go index 5b2d28f8..218d147a 100644 --- a/client/x/evmengine/keeper/evmmsgs.go +++ b/client/x/evmengine/keeper/evmmsgs.go @@ -1,10 +1,7 @@ package keeper import ( - "bytes" "context" - "slices" - "sort" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" @@ -31,41 +28,18 @@ func (k *Keeper) evmEvents(ctx context.Context, blockHash common.Hash) ([]*types return nil, errors.Wrap(err, "filter logs") } - ll := make([]*types.EVMEvent, 0, len(logs)) + events = make([]*types.EVMEvent, 0, len(logs)) for _, l := range logs { - topics := make([][]byte, 0, len(l.Topics)) - for _, t := range l.Topics { - topics = append(topics, t.Bytes()) + evmEvent, err := types.EthLogToEVMEvent(l) + if err != nil { + return nil, errors.Wrap(err, "convert log") } - ll = append(ll, &types.EVMEvent{ - Address: l.Address.Bytes(), - Topics: topics, - Data: l.Data, - }) - } - for _, log := range ll { - if err := log.Verify(); err != nil { - return nil, errors.Wrap(err, "verify log") - } + events = append(events, evmEvent) } - events = append(events, ll...) - // Sort by Address > Topics > Data // This avoids dependency on runtime ordering. - sort.Slice(events, func(i, j int) bool { - if cmp := bytes.Compare(events[i].Address, events[j].Address); cmp != 0 { - return cmp < 0 - } - - topicI := slices.Concat(events[i].Topics...) - topicJ := slices.Concat(events[j].Topics...) - if cmp := bytes.Compare(topicI, topicJ); cmp != 0 { - return cmp < 0 - } - - return bytes.Compare(events[i].Data, events[j].Data) < 0 - }) + types.SortEVMEvents(events) return events, nil } diff --git a/client/x/evmengine/types/tx.go b/client/x/evmengine/types/tx.go index 03bc0bb3..5ed1369d 100644 --- a/client/x/evmengine/types/tx.go +++ b/client/x/evmengine/types/tx.go @@ -1,6 +1,10 @@ package types import ( + "bytes" + "slices" + "sort" + "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -47,3 +51,39 @@ func (l *EVMEvent) Verify() error { return nil } + +// EthLogToEVMEvent converts an Ethereum Log to an EVMEvent. +func EthLogToEVMEvent(ethLog ethtypes.Log) (*EVMEvent, error) { + topics := make([][]byte, 0, len(ethLog.Topics)) + for _, t := range ethLog.Topics { + topics = append(topics, t.Bytes()) + } + + evmEvent := &EVMEvent{ + Address: ethLog.Address.Bytes(), + Topics: topics, + Data: ethLog.Data, + } + if err := evmEvent.Verify(); err != nil { + return nil, errors.Wrap(err, "verify log") + } + + return evmEvent, nil +} + +// SortEVMEvents sorts EVM events by Address > Topics > Data. +func SortEVMEvents(events []*EVMEvent) { + sort.Slice(events, func(i, j int) bool { + if cmp := bytes.Compare(events[i].Address, events[j].Address); cmp != 0 { + return cmp < 0 + } + + topicI := slices.Concat(events[i].Topics...) + topicJ := slices.Concat(events[j].Topics...) + if cmp := bytes.Compare(topicI, topicJ); cmp != 0 { + return cmp < 0 + } + + return bytes.Compare(events[i].Data, events[j].Data) < 0 + }) +} diff --git a/client/x/evmengine/types/tx_test.go b/client/x/evmengine/types/tx_test.go index 73c05990..1c745f36 100644 --- a/client/x/evmengine/types/tx_test.go +++ b/client/x/evmengine/types/tx_test.go @@ -1,13 +1,21 @@ package types_test import ( + "bytes" + "math/big" + "slices" "testing" + "github.com/cometbft/cometbft/crypto" + k1 "github.com/cometbft/cometbft/crypto/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/decred/dcrd/dcrec/secp256k1" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" + "github.com/piplabs/story/client/genutil/evm/predeploys" "github.com/piplabs/story/client/x/evmengine/types" "github.com/piplabs/story/contracts/bindings" ) @@ -162,3 +170,316 @@ func TestEVMEvent_Verify(t *testing.T) { }) } } + +func TestEthLogToEVMEvent(t *testing.T) { + t.Parallel() + upgradeAbi := initializeABI(t) + data, err := upgradeAbi.Events["SoftwareUpgrade"].Inputs.NonIndexed().Pack("test-upgrade", int64(1), "test-info") + require.NoError(t, err) + + tcs := []struct { + name string + log ethtypes.Log + expectedResult *types.EVMEvent + expectedErr string + }{ + { + name: "pass: zero address", + log: ethtypes.Log{ + Address: emptyAddr, + Topics: []common.Hash{types.SoftwareUpgradeEvent.ID}, + Data: data, + }, + expectedResult: &types.EVMEvent{ + Address: emptyAddr.Bytes(), + Topics: [][]byte{types.SoftwareUpgradeEvent.ID.Bytes()}, + Data: data, + }, + }, + { + name: "pass: empty data", + log: ethtypes.Log{ + Address: dummyContractAddress, + Topics: []common.Hash{types.SoftwareUpgradeEvent.ID}, + Data: emptyData, + }, + expectedResult: &types.EVMEvent{ + Address: dummyContractAddress.Bytes(), + Topics: [][]byte{types.SoftwareUpgradeEvent.ID.Bytes()}, + Data: emptyData, + }, + }, + { + name: "pass: zero address & empty data", + log: ethtypes.Log{ + Address: emptyAddr, + Topics: []common.Hash{types.SoftwareUpgradeEvent.ID}, + Data: emptyData, + }, + expectedResult: &types.EVMEvent{ + Address: emptyAddr.Bytes(), + Topics: [][]byte{types.SoftwareUpgradeEvent.ID.Bytes()}, + Data: emptyData, + }, + }, + { + name: "pass: full log", + log: ethtypes.Log{ + Address: dummyContractAddress, + Topics: []common.Hash{types.SoftwareUpgradeEvent.ID}, + Data: data, + }, + expectedResult: &types.EVMEvent{ + Address: dummyContractAddress.Bytes(), + Topics: [][]byte{types.SoftwareUpgradeEvent.ID.Bytes()}, + Data: data, + }, + }, + { + name: "fail: empty topics", + log: ethtypes.Log{ + Address: dummyContractAddress, + Topics: []common.Hash{}, + Data: data, + }, + expectedErr: "verify log: empty topics", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := types.EthLogToEVMEvent(tc.log) + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedResult, result) + } + }) + } +} + +func TestSortEVMEvents(t *testing.T) { + t.Parallel() + stakingAbi, err := bindings.IPTokenStakingMetaData.GetAbi() + require.NoError(t, err, "failed to load ABI") + stakingAddr := common.HexToAddress(predeploys.IPTokenStaking) + slashingAbi, err := bindings.IPTokenSlashingMetaData.GetAbi() + require.NoError(t, err, "failed to load ABI") + slashingAddr := common.HexToAddress(predeploys.IPTokenSlashing) + // Ensure that stakingAddr < slashingAddr before sorting, + // so that we know the initial order of addresses. + // This check is necessary to validate that the sorting function + // will correctly reorder them if input in the wrong order. + // For example, if the input order is slashingAddr, stakingAddr, + // the sorted result should be stakingAddr, slashingAddr. + require.Negative(t, bytes.Compare(stakingAddr.Bytes(), slashingAddr.Bytes())) + + withdrawEv := stakingAbi.Events["Withdraw"].ID.Bytes() + depositEv := stakingAbi.Events["Deposit"].ID.Bytes() + // Ensure that withdrawEv < depositEv before testing the sorting by topics. + // This check is necessary to establish the initial order of event topics. + // It helps verify that the sorting function will reorder them correctly + // if they are initially in the wrong order. + // For example, if the input order is depositEv, withdrawEv, + // the sorted result should be withdrawEv, depositEv. + require.Negative(t, bytes.Compare(withdrawEv, depositEv)) + unjailEv := slashingAbi.Events["Unjail"].ID.Bytes() + + // prepare data + pubKeys, _, _ := createAddresses(2) + delPubKey := pubKeys[0] + valPubKey := pubKeys[1] + delSecp256k1PubKey, err := secp256k1.ParsePubKey(delPubKey.Bytes()) + require.NoError(t, err) + uncompressedDelPubKeyBytes := delSecp256k1PubKey.SerializeUncompressed() + gwei, exp := big.NewInt(10), big.NewInt(9) + gwei.Exp(gwei, exp, nil) + delAmtGwei := new(big.Int).Mul(gwei, new(big.Int).SetUint64(100)) + + // staking contract events + withdrawData, err := stakingAbi.Events["Withdraw"].Inputs.NonIndexed().Pack( + delPubKey.Bytes(), valPubKey.Bytes(), delAmtGwei, + ) + require.NoError(t, err) + depositData, err := stakingAbi.Events["Deposit"].Inputs.NonIndexed().Pack( + uncompressedDelPubKeyBytes, delPubKey.Bytes(), valPubKey.Bytes(), delAmtGwei, + ) + require.NoError(t, err) + require.Negative(t, bytes.Compare(withdrawData, depositData), "withdrawData should be less than depositData") + cpyDelPubKey := delPubKey.Bytes() + cpyDelPubKey[0] += 1 // add 1 to the first byte so it should be greater than delPubKey + depositData2, err := stakingAbi.Events["Deposit"].Inputs.NonIndexed().Pack( + uncompressedDelPubKeyBytes, delPubKey.Bytes(), valPubKey.Bytes(), delAmtGwei, + ) + require.NoError(t, err) + require.Negative(t, bytes.Compare(depositData, depositData2), "depositData should be less than depositData2") + // slashing contract events + unjailData, err := slashingAbi.Events["Unjail"].Inputs.NonIndexed().Pack(valPubKey.Bytes()) + require.NoError(t, err) + + tcs := []struct { + name string + evmEvents []*types.EVMEvent + expectedResult []*types.EVMEvent + }{ + { + name: "pass: single contract and single event", + evmEvents: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{withdrawEv}, + Data: withdrawData, + }, + }, + expectedResult: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{withdrawEv}, + Data: withdrawData, + }, + }, + }, + { + name: "pass: single contract and multiple event - sorted by topics", + evmEvents: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{withdrawEv}, + Data: withdrawData, + }, + }, + expectedResult: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{withdrawEv}, + Data: withdrawData, + }, + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + }, + }, + { + name: "pass: single contract and multiple event - sorted by data", + evmEvents: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData2, + }, + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + }, + expectedResult: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData2, + }, + }, + }, + { + name: "pass: multiple contract - should be sorted by address", + evmEvents: []*types.EVMEvent{ + { + Address: slashingAddr.Bytes(), + Topics: [][]byte{unjailEv}, + Data: unjailData, + }, + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + }, + expectedResult: []*types.EVMEvent{ + { + Address: stakingAddr.Bytes(), + Topics: [][]byte{depositEv}, + Data: depositData, + }, + { + Address: slashingAddr.Bytes(), + Topics: [][]byte{unjailEv}, + Data: unjailData, + }, + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + types.SortEVMEvents(tc.evmEvents) + require.Equal(t, tc.expectedResult, tc.evmEvents) + require.True(t, isSortedEVMEvents(tc.evmEvents)) + }) + } +} + +func createAddresses(count int) ([]crypto.PubKey, []sdk.AccAddress, []sdk.ValAddress) { + var pubKeys []crypto.PubKey + var accAddrs []sdk.AccAddress + var valAddrs []sdk.ValAddress + for range count { + pubKey := k1.GenPrivKey().PubKey() + accAddr := sdk.AccAddress(pubKey.Address().Bytes()) + valAddr := sdk.ValAddress(pubKey.Address().Bytes()) + pubKeys = append(pubKeys, pubKey) + accAddrs = append(accAddrs, accAddr) + valAddrs = append(valAddrs, valAddr) + } + + return pubKeys, accAddrs, valAddrs +} + +// isSortedEVMEvents check if the events are sorted by ascending order of address, topics, and data. +func isSortedEVMEvents(events []*types.EVMEvent) bool { + for i := 1; i < len(events); i++ { + // Compare addresses first + addressComparison := bytes.Compare(events[i-1].Address, events[i].Address) + if addressComparison > 0 { + // it is not sorted by ascending order of address + return false + } + + if addressComparison == 0 { + // If addresses are equal, compare by topics + previousTopic := slices.Concat(events[i-1].Topics...) + currentTopic := slices.Concat(events[i].Topics...) + topicComparison := bytes.Compare(previousTopic, currentTopic) + + if topicComparison > 0 { + // it is not sorted by ascending order of topics + return false + } + + if topicComparison == 0 { + // If topics are also equal, compare by data + if bytes.Compare(events[i-1].Data, events[i].Data) > 0 { + // it is not sorted by ascending order of data + return false + } + } + } + } + + return true +}