diff --git a/indexer/kv_indexer.go b/indexer/kv_indexer.go index 35bf8c0edc..a56491e9cd 100644 --- a/indexer/kv_indexer.go +++ b/indexer/kv_indexer.go @@ -25,6 +25,10 @@ import ( const ( KeyPrefixTxHash = 1 KeyPrefixTxIndex = 2 + // KeyPrefixTxDerived marks a tx hash as a derived EVM tx (event-only, no embedded + // MsgEthereumTx). Lets the RPC backend cheaply decide whether a KV-indexer hit needs + // its TxResultAdditionalFields rebuilt from events, without parsing every lookup. + KeyPrefixTxDerived = 3 // TxIndexKeyLength is the length of tx-index key TxIndexKeyLength = 1 + 8 + 8 @@ -70,6 +74,15 @@ func (kv *KVIndexer) IndexBlock(block *cmttypes.Block, txResults []*abci.ExecTxR } if !isEthTx(tx) { + // Derived txs (internal EVM executions emitted only as events, not as an + // embedded MsgEthereumTx) still occupy slots in the block's eth-tx index + // sequence (#18). Index them by hash and by (height, ethTxIndex), advancing + // the shared ethTxIndex so it stays aligned with the emitted txIndex for any + // standard eth txs that follow in the same block. + ethTxIndex, err = kv.indexDerivedTxs(batch, height, uint32(txIndex), result, ethTxIndex) //#nosec G115 -- int overflow is not a concern here + if err != nil { + kv.logger.Error("Fail to index derived txs", "err", err, "block", height, "txIndex", txIndex) + } continue } @@ -123,6 +136,64 @@ func (kv *KVIndexer) IndexBlock(block *cmttypes.Block, txResults []*abci.ExecTxR return nil } +// indexDerivedTxs indexes the derived EVM transactions carried as events inside a +// non-eth Cosmos tx (e.g. a Universal Executor MsgExecutePayload). Each derived tx is +// stored by hash and by (height, ethTxIndex) using the same schema as standard +// MsgEthereumTx entries, so eth_getTransactionByHash / Receipt and block-and-index +// lookups resolve it directly without depending on a CometBFT tx_search fallback. +// +// ethTxIndex is the shared, block-level eth tx counter; it is advanced once per derived +// tx and returned so the caller's sequence stays aligned with the txIndex the keeper +// emits (#18) for both derived and standard txs. +func (kv *KVIndexer) indexDerivedTxs( + batch dbm.Batch, + height int64, + txIndex uint32, + result *abci.ExecTxResult, + ethTxIndex int32, +) (int32, error) { + // nil tx: ParseTxResult only needs it for the failed-cosmos-tx gas fallback, which + // assumes embedded MsgEthereumTx messages and does not apply to derived txs. + txs, err := rpctypes.ParseTxResult(result, nil) + if err != nil { + return ethTxIndex, err + } + + var cumulativeGasUsed uint64 + for _, parsed := range txs.Txs { + if parsed.Type != evmtypes.DerivedTxType { + continue + } + + if parsed.EthTxIndex >= 0 && parsed.EthTxIndex != ethTxIndex { + kv.logger.Error("derived eth tx index doesn't match", "expect", ethTxIndex, "found", parsed.EthTxIndex) + } + + cumulativeGasUsed += parsed.GasUsed + txResult := cosmosevmtypes.TxResult{ + Height: height, + TxIndex: txIndex, + MsgIndex: uint32(parsed.MsgIndex), //#nosec G115 -- int overflow is not a concern here + EthTxIndex: ethTxIndex, + Failed: parsed.Failed, + GasUsed: parsed.GasUsed, + CumulativeGasUsed: cumulativeGasUsed, + } + ethTxIndex++ + + if err := saveTxResult(kv.clientCtx.Codec, batch, parsed.Hash, &txResult); err != nil { + return ethTxIndex, errorsmod.Wrapf(err, "indexDerivedTxs %d", height) + } + // Mark the hash as derived in the same batch, so it commits atomically with the + // tx-hash/tx-index entries: a derived tx is findable iff its marker exists. + if err := batch.Set(DerivedTxHashKey(parsed.Hash), []byte{1}); err != nil { + return ethTxIndex, errorsmod.Wrapf(err, "indexDerivedTxs %d set derived key", height) + } + } + + return ethTxIndex, nil +} + // LastIndexedBlock returns the latest indexed block number, returns -1 if db is empty func (kv *KVIndexer) LastIndexedBlock() (int64, error) { return LoadLastBlock(kv.db) @@ -149,6 +220,17 @@ func (kv *KVIndexer) GetByTxHash(hash common.Hash) (*cosmosevmtypes.TxResult, er return &txKey, nil } +// IsDerivedTx reports whether the hash was indexed as a derived EVM tx (event-only, no +// embedded MsgEthereumTx). A cheap single key read used by the RPC backend to gate the +// (more expensive) rebuild of TxResultAdditionalFields from block events. +func (kv *KVIndexer) IsDerivedTx(hash common.Hash) (bool, error) { + bz, err := kv.db.Get(DerivedTxHashKey(hash)) + if err != nil { + return false, errorsmod.Wrapf(err, "IsDerivedTx %s", hash.Hex()) + } + return len(bz) > 0, nil +} + // GetByBlockAndIndex finds eth tx by block number and eth tx index func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*cosmosevmtypes.TxResult, error) { bz, err := kv.db.Get(TxIndexKey(blockNumber, txIndex)) @@ -161,11 +243,31 @@ func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*cosm return kv.GetByTxHash(common.BytesToHash(bz)) } +// IsDerivedTxByBlockAndIndex reports whether the tx at (blockNumber, eth tx index) is a +// derived EVM tx. It resolves the hash from the block-index entry and consults the derived +// marker — two cheap key reads — so the RPC backend can gate derived-tx reconstruction on +// the block-index path without reparsing block events for ordinary txs. +func (kv *KVIndexer) IsDerivedTxByBlockAndIndex(blockNumber int64, txIndex int32) (bool, error) { + bz, err := kv.db.Get(TxIndexKey(blockNumber, txIndex)) + if err != nil { + return false, errorsmod.Wrapf(err, "IsDerivedTxByBlockAndIndex %d %d", blockNumber, txIndex) + } + if len(bz) == 0 { + return false, nil + } + return kv.IsDerivedTx(common.BytesToHash(bz)) +} + // TxHashKey returns the key for db entry: `tx hash -> tx result struct` func TxHashKey(hash common.Hash) []byte { return append([]byte{KeyPrefixTxHash}, hash.Bytes()...) } +// DerivedTxHashKey returns the key for db entry: `tx hash -> derived marker` +func DerivedTxHashKey(hash common.Hash) []byte { + return append([]byte{KeyPrefixTxDerived}, hash.Bytes()...) +} + // TxIndexKey returns the key for db entry: `(block number, tx index) -> tx hash` func TxIndexKey(blockNumber int64, txIndex int32) []byte { bz1 := sdk.Uint64ToBigEndian(uint64(blockNumber)) //nolint:gosec // G115 // block number won't exceed uint64 diff --git a/indexer/kv_indexer_test.go b/indexer/kv_indexer_test.go index 2e94456fe4..4ab518f48b 100644 --- a/indexer/kv_indexer_test.go +++ b/indexer/kv_indexer_test.go @@ -2,6 +2,7 @@ package indexer_test import ( "math/big" + "strconv" "testing" "github.com/ethereum/go-ethereum/common" @@ -190,3 +191,158 @@ func TestKVIndexer(t *testing.T) { }) } } + +// TestKVIndexerDerivedTxs verifies that derived EVM txs (internal executions emitted +// only as events, with txType=DerivedTxType) are indexed by hash and block index just +// like standard MsgEthereumTx txs, and that they share a single eth-tx index sequence +// with standard txs in the same block. +func TestKVIndexerDerivedTxs(t *testing.T) { + priv, err := ethsecp256k1.GenerateKey() + require.NoError(t, err) + from := common.BytesToAddress(priv.PubKey().Address().Bytes()) + signer := utiltx.NewSigner(priv) + ethSigner := ethtypes.LatestSignerForChainID(nil) + + to := common.BigToAddress(big.NewInt(1)) + stdTx := types.NewTx(&types.EvmTxArgs{Nonce: 0, To: &to, Amount: big.NewInt(1000), GasLimit: 21000}) + stdTx.From = from.Hex() + require.NoError(t, stdTx.Sign(ethSigner, signer)) + stdHash := stdTx.AsTransaction().Hash() + + nw := network.New() + encodingConfig := nw.GetEncodingConfig() + clientCtx := client.Context{}.WithTxConfig(encodingConfig.TxConfig).WithCodec(encodingConfig.Codec) + + // standard eth wrapper tx (recognized as eth via the ethereum extension option) + stdWrapper, err := stdTx.BuildTx(clientCtx.TxConfig.NewTxBuilder(), constants.ExampleAttoDenom) + require.NoError(t, err) + stdBz, err := clientCtx.TxConfig.TxEncoder()(stdWrapper) + require.NoError(t, err) + + // non-eth Cosmos tx wrapper (no eth extension) — the carrier for a derived tx + builder := clientCtx.TxConfig.NewTxBuilder() + require.NoError(t, builder.SetMsgs(stdTx)) + nonEthBz, err := clientCtx.TxConfig.TxEncoder()(builder.GetTx()) + require.NoError(t, err) + + derivedHash := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadbeef") + + gas := func(v int64) string { return strconv.FormatInt(v, 10) } + idx := func(v int32) string { return strconv.FormatInt(int64(v), 10) } + + // derivedResult builds a successful tx result whose events describe one derived EVM + // tx (ethereum_tx + tx_log + message{txType=DerivedTxType}) at the given eth txIndex. + derivedResult := func(hash common.Hash, txIndex int32, gasUsed int64) *abci.ExecTxResult { + return &abci.ExecTxResult{ + Code: 0, + GasUsed: gasUsed, + Events: []abci.Event{ + {Type: types.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: types.AttributeKeyEthereumTxHash, Value: hash.Hex()}, + {Key: types.AttributeKeyTxIndex, Value: idx(txIndex)}, + {Key: types.AttributeKeyTxGasUsed, Value: gas(gasUsed)}, + {Key: types.AttributeKeyRecipient, Value: to.Hex()}, + }}, + {Type: types.EventTypeTxLog, Attributes: []abci.EventAttribute{}}, + {Type: "message", Attributes: []abci.EventAttribute{ + {Key: "module", Value: "evm"}, + {Key: "sender", Value: from.Hex()}, + {Key: types.AttributeKeyTxType, Value: strconv.FormatUint(uint64(types.DerivedTxType), 10)}, + }}, + }, + } + } + + // standardResult builds a successful tx result for a normal MsgEthereumTx. GasUsed is + // set on the result because ParseTxResult overwrites a single non-derived tx's gas + // with result.GasUsed (the derived path keeps the event-reported gas instead). + standardResult := func(hash common.Hash, txIndex int32, gasUsed int64) *abci.ExecTxResult { + return &abci.ExecTxResult{ + Code: 0, + GasUsed: gasUsed, + Events: []abci.Event{ + {Type: types.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: types.AttributeKeyEthereumTxHash, Value: hash.Hex()}, + {Key: types.AttributeKeyTxIndex, Value: idx(txIndex)}, + {Key: types.AttributeKeyTxGasUsed, Value: gas(gasUsed)}, + {Key: types.AttributeKeyRecipient, Value: to.Hex()}, + }}, + }, + } + } + + t.Run("derived tx is indexed by hash and block index", func(t *testing.T) { + db := dbm.NewMemDB() + idxer := indexer.NewKVIndexer(db, log.NewNopLogger(), clientCtx) + + block := &cmttypes.Block{Header: cmttypes.Header{Height: 1}, Data: cmttypes.Data{Txs: []cmttypes.Tx{nonEthBz}}} + require.NoError(t, idxer.IndexBlock(block, []*abci.ExecTxResult{derivedResult(derivedHash, 0, 50000)})) + + // Resolvable by hash — without indexing derived txs this lookup misses. + res, err := idxer.GetByTxHash(derivedHash) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, int32(0), res.EthTxIndex) + require.Equal(t, uint64(50000), res.GasUsed) + require.False(t, res.Failed) + + // ...and by block index, returning the same record. + byIdx, err := idxer.GetByBlockAndIndex(1, 0) + require.NoError(t, err) + require.Equal(t, res, byIdx) + + // marked derived so the RPC backend rebuilds its additional fields from events + isDerived, err := idxer.IsDerivedTx(derivedHash) + require.NoError(t, err) + require.True(t, isDerived) + }) + + t.Run("derived and standard txs share one eth-tx index sequence", func(t *testing.T) { + db := dbm.NewMemDB() + idxer := indexer.NewKVIndexer(db, log.NewNopLogger(), clientCtx) + + // Block order: derived tx (Cosmos tx 0) then standard tx (Cosmos tx 1). With #18 + // the keeper advances the eth txIndex for the derived tx, so the standard tx + // emits txIndex=1. The indexer must mirror that by counting the derived tx — else + // the standard tx is stored under index 0 and block-and-index lookups diverge. + block := &cmttypes.Block{ + Header: cmttypes.Header{Height: 1}, + Data: cmttypes.Data{Txs: []cmttypes.Tx{nonEthBz, stdBz}}, + } + results := []*abci.ExecTxResult{ + derivedResult(derivedHash, 0, 50000), + standardResult(stdHash, 1, 21000), + } + require.NoError(t, idxer.IndexBlock(block, results)) + + // derived → eth index 0 (Cosmos tx 0), standard → eth index 1 (Cosmos tx 1) + dByHash, err := idxer.GetByTxHash(derivedHash) + require.NoError(t, err) + require.Equal(t, int32(0), dByHash.EthTxIndex) + require.Equal(t, uint32(0), dByHash.TxIndex) + require.Equal(t, uint64(50000), dByHash.GasUsed) + + sByHash, err := idxer.GetByTxHash(stdHash) + require.NoError(t, err) + require.Equal(t, int32(1), sByHash.EthTxIndex) + require.Equal(t, uint32(1), sByHash.TxIndex) + require.Equal(t, uint64(21000), sByHash.GasUsed) + + // block-and-index lookups resolve to the same records (no collision/divergence) + d0, err := idxer.GetByBlockAndIndex(1, 0) + require.NoError(t, err) + require.Equal(t, dByHash, d0) + + s1, err := idxer.GetByBlockAndIndex(1, 1) + require.NoError(t, err) + require.Equal(t, sByHash, s1) + + // only the derived tx carries the derived marker + isDerived, err := idxer.IsDerivedTx(derivedHash) + require.NoError(t, err) + require.True(t, isDerived) + isStdDerived, err := idxer.IsDerivedTx(stdHash) + require.NoError(t, err) + require.False(t, isStdDerived) + }) +} diff --git a/rpc/backend/client_test.go b/rpc/backend/client_test.go index 7309f61dbe..71d4f36c13 100644 --- a/rpc/backend/client_test.go +++ b/rpc/backend/client_test.go @@ -215,6 +215,24 @@ func RegisterBlockResults( return res, nil } +// RegisterBlockResultsWithTxResults mocks BlockResults so it returns the supplied +// per-tx results verbatim. Used to feed derived-tx events (ethereum_tx + message) that +// the backend reparses to rebuild a derived tx's additional fields. +func RegisterBlockResultsWithTxResults( + client *mocks.Client, + height int64, + txResults []*abci.ExecTxResult, +) (*cmtrpctypes.ResultBlockResults, error) { + res := &cmtrpctypes.ResultBlockResults{ + Height: height, + TxsResults: txResults, + } + + client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(res, nil) + return res, nil +} + func RegisterBlockResultsError(client *mocks.Client, height int64) { client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). Return(nil, errortypes.ErrInvalidRequest) diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index 3b82af1e14..74d0798d51 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -411,6 +411,62 @@ func (b *Backend) GetTransactionByBlockNumberAndIndex(blockNum rpctypes.BlockNum return b.GetTransactionByBlockAndIndex(block, idx) } +// derivedTxAdditionalFields rebuilds the TxResultAdditionalFields for a tx located via +// the KV indexer when (and only when) that tx is a derived EVM tx — an internal execution +// recorded only as events, with no embedded MsgEthereumTx to decode. The KV indexer stores +// just the TxResult, so without this the serving paths (GetTransactionByHash / Receipt / +// TraceTransaction) would treat a derived tx as standard and panic on the MsgEthereumTx +// cast. Standard txs return (nil, nil); the IsDerivedTx marker gate keeps their lookups +// cheap (one key read, no event reparse). +func (b *Backend) derivedTxAdditionalFields(hash common.Hash, res *types.TxResult) (*rpctypes.TxResultAdditionalFields, error) { + derived, err := b.indexer.IsDerivedTx(hash) + if err != nil { + return nil, err + } + if !derived { + return nil, nil + } + return b.buildDerivedAdditional(res) +} + +// buildDerivedAdditional re-parses the block events for res's Cosmos tx and rebuilds the +// TxResultAdditionalFields for the derived EVM tx at res.MsgIndex. Callers must have +// already confirmed the entry is derived via a marker (IsDerivedTx for the by-hash path, +// IsDerivedTxByBlockAndIndex for the by-block-index path), so a missing or non-derived +// parse result is treated as an error. +func (b *Backend) buildDerivedAdditional(res *types.TxResult) (*rpctypes.TxResultAdditionalFields, error) { + blockRes, err := b.rpcClient.BlockResults(b.ctx, &res.Height) + if err != nil { + return nil, errorsmod.Wrapf(err, "block results for derived tx at height %d", res.Height) + } + if int(res.TxIndex) >= len(blockRes.TxsResults) { + return nil, fmt.Errorf("derived tx index %d out of bounds at height %d", res.TxIndex, res.Height) + } + + parsedTxs, err := rpctypes.ParseTxResult(blockRes.TxsResults[res.TxIndex], nil) + if err != nil { + return nil, errorsmod.Wrapf(err, "parse derived tx events at height %d", res.Height) + } + parsed := parsedTxs.GetTxByMsgIndex(int(res.MsgIndex)) + if parsed == nil || parsed.Type != evmtypes.DerivedTxType { + return nil, fmt.Errorf("derived tx not found in events: height %d, txIndex %d, msgIndex %d", + res.Height, res.TxIndex, res.MsgIndex) + } + + return &rpctypes.TxResultAdditionalFields{ + Value: parsed.Amount, + Hash: parsed.Hash, + TxHash: parsed.TxHash, + Type: parsed.Type, + Recipient: parsed.Recipient, + Sender: parsed.Sender, + GasUsed: parsed.GasUsed, + Data: parsed.Data, + Nonce: parsed.Nonce, + GasLimit: &parsed.GasLimit, + }, nil +} + // GetTxByEthHash uses `/tx_query` to find transaction by ethereum tx hash // TODO: Don't need to convert once hashing is fixed on Tendermint // https://github.com/cometbft/cometbft/issues/6539 @@ -418,9 +474,14 @@ func (b *Backend) GetTxByEthHash(hash common.Hash) (*types.TxResult, *rpctypes.T if b.indexer != nil { txRes, err := b.indexer.GetByTxHash(hash) if err == nil { - return txRes, nil, nil + // indexer hit: rebuild additional fields when this is a derived tx + additional, derr := b.derivedTxAdditionalFields(hash, txRes) + if derr != nil { + return nil, nil, derr + } + return txRes, additional, nil } - // indexer miss or no derived-tx metadata — fall through to event-query reconstruction + // indexer miss — fall through to event-query (tx_search) reconstruction } // fallback to tendermint tx indexer @@ -438,9 +499,14 @@ func (b *Backend) GetTxByEthHashAndMsgIndex(hash common.Hash, index int) (*types if b.indexer != nil { txRes, err := b.indexer.GetByTxHash(hash) if err == nil { - return txRes, nil, nil + // indexer hit: rebuild additional fields when this is a derived tx + additional, derr := b.derivedTxAdditionalFields(hash, txRes) + if derr != nil { + return nil, nil, derr + } + return txRes, additional, nil } - // indexer miss or no derived-tx metadata — fall through to event-query reconstruction + // indexer miss — fall through to event-query (tx_search) reconstruction } // fallback to tendermint tx indexer @@ -460,6 +526,21 @@ func (b *Backend) GetTxByTxIndex(height int64, index uint) (*types.TxResult, *rp if b.indexer != nil { txRes, err := b.indexer.GetByBlockAndIndex(height, int32Index) if err == nil { + // Only derived block-index entries need their additional fields rebuilt (so + // trace predecessors reconstruct them instead of treating them as standard). + // The marker gate keeps ordinary txs cheap — no event reparse, matching the + // prior behavior — so a standard predecessor never triggers a BlockResults read. + derived, derr := b.indexer.IsDerivedTxByBlockAndIndex(height, int32Index) + if derr != nil { + return nil, nil, derr + } + if derived { + additional, aerr := b.buildDerivedAdditional(txRes) + if aerr != nil { + return nil, nil, aerr + } + return txRes, additional, nil + } return txRes, nil, nil } } diff --git a/rpc/backend/tx_info_test.go b/rpc/backend/tx_info_test.go index aaee81ac8c..58c4e4a757 100644 --- a/rpc/backend/tx_info_test.go +++ b/rpc/backend/tx_info_test.go @@ -14,6 +14,7 @@ import ( "github.com/cometbft/cometbft/types" dbm "github.com/cosmos/cosmos-db" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" "github.com/cosmos/evm/indexer" "github.com/cosmos/evm/rpc/backend/mocks" rpctypes "github.com/cosmos/evm/rpc/types" @@ -131,6 +132,80 @@ func (suite *BackendTestSuite) TestGetTransactionByHash() { } } +// TestGetTransactionByHashDerived verifies that a derived EVM tx (event-only, no embedded +// MsgEthereumTx) indexed in the KV indexer is served by eth_getTransactionByHash. The +// backend must rebuild the tx's additional fields from block events rather than casting +// the carrier Cosmos message to *MsgEthereumTx — which, before the fix, would panic. +func (suite *BackendTestSuite) TestGetTransactionByHashDerived() { + // Carrier is a non-eth Cosmos message (a bank MsgSend, standing in for e.g. a + // Universal Executor MsgExecutePayload): no ethereum extension option, so the indexer + // takes the derived path, and casting it to *MsgEthereumTx is exactly what panics + // without the reconstruction fix. The derived EVM execution lives in the events. + carrierMsg := &banktypes.MsgSend{FromAddress: suite.acc.String(), ToAddress: suite.acc.String()} + builder := suite.backend.clientCtx.TxConfig.NewTxBuilder() + suite.Require().NoError(builder.SetMsgs(carrierMsg)) + carrierBz, err := suite.backend.clientCtx.TxConfig.TxEncoder()(builder.GetTx()) + suite.Require().NoError(err) + + derivedHash := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadbeef") + sender := common.BytesToAddress([]byte("derived-sender")) + recipient := common.BytesToAddress([]byte("derived-recipient")) + + block := &types.Block{Header: types.Header{Height: 1, ChainID: "test"}, Data: types.Data{Txs: []types.Tx{carrierBz}}} + responseDeliver := []*abci.ExecTxResult{ + { + Code: 0, + GasUsed: 50000, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: derivedHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "50000"}, + {Key: "recipient", Value: recipient.Hex()}, + {Key: "txNonce", Value: "7"}, + {Key: "txGasLimit", Value: "60000"}, + {Key: "txData", Value: "0x"}, + }}, + {Type: evmtypes.EventTypeTxLog, Attributes: []abci.EventAttribute{}}, + {Type: "message", Attributes: []abci.EventAttribute{ + {Key: "module", Value: "evm"}, + {Key: "sender", Value: sender.Hex()}, + {Key: "txType", Value: "99"}, // evmtypes.DerivedTxType + }}, + }, + }, + } + + client := suite.backend.clientCtx.Client.(*mocks.Client) + queryClient := suite.backend.queryClient.QueryClient.(*mocks.EVMQueryClient) + _, err = RegisterBlock(client, 1, carrierBz) + suite.Require().NoError(err) + _, err = RegisterBlockResultsWithTxResults(client, 1, responseDeliver) + suite.Require().NoError(err) + RegisterBaseFee(queryClient, math.NewInt(1)) + + db := dbm.NewMemDB() + suite.backend.indexer = indexer.NewKVIndexer(db, log.NewNopLogger(), suite.backend.clientCtx) + suite.Require().NoError(suite.backend.indexer.IndexBlock(block, responseDeliver)) + + isDerived, err := suite.backend.indexer.IsDerivedTx(derivedHash) + suite.Require().NoError(err) + suite.Require().True(isDerived) + + // Must not panic and must return the tx rebuilt from events. + rpcTx, err := suite.backend.GetTransactionByHash(derivedHash) + suite.Require().NoError(err) + suite.Require().NotNil(rpcTx) + suite.Require().Equal(derivedHash, rpcTx.Hash) + suite.Require().Equal(sender, rpcTx.From) + suite.Require().NotNil(rpcTx.To) + suite.Require().Equal(recipient, *rpcTx.To) + suite.Require().Equal(hexutil.Uint64(7), rpcTx.Nonce) + suite.Require().Equal(hexutil.Uint64(50000), rpcTx.Gas) + suite.Require().Equal(big.NewInt(1000), (*big.Int)(rpcTx.Value)) +} + func (suite *BackendTestSuite) TestGetTransactionsByHashPending() { msgEthereumTx, bz := suite.buildEthereumTx() rpcTransaction, _ := rpctypes.NewRPCTransaction(msgEthereumTx.AsTransaction(), common.Hash{}, 0, 0, big.NewInt(1), suite.backend.chainID) @@ -548,6 +623,69 @@ func (suite *BackendTestSuite) TestGetTransactionByTxIndex() { } } +// TestGetTxByTxIndexDerived verifies the block-index lookup reconstructs a derived tx's +// additional fields on a KV-indexer hit — consistent with the by-hash lookup — so trace +// predecessors that are derived txs are rebuilt instead of silently dropped. +func (suite *BackendTestSuite) TestGetTxByTxIndexDerived() { + carrierMsg := &banktypes.MsgSend{FromAddress: suite.acc.String(), ToAddress: suite.acc.String()} + builder := suite.backend.clientCtx.TxConfig.NewTxBuilder() + suite.Require().NoError(builder.SetMsgs(carrierMsg)) + carrierBz, err := suite.backend.clientCtx.TxConfig.TxEncoder()(builder.GetTx()) + suite.Require().NoError(err) + + derivedHash := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadf00d") + sender := common.BytesToAddress([]byte("derived-sender")) + recipient := common.BytesToAddress([]byte("derived-recipient")) + + block := &types.Block{Header: types.Header{Height: 1, ChainID: "test"}, Data: types.Data{Txs: []types.Tx{carrierBz}}} + responseDeliver := []*abci.ExecTxResult{ + { + Code: 0, + GasUsed: 50000, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: derivedHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "50000"}, + {Key: "recipient", Value: recipient.Hex()}, + {Key: "txNonce", Value: "7"}, + {Key: "txGasLimit", Value: "60000"}, + {Key: "txData", Value: "0x"}, + }}, + {Type: evmtypes.EventTypeTxLog, Attributes: []abci.EventAttribute{}}, + {Type: "message", Attributes: []abci.EventAttribute{ + {Key: "module", Value: "evm"}, + {Key: "sender", Value: sender.Hex()}, + {Key: "txType", Value: "99"}, // evmtypes.DerivedTxType + }}, + }, + }, + } + + client := suite.backend.clientCtx.Client.(*mocks.Client) + _, err = RegisterBlockResultsWithTxResults(client, 1, responseDeliver) + suite.Require().NoError(err) + + db := dbm.NewMemDB() + suite.backend.indexer = indexer.NewKVIndexer(db, log.NewNopLogger(), suite.backend.clientCtx) + suite.Require().NoError(suite.backend.indexer.IndexBlock(block, responseDeliver)) + + // derived tx is at eth block-index 0 + txRes, additional, err := suite.backend.GetTxByTxIndex(1, 0) + suite.Require().NoError(err) + suite.Require().NotNil(txRes) + suite.Require().Equal(int32(0), txRes.EthTxIndex) + + // the block-index lookup must rebuild the derived tx's additional fields (was nil before) + suite.Require().NotNil(additional) + suite.Require().Equal(derivedHash, additional.Hash) + suite.Require().Equal(recipient, additional.Recipient) + suite.Require().Equal(sender, additional.Sender) + suite.Require().Equal(uint64(7), additional.Nonce) + suite.Require().Equal(uint64(50000), additional.GasUsed) +} + func (suite *BackendTestSuite) TestQueryTendermintTxIndexer() { testCases := []struct { name string @@ -659,6 +797,70 @@ func (suite *BackendTestSuite) TestGetTransactionReceipt() { } } +// TestGetTransactionReceiptDerived verifies eth_getTransactionReceipt for a KV-indexed +// derived tx: the receipt path must rebuild the tx from events (additional fields) rather +// than casting the carrier Cosmos message — which, before the fix, would panic. +func (suite *BackendTestSuite) TestGetTransactionReceiptDerived() { + carrierMsg := &banktypes.MsgSend{FromAddress: suite.acc.String(), ToAddress: suite.acc.String()} + builder := suite.backend.clientCtx.TxConfig.NewTxBuilder() + suite.Require().NoError(builder.SetMsgs(carrierMsg)) + carrierBz, err := suite.backend.clientCtx.TxConfig.TxEncoder()(builder.GetTx()) + suite.Require().NoError(err) + + derivedHash := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadcafe") + sender := common.BytesToAddress([]byte("derived-sender")) + recipient := common.BytesToAddress([]byte("derived-recipient")) + + block := &types.Block{Header: types.Header{Height: 1, ChainID: "test"}, Data: types.Data{Txs: []types.Tx{carrierBz}}} + responseDeliver := []*abci.ExecTxResult{ + { + Code: 0, + GasUsed: 50000, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: derivedHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "50000"}, + {Key: "recipient", Value: recipient.Hex()}, + {Key: "txNonce", Value: "7"}, + {Key: "txGasLimit", Value: "60000"}, + {Key: "txData", Value: "0x"}, + }}, + {Type: evmtypes.EventTypeTxLog, Attributes: []abci.EventAttribute{}}, + {Type: "message", Attributes: []abci.EventAttribute{ + {Key: "module", Value: "evm"}, + {Key: "sender", Value: sender.Hex()}, + {Key: "txType", Value: "99"}, // evmtypes.DerivedTxType + }}, + }, + }, + } + + client := suite.backend.clientCtx.Client.(*mocks.Client) + queryClient := suite.backend.queryClient.QueryClient.(*mocks.EVMQueryClient) + _, err = RegisterBlock(client, 1, carrierBz) + suite.Require().NoError(err) + _, err = RegisterBlockResultsWithTxResults(client, 1, responseDeliver) + suite.Require().NoError(err) + RegisterBaseFee(queryClient, math.NewInt(1)) + + db := dbm.NewMemDB() + suite.backend.indexer = indexer.NewKVIndexer(db, log.NewNopLogger(), suite.backend.clientCtx) + suite.Require().NoError(suite.backend.indexer.IndexBlock(block, responseDeliver)) + + // Must not panic and must return a receipt rebuilt from events. + receipt, err := suite.backend.GetTransactionReceipt(derivedHash) + suite.Require().NoError(err) + suite.Require().NotNil(receipt) + suite.Require().Equal(derivedHash, receipt["transactionHash"]) + suite.Require().Equal(hexutil.Uint(ethtypes.ReceiptStatusSuccessful), receipt["status"]) + suite.Require().Equal(sender, receipt["from"]) + suite.Require().Equal(hexutil.Uint64(50000), receipt["gasUsed"]) + suite.Require().NotNil(receipt["to"]) + suite.Require().Equal(recipient, *receipt["to"].(*common.Address)) +} + func (suite *BackendTestSuite) TestGetGasUsed() { origin := suite.backend.cfg.JSONRPC.FixRevertGasRefundHeight testCases := []struct { diff --git a/types/indexer.go b/types/indexer.go index 41a452b943..09007115a1 100644 --- a/types/indexer.go +++ b/types/indexer.go @@ -17,4 +17,8 @@ type EVMTxIndexer interface { GetByTxHash(common.Hash) (*TxResult, error) // GetByBlockAndIndex returns nil if tx not found. GetByBlockAndIndex(int64, int32) (*TxResult, error) + // IsDerivedTx reports whether the hash was indexed as a derived (event-only) EVM tx. + IsDerivedTx(common.Hash) (bool, error) + // IsDerivedTxByBlockAndIndex reports whether the tx at (height, eth tx index) is derived. + IsDerivedTxByBlockAndIndex(int64, int32) (bool, error) }