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) }