Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions indexer/kv_indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +74,15 @@ func (kv *KVIndexer) IndexBlock(block *cmttypes.Block, txResults []*abci.ExecTxR
}

if !isEthTx(tx) {
// Derived txs (internal EVM executions emitted only as events, not as an
// embedded MsgEthereumTx) still occupy slots in the block's eth-tx index
// sequence (#18). Index them by hash and by (height, ethTxIndex), advancing
// the shared ethTxIndex so it stays aligned with the emitted txIndex for any
// standard eth txs that follow in the same block.
ethTxIndex, err = kv.indexDerivedTxs(batch, height, uint32(txIndex), result, ethTxIndex) //#nosec G115 -- int overflow is not a concern here
if err != nil {
kv.logger.Error("Fail to index derived txs", "err", err, "block", height, "txIndex", txIndex)
}
continue
}

Expand Down Expand Up @@ -123,6 +136,64 @@ func (kv *KVIndexer) IndexBlock(block *cmttypes.Block, txResults []*abci.ExecTxR
return nil
}

// indexDerivedTxs indexes the derived EVM transactions carried as events inside a
// non-eth Cosmos tx (e.g. a Universal Executor MsgExecutePayload). Each derived tx is
// stored by hash and by (height, ethTxIndex) using the same schema as standard
// MsgEthereumTx entries, so eth_getTransactionByHash / Receipt and block-and-index
// lookups resolve it directly without depending on a CometBFT tx_search fallback.
//
// ethTxIndex is the shared, block-level eth tx counter; it is advanced once per derived
// tx and returned so the caller's sequence stays aligned with the txIndex the keeper
// emits (#18) for both derived and standard txs.
func (kv *KVIndexer) indexDerivedTxs(
batch dbm.Batch,
height int64,
txIndex uint32,
result *abci.ExecTxResult,
ethTxIndex int32,
) (int32, error) {
// nil tx: ParseTxResult only needs it for the failed-cosmos-tx gas fallback, which
// assumes embedded MsgEthereumTx messages and does not apply to derived txs.
txs, err := rpctypes.ParseTxResult(result, nil)
if err != nil {
return ethTxIndex, err
}

var cumulativeGasUsed uint64
for _, parsed := range txs.Txs {
if parsed.Type != evmtypes.DerivedTxType {
continue
}

if parsed.EthTxIndex >= 0 && parsed.EthTxIndex != ethTxIndex {
kv.logger.Error("derived eth tx index doesn't match", "expect", ethTxIndex, "found", parsed.EthTxIndex)
}

cumulativeGasUsed += parsed.GasUsed
txResult := cosmosevmtypes.TxResult{
Height: height,
TxIndex: txIndex,
MsgIndex: uint32(parsed.MsgIndex), //#nosec G115 -- int overflow is not a concern here
EthTxIndex: ethTxIndex,
Failed: parsed.Failed,
GasUsed: parsed.GasUsed,
CumulativeGasUsed: cumulativeGasUsed,
}
ethTxIndex++

if err := saveTxResult(kv.clientCtx.Codec, batch, parsed.Hash, &txResult); err != nil {
return ethTxIndex, errorsmod.Wrapf(err, "indexDerivedTxs %d", height)
}
// Mark the hash as derived in the same batch, so it commits atomically with the
// tx-hash/tx-index entries: a derived tx is findable iff its marker exists.
if err := batch.Set(DerivedTxHashKey(parsed.Hash), []byte{1}); err != nil {
return ethTxIndex, errorsmod.Wrapf(err, "indexDerivedTxs %d set derived key", height)
}
}

return ethTxIndex, nil
}

// LastIndexedBlock returns the latest indexed block number, returns -1 if db is empty
func (kv *KVIndexer) LastIndexedBlock() (int64, error) {
return LoadLastBlock(kv.db)
Expand All @@ -149,6 +220,17 @@ func (kv *KVIndexer) GetByTxHash(hash common.Hash) (*cosmosevmtypes.TxResult, er
return &txKey, nil
}

// IsDerivedTx reports whether the hash was indexed as a derived EVM tx (event-only, no
// embedded MsgEthereumTx). A cheap single key read used by the RPC backend to gate the
// (more expensive) rebuild of TxResultAdditionalFields from block events.
func (kv *KVIndexer) IsDerivedTx(hash common.Hash) (bool, error) {
bz, err := kv.db.Get(DerivedTxHashKey(hash))
if err != nil {
return false, errorsmod.Wrapf(err, "IsDerivedTx %s", hash.Hex())
}
return len(bz) > 0, nil
}

// GetByBlockAndIndex finds eth tx by block number and eth tx index
func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*cosmosevmtypes.TxResult, error) {
bz, err := kv.db.Get(TxIndexKey(blockNumber, txIndex))
Expand All @@ -161,11 +243,31 @@ func (kv *KVIndexer) GetByBlockAndIndex(blockNumber int64, txIndex int32) (*cosm
return kv.GetByTxHash(common.BytesToHash(bz))
}

// IsDerivedTxByBlockAndIndex reports whether the tx at (blockNumber, eth tx index) is a
// derived EVM tx. It resolves the hash from the block-index entry and consults the derived
// marker — two cheap key reads — so the RPC backend can gate derived-tx reconstruction on
// the block-index path without reparsing block events for ordinary txs.
func (kv *KVIndexer) IsDerivedTxByBlockAndIndex(blockNumber int64, txIndex int32) (bool, error) {
bz, err := kv.db.Get(TxIndexKey(blockNumber, txIndex))
if err != nil {
return false, errorsmod.Wrapf(err, "IsDerivedTxByBlockAndIndex %d %d", blockNumber, txIndex)
}
if len(bz) == 0 {
return false, nil
}
return kv.IsDerivedTx(common.BytesToHash(bz))
}

// TxHashKey returns the key for db entry: `tx hash -> tx result struct`
func TxHashKey(hash common.Hash) []byte {
return append([]byte{KeyPrefixTxHash}, hash.Bytes()...)
}

// DerivedTxHashKey returns the key for db entry: `tx hash -> derived marker`
func DerivedTxHashKey(hash common.Hash) []byte {
return append([]byte{KeyPrefixTxDerived}, hash.Bytes()...)
}

// TxIndexKey returns the key for db entry: `(block number, tx index) -> tx hash`
func TxIndexKey(blockNumber int64, txIndex int32) []byte {
bz1 := sdk.Uint64ToBigEndian(uint64(blockNumber)) //nolint:gosec // G115 // block number won't exceed uint64
Expand Down
156 changes: 156 additions & 0 deletions indexer/kv_indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package indexer_test

import (
"math/big"
"strconv"
"testing"

"github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -190,3 +191,158 @@ func TestKVIndexer(t *testing.T) {
})
}
}

// TestKVIndexerDerivedTxs verifies that derived EVM txs (internal executions emitted
// only as events, with txType=DerivedTxType) are indexed by hash and block index just
// like standard MsgEthereumTx txs, and that they share a single eth-tx index sequence
// with standard txs in the same block.
func TestKVIndexerDerivedTxs(t *testing.T) {
priv, err := ethsecp256k1.GenerateKey()
require.NoError(t, err)
from := common.BytesToAddress(priv.PubKey().Address().Bytes())
signer := utiltx.NewSigner(priv)
ethSigner := ethtypes.LatestSignerForChainID(nil)

to := common.BigToAddress(big.NewInt(1))
stdTx := types.NewTx(&types.EvmTxArgs{Nonce: 0, To: &to, Amount: big.NewInt(1000), GasLimit: 21000})
stdTx.From = from.Hex()
require.NoError(t, stdTx.Sign(ethSigner, signer))
stdHash := stdTx.AsTransaction().Hash()

nw := network.New()
encodingConfig := nw.GetEncodingConfig()
clientCtx := client.Context{}.WithTxConfig(encodingConfig.TxConfig).WithCodec(encodingConfig.Codec)

// standard eth wrapper tx (recognized as eth via the ethereum extension option)
stdWrapper, err := stdTx.BuildTx(clientCtx.TxConfig.NewTxBuilder(), constants.ExampleAttoDenom)
require.NoError(t, err)
stdBz, err := clientCtx.TxConfig.TxEncoder()(stdWrapper)
require.NoError(t, err)

// non-eth Cosmos tx wrapper (no eth extension) — the carrier for a derived tx
builder := clientCtx.TxConfig.NewTxBuilder()
require.NoError(t, builder.SetMsgs(stdTx))
nonEthBz, err := clientCtx.TxConfig.TxEncoder()(builder.GetTx())
require.NoError(t, err)

derivedHash := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadbeef")

gas := func(v int64) string { return strconv.FormatInt(v, 10) }
idx := func(v int32) string { return strconv.FormatInt(int64(v), 10) }

// derivedResult builds a successful tx result whose events describe one derived EVM
// tx (ethereum_tx + tx_log + message{txType=DerivedTxType}) at the given eth txIndex.
derivedResult := func(hash common.Hash, txIndex int32, gasUsed int64) *abci.ExecTxResult {
return &abci.ExecTxResult{
Code: 0,
GasUsed: gasUsed,
Events: []abci.Event{
{Type: types.EventTypeEthereumTx, Attributes: []abci.EventAttribute{
{Key: types.AttributeKeyEthereumTxHash, Value: hash.Hex()},
{Key: types.AttributeKeyTxIndex, Value: idx(txIndex)},
{Key: types.AttributeKeyTxGasUsed, Value: gas(gasUsed)},
{Key: types.AttributeKeyRecipient, Value: to.Hex()},
}},
{Type: types.EventTypeTxLog, Attributes: []abci.EventAttribute{}},
{Type: "message", Attributes: []abci.EventAttribute{
{Key: "module", Value: "evm"},
{Key: "sender", Value: from.Hex()},
{Key: types.AttributeKeyTxType, Value: strconv.FormatUint(uint64(types.DerivedTxType), 10)},
}},
},
}
}

// standardResult builds a successful tx result for a normal MsgEthereumTx. GasUsed is
// set on the result because ParseTxResult overwrites a single non-derived tx's gas
// with result.GasUsed (the derived path keeps the event-reported gas instead).
standardResult := func(hash common.Hash, txIndex int32, gasUsed int64) *abci.ExecTxResult {
return &abci.ExecTxResult{
Code: 0,
GasUsed: gasUsed,
Events: []abci.Event{
{Type: types.EventTypeEthereumTx, Attributes: []abci.EventAttribute{
{Key: types.AttributeKeyEthereumTxHash, Value: hash.Hex()},
{Key: types.AttributeKeyTxIndex, Value: idx(txIndex)},
{Key: types.AttributeKeyTxGasUsed, Value: gas(gasUsed)},
{Key: types.AttributeKeyRecipient, Value: to.Hex()},
}},
},
}
}

t.Run("derived tx is indexed by hash and block index", func(t *testing.T) {
db := dbm.NewMemDB()
idxer := indexer.NewKVIndexer(db, log.NewNopLogger(), clientCtx)

block := &cmttypes.Block{Header: cmttypes.Header{Height: 1}, Data: cmttypes.Data{Txs: []cmttypes.Tx{nonEthBz}}}
require.NoError(t, idxer.IndexBlock(block, []*abci.ExecTxResult{derivedResult(derivedHash, 0, 50000)}))

// Resolvable by hash — without indexing derived txs this lookup misses.
res, err := idxer.GetByTxHash(derivedHash)
require.NoError(t, err)
require.NotNil(t, res)
require.Equal(t, int32(0), res.EthTxIndex)
require.Equal(t, uint64(50000), res.GasUsed)
require.False(t, res.Failed)

// ...and by block index, returning the same record.
byIdx, err := idxer.GetByBlockAndIndex(1, 0)
require.NoError(t, err)
require.Equal(t, res, byIdx)

// marked derived so the RPC backend rebuilds its additional fields from events
isDerived, err := idxer.IsDerivedTx(derivedHash)
require.NoError(t, err)
require.True(t, isDerived)
})

t.Run("derived and standard txs share one eth-tx index sequence", func(t *testing.T) {
db := dbm.NewMemDB()
idxer := indexer.NewKVIndexer(db, log.NewNopLogger(), clientCtx)

// Block order: derived tx (Cosmos tx 0) then standard tx (Cosmos tx 1). With #18
// the keeper advances the eth txIndex for the derived tx, so the standard tx
// emits txIndex=1. The indexer must mirror that by counting the derived tx — else
// the standard tx is stored under index 0 and block-and-index lookups diverge.
block := &cmttypes.Block{
Header: cmttypes.Header{Height: 1},
Data: cmttypes.Data{Txs: []cmttypes.Tx{nonEthBz, stdBz}},
}
results := []*abci.ExecTxResult{
derivedResult(derivedHash, 0, 50000),
standardResult(stdHash, 1, 21000),
}
require.NoError(t, idxer.IndexBlock(block, results))

// derived → eth index 0 (Cosmos tx 0), standard → eth index 1 (Cosmos tx 1)
dByHash, err := idxer.GetByTxHash(derivedHash)
require.NoError(t, err)
require.Equal(t, int32(0), dByHash.EthTxIndex)
require.Equal(t, uint32(0), dByHash.TxIndex)
require.Equal(t, uint64(50000), dByHash.GasUsed)

sByHash, err := idxer.GetByTxHash(stdHash)
require.NoError(t, err)
require.Equal(t, int32(1), sByHash.EthTxIndex)
require.Equal(t, uint32(1), sByHash.TxIndex)
require.Equal(t, uint64(21000), sByHash.GasUsed)

// block-and-index lookups resolve to the same records (no collision/divergence)
d0, err := idxer.GetByBlockAndIndex(1, 0)
require.NoError(t, err)
require.Equal(t, dByHash, d0)

s1, err := idxer.GetByBlockAndIndex(1, 1)
require.NoError(t, err)
require.Equal(t, sByHash, s1)

// only the derived tx carries the derived marker
isDerived, err := idxer.IsDerivedTx(derivedHash)
require.NoError(t, err)
require.True(t, isDerived)
isStdDerived, err := idxer.IsDerivedTx(stdHash)
require.NoError(t, err)
require.False(t, isStdDerived)
})
}
18 changes: 18 additions & 0 deletions rpc/backend/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,24 @@ func RegisterBlockResults(
return res, nil
}

// RegisterBlockResultsWithTxResults mocks BlockResults so it returns the supplied
// per-tx results verbatim. Used to feed derived-tx events (ethereum_tx + message) that
// the backend reparses to rebuild a derived tx's additional fields.
func RegisterBlockResultsWithTxResults(
client *mocks.Client,
height int64,
txResults []*abci.ExecTxResult,
) (*cmtrpctypes.ResultBlockResults, error) {
res := &cmtrpctypes.ResultBlockResults{
Height: height,
TxsResults: txResults,
}

client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")).
Return(res, nil)
return res, nil
}

func RegisterBlockResultsError(client *mocks.Client, height int64) {
client.On("BlockResults", rpc.ContextWithHeight(height), mock.AnythingOfType("*int64")).
Return(nil, errortypes.ErrInvalidRequest)
Expand Down
Loading
Loading