From b41deef4f7f34c2b8166d0b0aa8a7a327aa00388 Mon Sep 17 00:00:00 2001 From: vietddude Date: Fri, 27 Mar 2026 17:33:23 +0700 Subject: [PATCH] feat: enhance Cosmos transaction parsing to handle fees and tips as ancillary transactions --- internal/indexer/cosmos.go | 123 +++++++- internal/indexer/cosmos_test.go | 480 ++++++++++++++++++++++++++++---- 2 files changed, 541 insertions(+), 62 deletions(-) diff --git a/internal/indexer/cosmos.go b/internal/indexer/cosmos.go index 2c6999e..22a6163 100644 --- a/internal/indexer/cosmos.go +++ b/internal/indexer/cosmos.go @@ -290,6 +290,15 @@ func (c *CosmosIndexer) extractTransferTransactions( fee := extractCosmosFee(txResult.Events, nativeDenom) transfers := extractCosmosTransfers(txResult.Events) + if ancillaryCoin, ok := extractCosmosAncillaryNativeCoin(txResult.Events, nativeDenom); ok { + transfers = stripCosmosFeeTransfers(transfers, ancillaryCoin) + } + if feeCoin, ok := extractCosmosFeeCoin(txResult.Events, nativeDenom); ok { + transfers = stripCosmosFeeTransfers(transfers, feeCoin) + } + if tipCoin, ok := extractCosmosTipCoin(txResult.Events, nativeDenom); ok { + transfers = stripCosmosFeeTransfers(transfers, tipCoin) + } feeAssigned := false for transferIndex, transfer := range transfers { @@ -330,11 +339,12 @@ func (c *CosmosIndexer) extractTransferTransactions( } type cosmosTransfer struct { - sender string - recipient string - amount string - denom string - source cosmosTransferSource + sender string + recipient string + amount string + denom string + source cosmosTransferSource + hasMsgIndex bool } type cosmosTransferSource uint8 @@ -422,11 +432,12 @@ func extractCosmosBankTransfers( continue } transfers = append(transfers, cosmosTransfer{ - sender: senders[i], - recipient: recipients[i], - amount: coin.Amount, - denom: coin.Denom, - source: cosmosTransferSourceBank, + sender: senders[i], + recipient: recipients[i], + amount: coin.Amount, + denom: coin.Denom, + source: cosmosTransferSourceBank, + hasMsgIndex: len(msgIndexes) > 0, }) } } @@ -435,6 +446,28 @@ func extractCosmosBankTransfers( return transfers } +func stripCosmosFeeTransfers(transfers []cosmosTransfer, feeCoin cosmosCoin) []cosmosTransfer { + if feeCoin.Amount == "" || feeCoin.Denom == "" { + return transfers + } + + filtered := make([]cosmosTransfer, 0, len(transfers)) + removed := false + for _, transfer := range transfers { + if !removed && + transfer.source == cosmosTransferSourceBank && + !transfer.hasMsgIndex && + transfer.amount == feeCoin.Amount && + normalizeCosmosDenom(transfer.denom) == normalizeCosmosDenom(feeCoin.Denom) { + removed = true + continue + } + filtered = append(filtered, transfer) + } + + return filtered +} + type cosmosPacketData struct { Amount string `json:"amount"` Denom string `json:"denom"` @@ -1000,6 +1033,76 @@ func extractCosmosFee(events []cosmos.Event, nativeDenom string) decimal.Decimal return decimal.Zero } +func extractCosmosFeeCoin(events []cosmos.Event, nativeDenom string) (cosmosCoin, bool) { + if feeCoin, ok := extractCosmosCoinFromEvent(events, "tx", "fee", nativeDenom); ok { + return feeCoin, true + } + if feeCoin, ok := extractCosmosCoinFromEvent(events, "fee_pay", "fee", nativeDenom); ok { + return feeCoin, true + } + return cosmosCoin{}, false +} + +func extractCosmosTipCoin(events []cosmos.Event, nativeDenom string) (cosmosCoin, bool) { + return extractCosmosCoinFromEvent(events, "tip_pay", "tip", nativeDenom) +} + +func extractCosmosAncillaryNativeCoin(events []cosmos.Event, nativeDenom string) (cosmosCoin, bool) { + feeCoin, hasFee := extractCosmosFeeCoin(events, nativeDenom) + tipCoin, hasTip := extractCosmosTipCoin(events, nativeDenom) + + switch { + case hasFee && hasTip: + if normalizeCosmosDenom(feeCoin.Denom) != normalizeCosmosDenom(tipCoin.Denom) { + return cosmosCoin{}, false + } + feeAmount, err := decimal.NewFromString(feeCoin.Amount) + if err != nil { + return cosmosCoin{}, false + } + tipAmount, err := decimal.NewFromString(tipCoin.Amount) + if err != nil { + return cosmosCoin{}, false + } + return cosmosCoin{ + Amount: feeAmount.Add(tipAmount).String(), + Denom: feeCoin.Denom, + }, true + case hasFee: + return feeCoin, true + case hasTip: + return tipCoin, true + default: + return cosmosCoin{}, false + } +} + +func extractCosmosCoinFromEvent(events []cosmos.Event, eventType, keyName, nativeDenom string) (cosmosCoin, bool) { + for _, event := range events { + if event.Type != eventType { + continue + } + for _, attr := range event.Attributes { + key := strings.ToLower(strings.TrimSpace(decodeCosmosEventValue(attr.Key))) + if key != keyName { + continue + } + + value := strings.TrimSpace(decodeCosmosEventValue(attr.Value)) + if value == "" { + continue + } + + coins := parseCosmosCoins(value) + if len(coins) == 0 { + continue + } + return pickCosmosFeeCoin(coins, nativeDenom), true + } + } + return cosmosCoin{}, false +} + func extractCosmosFeeFromEvent(events []cosmos.Event, eventType, feeKey, nativeDenom string) (decimal.Decimal, bool) { for _, event := range events { if event.Type != eventType { diff --git a/internal/indexer/cosmos_test.go b/internal/indexer/cosmos_test.go index 179b90d..2a81464 100644 --- a/internal/indexer/cosmos_test.go +++ b/internal/indexer/cosmos_test.go @@ -112,6 +112,240 @@ func TestCosmosConvertBlock_ParsesTransfersAndFee(t *testing.T) { assert.Equal(t, "0", tokenTx.TxFee.String()) } +func TestCosmosConvertBlock_DoesNotEmitFeeAsSeparateTransaction(t *testing.T) { + txPayload := []byte("tx-fee-filter") + txEncoded := base64.StdEncoding.EncodeToString(txPayload) + + idx := &CosmosIndexer{ + chainName: "cosmos_hub_testnet", + config: config.ChainConfig{ + NetworkId: "cosmos_hub_testnet", + NativeDenom: "uatom", + }, + } + + blockData := &cosmos.BlockResponse{ + BlockID: cosmos.BlockID{Hash: "BLOCK_HASH"}, + Block: cosmos.Block{ + Header: cosmos.BlockHeader{ + Height: "101", + Time: "2026-03-26T07:41:00Z", + LastBlockID: cosmos.LastBlockID{ + Hash: "PARENT_HASH", + }, + }, + Data: cosmos.BlockData{ + Txs: []string{txEncoded}, + }, + }, + } + + blockResults := &cosmos.BlockResultsResponse{ + Height: "101", + TxsResults: []cosmos.TxResult{ + { + Code: 0, + Events: []cosmos.Event{ + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1feecollector"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3691uatom"}, + }, + }, + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1recipient"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3968617uatom"}, + {Key: "msg_index", Value: "0"}, + }, + }, + { + Type: "fee_pay", + Attributes: []cosmos.EventAttribute{ + {Key: "fee", Value: "3691uatom"}, + }, + }, + }, + }, + }, + } + + block, err := idx.convertBlock(blockData, blockResults) + require.NoError(t, err) + require.NotNil(t, block) + require.Len(t, block.Transactions, 1) + + tx := block.Transactions[0] + assert.Equal(t, "cosmos1sender", tx.FromAddress) + assert.Equal(t, "cosmos1recipient", tx.ToAddress) + assert.Equal(t, "3968617", tx.Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, tx.Type) + assert.Equal(t, "0.003691", tx.TxFee.String()) +} + +func TestCosmosConvertBlock_DoesNotEmitTipAsSeparateTransaction(t *testing.T) { + txPayload := []byte("tx-tip-filter") + txEncoded := base64.StdEncoding.EncodeToString(txPayload) + + idx := &CosmosIndexer{ + chainName: "cosmos_hub_testnet", + config: config.ChainConfig{ + NetworkId: "cosmos_hub_testnet", + NativeDenom: "uatom", + TwoWayIndexing: true, + }, + } + + blockData := &cosmos.BlockResponse{ + BlockID: cosmos.BlockID{Hash: "BLOCK_HASH"}, + Block: cosmos.Block{ + Header: cosmos.BlockHeader{ + Height: "102", + Time: "2026-03-26T07:41:00Z", + LastBlockID: cosmos.LastBlockID{ + Hash: "PARENT_HASH", + }, + }, + Data: cosmos.BlockData{ + Txs: []string{txEncoded}, + }, + }, + } + + blockResults := &cosmos.BlockResultsResponse{ + Height: "102", + TxsResults: []cosmos.TxResult{ + { + Code: 0, + Events: []cosmos.Event{ + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1tipcollector"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3209uatom"}, + }, + }, + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1recipient"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3968617uatom"}, + {Key: "msg_index", Value: "0"}, + }, + }, + { + Type: "tip_pay", + Attributes: []cosmos.EventAttribute{ + {Key: "tip", Value: "3209uatom"}, + {Key: "tip_payee", Value: "cosmos1tipcollector"}, + }, + }, + }, + }, + }, + } + + block, err := idx.convertBlock(blockData, blockResults) + require.NoError(t, err) + require.NotNil(t, block) + require.Len(t, block.Transactions, 1) + + tx := block.Transactions[0] + assert.Equal(t, "cosmos1sender", tx.FromAddress) + assert.Equal(t, "cosmos1recipient", tx.ToAddress) + assert.Equal(t, "3968617", tx.Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, tx.Type) +} + +func TestCosmosConvertBlock_DoesNotEmitCombinedFeeAndTipAsSeparateTransaction(t *testing.T) { + txPayload := []byte("tx-fee-tip-filter") + txEncoded := base64.StdEncoding.EncodeToString(txPayload) + + idx := &CosmosIndexer{ + chainName: "cosmos_hub_testnet", + config: config.ChainConfig{ + NetworkId: "cosmos_hub_testnet", + NativeDenom: "uatom", + TwoWayIndexing: true, + }, + } + + blockData := &cosmos.BlockResponse{ + BlockID: cosmos.BlockID{Hash: "BLOCK_HASH"}, + Block: cosmos.Block{ + Header: cosmos.BlockHeader{ + Height: "103", + Time: "2026-03-26T07:41:00Z", + LastBlockID: cosmos.LastBlockID{ + Hash: "PARENT_HASH", + }, + }, + Data: cosmos.BlockData{ + Txs: []string{txEncoded}, + }, + }, + } + + blockResults := &cosmos.BlockResultsResponse{ + Height: "103", + TxsResults: []cosmos.TxResult{ + { + Code: 0, + Events: []cosmos.Event{ + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1tippayer"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3691uatom"}, + }, + }, + { + Type: "transfer", + Attributes: []cosmos.EventAttribute{ + {Key: "recipient", Value: "cosmos1recipient"}, + {Key: "sender", Value: "cosmos1sender"}, + {Key: "amount", Value: "3968617uatom"}, + {Key: "msg_index", Value: "0"}, + }, + }, + { + Type: "fee_pay", + Attributes: []cosmos.EventAttribute{ + {Key: "fee", Value: "482uatom"}, + }, + }, + { + Type: "tip_pay", + Attributes: []cosmos.EventAttribute{ + {Key: "tip", Value: "3209uatom"}, + {Key: "tip_payee", Value: "cosmos1tipcollector"}, + }, + }, + }, + }, + }, + } + + block, err := idx.convertBlock(blockData, blockResults) + require.NoError(t, err) + require.NotNil(t, block) + require.Len(t, block.Transactions, 1) + + tx := block.Transactions[0] + assert.Equal(t, "cosmos1sender", tx.FromAddress) + assert.Equal(t, "cosmos1recipient", tx.ToAddress) + assert.Equal(t, "3968617", tx.Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, tx.Type) + assert.Equal(t, "0.000482", tx.TxFee.String()) +} + func TestExtractCosmosTransfers_PlainEventAttributes(t *testing.T) { events := []cosmos.Event{ { @@ -1127,59 +1361,12 @@ func TestCosmosConvertBlock_ParsesBatchTransferEvent(t *testing.T) { assert.True(t, third.TxFee.IsZero()) } -func TestCosmosMainnetFetchAndParseTransactions(t *testing.T) { +func TestCosmosFetchAndParseTransactions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - type cosmosRealTxCase struct { - name string - rpcURL string - height uint64 - txHash string - chainName string - networkID string - nativeDenom string - wantType constant.TxType - } - - // Fill these with real Cosmos mainnet transactions. - // The parser currently works block-first, so each case needs: - // - rpcURL: chain RPC endpoint - // - height: exact block height containing the tx - // - txHash: expected tx hash inside the parsed block output - testCases := []cosmosRealTxCase{ - { - name: "cosmoshub native transfer", - rpcURL: "https://cosmos-rpc.publicnode.com", - height: 30296492, - txHash: "10C90080AF071736C71F55130D672CB885DAC12963C4319D005420EF580706B7", - chainName: "cosmoshub_mainnet", - networkID: "cosmoshub-4", - nativeDenom: "uatom", - wantType: constant.TxTypeNativeTransfer, - }, - { - name: "osmosis ibc or token transfer", - rpcURL: "https://cosmos-rpc.publicnode.com", - chainName: "cosmoshub_mainnet", - height: 30296490, - txHash: "A3264C9CA23F78669D4EF6C0ADC3178AF08F9B74E555B1E0480A716FF264B74E", - networkID: "cosmoshub-4", - nativeDenom: "uatom", - wantType: constant.TxTypeTokenTransfer, - }, - { - name: "recv packet transfer", - rpcURL: "https://cosmos-rpc.publicnode.com", - chainName: "cosmoshub_mainnet", - height: 30297381, - txHash: "69F40A5F4E9BD58D5A5F30DBC4C53086D9080E703E77A44543807978207706B8", - networkID: "cosmoshub-4", - nativeDenom: "uatom", - wantType: constant.TxTypeTokenTransfer, - }, - } + testCases := cosmosRealTxCases() enabled := false for _, tc := range testCases { @@ -1189,7 +1376,7 @@ func TestCosmosMainnetFetchAndParseTransactions(t *testing.T) { } } if !enabled { - t.Skip("hardcoded real mainnet test cases are empty") + t.Skip("hardcoded real Cosmos RPC test cases are empty") } for _, tc := range testCases { @@ -1212,11 +1399,11 @@ func TestCosmosMainnetFetchAndParseTransactions(t *testing.T) { } blockData, err := client.GetBlock(ctx, tc.height) - require.NoError(t, err, "should fetch real mainnet block %d", tc.height) + require.NoError(t, err, "should fetch real Cosmos block %d", tc.height) require.NotNil(t, blockData) blockResults, err := client.GetBlockResults(ctx, tc.height) - require.NoError(t, err, "should fetch real mainnet block_results %d", tc.height) + require.NoError(t, err, "should fetch real Cosmos block_results %d", tc.height) require.NotNil(t, blockResults) block, err := idx.convertBlock(blockData, blockResults) @@ -1250,6 +1437,195 @@ func TestCosmosMainnetFetchAndParseTransactions(t *testing.T) { } } +type cosmosRealTxCase struct { + name string + rpcURL string + height uint64 + txHash string + chainName string + networkID string + nativeDenom string + wantType constant.TxType +} + +func cosmosRealTxCases() []cosmosRealTxCase { + return []cosmosRealTxCase{ + { + name: "cosmoshub native transfer", + rpcURL: "https://cosmos-rpc.publicnode.com", + height: 30296492, + txHash: "10C90080AF071736C71F55130D672CB885DAC12963C4319D005420EF580706B7", + chainName: "cosmoshub_mainnet", + networkID: "cosmoshub-4", + nativeDenom: "uatom", + wantType: constant.TxTypeNativeTransfer, + }, + { + name: "osmosis ibc or token transfer", + rpcURL: "https://cosmos-rpc.publicnode.com", + chainName: "cosmoshub_mainnet", + height: 30296490, + txHash: "A3264C9CA23F78669D4EF6C0ADC3178AF08F9B74E555B1E0480A716FF264B74E", + networkID: "cosmoshub-4", + nativeDenom: "uatom", + wantType: constant.TxTypeTokenTransfer, + }, + { + name: "recv packet transfer", + rpcURL: "https://cosmos-rpc.publicnode.com", + chainName: "cosmoshub_mainnet", + height: 30297381, + txHash: "69F40A5F4E9BD58D5A5F30DBC4C53086D9080E703E77A44543807978207706B8", + networkID: "cosmoshub-4", + nativeDenom: "uatom", + wantType: constant.TxTypeTokenTransfer, + }, + { + name: "cosmoshub testnet native transfer", + rpcURL: "https://cosmos-testnet-rpc.itrocket.net", + height: 16594459, + txHash: "FCC7183F43F150CC5B01E9F79FF3BB30B04153944039DB5AD428B9F8979789AA", + chainName: "cosmos_hub_testnet", + networkID: "cosmos_hub_testnet", + nativeDenom: "uatom", + wantType: constant.TxTypeNativeTransfer, + }, + } +} + +func TestCosmosRealTx_SemanticTransactionCounts(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + for _, tc := range cosmosRealTxCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := cosmos.NewCosmosClient(tc.rpcURL, nil, 15*time.Second, nil) + blockData, err := client.GetBlock(ctx, tc.height) + require.NoError(t, err) + blockResults, err := client.GetBlockResults(ctx, tc.height) + require.NoError(t, err) + + idx := &CosmosIndexer{ + chainName: tc.chainName, + config: config.ChainConfig{ + NetworkId: tc.networkID, + NativeDenom: tc.nativeDenom, + }, + } + + block, err := idx.convertBlock(blockData, blockResults) + require.NoError(t, err) + + count := 0 + for _, item := range block.Transactions { + if item.TxHash == tc.txHash { + count++ + t.Logf( + "semantic tx %d: type=%s from=%s to=%s amount=%s asset=%s fee=%s", + count, + item.Type, + item.FromAddress, + item.ToAddress, + item.Amount, + item.AssetAddress, + item.TxFee.String(), + ) + } + } + + t.Logf("tx_hash=%s semantic_tx_count=%d", tc.txHash, count) + require.Greater(t, count, 0) + }) + } +} + +func TestCosmosRealTx_ExtractTransfersForFeeCase(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := cosmos.NewCosmosClient("https://cosmos-testnet-rpc.itrocket.net", nil, 15*time.Second, nil) + tx, err := client.GetTxByHash(ctx, "FCC7183F43F150CC5B01E9F79FF3BB30B04153944039DB5AD428B9F8979789AA") + require.NoError(t, err) + require.NotNil(t, tx) + + transfers := extractCosmosTransfers(tx.TxResult.Events) + require.Len(t, transfers, 3) + + assert.Equal(t, "cosmos16cdsc4vsd3rezqqt93qt5mx7prqcvgff4m02lt", transfers[0].sender) + assert.Equal(t, "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", transfers[0].recipient) + assert.Equal(t, "3691", transfers[0].amount) + assert.Equal(t, "uatom", transfers[0].denom) + + assert.Equal(t, "cosmos16cdsc4vsd3rezqqt93qt5mx7prqcvgff4m02lt", transfers[1].sender) + assert.Equal(t, "cosmos1gxunc46sgwklqtactqvkv9z3kc7mzeqztq0kls", transfers[1].recipient) + assert.Equal(t, "3968617", transfers[1].amount) + assert.Equal(t, "uatom", transfers[1].denom) + + assert.Equal(t, "cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye", transfers[2].sender) + assert.Equal(t, "cosmos146zd98kguwau7y3mfrrs9k4fsthv9qct5u26xa", transfers[2].recipient) + assert.Equal(t, "3209", transfers[2].amount) + assert.Equal(t, "uatom", transfers[2].denom) +} + +func TestCosmosRealTx_ConvertBlockReturnsSingleSemanticTransfer(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := cosmos.NewCosmosClient("https://cosmos-testnet-rpc.itrocket.net", nil, 15*time.Second, nil) + blockData, err := client.GetBlock(ctx, 16594459) + require.NoError(t, err) + require.NotNil(t, blockData) + + blockResults, err := client.GetBlockResults(ctx, 16594459) + require.NoError(t, err) + require.NotNil(t, blockResults) + + idx := &CosmosIndexer{ + chainName: "cosmos_hub_testnet", + config: config.ChainConfig{ + NetworkId: "cosmos_hub_testnet", + NativeDenom: "uatom", + TwoWayIndexing: true, + }, + pubkeyStore: mockCosmosPubkeyStore{ + addresses: map[string]struct{}{ + "cosmos16cdsc4vsd3rezqqt93qt5mx7prqcvgff4m02lt": {}, + }, + }, + } + + block, err := idx.convertBlock(blockData, blockResults) + require.NoError(t, err) + require.NotNil(t, block) + + var matches []types.Transaction + for _, tx := range block.Transactions { + if tx.TxHash == "FCC7183F43F150CC5B01E9F79FF3BB30B04153944039DB5AD428B9F8979789AA" { + matches = append(matches, tx) + } + } + + require.Len(t, matches, 1) + assert.Equal(t, "cosmos16cdsc4vsd3rezqqt93qt5mx7prqcvgff4m02lt", matches[0].FromAddress) + assert.Equal(t, "cosmos1gxunc46sgwklqtactqvkv9z3kc7mzeqztq0kls", matches[0].ToAddress) + assert.Equal(t, "3968617", matches[0].Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, matches[0].Type) + assert.Equal(t, "0.000482", matches[0].TxFee.String()) +} + func b64(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) }