From 99c7a335417f09ef2e0b8ce8087917f707bb2028 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:23:10 +0530 Subject: [PATCH 01/12] fix(rpc): resolve ghost logs for derived txs by routing JSON-RPC through event-based log parsing --- rpc/backend/blocks.go | 4 +- rpc/backend/comet_to_eth.go | 90 ++++++++++++++++--- rpc/backend/tx_info.go | 16 +++- rpc/backend/tx_info_test.go | 2 +- .../rpc/backend/test_backend_suite.go | 2 +- 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/rpc/backend/blocks.go b/rpc/backend/blocks.go index 50727bcf13..b01e22001d 100644 --- a/rpc/backend/blocks.go +++ b/rpc/backend/blocks.go @@ -197,9 +197,9 @@ func (b *Backend) GetBlockReceipts( return nil, fmt.Errorf("block result not found for height %d", resBlock.Block.Height) } - msgs, _ := b.EthMsgsFromCometBlock(resBlock, blockRes) + msgs, txsAdditional := b.EthMsgsFromCometBlock(resBlock, blockRes) - receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, msgs) + receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, msgs, txsAdditional) if err != nil { return nil, fmt.Errorf("failed to get receipts from comet block: %w, ", err) diff --git a/rpc/backend/comet_to_eth.go b/rpc/backend/comet_to_eth.go index 10e86e7ff3..88f00d54fe 100644 --- a/rpc/backend/comet_to_eth.go +++ b/rpc/backend/comet_to_eth.go @@ -1,6 +1,7 @@ package backend import ( + "encoding/json" "fmt" "math/big" @@ -256,7 +257,7 @@ func (b *Backend) EthBlockFromCometBlock( } // 7. receipts - receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, msgs) + receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, msgs, additionals) if err != nil { return nil, fmt.Errorf("failed to get receipts from comet block: %w", err) } @@ -310,10 +311,39 @@ func (b *Backend) MinerFromCometBlock( return common.BytesToAddress(validatorAccAddr), nil } +// derivedTxLogsFromEvents finds EVM logs for a derived tx by scanning tx_log events and +// matching each log's TxHash to the given hash. Returns nil, nil when no matching logs are +// found — valid for a successful derived tx that emits no EVM events. +func derivedTxLogsFromEvents(events []abci.Event, txHash common.Hash, blockNumber uint64) ([]*ethtypes.Log, error) { + var result []*ethtypes.Log + for _, event := range events { + if event.Type != evmtypes.EventTypeTxLog { + continue + } + for _, attr := range event.Attributes { + if attr.Key != evmtypes.AttributeKeyTxLog { + continue + } + var log evmtypes.Log + if err := json.Unmarshal([]byte(attr.Value), &log); err != nil { + return nil, err + } + if common.HexToHash(log.TxHash) != txHash { + continue + } + l := log.ToEthereum() + l.BlockNumber = blockNumber + result = append(result, l) + } + } + return result, nil +} + func (b *Backend) ReceiptsFromCometBlock( resBlock *cmtrpctypes.ResultBlock, blockRes *cmtrpctypes.ResultBlockResults, msgs []*evmtypes.MsgEthereumTx, + additionals []*rpctypes.TxResultAdditionalFields, ) ([]*ethtypes.Receipt, error) { baseFee, err := b.BaseFee(blockRes) if err != nil { @@ -321,13 +351,27 @@ func (b *Backend) ReceiptsFromCometBlock( b.Logger.Error("failed to fetch Base Fee from prunned block. Check node prunning configuration", "height", resBlock.Block.Height, "error", err) } + blockHeight := uint64(resBlock.Block.Height) // #nosec G115 blockHash := common.BytesToHash(resBlock.BlockID.Hash) receipts := make([]*ethtypes.Receipt, len(msgs)) cumulatedGasUsed := uint64(0) for i, ethMsg := range msgs { - txResult, _, err := b.GetTxByEthHash(ethMsg.Hash()) + var additional *rpctypes.TxResultAdditionalFields + if additionals != nil && i < len(additionals) { + additional = additionals[i] + } + + // Derived txs must be looked up by the event hash (additional.Hash); native txs use ethMsg.Hash(). + var lookupHash common.Hash + if additional != nil { + lookupHash = additional.Hash + } else { + lookupHash = ethMsg.Hash() + } + + txResult, _, err := b.GetTxByEthHash(lookupHash) if err != nil { - return nil, fmt.Errorf("tx not found: hash=%s, error=%s", ethMsg.Hash(), err.Error()) + return nil, fmt.Errorf("tx not found: hash=%s, error=%s", lookupHash, err.Error()) } cumulatedGasUsed += txResult.GasUsed @@ -351,18 +395,40 @@ func (b *Backend) ReceiptsFromCometBlock( contractAddress = crypto.CreateAddress(ethMsg.GetSender(), ethMsg.Raw.Nonce()) } - msgIndex := int(txResult.MsgIndex) // #nosec G115 -- checked for int overflow already - logs, err := evmtypes.DecodeMsgLogs( - blockRes.TxsResults[txResult.TxIndex].Data, - msgIndex, - uint64(resBlock.Block.Height), // #nosec G115 -- checked for int overflow already - ) - if err != nil { - return nil, fmt.Errorf("failed to convert tx result to eth receipt: %w", err) + var logs []*ethtypes.Log + if additional != nil { + // Derived tx: MsgIndex is math.MaxUint32 (sentinel). Parse logs from tx_log events + // by matching TxHash instead of using the protobuf-encoded Data field. + logs, err = derivedTxLogsFromEvents( + blockRes.TxsResults[txResult.TxIndex].Events, + additional.Hash, + blockHeight, + ) + if err != nil { + return nil, fmt.Errorf("failed to parse derived tx logs: %w", err) + } + } else { + msgIndex := int(txResult.MsgIndex) // #nosec G115 -- checked for int overflow already + logs, err = evmtypes.DecodeMsgLogs( + blockRes.TxsResults[txResult.TxIndex].Data, + msgIndex, + blockHeight, + ) + if err != nil { + return nil, fmt.Errorf("failed to convert tx result to eth receipt: %w", err) + } } bloom := ethtypes.CreateBloom(ðtypes.Receipt{Logs: logs}) + // Derived txs use the event hash as the canonical TxHash in the receipt. + var txHash common.Hash + if additional != nil { + txHash = additional.Hash + } else { + txHash = ethMsg.Hash() + } + receipt := ðtypes.Receipt{ // Consensus fields: These fields are defined by the Yellow Paper Type: ethMsg.Raw.Type(), @@ -373,7 +439,7 @@ func (b *Backend) ReceiptsFromCometBlock( Logs: logs, // Implementation fields: These fields are added by geth when processing a transaction. - TxHash: ethMsg.Hash(), + TxHash: txHash, ContractAddress: contractAddress, GasUsed: txResult.GasUsed, EffectiveGasPrice: effectiveGasPrice, diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index db249c2674..14d6456b89 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -221,7 +221,7 @@ func (b *Backend) GetTransactionReceipt(hash common.Hash) (map[string]interface{ return nil, fmt.Errorf("block result not found at height %d: %w", res.Height, err) } - receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, []*evmtypes.MsgEthereumTx{ethMsg}) + receipts, err := b.ReceiptsFromCometBlock(resBlock, blockRes, []*evmtypes.MsgEthereumTx{ethMsg}, []*rpctypes.TxResultAdditionalFields{additional}) if err != nil { return nil, fmt.Errorf("failed to get receipts from comet block") } @@ -245,7 +245,7 @@ func (b *Backend) GetTransactionReceipt(hash common.Hash) (map[string]interface{ func (b *Backend) GetTransactionLogs(hash common.Hash) ([]*ethtypes.Log, error) { hexTx := hash.Hex() - res, _, err := b.GetTxByEthHash(hash) + res, additional, err := b.GetTxByEthHash(hash) if err != nil { b.Logger.Debug("tx not found", "hash", hexTx, "error", err.Error()) return nil, nil @@ -265,7 +265,17 @@ func (b *Backend) GetTransactionLogs(hash common.Hash) ([]*ethtypes.Log, error) if err != nil { return nil, err } - // parse tx logs from events + + if additional != nil { + // Derived tx: MsgIndex is math.MaxUint32 (sentinel). Parse logs from tx_log events + // by matching TxHash instead of using the protobuf-encoded Data field. + return derivedTxLogsFromEvents( + resBlockResult.TxsResults[res.TxIndex].Events, + additional.Hash, + height, + ) + } + index := int(res.MsgIndex) // #nosec G701 logs, err := evmtypes.DecodeMsgLogs( resBlockResult.TxsResults[res.TxIndex].Data, diff --git a/rpc/backend/tx_info_test.go b/rpc/backend/tx_info_test.go index 4f5323eaa2..36fed32378 100644 --- a/rpc/backend/tx_info_test.go +++ b/rpc/backend/tx_info_test.go @@ -491,7 +491,7 @@ func TestReceiptsFromCometBlock(t *testing.T) { backend.Indexer = mockIndexer mockEVMQueryClient := backend.QueryClient.QueryClient.(*mocks.EVMQueryClient) mockEVMQueryClient.On("BaseFee", mock.Anything, mock.Anything).Return(&evmtypes.QueryBaseFeeResponse{}, nil) - receipts, err := backend.ReceiptsFromCometBlock(resBlock, blockRes, msgs) + receipts, err := backend.ReceiptsFromCometBlock(resBlock, blockRes, msgs, nil) require.NoError(t, err) require.Len(t, receipts, 1) actualTxIndex := receipts[0].TransactionIndex diff --git a/tests/integration/rpc/backend/test_backend_suite.go b/tests/integration/rpc/backend/test_backend_suite.go index c056d6d6a6..095b5d36cc 100644 --- a/tests/integration/rpc/backend/test_backend_suite.go +++ b/tests/integration/rpc/backend/test_backend_suite.go @@ -208,7 +208,7 @@ func (s *TestSuite) buildEthBlock( } // 5) Build receipts - receipts, err := s.backend.ReceiptsFromCometBlock(resBlock, blockRes, msgs) + receipts, err := s.backend.ReceiptsFromCometBlock(resBlock, blockRes, msgs, nil) s.Require().NoError(err) // 6) Gas used From 0fa1ec322dce17c400551b059d74d028b5c825a1 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:48:56 +0530 Subject: [PATCH 02/12] fix(indexer): index derived EVM txs by hash and block-index, rebuild additional fields on KV hit --- indexer/kv_indexer.go | 102 ++++++++++++++++++++++++++++++++++++ rpc/backend/comet_to_eth.go | 4 +- rpc/backend/tx_info.go | 95 ++++++++++++++++++++++++++++++--- rpc/backend/tx_info_test.go | 8 +++ server/types/indexer.go | 4 ++ 5 files changed, 203 insertions(+), 10 deletions(-) diff --git a/indexer/kv_indexer.go b/indexer/kv_indexer.go index 9bf85ccdc3..dc5e3cd4d8 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. 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 + if err != nil { + kv.logger.Error("Fail to index derived txs", "err", err, "block", height, "txIndex", txIndex) + } continue } @@ -161,11 +174,100 @@ func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*serv return kv.GetByTxHash(common.BytesToHash(bz)) } +// 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 +} + +// 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)) +} + +// 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 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 := servertypes.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 +} + // 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/rpc/backend/comet_to_eth.go b/rpc/backend/comet_to_eth.go index 88f00d54fe..bb490b0b9a 100644 --- a/rpc/backend/comet_to_eth.go +++ b/rpc/backend/comet_to_eth.go @@ -397,8 +397,8 @@ func (b *Backend) ReceiptsFromCometBlock( var logs []*ethtypes.Log if additional != nil { - // Derived tx: MsgIndex is math.MaxUint32 (sentinel). Parse logs from tx_log events - // by matching TxHash instead of using the protobuf-encoded Data field. + // Derived tx: no MsgEthereumTxResponse in the Cosmos tx Data field. + // Parse logs from tx_log ABCI events by matching TxHash instead. logs, err = derivedTxLogsFromEvents( blockRes.TxsResults[txResult.TxIndex].Events, additional.Hash, diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index 14d6456b89..f43962d40e 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -267,8 +267,8 @@ func (b *Backend) GetTransactionLogs(hash common.Hash) ([]*ethtypes.Log, error) } if additional != nil { - // Derived tx: MsgIndex is math.MaxUint32 (sentinel). Parse logs from tx_log events - // by matching TxHash instead of using the protobuf-encoded Data field. + // Derived tx: no MsgEthereumTxResponse in the Cosmos tx Data field. + // Parse logs from tx_log ABCI events by matching TxHash instead. return derivedTxLogsFromEvents( resBlockResult.TxsResults[res.TxIndex].Events, additional.Hash, @@ -329,16 +329,77 @@ 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 *servertypes.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 *servertypes.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 CometBFT // https://github.com/cometbft/cometbft/issues/6539 func (b *Backend) GetTxByEthHash(hash common.Hash) (*servertypes.TxResult, *rpctypes.TxResultAdditionalFields, error) { if b.Indexer != nil { txRes, err := b.Indexer.GetByTxHash(hash) - if err != nil { - return nil, nil, err + if err == 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 } - return txRes, nil, nil + // Indexer miss — fall through to CometBFT tx_search for derived tx reconstruction. } // fallback to CometBFT tx indexer @@ -355,10 +416,15 @@ func (b *Backend) GetTxByEthHash(hash common.Hash) (*servertypes.TxResult, *rpct func (b *Backend) GetTxByEthHashAndMsgIndex(hash common.Hash, index int) (*servertypes.TxResult, *rpctypes.TxResultAdditionalFields, error) { if b.Indexer != nil { txRes, err := b.Indexer.GetByTxHash(hash) - if err != nil { - return nil, nil, err + if err == 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 } - return txRes, nil, nil + // Indexer miss — fall through to CometBFT tx_search for derived tx reconstruction. } // fallback to CometBFT tx indexer @@ -378,6 +444,19 @@ func (b *Backend) GetTxByTxIndex(height int64, index uint) (*servertypes.TxResul 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). + 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 36fed32378..aa520a52ee 100644 --- a/rpc/backend/tx_info_test.go +++ b/rpc/backend/tx_info_test.go @@ -446,6 +446,14 @@ func (m *MockIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*ser return nil, nil } +func (m *MockIndexer) IsDerivedTx(hash common.Hash) (bool, error) { + return false, nil +} + +func (m *MockIndexer) IsDerivedTxByBlockAndIndex(blockNumber int64, txIndex int32) (bool, error) { + return false, nil +} + func TestReceiptsFromCometBlock(t *testing.T) { backend := setupMockBackend(t) height := int64(100) diff --git a/server/types/indexer.go b/server/types/indexer.go index 41a452b943..09007115a1 100644 --- a/server/types/indexer.go +++ b/server/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) } From 17f880b3ee39f29fe3a1a4bbadd716587dd1be53 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:41:30 +0530 Subject: [PATCH 03/12] fix(rpc): correct TraceTransaction predecessor assembly to use EthTxIndex instead of TxIndex (F-2026-17754) --- encoding/config.go | 1 + evmd/go.mod | 4 +- evmd/go.sum | 19 +- rpc/backend/backend_suite_test.go | 148 +++++ rpc/backend/client_test.go | 309 +++++++++ rpc/backend/tracing.go | 82 ++- rpc/backend/tracing_test.go | 601 ++++++++++++++++++ tests/integration/rpc/backend/test_tracing.go | 8 +- 8 files changed, 1109 insertions(+), 63 deletions(-) create mode 100644 rpc/backend/backend_suite_test.go create mode 100644 rpc/backend/client_test.go create mode 100644 rpc/backend/tracing_test.go diff --git a/encoding/config.go b/encoding/config.go index 4e1d897fd5..bf0013c5ad 100644 --- a/encoding/config.go +++ b/encoding/config.go @@ -48,6 +48,7 @@ func MakeConfig(evmChainID uint64) Config { codec := amino.NewProtoCodec(interfaceRegistry) enccodec.RegisterLegacyAminoCodec(cdc) enccodec.RegisterInterfaces(interfaceRegistry) + evmtypes.RegisterInterfaces(interfaceRegistry) eip712.SetEncodingConfig(cdc, interfaceRegistry, evmChainID) // This is needed for the EIP712 txs because currently is using diff --git a/evmd/go.mod b/evmd/go.mod index bcd83b1be3..85af9c70da 100644 --- a/evmd/go.mod +++ b/evmd/go.mod @@ -99,8 +99,8 @@ require ( github.com/desertbit/timer v1.0.1 // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.2.0 // indirect - github.com/dlclark/regexp2 v1.7.0 // indirect - github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/emicklei/dot v1.6.2 // indirect diff --git a/evmd/go.sum b/evmd/go.sum index 13d5f5d2d1..4ca4634343 100644 --- a/evmd/go.sum +++ b/evmd/go.sum @@ -670,6 +670,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -785,15 +787,12 @@ github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXH github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= @@ -917,19 +916,15 @@ github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= -github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 h1:+3HCtB74++ClLy8GgjUQYeC8R4ILzVcIe8+5edAJJnE= -github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= -github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= -github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -1170,7 +1165,6 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= @@ -1300,7 +1294,6 @@ github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSAS github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2tzCixLaUPQ= github.com/improbable-eng/grpc-web v0.15.0/go.mod h1:1sy9HKV4Jt9aEs9JSnkWlRJPuPtwNr0l57L4f878wP8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= diff --git a/rpc/backend/backend_suite_test.go b/rpc/backend/backend_suite_test.go new file mode 100644 index 0000000000..89ecd36a89 --- /dev/null +++ b/rpc/backend/backend_suite_test.go @@ -0,0 +1,148 @@ +package backend + +import ( + "math/big" + "os" + "testing" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/suite" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/evm/indexer" + "github.com/cosmos/evm/rpc/backend/mocks" + "github.com/cosmos/evm/testutil/constants" + utiltx "github.com/cosmos/evm/testutil/tx" + evmtypes "github.com/cosmos/evm/x/vm/types" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + sdk "github.com/cosmos/cosmos-sdk/types" + authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" + + "cosmossdk.io/log" +) + +// TestMain initializes the global EVM chain config required by NewBackend. +// Without this, GetEthChainConfig() panics on a nil dereference. +func TestMain(m *testing.M) { + configurator := evmtypes.NewEVMConfigurator() + configurator.ResetTestConfig() + ethCfg := evmtypes.DefaultChainConfig(constants.ExampleChainID.EVMChainID) + if err := evmtypes.SetChainConfig(ethCfg); err != nil { + panic(err) + } + coinInfo := constants.ExampleChainCoinInfo[constants.ExampleChainID] + if err := evmtypes.NewEVMConfigurator(). + WithEVMCoinInfo(coinInfo). + Configure(); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +// BackendTestSuite wraps setupMockBackend and adds helpers used by TraceTransaction tests. +type BackendTestSuite struct { + suite.Suite + backend *Backend + signer keyring.Signer +} + +func TestBackendTestSuite(t *testing.T) { + suite.Run(t, new(BackendTestSuite)) +} + +func (suite *BackendTestSuite) SetupTest() { + suite.backend = setupMockBackend(suite.T()) + _, priv := utiltx.NewAddrKey() + suite.signer = utiltx.NewSigner(priv) +} + +// buildEthereumTx returns an unsigned legacy EVM tx and its pre-encoded bytes. +// From is left empty; call signAndEncodeEthTx for a fully signed single-msg tx. +func (suite *BackendTestSuite) buildEthereumTx() (*evmtypes.MsgEthereumTx, []byte) { + ethTxParams := evmtypes.EvmTxArgs{ + ChainID: suite.backend.EvmChainID, + Nonce: 0, + To: &common.Address{}, + Amount: big.NewInt(0), + GasLimit: 100000, + GasPrice: big.NewInt(1), + } + msg := evmtypes.NewTx(ðTxParams) + + txBuilder := suite.backend.ClientCtx.TxConfig.NewTxBuilder() + suite.Require().NoError(txBuilder.SetMsgs(msg)) + bz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()(txBuilder.GetTx()) + suite.Require().NoError(err) + return msg, bz +} + +// signAndEncodeEthTx signs msg with a fresh ephemeral key and encodes it as a +// single-message Cosmos tx. The msg.From field is updated in-place; hashes +// computed via msg.AsTransaction().Hash() are valid after this returns. +func (suite *BackendTestSuite) signAndEncodeEthTx(msg *evmtypes.MsgEthereumTx) []byte { + from, priv := utiltx.NewAddrKey() + signer := utiltx.NewSigner(priv) + ethSigner := ethtypes.LatestSigner(suite.backend.ChainConfig()) + msg.From = from.Bytes() + suite.Require().NoError(msg.Sign(ethSigner, signer)) + + evmDenom := evmtypes.GetEVMCoinDenom() + tx, err := msg.BuildTx(suite.backend.ClientCtx.TxConfig.NewTxBuilder(), evmDenom) + suite.Require().NoError(err) + txBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()(tx) + suite.Require().NoError(err) + return txBz +} + +// buildAndEncodeMultiMsgEthTx builds a single EVM Cosmos tx containing multiple +// MsgEthereumTx messages. Each message is signed with a fresh ephemeral key so +// their hashes are unique even when underlying tx params are identical. +func (suite *BackendTestSuite) buildAndEncodeMultiMsgEthTx(msgs ...*evmtypes.MsgEthereumTx) []byte { + ethSigner := ethtypes.LatestSigner(suite.backend.ChainConfig()) + for _, msg := range msgs { + from, priv := utiltx.NewAddrKey() + signer := utiltx.NewSigner(priv) + msg.From = from.Bytes() + suite.Require().NoError(msg.Sign(ethSigner, signer)) + msg.From = nil // BuildTx expects empty From for multi-msg txs + } + + extBuilder, ok := suite.backend.ClientCtx.TxConfig.NewTxBuilder().(authtx.ExtensionOptionsTxBuilder) + suite.Require().True(ok) + + option, err := codectypes.NewAnyWithValue(&evmtypes.ExtensionOptionsEthereumTx{}) + suite.Require().NoError(err) + extBuilder.SetExtensionOptions(option) + + sdkMsgs := make([]sdk.Msg, len(msgs)) + for i, msg := range msgs { + sdkMsgs[i] = msg + } + suite.Require().NoError(extBuilder.SetMsgs(sdkMsgs...)) + + bz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()(extBuilder.GetTx()) + suite.Require().NoError(err) + return bz +} + +// resetIndexer creates a fresh KV indexer and installs it on the backend. +func (suite *BackendTestSuite) resetIndexer() { + suite.backend.Indexer = indexer.NewKVIndexer( + dbm.NewMemDB(), + log.NewNopLogger(), + suite.backend.ClientCtx, + ) +} + +// mockClient returns the mock CometBFT client used by this backend. +func (suite *BackendTestSuite) mockClient() *mocks.Client { + return suite.backend.ClientCtx.Client.(*mocks.Client) +} + +// mockQueryClient returns the mock EVM gRPC query client used by this backend. +func (suite *BackendTestSuite) mockQueryClient() *mocks.EVMQueryClient { + return suite.backend.QueryClient.QueryClient.(*mocks.EVMQueryClient) +} diff --git a/rpc/backend/client_test.go b/rpc/backend/client_test.go new file mode 100644 index 0000000000..b5c7d2a3c7 --- /dev/null +++ b/rpc/backend/client_test.go @@ -0,0 +1,309 @@ +package backend + +import ( + "context" + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + abci "github.com/cometbft/cometbft/abci/types" + "github.com/cometbft/cometbft/libs/bytes" + cmtversion "github.com/cometbft/cometbft/proto/tendermint/version" + cmtrpcclient "github.com/cometbft/cometbft/rpc/client" + cmtrpctypes "github.com/cometbft/cometbft/rpc/core/types" + "github.com/cometbft/cometbft/types" + "github.com/cometbft/cometbft/version" + + "github.com/cosmos/evm/rpc/backend/mocks" + rpc "github.com/cosmos/evm/rpc/types" + "github.com/cosmos/evm/testutil/constants" + evmtypes "github.com/cosmos/evm/x/vm/types" + + "github.com/cosmos/cosmos-sdk/client" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +var _ cmtrpcclient.Client = &mocks.Client{} + +// ChainID is the chain ID string used by block-level mock helpers. +var ChainID = constants.ExampleChainID.ChainID + +// Tx Search + +func RegisterTxSearch(client *mocks.Client, query string, txBz []byte) { + resulTxs := []*cmtrpctypes.ResultTx{{Tx: txBz}} + client.On("TxSearch", rpc.ContextWithHeight(1), query, false, (*int)(nil), (*int)(nil), ""). + Return(&cmtrpctypes.ResultTxSearch{Txs: resulTxs, TotalCount: 1}, nil) +} + +func RegisterTxSearchEmpty(client *mocks.Client, query string) { + client.On("TxSearch", rpc.ContextWithHeight(1), query, false, (*int)(nil), (*int)(nil), ""). + Return(&cmtrpctypes.ResultTxSearch{}, nil) +} + +// RegisterTxSearchWithResult registers a TxSearch mock that returns a single ResultTx +// with explicit raw tx bytes (nil for derived txs with no Cosmos envelope), block height, +// Cosmos-tx-slot index, and ABCI events. Needed when the KV indexer has no entry and +// code falls through to CometBFT TxSearch. +func RegisterTxSearchWithResult( + client *mocks.Client, + query string, + height int64, + txSlot uint32, + txBz types.Tx, + events []abci.Event, +) { + resultTx := &cmtrpctypes.ResultTx{ + Height: height, + Index: txSlot, + Tx: txBz, + TxResult: abci.ExecTxResult{ + Code: 0, + Events: events, + }, + } + client.On("TxSearch", rpc.ContextWithHeight(1), query, false, (*int)(nil), (*int)(nil), ""). + Return(&cmtrpctypes.ResultTxSearch{Txs: []*cmtrpctypes.ResultTx{resultTx}, TotalCount: 1}, nil) +} + +func RegisterTxSearchError(client *mocks.Client, query string) { + client.On("TxSearch", rpc.ContextWithHeight(1), query, false, (*int)(nil), (*int)(nil), ""). + Return(nil, errortypes.ErrInvalidRequest) +} + +// Block + +func RegisterBlockMultipleTxs( + client *mocks.Client, + height int64, + txs []types.Tx, +) (*cmtrpctypes.ResultBlock, error) { + block := types.MakeBlock(height, txs, nil, nil) + block.ChainID = ChainID + resBlock := &cmtrpctypes.ResultBlock{Block: block} + client.On("Block", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")).Return(resBlock, nil) + return resBlock, nil +} + +func RegisterBlock( + client *mocks.Client, + height int64, + tx []byte, +) (*cmtrpctypes.ResultBlock, error) { + if tx == nil { + emptyBlock := types.MakeBlock(height, []types.Tx{}, nil, nil) + emptyBlock.ChainID = ChainID + resBlock := &cmtrpctypes.ResultBlock{Block: emptyBlock} + client.On("Block", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")).Return(resBlock, nil) + return resBlock, nil + } + block := types.MakeBlock(height, []types.Tx{tx}, nil, nil) + block.ChainID = ChainID + resBlock := &cmtrpctypes.ResultBlock{Block: block} + client.On("Block", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")).Return(resBlock, nil) + return resBlock, nil +} + +func RegisterBlockError(client *mocks.Client, height int64) { + client.On("Block", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(nil, errortypes.ErrInvalidRequest) +} + +func TestRegisterBlock(t *testing.T) { + client := mocks.NewClient(t) + height := rpc.BlockNumber(1).Int64() + _, err := RegisterBlock(client, height, nil) + require.NoError(t, err) + + res, err := client.Block(rpc.ContextWithHeight(height), &height) + + emptyBlock := types.MakeBlock(height, []types.Tx{}, nil, nil) + emptyBlock.ChainID = ChainID + resBlock := &cmtrpctypes.ResultBlock{Block: emptyBlock} + require.Equal(t, resBlock, res) + require.NoError(t, err) +} + +// ConsensusParams + +func RegisterConsensusParams(client *mocks.Client, height int64) { + consensusParams := types.DefaultConsensusParams() + client.On("ConsensusParams", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(&cmtrpctypes.ResultConsensusParams{ConsensusParams: *consensusParams}, nil) +} + +func RegisterConsensusParamsError(client *mocks.Client, height int64) { + client.On("ConsensusParams", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(nil, errortypes.ErrInvalidRequest) +} + +func TestRegisterConsensusParams(t *testing.T) { + client := mocks.NewClient(t) + height := int64(1) + RegisterConsensusParams(client, height) + + res, err := client.ConsensusParams(rpc.ContextWithHeight(height), &height) + consensusParams := types.DefaultConsensusParams() + require.Equal(t, &cmtrpctypes.ResultConsensusParams{ConsensusParams: *consensusParams}, res) + require.NoError(t, err) +} + +// BlockResults + +func RegisterBlockResultsWithEventLog(client *mocks.Client, height int64) (*cmtrpctypes.ResultBlockResults, error) { + res := &cmtrpctypes.ResultBlockResults{ + Height: height, + TxsResults: []*abci.ExecTxResult{ + {Code: 0, GasUsed: 0, Events: []abci.Event{{ + Type: evmtypes.EventTypeTxLog, + Attributes: []abci.EventAttribute{{ + Key: evmtypes.AttributeKeyTxLog, + Value: "{\"test\": \"hello\"}", + Index: true, + }}, + }}}, + }, + } + client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(res, nil) + return res, nil +} + +func RegisterBlockResults( + client *mocks.Client, + height int64, +) (*cmtrpctypes.ResultBlockResults, error) { + res := &cmtrpctypes.ResultBlockResults{ + Height: height, + TxsResults: []*abci.ExecTxResult{{Code: 0, GasUsed: 0}}, + } + client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(res, nil) + return res, nil +} + +// RegisterBlockResultsWithTxs registers a BlockResults mock with custom per-slot ABCI +// results. Required when the after-loop derived-tx section in TraceTransaction fetches +// BlockResults to scan for intra-slot derived-tx predecessor ordering. +func RegisterBlockResultsWithTxs( + client *mocks.Client, + height int64, + txResults []*abci.ExecTxResult, +) *cmtrpctypes.ResultBlockResults { + res := &cmtrpctypes.ResultBlockResults{ + Height: height, + TxsResults: txResults, + } + client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(res, nil) + return res +} + +func RegisterBlockResultsError(client *mocks.Client, height int64) { + client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")). + Return(nil, errortypes.ErrInvalidRequest) +} + +func TestRegisterBlockResults(t *testing.T) { + client := mocks.NewClient(t) + height := int64(1) + _, err := RegisterBlockResults(client, height) + require.NoError(t, err) + + res, err := client.BlockResults(rpc.ContextWithHeight(height), &height) + expRes := &cmtrpctypes.ResultBlockResults{ + Height: height, + TxsResults: []*abci.ExecTxResult{{Code: 0, GasUsed: 0}}, + } + require.Equal(t, expRes, res) + require.NoError(t, err) +} + +// BlockByHash + +func RegisterBlockByHash( + client *mocks.Client, + _ common.Hash, + tx []byte, +) (*cmtrpctypes.ResultBlock, error) { + block := types.MakeBlock(1, []types.Tx{tx}, nil, nil) + resBlock := &cmtrpctypes.ResultBlock{Block: block} + client.On("BlockByHash", rpc.ContextWithHeight(1), []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}). + Return(resBlock, nil) + return resBlock, nil +} + +func RegisterBlockByHashError(client *mocks.Client, _ common.Hash, _ []byte) { + client.On("BlockByHash", rpc.ContextWithHeight(1), []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}). + Return(nil, errortypes.ErrInvalidRequest) +} + +// HeaderByHash + +func RegisterHeaderByHash( + client *mocks.Client, + _ common.Hash, + _ []byte, +) (*cmtrpctypes.ResultHeader, error) { + header := &types.Header{ + Version: cmtversion.Consensus{Block: version.BlockProtocol, App: 0}, + Height: 1, + } + resHeader := &cmtrpctypes.ResultHeader{Header: header} + client.On("HeaderByHash", rpc.ContextWithHeight(1), bytes.HexBytes{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}). + Return(resHeader, nil) + return resHeader, nil +} + +func RegisterHeaderByHashError(client *mocks.Client, _ common.Hash, _ []byte) { + client.On("HeaderByHash", rpc.ContextWithHeight(1), bytes.HexBytes{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}). + Return(nil, errortypes.ErrInvalidRequest) +} + +// ABCIQuery + +func RegisterABCIQueryWithOptions(client *mocks.Client, height int64, path string, data bytes.HexBytes, opts cmtrpcclient.ABCIQueryOptions) { + client.On("ABCIQueryWithOptions", context.Background(), path, data, opts). + Return(&cmtrpctypes.ResultABCIQuery{ + Response: abci.ResponseQuery{ + Value: []byte{2}, + Height: height, + }, + }, nil) +} + +func RegisterABCIQueryWithOptionsError(clients *mocks.Client, path string, data bytes.HexBytes, opts cmtrpcclient.ABCIQueryOptions) { + clients.On("ABCIQueryWithOptions", context.Background(), path, data, opts). + Return(nil, errortypes.ErrInvalidRequest) +} + +func RegisterABCIQueryAccount(clients *mocks.Client, data bytes.HexBytes, opts cmtrpcclient.ABCIQueryOptions, acc client.Account) { + baseAccount := authtypes.NewBaseAccount(acc.GetAddress(), acc.GetPubKey(), acc.GetAccountNumber(), acc.GetSequence()) + accAny, _ := codectypes.NewAnyWithValue(baseAccount) + accResponse := authtypes.QueryAccountResponse{Account: accAny} + respBz, _ := accResponse.Marshal() + clients.On("ABCIQueryWithOptions", context.Background(), "/cosmos.auth.v1beta1.Query/Account", data, opts). + Return(&cmtrpctypes.ResultABCIQuery{ + Response: abci.ResponseQuery{ + Value: respBz, + Height: 1, + }, + }, nil) +} + +// EVM query client helpers for tracing tests + +func RegisterTraceTransactionWithPredecessors(queryClient *mocks.EVMQueryClient, _ *evmtypes.MsgEthereumTx, _ []*evmtypes.MsgEthereumTx) { + data, _ := json.Marshal(map[string]interface{}{"test": "hello"}) + queryClient.On("TraceTx", rpc.ContextWithHeight(1), mock.Anything). + Return(&evmtypes.QueryTraceTxResponse{Data: data}, nil) +} + +func RegisterTraceTransaction(queryClient *mocks.EVMQueryClient, msgEthTx *evmtypes.MsgEthereumTx) { + RegisterTraceTransactionWithPredecessors(queryClient, msgEthTx, nil) +} diff --git a/rpc/backend/tracing.go b/rpc/backend/tracing.go index d37703113c..6505b977e3 100644 --- a/rpc/backend/tracing.go +++ b/rpc/backend/tracing.go @@ -50,7 +50,14 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi } var predecessors []*evmtypes.MsgEthereumTx - for i := 0; i < int(transaction.TxIndex); i++ { + // Use EthTxIndex (Ethereum execution counter) as the loop bound, not TxIndex + // (Cosmos tx slot). The two diverge whenever a Cosmos tx holds multiple EVM + // messages, contains no EVM messages, or derived txs shift the counter. + ethTxCount := int(transaction.EthTxIndex) + if ethTxCount < 0 { + ethTxCount = 0 + } + for i := 0; i < ethTxCount; i++ { predecessorTx, txAdditional, err := b.GetTxByTxIndex(blk.Block.Height, uint(i)) if err != nil { b.Logger.Debug("failed to get tx by index", @@ -60,47 +67,31 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi continue } + // The after-loop section below handles all predecessors that share the same + // Cosmos tx slot as the target (intra-tx ordering by MsgIndex / derived-tx + // event order). Skip them here to avoid double-counting. + if int(predecessorTx.TxIndex) == int(transaction.TxIndex) { + continue + } + if txAdditional != nil { - // This is a derived tx, fetch all derived txs from events in this Cosmos tx - blockRes, err := b.RPCClient.BlockResults(b.Ctx, &blk.Block.Height) - if err == nil && i < len(blockRes.TxsResults) { - txResult := blockRes.TxsResults[i] - cosmosTx, err := b.ClientCtx.TxConfig.TxDecoder()(blk.Block.Txs[i]) - if err == nil { - parsedTxs, err := rpctypes.ParseTxResult(txResult, cosmosTx) - if err == nil { - for _, parsedTx := range parsedTxs.Txs { - // Stop when we reach the current transaction - if parsedTx.Hash == txAdditional.Hash { - break - } - // Only include derived txs - if parsedTx.Type == evmtypes.DerivedTxType { - ethMsg := b.parseDerivedTxFromAdditionalFields(&rpctypes.TxResultAdditionalFields{ - Value: parsedTx.Amount, - Hash: parsedTx.Hash, - TxHash: parsedTx.TxHash, - Type: parsedTx.Type, - Recipient: parsedTx.Recipient, - Sender: parsedTx.Sender, - GasUsed: parsedTx.GasUsed, - Data: parsedTx.Data, - Nonce: parsedTx.Nonce, - GasLimit: &parsedTx.GasLimit, - }) - if ethMsg != nil { - predecessors = append(predecessors, ethMsg) - } - } - } - } - } + // Derived tx: add it directly. The old approach scanned parsedTxs.Txs + // for "all derived txs before txAdditional.Hash", which (a) skipped the + // tx at txAdditional.Hash itself — so the last derived tx in a series + // was always missed — and (b) double-counted earlier derived txs that + // were already added by their own outer-loop iterations. Each iteration + // of this loop corresponds to exactly one Ethereum execution, so adding + // txAdditional directly is both correct and complete. + ethMsg := b.parseDerivedTxFromAdditionalFields(txAdditional) + if ethMsg != nil { + predecessors = append(predecessors, ethMsg) } continue } - // Fallback: decode as normal Cosmos tx - tx, err := b.ClientCtx.TxConfig.TxDecoder()(blk.Block.Txs[i]) + // Fallback: decode as normal Cosmos tx. Use predecessorTx.TxIndex (Cosmos slot) + // rather than i (Ethereum index) to address the correct block entry. + tx, err := b.ClientCtx.TxConfig.TxDecoder()(blk.Block.Txs[predecessorTx.TxIndex]) if err != nil { b.Logger.Debug("failed to decode transaction in block", "height", blk.Block.Height, @@ -109,14 +100,13 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi continue } - index := int(predecessorTx.MsgIndex) - for j := 0; j < index; j++ { - msg := tx.GetMsgs()[j] - // Check if it’s a normal Ethereum tx - if ethMsg, ok := msg.(*evmtypes.MsgEthereumTx); ok { - predecessors = append(predecessors, ethMsg) - continue - } + // Add the EVM message at this Ethereum index directly. The inner loop used + // here previously ran j < MsgIndex, which added only messages BEFORE the + // current position and left the message AT MsgIndex itself unhandled — + // causing the last message of any multi-message predecessor Cosmos tx to + // be silently dropped from the predecessor set. + if ethMsg, ok := tx.GetMsgs()[int(predecessorTx.MsgIndex)].(*evmtypes.MsgEthereumTx); ok { + predecessors = append(predecessors, ethMsg) } } @@ -142,7 +132,7 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi if additional != nil { // This is a derived tx, fetch all derived txs from events in this Cosmos tx blockRes, err := b.RPCClient.BlockResults(b.Ctx, &blk.Block.Height) - if err == nil && int(transaction.TxIndex) < len(blockRes.TxsResults) { + if err == nil && blockRes != nil && int(transaction.TxIndex) < len(blockRes.TxsResults) { txResult := blockRes.TxsResults[transaction.TxIndex] parsedTxs, err := rpctypes.ParseTxResult(txResult, tx) if err == nil { diff --git a/rpc/backend/tracing_test.go b/rpc/backend/tracing_test.go new file mode 100644 index 0000000000..e25f55e317 --- /dev/null +++ b/rpc/backend/tracing_test.go @@ -0,0 +1,601 @@ +package backend + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + abci "github.com/cometbft/cometbft/abci/types" + "github.com/cometbft/cometbft/types" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/evm/indexer" + rpctypes "github.com/cosmos/evm/rpc/types" + evmtypes "github.com/cosmos/evm/x/vm/types" + + "cosmossdk.io/log" +) + +func (suite *BackendTestSuite) TestTraceTransaction() { + msgEthereumTx, _ := suite.buildEthereumTx() + msgEthereumTx2, _ := suite.buildEthereumTx() + + txHash := msgEthereumTx.AsTransaction().Hash() + txHash2 := msgEthereumTx2.AsTransaction().Hash() + + txBz := suite.signAndEncodeEthTx(msgEthereumTx) + txBz2 := suite.signAndEncodeEthTx(msgEthereumTx2) + + // Recompute hashes after signing (From is set by signAndEncodeEthTx). + txHash = msgEthereumTx.AsTransaction().Hash() + txHash2 = msgEthereumTx2.AsTransaction().Hash() + + testCases := []struct { + name string + registerMock func() + block *types.Block + responseBlock []*abci.ExecTxResult + expPass bool + }{ + { + "fail - tx not found", + func() { + client := suite.mockClient() + query := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, txHash.Hex()) + RegisterTxSearchEmpty(client, query) + }, + &types.Block{Header: types.Header{Height: 1}, Data: types.Data{Txs: []types.Tx{}}}, + []*abci.ExecTxResult{ + { + Code: 0, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: txHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "21000"}, + {Key: "txHash", Value: ""}, + {Key: "recipient", Value: "0x775b87ef5D82ca211811C1a02CE0fE0CA3a455d7"}, + }}, + }, + }, + }, + false, + }, + { + "fail - block not found", + func() { + client := suite.mockClient() + RegisterBlockError(client, 1) + }, + &types.Block{Header: types.Header{Height: 1}, Data: types.Data{Txs: []types.Tx{txBz}}}, + []*abci.ExecTxResult{ + { + Code: 0, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: txHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "21000"}, + {Key: "txHash", Value: ""}, + {Key: "recipient", Value: "0x775b87ef5D82ca211811C1a02CE0fE0CA3a455d7"}, + }}, + }, + }, + }, + false, + }, + { + "pass - transaction found in a block with multiple transactions", + func() { + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{txBz, txBz2}) + suite.Require().NoError(err) + RegisterTraceTransactionWithPredecessors(queryClient, msgEthereumTx, nil) + RegisterConsensusParams(client, 1) + }, + &types.Block{Header: types.Header{Height: 1, ChainID: ChainID}, Data: types.Data{Txs: []types.Tx{txBz, txBz2}}}, + []*abci.ExecTxResult{ + { + Code: 0, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: txHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "21000"}, + {Key: "txHash", Value: ""}, + {Key: "recipient", Value: "0x775b87ef5D82ca211811C1a02CE0fE0CA3a455d7"}, + }}, + }, + }, + { + Code: 0, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: txHash2.Hex()}, + {Key: "txIndex", Value: "1"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "21000"}, + {Key: "txHash", Value: ""}, + {Key: "recipient", Value: "0x775b87ef5D82ca211811C1a02CE0fE0CA3a455d7"}, + }}, + }, + }, + }, + true, + }, + { + "pass - transaction found", + func() { + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err := RegisterBlock(client, 1, txBz) + suite.Require().NoError(err) + RegisterTraceTransaction(queryClient, msgEthereumTx) + RegisterConsensusParams(client, 1) + }, + &types.Block{Header: types.Header{Height: 1}, Data: types.Data{Txs: []types.Tx{txBz}}}, + []*abci.ExecTxResult{ + { + Code: 0, + Events: []abci.Event{ + {Type: evmtypes.EventTypeEthereumTx, Attributes: []abci.EventAttribute{ + {Key: "ethereumTxHash", Value: txHash.Hex()}, + {Key: "txIndex", Value: "0"}, + {Key: "amount", Value: "1000"}, + {Key: "txGasUsed", Value: "21000"}, + {Key: "txHash", Value: ""}, + {Key: "recipient", Value: "0x775b87ef5D82ca211811C1a02CE0fE0CA3a455d7"}, + }}, + }, + }, + }, + true, + }, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("case %s", tc.name), func() { + suite.SetupTest() + tc.registerMock() + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + err := suite.backend.Indexer.IndexBlock(tc.block, tc.responseBlock) + suite.Require().NoError(err) + _, err = suite.backend.TraceTransaction(txHash, nil) + + if tc.expPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +// TestTraceTransactionEthTxIndex verifies that TraceTransaction correctly traces +// a transaction that is not the first in a multi-tx block, using EthTxIndex (not +// TxIndex) as the predecessor-loop bound after the index-domain fix. +func (suite *BackendTestSuite) TestTraceTransactionEthTxIndex() { + suite.SetupTest() + + msgFirst, _ := suite.buildEthereumTx() + txBzFirst := suite.signAndEncodeEthTx(msgFirst) + txHashFirst := msgFirst.AsTransaction().Hash() + + msgTarget, _ := suite.buildEthereumTx() + txBzTarget := suite.signAndEncodeEthTx(msgTarget) + txHashTarget := msgTarget.AsTransaction().Hash() + + localBlock := types.MakeBlock(1, []types.Tx{txBzFirst, txBzTarget}, nil, nil) + localBlock.ChainID = ChainID + + responseBlock := []*abci.ExecTxResult{ + { + Code: 0, + Events: []abci.Event{{ + Type: evmtypes.EventTypeEthereumTx, + Attributes: []abci.EventAttribute{ + {Key: evmtypes.AttributeKeyEthereumTxHash, Value: txHashFirst.Hex()}, + {Key: evmtypes.AttributeKeyTxIndex, Value: "0"}, + {Key: evmtypes.AttributeKeyTxGasUsed, Value: "21000"}, + }, + }}, + }, + { + Code: 0, + Events: []abci.Event{{ + Type: evmtypes.EventTypeEthereumTx, + Attributes: []abci.EventAttribute{ + {Key: evmtypes.AttributeKeyEthereumTxHash, Value: txHashTarget.Hex()}, + {Key: evmtypes.AttributeKeyTxIndex, Value: "1"}, + {Key: evmtypes.AttributeKeyTxGasUsed, Value: "21000"}, + }, + }}, + }, + } + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + suite.Require().NoError(suite.backend.Indexer.IndexBlock(localBlock, responseBlock)) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{txBzFirst, txBzTarget}) + suite.Require().NoError(err) + + // EthTxIndex=1: the predecessor loop runs once (i=0) and fetches msgFirst. + RegisterTraceTransactionWithPredecessors(queryClient, msgTarget, []*evmtypes.MsgEthereumTx{msgFirst}) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(txHashTarget, nil) + suite.Require().NoError(err) +} + +// ethTxEvent returns a minimal EventTypeEthereumTx event suitable for IndexBlock. +func ethTxEvent(hash string, txIndex string) abci.Event { + return abci.Event{ + Type: evmtypes.EventTypeEthereumTx, + Attributes: []abci.EventAttribute{ + {Key: evmtypes.AttributeKeyEthereumTxHash, Value: hash}, + {Key: evmtypes.AttributeKeyTxIndex, Value: txIndex}, + {Key: evmtypes.AttributeKeyTxGasUsed, Value: "21000"}, + }, + } +} + +// TestTraceTransactionMultiMsgSameCosmosTarget traces the second of two EVM messages +// packed into a single Cosmos tx slot. The same-Cosmos-tx guard fires on every outer-loop +// iteration, leaving the after-loop to supply the sole predecessor. +// +// Block layout: slot0=[msg1, msg2=target] +// Expected predecessors: [msg1] +func (suite *BackendTestSuite) TestTraceTransactionMultiMsgSameCosmosTarget() { + suite.SetupTest() + + msg1, _ := suite.buildEthereumTx() + msgTarget, _ := suite.buildEthereumTx() + txBzMulti := suite.buildAndEncodeMultiMsgEthTx(msg1, msgTarget) + + hash1 := msg1.AsTransaction().Hash() + hashTarget := msgTarget.AsTransaction().Hash() + + localBlock := types.MakeBlock(1, []types.Tx{txBzMulti}, nil, nil) + localBlock.ChainID = ChainID + + responseBlock := []*abci.ExecTxResult{{ + Code: 0, + Events: []abci.Event{ + ethTxEvent(hash1.Hex(), "0"), + ethTxEvent(hashTarget.Hex(), "1"), + }, + }} + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + suite.Require().NoError(suite.backend.Indexer.IndexBlock(localBlock, responseBlock)) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{txBzMulti}) + suite.Require().NoError(err) + + RegisterTraceTransactionWithPredecessors(queryClient, msgTarget, []*evmtypes.MsgEthereumTx{msg1}) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashTarget, nil) + suite.Require().NoError(err) +} + +// TestTraceTransactionMultiMsgTargetIsThird traces the third of three EVM messages +// packed into a single Cosmos tx. The outer loop skips all same-slot entries; the +// after-loop adds both msg1 and msg2. +// +// Block layout: slot0=[msg1, msg2, msg3=target] +// Expected predecessors: [msg1, msg2] +func (suite *BackendTestSuite) TestTraceTransactionMultiMsgTargetIsThird() { + suite.SetupTest() + + msg1, _ := suite.buildEthereumTx() + msg2, _ := suite.buildEthereumTx() + msgTarget, _ := suite.buildEthereumTx() + txBzMulti := suite.buildAndEncodeMultiMsgEthTx(msg1, msg2, msgTarget) + + hash1 := msg1.AsTransaction().Hash() + hash2 := msg2.AsTransaction().Hash() + hashTarget := msgTarget.AsTransaction().Hash() + + localBlock := types.MakeBlock(1, []types.Tx{txBzMulti}, nil, nil) + localBlock.ChainID = ChainID + + responseBlock := []*abci.ExecTxResult{{ + Code: 0, + Events: []abci.Event{ + ethTxEvent(hash1.Hex(), "0"), + ethTxEvent(hash2.Hex(), "1"), + ethTxEvent(hashTarget.Hex(), "2"), + }, + }} + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + suite.Require().NoError(suite.backend.Indexer.IndexBlock(localBlock, responseBlock)) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{txBzMulti}) + suite.Require().NoError(err) + + RegisterTraceTransactionWithPredecessors(queryClient, msgTarget, []*evmtypes.MsgEthereumTx{msg1, msg2}) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashTarget, nil) + suite.Require().NoError(err) +} + +// TestTraceTransactionMultiMsgCosmosAsPredecessor traces a single-message target whose +// sole predecessor is a two-message Cosmos tx. Both messages must appear in the +// predecessor list — validates the fix that adds the message AT MsgIndex directly +// instead of the old inner loop that ran j Date: Fri, 12 Jun 2026 21:58:35 +0530 Subject: [PATCH 04/12] fix: eth_getBlockHash transaction, debug_traceTransaction --- rpc/backend/comet_to_eth.go | 46 +++++++++++++++++++++++++++++++++++-- x/vm/keeper/grpc_query.go | 15 +++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/rpc/backend/comet_to_eth.go b/rpc/backend/comet_to_eth.go index bb490b0b9a..e7e349c193 100644 --- a/rpc/backend/comet_to_eth.go +++ b/rpc/backend/comet_to_eth.go @@ -43,13 +43,55 @@ func (b *Backend) RPCBlockFromCometBlock( blockRes *cmtrpctypes.ResultBlockResults, fullTx bool, ) (map[string]interface{}, error) { - msgs, _ := b.EthMsgsFromCometBlock(resBlock, blockRes) + msgs, txsAdditional := b.EthMsgsFromCometBlock(resBlock, blockRes) ethBlock, err := b.EthBlockFromCometBlock(resBlock, blockRes) if err != nil { return nil, fmt.Errorf("failed to get rpc block from comet block: %w", err) } - return rpctypes.RPCMarshalBlock(ethBlock, resBlock, msgs, true, fullTx, b.ChainConfig()) + fields, err := rpctypes.RPCMarshalBlock(ethBlock, resBlock, msgs, true, fullTx, b.ChainConfig()) + if err != nil { + return nil, err + } + + // RPCMarshalBlock reads ethBlock.Transactions() which excludes derived txs (they + // are intentionally omitted from the ethBlock body in EthBlockFromCometBlock). + // Override the transactions field here so derived txs appear in the RPC response + // with their event-assigned hashes, not the reconstructed LegacyTx hash. + block := resBlock.Block + blockHash := common.BytesToHash(block.Hash()) + blockHeight := uint64(block.Height) //nolint:gosec // G115 + blockTime := uint64(block.Time.Unix()) //nolint:gosec // G115 + baseFee, _ := b.BaseFee(blockRes) + + ethRPCTxs := make([]interface{}, 0, len(msgs)) + for txIndex, ethMsg := range msgs { + if !fullTx { + var hash common.Hash + if txsAdditional[txIndex] != nil { + hash = txsAdditional[txIndex].Hash + } else { + hash = ethMsg.Hash() + } + ethRPCTxs = append(ethRPCTxs, hash) + continue + } + index := uint64(txIndex) //nolint:gosec // G115 + if txsAdditional[txIndex] == nil { + rpcTx := rpctypes.NewTransactionFromMsg(ethMsg, blockHash, blockHeight, blockTime, index, baseFee, b.ChainConfig()) + ethRPCTxs = append(ethRPCTxs, rpcTx) + } else { + rpcTx, txErr := rpctypes.NewRPCTransactionFromIncompleteMsg(ethMsg, blockHash, blockHeight, index, baseFee, b.EvmChainID, txsAdditional[txIndex].Hash) + if txErr != nil { + b.Logger.Debug("NewRPCTransactionFromIncompleteMsg failed", "error", txErr) + continue + } + ethRPCTxs = append(ethRPCTxs, rpcTx) + } + } + fields["transactions"] = ethRPCTxs + + return fields, nil } // BlockNumberFromComet returns the BlockNumber from BlockNumberOrHash diff --git a/x/vm/keeper/grpc_query.go b/x/vm/keeper/grpc_query.go index 295cb2ece1..29a39914d5 100644 --- a/x/vm/keeper/grpc_query.go +++ b/x/vm/keeper/grpc_query.go @@ -752,9 +752,18 @@ func (k *Keeper) traceTx( traceConfig *types.TraceConfig, commitMessage bool, ) (*any, uint, error) { - msg, err := core.TransactionToMessage(tx, signer, cfg.BaseFee) - if err != nil { - return nil, 0, status.Error(codes.Internal, err.Error()) + // Derived txs are unsigned (V=R=S=0) and cannot be recovered via the signer. + // Use the pre-populated from address directly, same as the predecessor loop. + var msg *core.Message + if isUnsigned(tx) { + m := unsignedTxAsMessage(from, tx, cfg.BaseFee) + msg = &m + } else { + var err error + msg, err = core.TransactionToMessage(tx, signer, cfg.BaseFee) + if err != nil { + return nil, 0, status.Error(codes.Internal, err.Error()) + } } return k.traceTxWithMsg(ctx, cfg, txConfig, msg, traceConfig, commitMessage) From 3d86bc9c73dd059389581bd5835ed2efae83a0b9 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:02:46 +0530 Subject: [PATCH 05/12] fix: some RPC related gaps --- rpc/backend/blocks.go | 5 +++++ rpc/backend/comet_to_eth.go | 25 +++++++++++++++---------- rpc/backend/tx_info.go | 12 +++++++++++- x/vm/keeper/grpc_query.go | 3 ++- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/rpc/backend/blocks.go b/rpc/backend/blocks.go index b01e22001d..81d6bae8de 100644 --- a/rpc/backend/blocks.go +++ b/rpc/backend/blocks.go @@ -223,6 +223,11 @@ func (b *Backend) GetBlockReceipts( if err != nil { return nil, fmt.Errorf("failed to marshal receipt") } + // Same override as GetTransactionReceipt: derived txs have an event-emitted + // hash that differs from the reconstructed LegacyTx hash. + if txsAdditional[i] != nil { + result[i]["transactionHash"] = txsAdditional[i].Hash + } } return result, nil } diff --git a/rpc/backend/comet_to_eth.go b/rpc/backend/comet_to_eth.go index e7e349c193..750dc99bbe 100644 --- a/rpc/backend/comet_to_eth.go +++ b/rpc/backend/comet_to_eth.go @@ -215,16 +215,21 @@ func (b *Backend) parseDerivedTxFromAdditionalFields( recipient := additional.Recipient gas := gasForDerivedEthTx(additional) - t := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: additional.Nonce, - Data: additional.Data, - Gas: gas, - To: &recipient, - GasPrice: nil, - Value: additional.Value, - V: big.NewInt(0), - R: big.NewInt(0), - S: big.NewInt(0), + // Use DynamicFeeTx (type 0x2) with explicit zero fee fields so that + // unsignedTxAsMessage never dereferences a nil GasPrice. LegacyTx with + // GasPrice: nil panics inside new(big.Int).Set(tx.GasPrice()). + t := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: b.EvmChainID, + Nonce: additional.Nonce, + Data: additional.Data, + Gas: gas, + To: &recipient, + Value: additional.Value, + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), }) ethMsg := &evmtypes.MsgEthereumTx{} ethMsg.FromEthereumTx(t) diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index f43962d40e..3efdafa068 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -238,7 +238,17 @@ func (b *Backend) GetTransactionReceipt(hash common.Hash) (map[string]interface{ return nil, fmt.Errorf("failed to get sender: %w", err) } - return rpctypes.RPCMarshalReceipt(receipts[0], ethTx, from) + result, err := rpctypes.RPCMarshalReceipt(receipts[0], ethTx, from) + if err != nil { + return nil, err + } + // RPCMarshalReceipt computes transactionHash from ethTx.Hash(), which for derived + // txs is the reconstructed LegacyTx hash — different from the event-emitted hash. + // Override so eth_getTransactionReceipt agrees with eth_getTransactionByHash. + if additional != nil { + result["transactionHash"] = additional.Hash + } + return result, nil } // GetTransactionLogs returns the transaction logs identified by hash. diff --git a/x/vm/keeper/grpc_query.go b/x/vm/keeper/grpc_query.go index 29a39914d5..1bc1f959bb 100644 --- a/x/vm/keeper/grpc_query.go +++ b/x/vm/keeper/grpc_query.go @@ -555,7 +555,8 @@ func (k Keeper) TraceTx(c context.Context, req *types.QueryTraceTxRequest) (*typ continue } } else { - derivedMsg := unsignedTxAsMessage(common.BytesToAddress(req.Msg.GetFrom()), ethTx, cfg.BaseFee) + // Use this predecessor's own From address, not the target tx's From. + derivedMsg := unsignedTxAsMessage(common.BytesToAddress(tx.GetFrom()), ethTx, cfg.BaseFee) msg = &derivedMsg } msg.GasLimit = min(msg.GasLimit, maxPredecessorGas) From 54957f3d663db52b01da702476425660652589d2 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:19:21 +0530 Subject: [PATCH 06/12] fix: minor rpc bug fixes --- rpc/backend/tracing.go | 36 +++++++----------------------------- rpc/backend/tx_info.go | 13 ++++++++++--- rpc/backend/utils.go | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/rpc/backend/tracing.go b/rpc/backend/tracing.go index 6505b977e3..e79717817d 100644 --- a/rpc/backend/tracing.go +++ b/rpc/backend/tracing.go @@ -6,7 +6,6 @@ import ( "math" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/pkg/errors" tmrpcclient "github.com/cometbft/cometbft/rpc/client" @@ -44,7 +43,7 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi return nil, fmt.Errorf("tx count %d is overflowing", len(blk.Block.Txs)) } txsLen := uint32(len(blk.Block.Txs)) // #nosec G115 -- checked for int overflow already - if txsLen < transaction.TxIndex { + if txsLen <= transaction.TxIndex { b.Logger.Debug("tx index out of bounds", "index", transaction.TxIndex, "hash", hash.String(), "height", blk.Block.Height) return nil, fmt.Errorf("transaction not included in block %v", blk.Block.Height) } @@ -246,11 +245,7 @@ func (b *Backend) TraceBlock(height rpctypes.BlockNumber, config *rpctypes.TraceConfig, block *tmrpctypes.ResultBlock, ) ([]*evmtypes.TxTraceResult, error) { - txs := block.Block.Txs - txsLength := len(txs) - - if txsLength == 0 { - // If there are no transactions return empty array + if len(block.Block.Txs) == 0 { return []*evmtypes.TxTraceResult{}, nil } @@ -259,28 +254,11 @@ func (b *Backend) TraceBlock(height rpctypes.BlockNumber, b.Logger.Debug("block result not found", "height", block.Block.Height, "error", err.Error()) return nil, nil } - txDecoder := b.ClientCtx.TxConfig.TxDecoder() - - var txsMessages []*evmtypes.MsgEthereumTx - for i, tx := range txs { - if !rpctypes.TxSucessOrExpectedFailure(blockRes.TxsResults[i]) { - b.Logger.Debug("invalid tx result code", "cosmos-hash", hexutil.Encode(tx.Hash())) - continue - } - decodedTx, err := txDecoder(tx) - if err != nil { - b.Logger.Error("failed to decode transaction", "hash", txs[i].Hash(), "error", err.Error()) - continue - } - for _, msg := range decodedTx.GetMsgs() { - ethMessage, ok := msg.(*evmtypes.MsgEthereumTx) - if !ok { - // Just considers Ethereum transactions - continue - } - txsMessages = append(txsMessages, ethMessage) - } + // EthMsgsFromCometBlock returns both native MsgEthereumTx and derived txs. + txsMessages, _ := b.EthMsgsFromCometBlock(block, blockRes) + if len(txsMessages) == 0 { + return []*evmtypes.TxTraceResult{}, nil } // minus one to get the context at the beginning of the block @@ -317,7 +295,7 @@ func (b *Backend) TraceBlock(height rpctypes.BlockNumber, return nil, err } - decodedResults := make([]*evmtypes.TxTraceResult, txsLength) + decodedResults := make([]*evmtypes.TxTraceResult, len(txsMessages)) if err := json.Unmarshal(res.Data, &decodedResults); err != nil { return nil, err } diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index 3efdafa068..593d09b04f 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -49,14 +49,21 @@ func (b *Backend) GetTransactionByHash(txHash common.Hash) (*rpctypes.RPCTransac var ethMsg *evmtypes.MsgEthereumTx if additional == nil { - // #nosec G115 always in range + if int(res.TxIndex) >= len(block.Block.Txs) { //nolint:gosec // G115 + return nil, fmt.Errorf("tx index %d out of range for block with %d txs", res.TxIndex, len(block.Block.Txs)) + } tx, err := b.ClientCtx.TxConfig.TxDecoder()(block.Block.Txs[res.TxIndex]) if err != nil { b.Logger.Debug("decoding failed", "error", err.Error()) return nil, fmt.Errorf("failed to decode tx: %w", err) } - ethMsg = tx.GetMsgs()[res.MsgIndex].(*evmtypes.MsgEthereumTx) - if ethMsg == nil { + msgs := tx.GetMsgs() + if int(res.MsgIndex) >= len(msgs) { //nolint:gosec // G115 + return nil, fmt.Errorf("msg index %d out of range for tx with %d msgs", res.MsgIndex, len(msgs)) + } + var ok bool + ethMsg, ok = msgs[res.MsgIndex].(*evmtypes.MsgEthereumTx) + if !ok || ethMsg == nil { b.Logger.Error("failed to get eth msg from sdk.Msgs") return nil, fmt.Errorf("failed to get eth msg from sdk.Msgs") } diff --git a/rpc/backend/utils.go b/rpc/backend/utils.go index ca46109b08..e59c877329 100644 --- a/rpc/backend/utils.go +++ b/rpc/backend/utils.go @@ -1,6 +1,7 @@ package backend import ( + "encoding/json" "fmt" "math" "math/big" @@ -316,6 +317,28 @@ func GetLogsFromBlockResults(blockRes *cmtrpctypes.ResultBlockResults) ([][]*eth if err != nil { return nil, err } + // Derived txs have no MsgEthereumTxResponse in txResult.Data; their logs + // are emitted as EventTypeTxLog ABCI events. Fall back to scanning events + // when DecodeTxLogs returns nothing. + if len(logs) == 0 { + for _, event := range txResult.Events { + if event.Type != evmtypes.EventTypeTxLog { + continue + } + for _, attr := range event.Attributes { + if attr.Key != evmtypes.AttributeKeyTxLog { + continue + } + var evmLog evmtypes.Log + if err := json.Unmarshal([]byte(attr.Value), &evmLog); err != nil { + return nil, err + } + l := evmLog.ToEthereum() + l.BlockNumber = height + logs = append(logs, l) + } + } + } blockLogs = append(blockLogs, logs) } return blockLogs, nil From fc30a1fa40095144bd4c92ea81b9aee87d7affa6 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:23:18 +0530 Subject: [PATCH 07/12] fix: solidity test CI --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cda314b6e1..d1f7589c3e 100644 --- a/Makefile +++ b/Makefile @@ -384,9 +384,13 @@ test-system: build-v04 build cd tests/systemtests/Counter && forge build $(MAKE) -C tests/systemtests test +# V04_REF is the fork's v0.4.0 release state (last commit before the v0.5.0 +# upgrade work began). This fork has no upstream-style v0.4.x git tag, so the +# legacy binary for the v0.4.0-to-v0.5.0 upgrade test is built from this commit. +V04_REF ?= b5053b7e build-v04: mkdir -p ./tests/systemtests/binaries/v0.4 - git checkout v0.4.1 + git checkout $(V04_REF) make build cp $(BUILDDIR)/evmd ./tests/systemtests/binaries/v0.4 git checkout - From acd74107937eff5c07a7348d9c7e47b308911b21 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Mon, 15 Jun 2026 18:39:21 +0530 Subject: [PATCH 08/12] test(rpc): repro TraceTransaction panic for derived target in multi-derived Cosmos tx (F-2026-17754) After-loop indexes tx.GetMsgs() by the derived-tx MsgIndex; panics on a derived target that isn't the first derived execution of its Cosmos tx. --- rpc/backend/tracing_test.go | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/rpc/backend/tracing_test.go b/rpc/backend/tracing_test.go index e25f55e317..fc436b7adf 100644 --- a/rpc/backend/tracing_test.go +++ b/rpc/backend/tracing_test.go @@ -599,3 +599,61 @@ func (suite *BackendTestSuite) TestTraceTransactionDerivedTxAsTarget() { _, err = suite.backend.TraceTransaction(hashDerivedTarget, nil) suite.Require().NoError(err) } + +// TestTraceTransactionDerivedTargetInMultiDerivedCosmosTx proves the residual +// Cosmos/Ethereum index-domain bug in the TraceTransaction after-loop (F-2026-17754). +// +// A single Cosmos tx emits MULTIPLE derived EVM txs (e.g. deployUEA + … + executePayload), +// so one Cosmos slot holds derived txs at MsgIndex 0,1,2,… while the Cosmos tx itself has +// 0/1 actual messages. The after-loop iterates `tx.GetMsgs()[0:transaction.MsgIndex]`, +// treating the DERIVED position as a COSMOS-message index — so tracing the 3rd derived tx +// (MsgIndex=2) indexes past the Cosmos message array and panics. +// +// Block layout: slot0 = one non-EVM Cosmos tx that produced 3 derived EVM txs. +// EthTxIndex: D0=0, D1=1, D2=2 (target); all share TxIndex=0, MsgIndex=0/1/2. +func (suite *BackendTestSuite) TestTraceTransactionDerivedTargetInMultiDerivedCosmosTx() { + suite.SetupTest() + + // One non-EVM Cosmos tx (no embedded MsgEthereumTx) that produced 3 derived EVM txs. + dummyTxBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()( + suite.backend.ClientCtx.TxConfig.NewTxBuilder().GetTx(), + ) + suite.Require().NoError(err) + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gasLimit := uint64(50000) + hashD0 := common.HexToHash("0xaa00000000000000000000000000000000000000000000000000000000000000") + hashD1 := common.HexToHash("0xbb00000000000000000000000000000000000000000000000000000000000000") + hashD2 := common.HexToHash("0xcc00000000000000000000000000000000000000000000000000000000000000") // target (3rd derived) + + // Empty indexer → lookups fall through to CometBFT TxSearch. + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + + client := suite.mockClient() + _, err = RegisterBlockMultipleTxs(client, 1, []types.Tx{dummyTxBz}) // single Cosmos slot + suite.Require().NoError(err) + + // GetTxByEthHash(D2): all 3 derived events are in slot 0; D2 is the 3rd → MsgIndex=2, EthTxIndex=2. + targetQuery := fmt.Sprintf("%s.%s='%s'", + evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashD2.Hex()) + RegisterTxSearchWithResult(client, targetQuery, 1, 0, nil, []abci.Event{ + derivedTxEvt(hashD0.Hex(), 0, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gasLimit), + }) + + // Outer predecessor loop runs for eth-indices 0 and 1; let those miss (they live in the + // target's own Cosmos slot and are skipped anyway). The panic is in the after-loop. + for i := 0; i < 2; i++ { + idxQuery := fmt.Sprintf("tx.height=%d AND %s.%s=%d", + 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, i) + RegisterTxSearchEmpty(client, idxQuery) + } + + // Tracing the 3rd derived EVM tx must not panic. On PR #27 it does: the after-loop runs + // `tx.GetMsgs()[0]` on the (0-message) Cosmos tx with transaction.MsgIndex=2. + suite.Require().NotPanics(func() { + _, _ = suite.backend.TraceTransaction(hashD2, nil) + }, "tracing the 3rd derived EVM tx of a multi-derived Cosmos tx must not panic (F-2026-17754)") +} From 2a6e72060d95d0f6270f5f20c9c60634af14eba5 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Mon, 15 Jun 2026 19:06:04 +0530 Subject: [PATCH 09/12] fix(indexer,rpc): drop duplicate methods left by audit/evm-merge merge IsDerivedTx, indexDerivedTxs, derivedTxAdditionalFields, buildDerivedAdditional were each declared twice, breaking the build. --- indexer/kv_indexer.go | 69 ------------------------------------------ rpc/backend/tx_info.go | 54 ++------------------------------- 2 files changed, 2 insertions(+), 121 deletions(-) diff --git a/indexer/kv_indexer.go b/indexer/kv_indexer.go index 50d46ff0c4..56183add39 100644 --- a/indexer/kv_indexer.go +++ b/indexer/kv_indexer.go @@ -243,17 +243,6 @@ func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*serv return kv.GetByTxHash(common.BytesToHash(bz)) } -// 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 -} - // 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 @@ -269,64 +258,6 @@ func (kv *KVIndexer) IsDerivedTxByBlockAndIndex(blockNumber int64, txIndex int32 return kv.IsDerivedTx(common.BytesToHash(bz)) } -// 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 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 := servertypes.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 -} - // TxHashKey returns the key for db entry: `tx hash -> tx result struct` func TxHashKey(hash common.Hash) []byte { return append([]byte{KeyPrefixTxHash}, hash.Bytes()...) diff --git a/rpc/backend/tx_info.go b/rpc/backend/tx_info.go index edf77500fe..3482f6732b 100644 --- a/rpc/backend/tx_info.go +++ b/rpc/backend/tx_info.go @@ -492,56 +492,6 @@ func (b *Backend) GetTxByTxIndex(height int64, index uint) (*servertypes.TxResul return txResult, txAdditional, nil } -// 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. Standard txs return -// (nil, nil); the IsDerivedTx marker keeps their lookups cheap (one key read, no event re-parse). -func (b *Backend) derivedTxAdditionalFields(hash common.Hash, res *servertypes.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 -// TxResultAdditionalFields for the derived EVM tx at res.MsgIndex. -func (b *Backend) buildDerivedAdditional(res *servertypes.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 -} - // QueryCometTxIndexer query tx in CometBFT tx indexer func (b *Backend) QueryCometTxIndexer( query string, @@ -595,8 +545,8 @@ func (b *Backend) GetTransactionByBlockAndIndex(block *cmtrpctypes.ResultBlock, } height := uint64(block.Block.Height) // #nosec G115 -- checked for int overflow already - blockTime := uint64(block.Block.Time.UTC().Unix()) // #nosec G115 -- checked for int overflow already - index := uint64(idx) // #nosec G115 -- checked for int overflow already + blockTime := uint64(block.Block.Time.UTC().Unix()) // #nosec G115 -- checked for int overflow already + index := uint64(idx) // #nosec G115 -- checked for int overflow already blockHash := common.BytesToHash(block.Block.Hash()) if additional == nil { return rpctypes.NewTransactionFromMsg(msg, blockHash, height, blockTime, index, baseFee, b.ChainConfig()), nil From 269c1aac2a8b42c4058707491f31dfef21d55f8e Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Mon, 15 Jun 2026 19:06:04 +0530 Subject: [PATCH 10/12] test(rpc): trace tx coverage for EVM + multi-derived predecessors and first-derived target (F-2026-17754) Adds a capturing TraceTx mock that asserts the assembled predecessor set. --- rpc/backend/client_test.go | 14 +++++ rpc/backend/tracing_test.go | 102 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/rpc/backend/client_test.go b/rpc/backend/client_test.go index b5c7d2a3c7..1b7f8f6dfc 100644 --- a/rpc/backend/client_test.go +++ b/rpc/backend/client_test.go @@ -307,3 +307,17 @@ func RegisterTraceTransactionWithPredecessors(queryClient *mocks.EVMQueryClient, func RegisterTraceTransaction(queryClient *mocks.EVMQueryClient, msgEthTx *evmtypes.MsgEthereumTx) { RegisterTraceTransactionWithPredecessors(queryClient, msgEthTx, nil) } + +// RegisterTraceTransactionCapture mocks TraceTx with a fixed payload and captures the +// request, so a test can assert the predecessor set TraceTransaction actually assembled +// (the other helpers ignore it via mock.Anything). +func RegisterTraceTransactionCapture(queryClient *mocks.EVMQueryClient, captured **evmtypes.QueryTraceTxRequest) { + data, _ := json.Marshal(map[string]interface{}{"test": "hello"}) + queryClient.On("TraceTx", rpc.ContextWithHeight(1), mock.Anything). + Run(func(args mock.Arguments) { + if req, ok := args.Get(1).(*evmtypes.QueryTraceTxRequest); ok { + *captured = req + } + }). + Return(&evmtypes.QueryTraceTxResponse{Data: data}, nil) +} diff --git a/rpc/backend/tracing_test.go b/rpc/backend/tracing_test.go index fc436b7adf..7098f78a46 100644 --- a/rpc/backend/tracing_test.go +++ b/rpc/backend/tracing_test.go @@ -657,3 +657,105 @@ func (suite *BackendTestSuite) TestTraceTransactionDerivedTargetInMultiDerivedCo _, _ = suite.backend.TraceTransaction(hashD2, nil) }, "tracing the 3rd derived EVM tx of a multi-derived Cosmos tx must not panic (F-2026-17754)") } + +// TestTraceTransactionFirstDerivedTargetInMultiDerivedCosmosTx is the passing boundary to +// the multi-derived repro: tracing the FIRST derived EVM tx (MsgIndex=0) of a multi-derived +// Cosmos tx works — the after-loop's standard-message slice runs zero times — and the +// predecessor set is correctly empty. (The 3rd derived tx of the same Cosmos tx panics.) +func (suite *BackendTestSuite) TestTraceTransactionFirstDerivedTargetInMultiDerivedCosmosTx() { + suite.SetupTest() + + dummyTxBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()( + suite.backend.ClientCtx.TxConfig.NewTxBuilder().GetTx(), + ) + suite.Require().NoError(err) + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gasLimit := uint64(50000) + hashD0 := common.HexToHash("0xd000000000000000000000000000000000000000000000000000000000000000") + hashD1 := common.HexToHash("0xd100000000000000000000000000000000000000000000000000000000000000") + hashD2 := common.HexToHash("0xd200000000000000000000000000000000000000000000000000000000000000") + derivedEvents := []abci.Event{ + derivedTxEvt(hashD0.Hex(), 0, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gasLimit), + } + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err = RegisterBlockMultipleTxs(client, 1, []types.Tx{dummyTxBz}) + suite.Require().NoError(err) + + // GetTxByEthHash(D0): the 3 derived events are in slot 0; D0 is first → MsgIndex=0, EthTxIndex=0. + targetQuery := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashD0.Hex()) + RegisterTxSearchWithResult(client, targetQuery, 1, 0, nil, derivedEvents) + // After-loop derived scan reads BlockResults for slot 0. + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{{Code: 0, Events: derivedEvents}}) + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashD0, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + suite.Require().Empty(captured.Predecessors, "first derived tx of a Cosmos tx has no predecessors") +} + +// TestTraceTransactionEvmTargetWithMultiDerivedPredecessors verifies a normal EVM target +// whose predecessors are 3 derived EVM txs emitted by a single earlier Cosmos tx. The +// (fixed) outer loop must enumerate all three by eth-index; this is the working path that +// contrasts the broken derived-target after-loop. +func (suite *BackendTestSuite) TestTraceTransactionEvmTargetWithMultiDerivedPredecessors() { + suite.SetupTest() + + dummyTxBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()( + suite.backend.ClientCtx.TxConfig.NewTxBuilder().GetTx(), + ) + suite.Require().NoError(err) + + msgTarget, _ := suite.buildEthereumTx() + txBzTarget := suite.signAndEncodeEthTx(msgTarget) + hashTarget := msgTarget.AsTransaction().Hash() + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gasLimit := uint64(50000) + derivedHashes := []common.Hash{ + common.HexToHash("0xe000000000000000000000000000000000000000000000000000000000000000"), + common.HexToHash("0xe100000000000000000000000000000000000000000000000000000000000000"), + common.HexToHash("0xe200000000000000000000000000000000000000000000000000000000000000"), + } + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err = RegisterBlockMultipleTxs(client, 1, []types.Tx{dummyTxBz, txBzTarget}) + suite.Require().NoError(err) + + // GetTxByEthHash(target): standard EVM tx at eth-index 3, Cosmos slot 1. + targetQuery := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashTarget.Hex()) + RegisterTxSearchWithResult(client, targetQuery, 1, 1, txBzTarget, []abci.Event{ethTxEvent(hashTarget.Hex(), "3")}) + + // Outer predecessor loop i=0,1,2 → the 3 derived txs (all in Cosmos slot 0). + for i, h := range derivedHashes { + idxQuery := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, i) + RegisterTxSearchWithResult(client, idxQuery, 1, 0, nil, + []abci.Event{derivedTxEvt(h.Hex(), i, sender.Hex(), recipient.Hex(), gasLimit)}) + } + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashTarget, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + + suite.Require().Len(captured.Predecessors, len(derivedHashes), + "all 3 derived txs of the earlier Cosmos tx must be assembled as predecessors") +} From 69dc3437256b68102e50e3dd73cd26d013271530 Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:25:24 +0530 Subject: [PATCH 11/12] fix(rpc): skip standard-message predecessor loop for derived trace targets to prevent out-of-range panic --- rpc/backend/tracing.go | 31 ++++++++++++++++++++++--------- rpc/backend/tracing_test.go | 23 ++++++++++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/rpc/backend/tracing.go b/rpc/backend/tracing.go index e79717817d..54b473533a 100644 --- a/rpc/backend/tracing.go +++ b/rpc/backend/tracing.go @@ -115,15 +115,28 @@ func (b *Backend) TraceTransaction(hash common.Hash, config *rpctypes.TraceConfi return nil, err } - // add predecessor messages in current cosmos tx - index := int(transaction.MsgIndex) // #nosec G115 - - for i := 0; i < index; i++ { - msg := tx.GetMsgs()[i] - // Check if it's a normal Ethereum tx - if ethMsg, ok := msg.(*evmtypes.MsgEthereumTx); ok { - predecessors = append(predecessors, ethMsg) - continue + // Add the standard EVM predecessor messages that live in the target's own + // Cosmos tx. This only applies to a NON-derived target: transaction.MsgIndex + // is then a genuine Cosmos-message index, so iterating tx.GetMsgs()[0:MsgIndex] + // collects the earlier MsgEthereumTx messages of the same tx. + // + // For a DERIVED target, MsgIndex is the derived-tx ordinal among the txs the + // Cosmos tx emitted (0, 1, 2, …), NOT a Cosmos-message index. A single + // MsgExecutePayload can emit several derived EVM txs (deployUEA + … + + // executePayload), so MsgIndex routinely exceeds len(tx.GetMsgs()) and the old + // unconditional loop indexed past the message array and panicked + // (F-2026-17754). The in-tx predecessors of a derived target are assembled from + // the Cosmos tx's events in the derived-tx block below, which is the correct + // index domain, so this loop must be skipped for derived targets. + if additional == nil { + index := int(transaction.MsgIndex) // #nosec G115 + for i := 0; i < index; i++ { + msg := tx.GetMsgs()[i] + // Check if it's a normal Ethereum tx + if ethMsg, ok := msg.(*evmtypes.MsgEthereumTx); ok { + predecessors = append(predecessors, ethMsg) + continue + } } } diff --git a/rpc/backend/tracing_test.go b/rpc/backend/tracing_test.go index 7098f78a46..eb134e2c36 100644 --- a/rpc/backend/tracing_test.go +++ b/rpc/backend/tracing_test.go @@ -651,11 +651,28 @@ func (suite *BackendTestSuite) TestTraceTransactionDerivedTargetInMultiDerivedCo RegisterTxSearchEmpty(client, idxQuery) } - // Tracing the 3rd derived EVM tx must not panic. On PR #27 it does: the after-loop runs - // `tx.GetMsgs()[0]` on the (0-message) Cosmos tx with transaction.MsgIndex=2. + // With the after-loop now skipped for derived targets, the derived block reconstructs the + // intra-slot predecessors (D0, D1) of D2 from slot-0 events instead. + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{ + {Code: 0, Events: []abci.Event{ + derivedTxEvt(hashD0.Hex(), 0, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gasLimit), + derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gasLimit), + }}, + }) + + queryClient := suite.mockQueryClient() + RegisterTraceTransactionWithPredecessors(queryClient, nil, nil) + RegisterConsensusParams(client, 1) + + // Tracing the 3rd derived EVM tx must not panic and must succeed. On the buggy version it + // panics: the after-loop runs `tx.GetMsgs()[0]` on the (0-message) Cosmos tx with + // transaction.MsgIndex=2. + var traceErr error suite.Require().NotPanics(func() { - _, _ = suite.backend.TraceTransaction(hashD2, nil) + _, traceErr = suite.backend.TraceTransaction(hashD2, nil) }, "tracing the 3rd derived EVM tx of a multi-derived Cosmos tx must not panic (F-2026-17754)") + suite.Require().NoError(traceErr) } // TestTraceTransactionFirstDerivedTargetInMultiDerivedCosmosTx is the passing boundary to From f10e26c7e3046d8f21b2238c55e8a2abb0506132 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Mon, 15 Jun 2026 19:37:15 +0530 Subject: [PATCH 12/12] test(rpc): regression guards for derived trace targets (mixed predecessors, 1-msg carrier, KV-indexer hit) Covers F-2026-17754 fix: derived target with mixed EVM+derived predecessors, 2nd/3rd derived tx of a 1-message carrier, and KV-index-hit resolution. --- rpc/backend/tracing_test.go | 216 ++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/rpc/backend/tracing_test.go b/rpc/backend/tracing_test.go index eb134e2c36..4c071a92d4 100644 --- a/rpc/backend/tracing_test.go +++ b/rpc/backend/tracing_test.go @@ -14,8 +14,24 @@ import ( evmtypes "github.com/cosmos/evm/x/vm/types" "cosmossdk.io/log" + + sdk "github.com/cosmos/cosmos-sdk/types" ) +// oneMsgNonEthCosmosTxBz encodes a non-eth Cosmos tx with exactly one message — a realistic +// stand-in for a MsgExecutePayload carrier that emits derived EVM txs. It uses a vm-module +// MsgUpdateParams (a registered, non-MsgEthereumTx message so it round-trips through the test +// app's interface registry and is never mistaken for a predecessor). Used to pin behaviour +// when a derived target's MsgIndex meets or exceeds len(GetMsgs()) of its 1-message carrier. +func (suite *BackendTestSuite) oneMsgNonEthCosmosTxBz() []byte { + authority := sdk.AccAddress(common.HexToAddress("0x1111111111111111111111111111111111111111").Bytes()).String() + builder := suite.backend.ClientCtx.TxConfig.NewTxBuilder() + suite.Require().NoError(builder.SetMsgs(&evmtypes.MsgUpdateParams{Authority: authority, Params: evmtypes.DefaultParams()})) + bz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()(builder.GetTx()) + suite.Require().NoError(err) + return bz +} + func (suite *BackendTestSuite) TestTraceTransaction() { msgEthereumTx, _ := suite.buildEthereumTx() msgEthereumTx2, _ := suite.buildEthereumTx() @@ -776,3 +792,203 @@ func (suite *BackendTestSuite) TestTraceTransactionEvmTargetWithMultiDerivedPred suite.Require().Len(captured.Predecessors, len(derivedHashes), "all 3 derived txs of the earlier Cosmos tx must be assembled as predecessors") } + +// TestTraceTransactionDerivedTargetWithMixedPredecessors traces a derived target (D3, the +// 3rd derived tx of its Cosmos tx) with MIXED predecessors: a standard EVM tx in an earlier +// Cosmos slot (assembled by the outer loop) AND the two prior derived txs of its own Cosmos +// tx (assembled by the derived-event scan). Correct predecessors: [EVM, D1, D2]. Before the +// F-2026-17754 fix this panicked: the standard-message after-loop ran at MsgIndex=2 over the +// empty Cosmos message array. The fix skips that loop for derived targets. +func (suite *BackendTestSuite) TestTraceTransactionDerivedTargetWithMixedPredecessors() { + suite.SetupTest() + + msgEvm, _ := suite.buildEthereumTx() + evmTxBz := suite.signAndEncodeEthTx(msgEvm) + evmHash := msgEvm.AsTransaction().Hash() + + dummyTxBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()( + suite.backend.ClientCtx.TxConfig.NewTxBuilder().GetTx(), + ) + suite.Require().NoError(err) + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gl := uint64(50000) + hashD1 := common.HexToHash("0xf100000000000000000000000000000000000000000000000000000000000000") + hashD2 := common.HexToHash("0xf200000000000000000000000000000000000000000000000000000000000000") + hashD3 := common.HexToHash("0xf300000000000000000000000000000000000000000000000000000000000000") // target + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err = RegisterBlockMultipleTxs(client, 1, []types.Tx{evmTxBz, dummyTxBz}) // slot0=EVM, slot1=derived carrier + suite.Require().NoError(err) + + d3Query := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashD3.Hex()) + RegisterTxSearchWithResult(client, d3Query, 1, 1, nil, []abci.Event{ + derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD3.Hex(), 3, sender.Hex(), recipient.Hex(), gl), + }) + // Outer loop eth-index 0 → EVM (slot0, added); eth-indices 1,2 → D1,D2 (slot1 = target's slot, skipped). + q0 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 0) + RegisterTxSearchWithResult(client, q0, 1, 0, evmTxBz, []abci.Event{ethTxEvent(evmHash.Hex(), "0")}) + q1 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 1) + RegisterTxSearchWithResult(client, q1, 1, 1, nil, []abci.Event{derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gl)}) + q2 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 2) + RegisterTxSearchWithResult(client, q2, 1, 1, nil, []abci.Event{derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gl)}) + + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{ + {Code: 0, Events: []abci.Event{ethTxEvent(evmHash.Hex(), "0")}}, + {Code: 0, Events: []abci.Event{ + derivedTxEvt(hashD1.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 2, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD3.Hex(), 3, sender.Hex(), recipient.Hex(), gl), + }}, + }) + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashD3, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + suite.Require().Len(captured.Predecessors, 3, "predecessors must be [EVM, D1, D2]") +} + +// TestTraceTransactionSecondDerivedTargetOneMsgCarrier — with a realistic 1-message Cosmos +// carrier, tracing the 2nd derived tx (MsgIndex=1) succeeds: the standard after-loop is now +// skipped for derived targets, and the derived-event scan supplies the predecessor. Before +// the fix the loop ran tx.GetMsgs()[0] (still in bounds for a 1-message carrier, so the 2nd +// derived tx happened not to panic — the threshold is the 3rd). Predecessor: [D1]. +func (suite *BackendTestSuite) TestTraceTransactionSecondDerivedTargetOneMsgCarrier() { + suite.SetupTest() + carrierBz := suite.oneMsgNonEthCosmosTxBz() + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gl := uint64(50000) + hashD1 := common.HexToHash("0xc100000000000000000000000000000000000000000000000000000000000000") + hashD2 := common.HexToHash("0xc200000000000000000000000000000000000000000000000000000000000000") // target (2nd) + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{carrierBz}) + suite.Require().NoError(err) + + d2Query := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashD2.Hex()) + RegisterTxSearchWithResult(client, d2Query, 1, 0, nil, []abci.Event{ + derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + }) + q0 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 0) + RegisterTxSearchWithResult(client, q0, 1, 0, nil, []abci.Event{derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl)}) + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{ + {Code: 0, Events: []abci.Event{ + derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + }}, + }) + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashD2, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + suite.Require().Len(captured.Predecessors, 1, "only the 1st derived tx (D1) precedes D2") +} + +// TestTraceTransactionThirdDerivedTargetOneMsgCarrier — same 1-message carrier as above, but +// tracing the 3rd derived tx (MsgIndex=2). This is the threshold the previous test approaches: +// before the fix the after-loop indexed GetMsgs()[1] on the length-1 Cosmos tx and panicked +// (F-2026-17754). With the loop skipped for derived targets it now succeeds; the two prior +// derived txs come from the derived-event scan. Predecessors: [D1, D2]. +func (suite *BackendTestSuite) TestTraceTransactionThirdDerivedTargetOneMsgCarrier() { + suite.SetupTest() + carrierBz := suite.oneMsgNonEthCosmosTxBz() + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gl := uint64(50000) + hashD1 := common.HexToHash("0x9100000000000000000000000000000000000000000000000000000000000000") + hashD2 := common.HexToHash("0x9200000000000000000000000000000000000000000000000000000000000000") + hashD3 := common.HexToHash("0x9300000000000000000000000000000000000000000000000000000000000000") // target (3rd) + + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err := RegisterBlockMultipleTxs(client, 1, []types.Tx{carrierBz}) + suite.Require().NoError(err) + + d3Query := fmt.Sprintf("%s.%s='%s'", evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyEthereumTxHash, hashD3.Hex()) + RegisterTxSearchWithResult(client, d3Query, 1, 0, nil, []abci.Event{ + derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD3.Hex(), 2, sender.Hex(), recipient.Hex(), gl), + }) + q0 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 0) + RegisterTxSearchWithResult(client, q0, 1, 0, nil, []abci.Event{derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl)}) + q1 := fmt.Sprintf("tx.height=%d AND %s.%s=%d", 1, evmtypes.TypeMsgEthereumTx, evmtypes.AttributeKeyTxIndex, 1) + RegisterTxSearchWithResult(client, q1, 1, 0, nil, []abci.Event{derivedTxEvt(hashD2.Hex(), 1, sender.Hex(), recipient.Hex(), gl)}) + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{ + {Code: 0, Events: []abci.Event{ + derivedTxEvt(hashD1.Hex(), 0, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD2.Hex(), 1, sender.Hex(), recipient.Hex(), gl), + derivedTxEvt(hashD3.Hex(), 2, sender.Hex(), recipient.Hex(), gl), + }}, + }) + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashD3, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + suite.Require().Len(captured.Predecessors, 2, "D1 and D2 precede D3") +} + +// TestTraceTransactionDerivedTargetViaKVIndexerHit traces a derived target resolved via the +// KV indexer HIT path (not tx_search): the tx is indexed, so GetTxByEthHash returns it and +// derivedTxAdditionalFields rebuilds its fields from BlockResults. No TxSearch is mocked, so +// a regression that falls through to tx_search would fail on an unexpected mock call. +func (suite *BackendTestSuite) TestTraceTransactionDerivedTargetViaKVIndexerHit() { + suite.SetupTest() + + dummyTxBz, err := suite.backend.ClientCtx.TxConfig.TxEncoder()( + suite.backend.ClientCtx.TxConfig.NewTxBuilder().GetTx(), + ) + suite.Require().NoError(err) + + sender := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + recipient := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + gl := uint64(50000) + hashD0 := common.HexToHash("0xb000000000000000000000000000000000000000000000000000000000000000") + derivedEvents := []abci.Event{derivedTxEvt(hashD0.Hex(), 0, sender.Hex(), recipient.Hex(), gl)} + + // Index the derived tx so GetTxByEthHash hits the KV indexer. + localBlock := types.MakeBlock(1, []types.Tx{dummyTxBz}, nil, nil) + localBlock.ChainID = ChainID + suite.backend.Indexer = indexer.NewKVIndexer(dbm.NewMemDB(), log.NewNopLogger(), suite.backend.ClientCtx) + suite.Require().NoError(suite.backend.Indexer.IndexBlock(localBlock, []*abci.ExecTxResult{{Code: 0, Events: derivedEvents}})) + + queryClient := suite.mockQueryClient() + client := suite.mockClient() + _, err = RegisterBlockMultipleTxs(client, 1, []types.Tx{dummyTxBz}) + suite.Require().NoError(err) + // BlockResults backs both derivedTxAdditionalFields (KV-hit reconstruction) and the after-loop. + RegisterBlockResultsWithTxs(client, 1, []*abci.ExecTxResult{{Code: 0, Events: derivedEvents}}) + + var captured *evmtypes.QueryTraceTxRequest + RegisterTraceTransactionCapture(queryClient, &captured) + RegisterConsensusParams(client, 1) + + _, err = suite.backend.TraceTransaction(hashD0, nil) + suite.Require().NoError(err) + suite.Require().NotNil(captured) + suite.Require().Empty(captured.Predecessors, "only derived tx in the block has no predecessors") +}