From 61762b7449a3f3f97e84a6874a00223ebb4b9859 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 26 Mar 2026 16:18:43 +0700 Subject: [PATCH] feat: enhance Aptos transaction parsing with support for batch transfers, event movements, and block version retrieval --- internal/indexer/aptos.go | 564 ++++++++++++++++++++++++++++--- internal/indexer/aptos_test.go | 586 +++++++++++++++++++++++++++++++-- internal/rpc/aptos/client.go | 38 +++ internal/rpc/aptos/types.go | 27 ++ 4 files changed, 1147 insertions(+), 68 deletions(-) diff --git a/internal/indexer/aptos.go b/internal/indexer/aptos.go index 0f322d7..617cf87 100644 --- a/internal/indexer/aptos.go +++ b/internal/indexer/aptos.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math/big" + "sort" "strconv" "strings" "sync" @@ -24,12 +25,22 @@ const ( aptosEntryFunctionPayload = "entry_function_payload" - aptosFnTransfer = "0x1::aptos_account::transfer" - aptosFnTransferCoins = "0x1::aptos_account::transfer_coins" - aptosFnTransferFA = "0x1::aptos_account::transfer_fungible_assets" - aptosFnCoinTransfer = "0x1::coin::transfer" - - aptosNativeTypeTag = "0x1::aptos_coin::aptoscoin" + aptosFnTransfer = "0x1::aptos_account::transfer" + aptosFnTransferCoins = "0x1::aptos_account::transfer_coins" + aptosFnBatchTransferCoins = "0x1::aptos_account::batch_transfer_coins" + aptosFnTransferFA = "0x1::aptos_account::transfer_fungible_assets" + aptosFnCoinTransfer = "0x1::coin::transfer" + + aptosNativeTypeTag = "0x1::aptos_coin::aptoscoin" + aptosNativeMetadataAddress = "0xa" + + aptosCoinDepositEventType = "0x1::coin::depositevent" + aptosCoinWithdrawEventType = "0x1::coin::withdrawevent" + aptosFADepositEventType = "0x1::fungible_asset::deposit" + aptosFAWithdrawEventType = "0x1::fungible_asset::withdraw" + aptosCoinStoreResourceType = "0x1::coin::coinstore" + aptosFStoreResourceType = "0x1::fungible_asset::fungiblestore" + aptosObjectCoreResourceType = "0x1::object::objectcore" ) type AptosIndexer struct { @@ -39,6 +50,30 @@ type AptosIndexer struct { pubkeyStore PubkeyStore } +type aptosAssetInfo struct { + TxType constant.TxType + AssetAddress string +} + +type aptosEventMovement struct { + IsWithdraw bool + Address string + Amount *big.Int + SequenceNumber string + EventIndex int + Asset aptosAssetInfo +} + +type aptosMovementGroup struct { + Withdrawals []aptosEventMovement + Deposits []aptosEventMovement +} + +type aptosTransferCandidate struct { + Tx types.Transaction + OrderIndex int +} + func NewAptosIndexer( chainName string, cfg config.ChainConfig, @@ -216,15 +251,20 @@ func (a *AptosIndexer) convertBlock( blockTs := parseAptosTimestamp(blockData.BlockTimestamp, 0) txs := make([]types.Transaction, 0, len(blockData.Transactions)) - for _, tx := range blockData.Transactions { - parsed, ok := a.extractTransfer(tx, blockHeight, blockTs) - if !ok { - continue - } - if !a.isMonitoredTransfer(parsed.FromAddress, parsed.ToAddress) { - continue + for txIndex, tx := range blockData.Transactions { + parsedTransfers := a.extractTransfers(tx, blockHeight, blockTs) + for transferIndex, parsed := range parsedTransfers { + parsed.BlockHash = blockData.BlockHash + parsed.TransferIndex = formatAptosTransferIndex( + txIndex, + transferIndex, + parsed.TransferIndex, + ) + if !a.isMonitoredTransfer(parsed.FromAddress, parsed.ToAddress) { + continue + } + txs = append(txs, parsed) } - txs = append(txs, parsed) } return &types.Block{ @@ -236,51 +276,487 @@ func (a *AptosIndexer) convertBlock( }, nil } -func (a *AptosIndexer) extractTransfer( +func (a *AptosIndexer) extractTransfers( tx aptos.Transaction, blockHeight, blockTs uint64, -) (types.Transaction, bool) { +) []types.Transaction { if strings.ToLower(strings.TrimSpace(tx.Type)) != "user_transaction" { - return types.Transaction{}, false + return nil } if !tx.Success { - return types.Transaction{}, false + return nil } - if tx.Payload == nil || strings.ToLower(strings.TrimSpace(tx.Payload.Type)) != aptosEntryFunctionPayload { - return types.Transaction{}, false + + movements := extractAptosEventMovements(tx) + if len(movements) == 0 { + return nil } - function := normalizeAptosFunction(tx.Payload.Function) - if !isAptosTransferFunction(function) { - return types.Transaction{}, false + timestamp := parseAptosTimestamp(tx.Timestamp, blockTs) + fee := convertAptosFeeToNative(tx.GasUsed, tx.GasUnitPrice) + baseTx := types.Transaction{ + TxHash: tx.Hash, + NetworkId: a.config.NetworkId, + BlockNumber: blockHeight, + TxFee: fee, + Timestamp: timestamp, + } + + return buildAptosTransfersFromMovements(baseTx, movements) +} + +func extractAptosEventMovements(tx aptos.Transaction) []aptosEventMovement { + coinEventStreams := parseAptosCoinEventStreams(tx.Changes) + storeOwners, storeMetadata := parseAptosFungibleStoreIndexes(tx.Changes) + fallbackAsset, hasFallbackAsset := inferAptosAssetFromPayload(tx.Payload) + + movements := make([]aptosEventMovement, 0, len(tx.Events)) + for idx, evt := range tx.Events { + eventType := normalizeAptosTypeTag(evt.Type) + switch eventType { + case aptosCoinDepositEventType, aptosCoinWithdrawEventType: + account := normalizeAptosAddress(evt.GUID.AccountAddress) + if account == "" { + continue + } + amount, ok := parseAptosEventAmount(evt.Data) + if !ok { + continue + } + + asset := coinEventStreams[coinEventStreamKey(account, evt.GUID.CreationNumber)] + if asset.TxType == "" && hasFallbackAsset { + asset = fallbackAsset + } + if asset.TxType == "" { + asset = aptosAssetInfo{TxType: constant.TxTypeNativeTransfer} + } + + movements = append(movements, aptosEventMovement{ + IsWithdraw: eventType == aptosCoinWithdrawEventType, + Address: account, + Amount: amount, + SequenceNumber: strings.TrimSpace(evt.SequenceNumber), + EventIndex: idx, + Asset: normalizeAptosAsset(asset), + }) + + case aptosFADepositEventType, aptosFAWithdrawEventType: + store, ok := parseAptosEventAddressField(evt.Data, "store") + if !ok { + continue + } + + owner := storeOwners[store] + if owner == "" { + owner = store + } + amount, ok := parseAptosEventAmount(evt.Data) + if !ok { + continue + } + + asset := aptosAssetInfo{ + TxType: constant.TxTypeTokenTransfer, + AssetAddress: storeMetadata[store], + } + if asset.AssetAddress == "" && hasFallbackAsset { + asset = fallbackAsset + } + + movements = append(movements, aptosEventMovement{ + IsWithdraw: eventType == aptosFAWithdrawEventType, + Address: owner, + Amount: amount, + SequenceNumber: strings.TrimSpace(evt.SequenceNumber), + EventIndex: idx, + Asset: normalizeAptosAsset(asset), + }) + } + } + return movements +} + +func buildAptosTransfersFromMovements(baseTx types.Transaction, movements []aptosEventMovement) []types.Transaction { + if len(movements) == 0 { + return nil + } + + groups := make(map[string]*aptosMovementGroup) + for _, movement := range movements { + if movement.Amount == nil || movement.Amount.Sign() <= 0 { + continue + } + if movement.Address == "" { + continue + } + + movement.Asset = normalizeAptosAsset(movement.Asset) + key := aptosMovementGroupKey(movement.Asset) + group := groups[key] + if group == nil { + group = &aptosMovementGroup{} + groups[key] = group + } + + if movement.IsWithdraw { + group.Withdrawals = append(group.Withdrawals, movement) + } else { + group.Deposits = append(group.Deposits, movement) + } + } + + candidates := make([]aptosTransferCandidate, 0) + for _, group := range groups { + if len(group.Withdrawals) == 0 || len(group.Deposits) == 0 { + continue + } + + sort.SliceStable(group.Withdrawals, func(i, j int) bool { + return group.Withdrawals[i].EventIndex < group.Withdrawals[j].EventIndex + }) + sort.SliceStable(group.Deposits, func(i, j int) bool { + return group.Deposits[i].EventIndex < group.Deposits[j].EventIndex + }) + + withdrawals := cloneAptosMovements(group.Withdrawals) + deposits := cloneAptosMovements(group.Deposits) + + wIdx, dIdx := 0, 0 + for wIdx < len(withdrawals) && dIdx < len(deposits) { + withdraw := &withdrawals[wIdx] + deposit := &deposits[dIdx] + if withdraw.Amount.Sign() <= 0 { + wIdx++ + continue + } + if deposit.Amount.Sign() <= 0 { + dIdx++ + continue + } + + amount := minAptosAmount(withdraw.Amount, deposit.Amount) + transfer := baseTx + transfer.FromAddress = withdraw.Address + transfer.ToAddress = deposit.Address + transfer.Amount = amount.String() + transfer.Type = withdraw.Asset.TxType + transfer.AssetAddress = withdraw.Asset.AssetAddress + transfer.TransferIndex = buildAptosTransferIndex(*withdraw, *deposit) + transfer.AssetAddress = normalizeAssetAddressForTxType(transfer.Type, transfer.AssetAddress) + + candidates = append(candidates, aptosTransferCandidate{ + Tx: transfer, + OrderIndex: min(withdraw.EventIndex, deposit.EventIndex), + }) + + withdraw.Amount.Sub(withdraw.Amount, amount) + deposit.Amount.Sub(deposit.Amount, amount) + if withdraw.Amount.Sign() == 0 { + wIdx++ + } + if deposit.Amount.Sign() == 0 { + dIdx++ + } + } + } + + if len(candidates) == 0 { + return nil + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].OrderIndex != candidates[j].OrderIndex { + return candidates[i].OrderIndex < candidates[j].OrderIndex + } + return candidates[i].Tx.TransferIndex < candidates[j].Tx.TransferIndex + }) + + txs := make([]types.Transaction, 0, len(candidates)) + for _, candidate := range candidates { + txs = append(txs, candidate.Tx) + } + return txs +} + +func cloneAptosMovements(src []aptosEventMovement) []aptosEventMovement { + out := make([]aptosEventMovement, len(src)) + for i := range src { + out[i] = src[i] + if src[i].Amount != nil { + out[i].Amount = new(big.Int).Set(src[i].Amount) + } + } + return out +} + +func minAptosAmount(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} + +func buildAptosTransferIndex(withdraw, deposit aptosEventMovement) string { + withdrawSeq := strings.TrimSpace(withdraw.SequenceNumber) + depositSeq := strings.TrimSpace(deposit.SequenceNumber) + if withdrawSeq == "" { + withdrawSeq = "na" + } + if depositSeq == "" { + depositSeq = "na" + } + return fmt.Sprintf( + "seq:%s:%s:%d:%d", + withdrawSeq, + depositSeq, + withdraw.EventIndex, + deposit.EventIndex, + ) +} + +func parseAptosCoinEventStreams(changes []aptos.WriteSetChange) map[string]aptosAssetInfo { + streams := make(map[string]aptosAssetInfo) + for _, change := range changes { + if strings.ToLower(strings.TrimSpace(change.Type)) != "write_resource" || change.Data == nil { + continue + } + + coinType, ok := parseAptosCoinTypeFromResourceType(change.Data.Type) + if !ok { + continue + } + + account := normalizeAptosAddress(change.Address) + if account == "" { + continue + } + + asset := classifyAptosCoinType(coinType) + if creation, ok := parseAptosEventHandleCreationNumber(change.Data.Data, "deposit_events"); ok { + streams[coinEventStreamKey(account, creation)] = asset + } + if creation, ok := parseAptosEventHandleCreationNumber(change.Data.Data, "withdraw_events"); ok { + streams[coinEventStreamKey(account, creation)] = asset + } + } + return streams +} + +func parseAptosFungibleStoreIndexes(changes []aptos.WriteSetChange) (map[string]string, map[string]string) { + storeOwners := make(map[string]string) + storeMetadata := make(map[string]string) + + for _, change := range changes { + if strings.ToLower(strings.TrimSpace(change.Type)) != "write_resource" || change.Data == nil { + continue + } + resourceType := normalizeAptosTypeTag(change.Data.Type) + address := normalizeAptosAddress(change.Address) + if address == "" { + continue + } + + switch resourceType { + case aptosObjectCoreResourceType: + if owner, ok := parseAptosAddressField(change.Data.Data, "owner"); ok { + storeOwners[address] = owner + } + case aptosFStoreResourceType: + if metadata, ok := parseAptosAddressLikeField(change.Data.Data, "metadata"); ok { + storeMetadata[address] = metadata + } + } + } + + return storeOwners, storeMetadata +} + +func parseAptosCoinTypeFromResourceType(resourceType string) (string, bool) { + resourceType = normalizeAptosTypeTag(resourceType) + prefix := aptosCoinStoreResourceType + "<" + if !strings.HasPrefix(resourceType, prefix) || !strings.HasSuffix(resourceType, ">") { + return "", false } - toAddress, amount, faMetadataAddress, ok := parseAptosTransferArgs(function, tx.Payload.Arguments) + + coinType := strings.TrimSpace( + strings.TrimSuffix(strings.TrimPrefix(resourceType, prefix), ">"), + ) + if coinType == "" { + return "", false + } + return normalizeAptosTypeTag(coinType), true +} + +func parseAptosEventHandleCreationNumber(data map[string]json.RawMessage, field string) (string, bool) { + raw, ok := data[field] if !ok { - return types.Transaction{}, false + return "", false } - fromAddress := normalizeAptosAddress(tx.Sender) - toAddress = normalizeAptosAddress(toAddress) - if fromAddress == "" || toAddress == "" { - return types.Transaction{}, false + var handle map[string]json.RawMessage + if err := json.Unmarshal(raw, &handle); err != nil { + return "", false } - txType, assetAddress := classifyAptosTransfer(function, tx.Payload.TypeArguments, faMetadataAddress) - timestamp := parseAptosTimestamp(tx.Timestamp, blockTs) - fee := convertAptosFeeToNative(tx.GasUsed, tx.GasUnitPrice) + if guidRaw, ok := handle["guid"]; ok { + var guid map[string]json.RawMessage + if err := json.Unmarshal(guidRaw, &guid); err == nil { + if idRaw, ok := guid["id"]; ok { + var id map[string]json.RawMessage + if err := json.Unmarshal(idRaw, &id); err == nil { + if creation, ok := parseAptosCreationNumber(id); ok { + return creation, true + } + } + } + if creation, ok := parseAptosCreationNumber(guid); ok { + return creation, true + } + } + } + + return parseAptosCreationNumber(handle) +} + +func parseAptosCreationNumber(obj map[string]json.RawMessage) (string, bool) { + for _, key := range []string{"creation_num", "creation_number"} { + raw, ok := obj[key] + if !ok { + continue + } + if creation, ok := parseAptosAmountArg(raw); ok { + return creation, true + } + } + return "", false +} - return types.Transaction{ - TxHash: tx.Hash, - NetworkId: a.config.NetworkId, - BlockNumber: blockHeight, - FromAddress: fromAddress, - ToAddress: toAddress, +func coinEventStreamKey(account, creationNumber string) string { + return normalizeAptosAddress(account) + "|" + strings.TrimSpace(creationNumber) +} + +func parseAptosEventAddressField(data aptos.EventData, key string) (string, bool) { + raw, ok := data[key] + if !ok { + return "", false + } + return parseAptosAddressLikeArg(raw) +} + +func parseAptosEventAmount(data aptos.EventData) (*big.Int, bool) { + raw, ok := data["amount"] + if !ok { + return nil, false + } + amount, ok := parseAptosAmountArg(raw) + if !ok { + return nil, false + } + value, ok := parseBigInt(amount) + if !ok || value.Sign() <= 0 { + return nil, false + } + return value, true +} + +func parseAptosAddressField(data map[string]json.RawMessage, key string) (string, bool) { + raw, ok := data[key] + if !ok { + return "", false + } + return parseAptosAddressArg(raw) +} + +func parseAptosAddressLikeField(data map[string]json.RawMessage, key string) (string, bool) { + raw, ok := data[key] + if !ok { + return "", false + } + return parseAptosAddressLikeArg(raw) +} + +func inferAptosAssetFromPayload(payload *aptos.TransactionPayload) (aptosAssetInfo, bool) { + if payload == nil || strings.ToLower(strings.TrimSpace(payload.Type)) != aptosEntryFunctionPayload { + return aptosAssetInfo{}, false + } + + function := normalizeAptosFunction(payload.Function) + if !isAptosTransferFunction(function) { + return aptosAssetInfo{}, false + } + + var faMetadataAddress string + if function == aptosFnTransferFA { + var ok bool + _, _, faMetadataAddress, ok = parseAptosTransferArgs(function, payload.Arguments) + if !ok { + return aptosAssetInfo{}, false + } + } + + txType, assetAddress := classifyAptosTransfer(function, payload.TypeArguments, faMetadataAddress) + return normalizeAptosAsset(aptosAssetInfo{ + TxType: txType, AssetAddress: assetAddress, - Amount: amount, - Type: txType, - TxFee: fee, - Timestamp: timestamp, - }, true + }), true +} + +func classifyAptosCoinType(coinType string) aptosAssetInfo { + coinType = normalizeAptosTypeTag(coinType) + if coinType == "" || coinType == aptosNativeTypeTag { + return aptosAssetInfo{ + TxType: constant.TxTypeNativeTransfer, + } + } + return aptosAssetInfo{ + TxType: constant.TxTypeTokenTransfer, + AssetAddress: coinType, + } +} + +func normalizeAptosAsset(asset aptosAssetInfo) aptosAssetInfo { + if asset.TxType == "" { + if strings.TrimSpace(asset.AssetAddress) == "" { + asset.TxType = constant.TxTypeNativeTransfer + } else { + asset.TxType = constant.TxTypeTokenTransfer + } + } + + asset.AssetAddress = strings.TrimSpace(asset.AssetAddress) + if asset.AssetAddress != "" { + asset.AssetAddress = normalizeAptosTypeTag(asset.AssetAddress) + if normalizedAddress := normalizeAptosAddress(asset.AssetAddress); normalizedAddress != "" { + asset.AssetAddress = normalizedAddress + } + } + if asset.AssetAddress == aptosNativeMetadataAddress { + asset.TxType = constant.TxTypeNativeTransfer + } + if asset.TxType == constant.TxTypeNativeTransfer { + asset.AssetAddress = "" + } + return asset +} + +func aptosMovementGroupKey(asset aptosAssetInfo) string { + return string(asset.TxType) + "|" + asset.AssetAddress +} + +func normalizeAssetAddressForTxType(txType constant.TxType, assetAddress string) string { + if txType == constant.TxTypeNativeTransfer { + return "" + } + return strings.TrimSpace(assetAddress) +} + +func formatAptosTransferIndex(txIndex, transferIndex int, rawIndex string) string { + rawIndex = strings.TrimSpace(rawIndex) + if rawIndex == "" { + return fmt.Sprintf("%d:%d", txIndex, transferIndex) + } + return fmt.Sprintf("%d:%d:%s", txIndex, transferIndex, rawIndex) } func (a *AptosIndexer) isMonitoredAddress(address string) bool { @@ -320,7 +796,7 @@ func classifyAptosError(err error) ErrorType { func isAptosTransferFunction(function string) bool { switch function { - case aptosFnTransfer, aptosFnTransferCoins, aptosFnTransferFA, aptosFnCoinTransfer: + case aptosFnTransfer, aptosFnTransferCoins, aptosFnBatchTransferCoins, aptosFnTransferFA, aptosFnCoinTransfer: return true default: return false diff --git a/internal/indexer/aptos_test.go b/internal/indexer/aptos_test.go index aa3670a..32bba6b 100644 --- a/internal/indexer/aptos_test.go +++ b/internal/indexer/aptos_test.go @@ -1,13 +1,19 @@ package indexer import ( + "context" "encoding/json" + "fmt" + "strconv" + "strings" "testing" + "time" "github.com/fystack/multichain-indexer/internal/rpc/aptos" "github.com/fystack/multichain-indexer/pkg/common/config" "github.com/fystack/multichain-indexer/pkg/common/constant" "github.com/fystack/multichain-indexer/pkg/common/enum" + "github.com/fystack/multichain-indexer/pkg/common/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,11 +22,53 @@ type mockAptosPubkeyStore struct { addresses map[string]struct{} } +const aptosMainnetRPC = "https://fullnode.mainnet.aptoslabs.com" + func (m mockAptosPubkeyStore) Exist(_ enum.NetworkType, address string) bool { _, ok := m.addresses[address] return ok } +type aptosRealTransferFixture struct { + name string + version uint64 + txHash string + wantFee string + wantTransfers []aptosRealTransferOutput +} + +type aptosRealTransferOutput struct { + txType constant.TxType + from string + to string + amount string + asset string + rawTransferIndex string +} + +func newTestAptosClient() *aptos.Client { + return aptos.NewAptosClient(aptosMainnetRPC, nil, 30*time.Second, nil) +} + +func newTestAptosIndexer() *AptosIndexer { + return &AptosIndexer{ + chainName: "aptos_mainnet", + config: config.ChainConfig{ + NetworkId: "aptos_mainnet", + }, + } +} + +func TestNormalizeAptosAsset_TreatsNativeMetadataAddressAsNative(t *testing.T) { + asset := normalizeAptosAsset(aptosAssetInfo{ + TxType: constant.TxTypeTokenTransfer, + AssetAddress: "0xA", + }) + + assert.Equal(t, constant.TxTypeNativeTransfer, asset.TxType) + assert.Empty(t, asset.AssetAddress) +} + func TestConvertAptosBlock_ParsesNativeTransferAndConvertsFeeToNative(t *testing.T) { idx := &AptosIndexer{ chainName: "aptos_mainnet", @@ -42,13 +90,27 @@ func TestConvertAptosBlock_ParsesNativeTransferAndConvertsFeeToNative(t *testing Sender: "0x00000000000000000000000000000000000000000000000000000000000A11CE", GasUsed: "5000", GasUnitPrice: "120", - Payload: &aptos.TransactionPayload{ - Type: "entry_function_payload", - Function: "0x1::aptos_account::transfer", - Arguments: []json.RawMessage{ - mustRawJSON(t, "0x000000000000000000000000000000000000000000000000000000000000B0B"), - mustRawJSON(t, "1000000"), - }, + Events: []aptos.Event{ + coinEvent( + t, + "0x00000000000000000000000000000000000000000000000000000000000A11CE", + "9", + "11", + "0x1::coin::WithdrawEvent", + "1000000", + ), + coinEvent( + t, + "0x0000000000000000000000000000000000000000000000000000000000000B0B", + "8", + "19", + "0x1::coin::DepositEvent", + "1000000", + ), + }, + Changes: []aptos.WriteSetChange{ + coinStoreChange(t, "0xa11ce", "0x1::aptos_coin::AptosCoin", "2", "9"), + coinStoreChange(t, "0xb0b", "0x1::aptos_coin::AptosCoin", "8", "3"), }, }, }, @@ -67,11 +129,13 @@ func TestConvertAptosBlock_ParsesNativeTransferAndConvertsFeeToNative(t *testing assert.Equal(t, "1000000", tx.Amount) assert.Equal(t, constant.TxTypeNativeTransfer, tx.Type) assert.Equal(t, "", tx.AssetAddress) + assert.Equal(t, "0xblockhash", tx.BlockHash) assert.Equal(t, "0.006", tx.TxFee.String()) assert.Equal(t, uint64(1735689600), tx.Timestamp) + assert.Equal(t, "0:0:seq:11:19:0:1", tx.TransferIndex) } -func TestConvertAptosBlock_ParsesTokenTransfer(t *testing.T) { +func TestConvertAptosBlock_ParsesCoinTransferFromScriptPayload(t *testing.T) { idx := &AptosIndexer{ chainName: "aptos_mainnet", config: config.ChainConfig{ @@ -93,13 +157,15 @@ func TestConvertAptosBlock_ParsesTokenTransfer(t *testing.T) { GasUsed: "12", GasUnitPrice: "100", Payload: &aptos.TransactionPayload{ - Type: "entry_function_payload", - Function: "0x1::coin::transfer", - TypeArguments: []string{"0xABCD::coin::USDC"}, - Arguments: []json.RawMessage{ - mustRawJSON(t, "0x2"), - mustRawJSON(t, "42"), - }, + Type: "script_payload", + }, + Events: []aptos.Event{ + coinEvent(t, "0x1", "3", "0", "0x1::coin::WithdrawEvent", "42"), + coinEvent(t, "0x2", "5", "1", "0x1::coin::DepositEvent", "42"), + }, + Changes: []aptos.WriteSetChange{ + coinStoreChange(t, "0x1", "0xABCD::coin::USDC", "2", "3"), + coinStoreChange(t, "0x2", "0xABCD::coin::USDC", "5", "4"), }, }, }, @@ -114,9 +180,10 @@ func TestConvertAptosBlock_ParsesTokenTransfer(t *testing.T) { assert.Equal(t, "0xabcd::coin::usdc", tx.AssetAddress) assert.Equal(t, "42", tx.Amount) assert.Equal(t, "0.000012", tx.TxFee.String()) + assert.Equal(t, "0:0:seq:0:1:0:1", tx.TransferIndex) } -func TestConvertAptosBlock_ParsesFungibleAssetTransfer(t *testing.T) { +func TestConvertAptosBlock_ParsesFungibleAssetTransferFromMultisigExecution(t *testing.T) { idx := &AptosIndexer{ chainName: "aptos_testnet", config: config.ChainConfig{ @@ -139,14 +206,17 @@ func TestConvertAptosBlock_ParsesFungibleAssetTransfer(t *testing.T) { GasUnitPrice: "100", Payload: &aptos.TransactionPayload{ Type: "entry_function_payload", - Function: "0x1::aptos_account::transfer_fungible_assets", - Arguments: []json.RawMessage{ - mustRawJSON(t, map[string]string{ - "inner": "0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832", - }), - mustRawJSON(t, "0xff26f441129a3727d21548cf080705700349b56e4ce616f07d80d87bb92bdb0c"), - mustRawJSON(t, "500000"), - }, + Function: "0x1::multisig_account::execute_transaction", + }, + Events: []aptos.Event{ + fungibleAssetEvent(t, "7", "0x1111", "0x1::fungible_asset::Withdraw", "500000"), + fungibleAssetEvent(t, "8", "0x2222", "0x1::fungible_asset::Deposit", "500000"), + }, + Changes: []aptos.WriteSetChange{ + objectCoreChange(t, "0x1111", "0x3a7936eefc38e9578a86d9c7e06f24360982fed60e0e79a78b51da001c91cee7"), + objectCoreChange(t, "0x2222", "0xff26f441129a3727d21548cf080705700349b56e4ce616f07d80d87bb92bdb0c"), + fungibleStoreChange(t, "0x1111", "0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832"), + fungibleStoreChange(t, "0x2222", "0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832"), }, }, }, @@ -163,6 +233,67 @@ func TestConvertAptosBlock_ParsesFungibleAssetTransfer(t *testing.T) { assert.Equal(t, constant.TxTypeTokenTransfer, tx.Type) assert.Equal(t, "0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832", tx.AssetAddress) assert.Equal(t, "0.000572", tx.TxFee.String()) + assert.Equal(t, "0:0:seq:7:8:0:1", tx.TransferIndex) +} + +func TestConvertAptosBlock_ParsesBatchTransfersAsMultipleMovements(t *testing.T) { + idx := &AptosIndexer{ + chainName: "aptos_mainnet", + config: config.ChainConfig{ + NetworkId: "aptos_mainnet", + }, + } + + blockData := &aptos.BlockResponse{ + BlockHeight: "44", + BlockHash: "0xblockhash4", + BlockTimestamp: "1735689601000000", + Transactions: []aptos.Transaction{ + { + Type: "user_transaction", + Hash: "0xbatchhash", + Timestamp: "1735689601000000", + Success: true, + Sender: "0xa11ce", + GasUsed: "100", + GasUnitPrice: "10", + Payload: &aptos.TransactionPayload{ + Type: "entry_function_payload", + Function: "0x1::aptos_account::batch_transfer_coins", + }, + Events: []aptos.Event{ + coinEvent(t, "0xa11ce", "9", "1", "0x1::coin::WithdrawEvent", "10"), + coinEvent(t, "0xb0b", "8", "2", "0x1::coin::DepositEvent", "10"), + coinEvent(t, "0xa11ce", "9", "3", "0x1::coin::WithdrawEvent", "20"), + coinEvent(t, "0xcafe", "7", "4", "0x1::coin::DepositEvent", "20"), + }, + Changes: []aptos.WriteSetChange{ + coinStoreChange(t, "0xa11ce", "0x1::aptos_coin::AptosCoin", "2", "9"), + coinStoreChange(t, "0xb0b", "0x1::aptos_coin::AptosCoin", "8", "3"), + coinStoreChange(t, "0xcafe", "0x1::aptos_coin::AptosCoin", "7", "4"), + }, + }, + }, + } + + block, err := idx.convertBlock(blockData, 44) + require.NoError(t, err) + require.Len(t, block.Transactions, 2) + + first := block.Transactions[0] + second := block.Transactions[1] + + assert.Equal(t, "0xa11ce", first.FromAddress) + assert.Equal(t, "0xb0b", first.ToAddress) + assert.Equal(t, "10", first.Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, first.Type) + assert.Equal(t, "0:0:seq:1:2:0:1", first.TransferIndex) + + assert.Equal(t, "0xa11ce", second.FromAddress) + assert.Equal(t, "0xcafe", second.ToAddress) + assert.Equal(t, "20", second.Amount) + assert.Equal(t, constant.TxTypeNativeTransfer, second.Type) + assert.Equal(t, "0:1:seq:3:4:2:3", second.TransferIndex) } func TestAptosMonitoredAddress_MatchesShortAndLongFormats(t *testing.T) { @@ -179,6 +310,413 @@ func TestAptosMonitoredAddress_MatchesShortAndLongFormats(t *testing.T) { assert.False(t, idx.isMonitoredAddress("0xbb")) } +func TestConvertAptosBlock_PrefixesTransferIndexWithTxPosition(t *testing.T) { + idx := &AptosIndexer{ + chainName: "aptos_mainnet", + config: config.ChainConfig{ + NetworkId: "aptos_mainnet", + }, + } + + blockData := &aptos.BlockResponse{ + BlockHeight: "66", + BlockHash: "0xblockhash66", + BlockTimestamp: "1735689600123456", + Transactions: []aptos.Transaction{ + { + Type: "user_transaction", + Hash: "0xtx-one", + Timestamp: "1735689600222333", + Success: true, + Sender: "0xa11ce", + GasUsed: "1", + GasUnitPrice: "1", + Events: []aptos.Event{ + coinEvent(t, "0xa11ce", "9", "11", "0x1::coin::WithdrawEvent", "1"), + coinEvent(t, "0xb0b", "8", "19", "0x1::coin::DepositEvent", "1"), + }, + Changes: []aptos.WriteSetChange{ + coinStoreChange(t, "0xa11ce", "0x1::aptos_coin::AptosCoin", "2", "9"), + coinStoreChange(t, "0xb0b", "0x1::aptos_coin::AptosCoin", "8", "3"), + }, + }, + { + Type: "user_transaction", + Hash: "0xtx-two", + Timestamp: "1735689600222444", + Success: true, + Sender: "0xcafe", + GasUsed: "1", + GasUnitPrice: "1", + Events: []aptos.Event{ + coinEvent(t, "0xcafe", "7", "21", "0x1::coin::WithdrawEvent", "2"), + coinEvent(t, "0xd00d", "6", "22", "0x1::coin::DepositEvent", "2"), + }, + Changes: []aptos.WriteSetChange{ + coinStoreChange(t, "0xcafe", "0x1::aptos_coin::AptosCoin", "4", "7"), + coinStoreChange(t, "0xd00d", "0x1::aptos_coin::AptosCoin", "6", "5"), + }, + }, + }, + } + + block, err := idx.convertBlock(blockData, 66) + require.NoError(t, err) + require.Len(t, block.Transactions, 2) + assert.Equal(t, "0:0:seq:11:19:0:1", block.Transactions[0].TransferIndex) + assert.Equal(t, "1:0:seq:21:22:0:1", block.Transactions[1].TransferIndex) + assert.Equal(t, "0xblockhash66", block.Transactions[0].BlockHash) + assert.Equal(t, "0xblockhash66", block.Transactions[1].BlockHash) +} + +func TestAptosMainnetFetchAndParseTransactions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + client := newTestAptosClient() + idx := newTestAptosIndexer() + + testCases := []aptosRealTransferFixture{ + { + name: "primary fungible store transfer", + version: 4677297376, + txHash: "0x834b588f580dee080bd36b0ccead00b73781b33afe9595a8c1d8866641971e0c", + wantFee: "0.00507", + wantTransfers: []aptosRealTransferOutput{ + { + txType: constant.TxTypeTokenTransfer, + from: "0xe175710417fdc335cfda20811011b79925abb9d26f1568833dfc64062ca023bd", + to: "0xae1a6f3d3daccaf77b55044cea133379934bba04a11b9d0bbd643eae5e6e9c70", + amount: "111000000", + asset: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", + rawTransferIndex: "seq:0:0:0:1", + }, + }, + }, + { + name: "custom module fungible asset transfer", + version: 4677299560, + txHash: "0x0606953617ff069057834b98de83bad68247d8888ca849330d2d4c746cd881fc", + wantFee: "0.005215", + wantTransfers: []aptosRealTransferOutput{ + { + txType: constant.TxTypeTokenTransfer, + from: "0x3b3116a1094480310f84ca6017b855021a49faf92e24c54cbb217c103666d61b", + to: "0x5a96fab415f43721a44c5a761ecfcccc3dae9c21f34313f0e594b49d8d4564f4", + amount: "15", + asset: "0x7217ddb8006d44e945286edb847ef3c75c3c9ea5fb855f576baacac5c0edc239", + rawTransferIndex: "seq:0:0:0:1", + }, + }, + }, + { + name: "aptos account native transfer", + version: 4677376519, + txHash: "0xc9a21616d74d8f0523242fa68222fe8a0817ea1867a53e1147a333b9bf05b962", + wantFee: "0.00006363", + wantTransfers: []aptosRealTransferOutput{ + { + txType: constant.TxTypeNativeTransfer, + from: "0x36fb8eec9a46703803be12ebdfb096c76b50fd19b7d4ace25f693c1d3d6b925b", + to: "0xbabf76f1e6ac1af9c2666c07be12f90b188ed484fcef0bf1408961b5461373e9", + amount: "310023637", + asset: "", + rawTransferIndex: "seq:0:0:0:1", + }, + }, + }, + { + name: "coin transfer apt native", + version: 4677354522, + txHash: "0xe812586e712c13b166f6023ce00a9351a9df5c056aec211eb55a7a662667aa15", + wantFee: "0.000116", + wantTransfers: []aptosRealTransferOutput{ + { + txType: constant.TxTypeNativeTransfer, + from: "0xa316b7d1fdd394698ab60a40c043fc4163dd421a61b6916eca2a25ea2784f04c", + to: "0xf597522b26d0f8c262834d736010c3f001b0fcfdd828eb1af2a151e75ed607c4", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:0:1", + }, + }, + }, + { + name: "batch transfer coins multi recipient", + version: 4677334780, + txHash: "0x4746d85426536fd1e1910a2bb273c2ae9f3d728a49fc1e1763b496fc6a2c6837", + wantFee: "0.000458", + wantTransfers: []aptosRealTransferOutput{ + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xa76745246a56f713d48d1825dd0849e1c8a8298eeedce4ef1b6263109d0e547c", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:0:1", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0x283e703969872c0012c4719bb95760247abb840047643ddec7472c910bbaf3fc", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:2:3", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xe1c2321c8fd9a04d8e91783c7a6e6f6691ca9c55ec51c35081f953d41188b98e", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:4:5", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xcd8032ba416c61bee0425afa181ef031f48a1ac3a66dd3ce2769ed2229c58aa3", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:6:7", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xa14e7fa10096e68d16e5389ff351e36961c1aeb7038b4414319389280e19f2c6", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:8:9", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0x43696ed951b79264c4b63536e53774065019dde61a5784d3763e4e024a98c747", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:10:11", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xa392cfe21b93ca73f7416c9d191e293312f642b0dedb58fc0fedc354c0460bc0", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:12:13", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xac3af7afb3c71aaca3c962b6de165f24e7c476791c4db16faae8f47bc5f6e9ca", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:14:15", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0x27bad347df1cbacdbf3b28e9efb4437be00a519ee3e14806e162a584d55f8168", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:16:17", + }, + { + txType: constant.TxTypeNativeTransfer, + from: "0xf04075549d2d842a726c96fb6d68661db44d62951ac97d3c807fe95f6e27fbf1", + to: "0xfe3c203c942fccb0987922fd9a4aac8899e3a1734557c92f6a50bd00f9f0cd7d", + amount: "1", + asset: "", + rawTransferIndex: "seq:0:0:18:19", + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + blockData, err := client.GetBlockByVersion(ctx, tc.version, true) + skipAptosRateLimit(t, err) + require.NoError(t, err) + require.NotNil(t, blockData) + + versionStr := strconv.FormatUint(tc.version, 10) + txIndex := -1 + for i, tx := range blockData.Transactions { + if tx.Version == versionStr { + txIndex = i + require.Equal(t, tc.txHash, tx.Hash) + break + } + } + require.NotEqual(t, -1, txIndex, "target version %d should exist in block payload", tc.version) + + blockHeight, err := strconv.ParseUint(blockData.BlockHeight, 10, 64) + require.NoError(t, err) + + block, err := idx.convertBlock(blockData, 0) + require.NoError(t, err) + + gotTransfers := make([]types.Transaction, 0, len(tc.wantTransfers)) + for _, tx := range block.Transactions { + if tx.TxHash == tc.txHash { + gotTransfers = append(gotTransfers, tx) + } + } + + require.Len(t, gotTransfers, len(tc.wantTransfers)) + for i, want := range tc.wantTransfers { + tx := gotTransfers[i] + assert.Equal(t, tc.txHash, tx.TxHash) + assert.Equal(t, "aptos_mainnet", tx.NetworkId) + assert.Equal(t, blockHeight, tx.BlockNumber) + assert.Equal(t, blockData.BlockHash, tx.BlockHash) + assert.Equal(t, want.from, tx.FromAddress) + assert.Equal(t, want.to, tx.ToAddress) + assert.Equal(t, want.amount, tx.Amount) + assert.Equal(t, want.txType, tx.Type) + assert.Equal(t, want.asset, tx.AssetAddress) + assert.Equal(t, tc.wantFee, tx.TxFee.String()) + assert.Equal(t, fmt.Sprintf("%d:%d:%s", txIndex, i, want.rawTransferIndex), tx.TransferIndex) + assert.NotZero(t, tx.Timestamp) + } + }) + } +} + +func skipAptosRateLimit(t *testing.T, err error) { + t.Helper() + + if err == nil { + return + } + + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "rate limit") || + strings.Contains(msg, "too many requests") || + strings.Contains(msg, "http 429") { + t.Skipf("aptos rpc rate limited: %v", err) + } +} + +func coinEvent( + t *testing.T, + account string, + creation string, + sequence string, + eventType string, + amount string, +) aptos.Event { + t.Helper() + return aptos.Event{ + GUID: aptos.EventGUID{ + CreationNumber: creation, + AccountAddress: account, + }, + SequenceNumber: sequence, + Type: eventType, + Data: aptos.EventData{ + "amount": mustRawJSON(t, amount), + }, + } +} + +func fungibleAssetEvent( + t *testing.T, + sequence string, + store string, + eventType string, + amount string, +) aptos.Event { + t.Helper() + return aptos.Event{ + GUID: aptos.EventGUID{ + CreationNumber: "0", + AccountAddress: "0x0", + }, + SequenceNumber: sequence, + Type: eventType, + Data: aptos.EventData{ + "store": mustRawJSON(t, store), + "amount": mustRawJSON(t, amount), + }, + } +} + +func coinStoreChange( + t *testing.T, + account string, + coinType string, + depositCreation string, + withdrawCreation string, +) aptos.WriteSetChange { + t.Helper() + + data := map[string]json.RawMessage{} + if depositCreation != "" { + data["deposit_events"] = mustRawJSON(t, map[string]any{ + "guid": map[string]any{ + "id": map[string]any{ + "addr": account, + "creation_num": depositCreation, + }, + }, + }) + } + if withdrawCreation != "" { + data["withdraw_events"] = mustRawJSON(t, map[string]any{ + "guid": map[string]any{ + "id": map[string]any{ + "addr": account, + "creation_num": withdrawCreation, + }, + }, + }) + } + + return aptos.WriteSetChange{ + Type: "write_resource", + Address: account, + Data: &aptos.WriteSetResource{ + Type: fmt.Sprintf("0x1::coin::CoinStore<%s>", coinType), + Data: data, + }, + } +} + +func objectCoreChange(t *testing.T, objectAddress, owner string) aptos.WriteSetChange { + t.Helper() + return aptos.WriteSetChange{ + Type: "write_resource", + Address: objectAddress, + Data: &aptos.WriteSetResource{ + Type: "0x1::object::ObjectCore", + Data: map[string]json.RawMessage{ + "owner": mustRawJSON(t, owner), + }, + }, + } +} + +func fungibleStoreChange(t *testing.T, storeAddress, metadataAddress string) aptos.WriteSetChange { + t.Helper() + return aptos.WriteSetChange{ + Type: "write_resource", + Address: storeAddress, + Data: &aptos.WriteSetResource{ + Type: "0x1::fungible_asset::FungibleStore", + Data: map[string]json.RawMessage{ + "metadata": mustRawJSON(t, map[string]any{ + "inner": metadataAddress, + }), + }, + }, + } +} + func mustRawJSON(t *testing.T, value any) json.RawMessage { t.Helper() diff --git a/internal/rpc/aptos/client.go b/internal/rpc/aptos/client.go index a32b96d..a37d054 100644 --- a/internal/rpc/aptos/client.go +++ b/internal/rpc/aptos/client.go @@ -106,6 +106,44 @@ func (c *Client) GetBlockByHeight( return &block, nil } +func (c *Client) GetBlockByVersion( + ctx context.Context, + version uint64, + withTransactions bool, +) (*BlockResponse, error) { + endpoint := c.endpoint(fmt.Sprintf("/v1/blocks/by_version/%d", version)) + params := map[string]string{"with_transactions": strconv.FormatBool(withTransactions)} + + raw, err := c.base.Do(ctx, http.MethodGet, endpoint, nil, params) + if err != nil { + return nil, fmt.Errorf("get block by version %d failed: %w", version, err) + } + + var block BlockResponse + if err := json.Unmarshal(raw, &block); err != nil { + return nil, fmt.Errorf("decode block by version %d failed: %w", version, err) + } + return &block, nil +} + +func (c *Client) GetTransactionByHash( + ctx context.Context, + hash string, +) (*Transaction, error) { + endpoint := c.endpoint(fmt.Sprintf("/v1/transactions/by_hash/%s", strings.TrimSpace(hash))) + + raw, err := c.base.Do(ctx, http.MethodGet, endpoint, nil, nil) + if err != nil { + return nil, fmt.Errorf("get transaction by hash %s failed: %w", hash, err) + } + + var tx Transaction + if err := json.Unmarshal(raw, &tx); err != nil { + return nil, fmt.Errorf("decode transaction by hash %s failed: %w", hash, err) + } + return &tx, nil +} + func IsNotFoundError(err error) bool { if err == nil { return false diff --git a/internal/rpc/aptos/types.go b/internal/rpc/aptos/types.go index 66bff3b..8bda85e 100644 --- a/internal/rpc/aptos/types.go +++ b/internal/rpc/aptos/types.go @@ -35,6 +35,8 @@ type Transaction struct { GasUsed string `json:"gas_used"` GasUnitPrice string `json:"gas_unit_price"` Payload *TransactionPayload `json:"payload"` + Events []Event `json:"events"` + Changes []WriteSetChange `json:"changes"` } // TransactionPayload is the entry function payload shape. @@ -44,3 +46,28 @@ type TransactionPayload struct { TypeArguments []string `json:"type_arguments"` Arguments []json.RawMessage `json:"arguments"` } + +type Event struct { + GUID EventGUID `json:"guid"` + SequenceNumber string `json:"sequence_number"` + Type string `json:"type"` + Data EventData `json:"data"` +} + +type EventGUID struct { + CreationNumber string `json:"creation_number"` + AccountAddress string `json:"account_address"` +} + +type EventData map[string]json.RawMessage + +type WriteSetChange struct { + Type string `json:"type"` + Address string `json:"address"` + Data *WriteSetResource `json:"data"` +} + +type WriteSetResource struct { + Type string `json:"type"` + Data map[string]json.RawMessage `json:"data"` +}