diff --git a/backfill/db-migrator/reconcile.go b/backfill/db-migrator/reconcile.go new file mode 100644 index 00000000..923f2b82 --- /dev/null +++ b/backfill/db-migrator/reconcile.go @@ -0,0 +1,455 @@ +package main + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "go-backfill/config" + "io" + "log" + "net/http" + "strings" + "time" + + _ "github.com/lib/pq" // PostgreSQL driver +) + +const ( + batchSize = 1000 + maxBlockId = 113630897 + baseAPIURL = "https://api.chainweb.com/chainweb/0.0/mainnet01" +) + +type ReconcileResult struct { + PayloadHash string + ChainId int + BlockId int +} + +type TransferData struct { + TransactionId int + Type string + Amount string + ChainId int + FromAcct string + ModuleHash string + ModuleName string + RequestKey string + ToAcct string + HasTokenId bool + TokenId string + OrderIndex int +} + +// Transaction types from process_payloads.go +type Event struct { + Params []interface{} `json:"params"` + Name string `json:"name"` + Module Module `json:"module"` + ModuleHash string `json:"moduleHash"` +} + +type Module struct { + Namespace *string `json:"namespace"` + Name string `json:"name"` +} + +type DecodedTransaction struct { + Hash string `json:"hash"` + Sigs json.RawMessage `json:"sigs"` + Cmd json.RawMessage `json:"cmd"` + Gas int `json:"gas"` + Result json.RawMessage `json:"result"` + ReqKey string `json:"reqKey"` + Logs string `json:"logs"` + Events []Event `json:"events"` + Continuation json.RawMessage `json:"continuation"` + Step int `json:"step"` + TTL string `json:"ttl"` + TxId int `json:"txId"` +} + +// Correct payload response from API +type PayloadAPIResponse struct { + Transactions [][2]string `json:"transactions"` + MinerData string `json:"minerData"` + TransactionsHash string `json:"transactionsHash"` + OutputsHash string `json:"outputsHash"` + PayloadHash string `json:"payloadHash"` +} + +// Transaction parts for decoding +type TransactionPart0 struct { + Hash string `json:"hash"` + Sigs json.RawMessage `json:"sigs"` + Cmd json.RawMessage `json:"cmd"` +} + +type TransactionPart1 struct { + Gas int `json:"gas"` + Result json.RawMessage `json:"result"` + ReqKey string `json:"reqKey"` + Logs string `json:"logs"` + Events []Event `json:"events"` + Continuation json.RawMessage `json:"continuation"` + TxId int `json:"txId"` +} + +func reconcile() { + envFile := flag.String("env", ".env", "Path to the .env file") + flag.Parse() + config.InitEnv(*envFile) + env := config.GetConfig() + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + env.DbHost, env.DbPort, env.DbUser, env.DbPassword, env.DbName) + + db, err := sql.Open("postgres", connStr) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + log.Println("Connected to database") + + // Test database connection + if err := db.Ping(); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + + // Process reconcile events in batches + if err := processReconcileEvents(db); err != nil { + log.Fatalf("Failed to process reconcile events: %v", err) + } + + log.Println("Finished processing reconcile events") +} + +func processReconcileEvents(db *sql.DB) error { + var lastBlockId int + totalProcessed := 0 + + // log.Printf("Starting reconcile events processing from block ID 1 to %d", maxBlockId) + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + for { + results, maxBlockIdFromBatch, err := fetchReconcileEventsBatch(db, lastBlockId, batchSize) + if err != nil { + return fmt.Errorf("failed to fetch batch: %v", err) + } + + // If no results, we're done + if len(results) == 0 { + // log.Printf("No more records to process. Total processed: %d (100.0%%)", totalProcessed) + break + } + + // Calculate progress percentage + progress := float64(lastBlockId) / float64(maxBlockId) * 100.0 + + // Process the batch + log.Printf("Processing batch of %d records (block ID: %d, progress: %.1f%%)", len(results), lastBlockId, progress) + + // Fetch payload data and extract request keys for each result + var allTransfers []TransferData + for _, result := range results { + transfers, err := processPayloadAndExtractRequestKeys(httpClient, db, result.PayloadHash, result.ChainId, result.BlockId) + if err != nil { + log.Printf("Error processing payload %s on chain %d: %v", result.PayloadHash, result.ChainId, err) + continue + } + allTransfers = append(allTransfers, transfers...) + } + + // Insert all transfers in a single database transaction + if len(allTransfers) > 0 { + err := insertTransfers(db, allTransfers) + if err != nil { + log.Printf("Error inserting transfers: %v", err) + } else { + log.Printf("Successfully inserted %d transfers", len(allTransfers)) + } + } + + totalProcessed += len(results) + lastBlockId = maxBlockIdFromBatch + + // If we got less than batchSize, we're likely done + if len(results) < batchSize { + // finalProgress := float64(lastBlockId) / float64(maxBlockId) * 100.0 + // log.Printf("Last batch processed. Total processed: %d (%.1f%%)", totalProcessed, finalProgress) + break + } + } + + return nil +} + +func fetchReconcileEventsBatch(db *sql.DB, lastBlockId int, limit int) ([]ReconcileResult, int, error) { + query := ` + SELECT DISTINCT b."payloadHash", b."chainId", b.id + FROM "Events" e + JOIN public."Transactions" t ON t.id = e."transactionId" + JOIN "Blocks" b ON t."blockId" = b.id + WHERE e.name = 'RECONCILE' + AND (e.module = 'marmalade.ledger' OR e.module = 'marmalade-v2.ledger') + AND b.id > $1 + ORDER BY b.id + LIMIT $2 + ` + + rows, err := db.Query(query, lastBlockId, limit) + if err != nil { + return nil, 0, fmt.Errorf("failed to execute query: %v", err) + } + defer rows.Close() + + var results []ReconcileResult + var maxBlockId int + + for rows.Next() { + var result ReconcileResult + + if err := rows.Scan(&result.PayloadHash, &result.ChainId, &result.BlockId); err != nil { + return nil, 0, fmt.Errorf("failed to scan row: %v", err) + } + + results = append(results, result) + if result.BlockId > maxBlockId { + maxBlockId = result.BlockId + } + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("error iterating rows: %v", err) + } + + return results, maxBlockId, nil +} + +func processPayloadAndExtractRequestKeys(client *http.Client, db *sql.DB, payloadHash string, chainId int, blockId int) ([]TransferData, error) { + // Use the payload endpoint to get transaction arrays + url := fmt.Sprintf("%s/chain/%d/payload/%s/outputs", baseAPIURL, chainId, payloadHash) + + // Make HTTP request + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to make HTTP request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code %d", resp.StatusCode) + } + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + // Parse as the correct Payload structure + var apiResponse PayloadAPIResponse + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + var transfers []TransferData + + // Process each transaction array [part0, part1] + for i, transactionParts := range apiResponse.Transactions { + if len(transactionParts) != 2 { + log.Printf("Transaction %d parts length is not 2, skipping", i) + continue + } + + // Extract reqKey and events from the second part (transactionParts[1]) + reqKey, events, err := extractRequestKeyAndEventsFromTransactionPart(transactionParts[1]) + if err != nil { + log.Printf("Error extracting data from transaction %d: %v", i, err) + continue + } + + // Filter and collect RECONCILE events + for orderIndex, event := range events { + if event.Name == "RECONCILE" && (*event.Module.Namespace == "marmalade-v2" || *event.Module.Namespace == "marmalade") { + // Handle module name with namespace if present + moduleName := event.Module.Name + if event.Module.Namespace != nil { + moduleName = *event.Module.Namespace + "." + event.Module.Name + } + + // Extract additional properties from params + var amount, tokenId, fromAcct, toAcct string + if len(event.Params) >= 4 { + // tokenId: params[0] + if tokenIdVal, ok := event.Params[0].(string); ok { + tokenId = tokenIdVal + } + + // amount: params[1] + if amountVal := event.Params[1]; amountVal != nil { + amount = fmt.Sprintf("%v", amountVal) + } + + // from_acct: params[2].account + if params2, ok := event.Params[2].(map[string]interface{}); ok { + if accountVal, ok := params2["account"].(string); ok { + fromAcct = accountVal + } + } + + // to_acct: params[3].account + if params3, ok := event.Params[3].(map[string]interface{}); ok { + if accountVal, ok := params3["account"].(string); ok { + toAcct = accountVal + } + } + } + + // Get transaction ID from database using reqKey and the specific blockId we're processing + transactionId, err := getTransactionId(db, reqKey, blockId) + if err != nil { + log.Printf("Error getting transaction ID for reqKey %s: %v", reqKey, err) + continue + } + + // Create transfer data + transfer := TransferData{ + TransactionId: transactionId, + Type: "poly-fungible", + Amount: amount, + ChainId: chainId, + FromAcct: fromAcct, + ModuleHash: event.ModuleHash, + ModuleName: moduleName, + RequestKey: reqKey, + ToAcct: toAcct, + HasTokenId: true, + TokenId: tokenId, + OrderIndex: orderIndex, + } + + transfers = append(transfers, transfer) + } + } + } + + return transfers, nil +} + +func extractRequestKeyAndEventsFromTransactionPart(transactionPart string) (string, []Event, error) { + // Decode the base64 transaction part + decodedData, err := decodeBase64(transactionPart) + if err != nil { + return "", nil, fmt.Errorf("failed to decode base64 transaction part: %v", err) + } + + // Parse as transaction part 1 (should contain reqKey and events) + var part1 TransactionPart1 + if err := json.Unmarshal(decodedData, &part1); err != nil { + return "", nil, fmt.Errorf("failed to parse transaction part JSON: %v", err) + } + + return part1.ReqKey, part1.Events, nil +} + +func decodeBase64(encodedData string) ([]byte, error) { + // Normalize the input by ensuring proper padding + encodedData = ensureBase64Padding(encodedData) + + // Attempt decoding using both standard and URL-safe Base64 encodings + var decodedData []byte + var err error + + decodedData, err = base64.StdEncoding.DecodeString(encodedData) + if err != nil { + decodedData, err = base64.URLEncoding.DecodeString(encodedData) + if err != nil { + return nil, fmt.Errorf("error decoding base64 data using both standard and URL-safe encodings: %w", err) + } + } + + return decodedData, nil +} + +// ensureBase64Padding adds missing padding to a Base64 string if necessary. +func ensureBase64Padding(base64Str string) string { + missingPadding := len(base64Str) % 4 + if missingPadding > 0 { + padding := strings.Repeat("=", 4-missingPadding) + base64Str += padding + } + return base64Str +} + +func getTransactionId(db *sql.DB, reqKey string, blockId int) (int, error) { + query := ` + SELECT t.id + FROM "Transactions" t + WHERE t.requestkey = $1 AND t."blockId" = $2 + LIMIT 1 + ` + + var transactionId int + err := db.QueryRow(query, reqKey, blockId).Scan(&transactionId) + if err != nil { + return 0, fmt.Errorf("failed to find transaction for reqKey %s in block %d: %v", reqKey, blockId, err) + } + + return transactionId, nil +} + +func insertTransfers(db *sql.DB, transfers []TransferData) error { + // Begin database transaction + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %v", err) + } + defer tx.Rollback() // Will be ignored if tx.Commit() succeeds + + // Prepare the insert statement + stmt, err := tx.Prepare(` + INSERT INTO "Transfers" ( + "transactionId", type, amount, "chainId", from_acct, + modulehash, modulename, requestkey, to_acct, + "hasTokenId", "tokenId", "orderIndex" + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `) + if err != nil { + return fmt.Errorf("failed to prepare statement: %v", err) + } + defer stmt.Close() + + // Insert each transfer + for _, transfer := range transfers { + _, err := stmt.Exec( + transfer.TransactionId, + transfer.Type, + transfer.Amount, + transfer.ChainId, + transfer.FromAcct, + transfer.ModuleHash, + transfer.ModuleName, + transfer.RequestKey, + transfer.ToAcct, + transfer.HasTokenId, + transfer.TokenId, + transfer.OrderIndex, + ) + if err != nil { + return fmt.Errorf("failed to insert transfer: %v", err) + } + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %v", err) + } + + return nil +} diff --git a/backfill/process/get_nft_transfers.go b/backfill/process/get_nft_transfers.go index e7308016..c3c587ee 100644 --- a/backfill/process/get_nft_transfers.go +++ b/backfill/process/get_nft_transfers.go @@ -7,24 +7,55 @@ import ( ) func GetNftTransfers(network string, chainId int, events []fetch.Event, reqKey string, transactionId int64) []repository.TransferAttributes { - const TransferNftSignature = "TRANSFER" + const ReconcileSignature = "RECONCILE" const TransferNftParamsLength = 4 transfers := make([]repository.TransferAttributes, 0, len(events)) for _, event := range events { - if event.Name == TransferNftSignature && len(event.Params) == TransferNftParamsLength { + // Check if Namespace is not nil before dereferencing + var isMarmalade = false + if event.Module.Namespace != nil { + isMarmalade = *event.Module.Namespace == "marmalade-v2" || *event.Module.Namespace == "marmalade" + } + var hasCorrectParams = len(event.Params) == TransferNftParamsLength + if (event.Name == ReconcileSignature) && hasCorrectParams && isMarmalade { + // RECONCILE events have different parameter structure: + // params[0] = tokenId (string) + // params[1] = amount + // params[2] = {account: fromAcct, current: X, previous: Y} + // params[3] = {account: toAcct, current: X, previous: Y} + var tokenId *string if tokenIdValue, ok := event.Params[0].(string); ok && tokenIdValue != "null" { tokenId = &tokenIdValue } - fromAcct, ok2 := event.Params[1].(string) - toAcct, ok3 := event.Params[2].(string) - amount, ok4 := GetAmountForTransfer(event.Params[3]) + // Extract amount from params[1] + amount, ok4 := GetAmountForTransfer(event.Params[1]) + + // Extract fromAcct from params[2].account + var fromAcct string + var ok2 bool + if params2, ok := event.Params[2].(map[string]interface{}); ok { + if accountVal, ok := params2["account"].(string); ok { + fromAcct = accountVal + ok2 = true + } + } + + // Extract toAcct from params[3].account + var toAcct string + var ok3 bool + if params3, ok := event.Params[3].(map[string]interface{}); ok { + if accountVal, ok := params3["account"].(string); ok { + toAcct = accountVal + ok3 = true + } + } if !ok2 || !ok3 || !ok4 { - log.Printf("Invalid NFT transfer parameters in event: %+v\n", event) + log.Printf("Invalid RECONCILE transfer parameters in event: %+v\n", event) continue } diff --git a/indexer/src/services/payload.ts b/indexer/src/services/payload.ts index 4f7fd7d6..2ae6a5f3 100644 --- a/indexer/src/services/payload.ts +++ b/indexer/src/services/payload.ts @@ -178,7 +178,7 @@ export async function processTransaction( hash: transactionInfo.hash, result: receiptInfo.result || null, logs: receiptInfo.logs || null, - num_events: eventsData ? eventsData.length : 0, + num_events: eventsData.length, requestkey: receiptInfo.reqKey, sender: cmdData?.meta?.sender || null, txid: receiptInfo.txId ? receiptInfo.txId.toString() : null, diff --git a/indexer/src/utils/transfers.ts b/indexer/src/utils/transfers.ts index 67bc9253..c88ea9df 100644 --- a/indexer/src/utils/transfers.ts +++ b/indexer/src/utils/transfers.ts @@ -32,80 +32,70 @@ export function getNftTransfers( eventsData: any, transactionAttributes: TransactionAttributes, ): TransferAttributes[] { - // Define constants for identifying NFT transfer events - // NFT transfers must have the event name "TRANSFER" - const TRANSFER_NFT_SIGNATURE = 'TRANSFER'; - // NFT transfers must have exactly 4 parameters - const TRANSFER_NFT_PARAMS_LENGTH = 4; + // Define constants for identifying RECONCILE events only + const RECONCILE_SIGNATURE = 'RECONCILE'; + // RECONCILE events must have exactly 4 parameters + const RECONCILE_PARAMS_LENGTH = 4; /** - * Define a predicate function to identify valid NFT transfer events - * This function checks if an event matches the NFT transfer signature by: - * 1. Verifying the event name is "TRANSFER" + * Define a predicate function to identify valid RECONCILE events + * RECONCILE events have a specific parameter structure: + * 1. Verifying the event name is "RECONCILE" * 2. Checking it has exactly 4 parameters - * 3. Validating that the parameters are of the correct types: + * 3. Validating parameter structure: * - First param (tokenId): must be a string - * - Second param (from_acct): must be a string - * - Third param (to_acct): must be a string - * - Fourth param (amount): must be a number + * - Second param (amount): must be a number + * - Third param: must be an object with account field + * - Fourth param: must be an object with account field */ - const transferNftSignature = (eventData: any) => - eventData.name == TRANSFER_NFT_SIGNATURE && - eventData.params.length == TRANSFER_NFT_PARAMS_LENGTH && + const reconcileSignature = (eventData: any) => + eventData.name == RECONCILE_SIGNATURE && + eventData.params.length == RECONCILE_PARAMS_LENGTH && + (eventData.module.namespace == 'marmalade-v2' || eventData.module.namespace == 'marmalade') && typeof eventData.params[0] == 'string' && - typeof eventData.params[1] == 'string' && - typeof eventData.params[2] == 'string' && - isAmountInCorrectFormat(eventData.params[3], transactionAttributes.requestkey); - - // Process each event that matches the NFT transfer signature - const transfers = eventsData - // Filter the events array to only include valid NFT transfers - .filter(transferNftSignature) - // Map each matching event to a TransferAttributes object - .map((eventData: any) => { - // Extract the parameters from the event data - const params = eventData.params; - // param[0] is the token ID (the unique identifier for this NFT) - const tokenId = params[0]; - // param[1] is the sender's account address - const from_acct = params[1]; - // param[2] is the receiver's account address - const to_acct = params[2]; - // param[3] is the amount being transferred (usually 1.0 for NFTs) - const amount = getAmount(params[3]); - - // Get the full module name (including namespace if present) - // This identifies which smart contract/module is handling the NFT - const modulename = eventData.module.namespace - ? `${eventData.module.namespace}.${eventData.module.name}` - : eventData.module.name; - - // Create and return a transfer object with all the extracted information - return { - // Set the amount being transferred - amount: amount, - // The blockchain chain ID where this transfer occurred - chainId: transactionAttributes.chainId, - // The sender's account address - from_acct: from_acct, - // The hash of the module that processed this transfer - modulehash: eventData.moduleHash, - // The name of the module that processed this transfer - modulename: modulename, - // The unique request key of the transaction - requestkey: transactionAttributes.requestkey, - // The receiver's account address - to_acct: to_acct, - // Flag indicating this is a token with a unique ID (true for NFTs) - hasTokenId: true, - // The unique identifier for this specific NFT - tokenId: tokenId, - // The type of token being transferred (poly-fungible for NFTs) - type: 'poly-fungible', - // The position of this transfer within the transaction's events - orderIndex: eventData.orderIndex, - }; - }) as TransferAttributes[]; + isAmountInCorrectFormat(eventData.params[1]) && + typeof eventData.params[2] == 'object' && + eventData.params[2]?.hasOwnProperty('account') && + typeof eventData.params[3] == 'object' && + eventData.params[3]?.hasOwnProperty('account'); + + // Process RECONCILE events only + const transfers = eventsData.filter(reconcileSignature).map((eventData: any) => { + // RECONCILE events have specific parameter structure: + // params[0] = tokenId (string) + // params[1] = amount + // params[2] = {account: fromAcct, current: X, previous: Y} + // params[3] = {account: toAcct, current: X, previous: Y} + const params = eventData.params; + + // param[0] is the token ID + const tokenId = params[0]; + // param[1] is the amount being transferred + const amount = getAmount(params[1]); + // param[2].account is the sender's account address + const from_acct = params[2]?.account || ''; + // param[3].account is the receiver's account address + const to_acct = params[3]?.account || ''; + + // Get the full module name (including namespace if present) + const modulename = eventData.module.namespace + ? `${eventData.module.namespace}.${eventData.module.name}` + : eventData.module.name; + + return { + amount: amount, + chainId: transactionAttributes.chainId, + from_acct: from_acct, + modulehash: eventData.moduleHash, + modulename: modulename, + requestkey: transactionAttributes.requestkey, + to_acct: to_acct, + hasTokenId: true, + tokenId: tokenId, + type: 'poly-fungible', + orderIndex: eventData.orderIndex, + }; + }) as TransferAttributes[]; return transfers; } @@ -149,7 +139,7 @@ export function getCoinTransfers( eventData.params.length == TRANSFER_COIN_PARAMS_LENGTH && typeof eventData.params[0] == 'string' && typeof eventData.params[1] == 'string' && - isAmountInCorrectFormat(eventData.params[2], transactionAttributes.requestkey); + isAmountInCorrectFormat(eventData.params[2]); // Process each event that matches the coin transfer signature const transfers = eventsData @@ -209,7 +199,7 @@ export function getCoinTransfers( * @param amount - The amount value to parse (number, decimal object, or integer object) * @returns The parsed amount as number/string, or null if invalid */ -function parseAmount(amount: any, requestKey?: string): number | string | null { +function parseAmount(amount: any): number | string | null { if (typeof amount === 'number') { return amount; } @@ -225,8 +215,8 @@ function parseAmount(amount: any, requestKey?: string): number | string | null { return null; } -function isAmountInCorrectFormat(amount: any, requestKey: string): boolean { - return parseAmount(amount, requestKey) !== null; +function isAmountInCorrectFormat(amount: any): boolean { + return parseAmount(amount) !== null; } function getAmount(amount: any): number | string { diff --git a/indexer/tests/unit/utils/get-nft-transfers.test.ts b/indexer/tests/unit/utils/get-nft-transfers.test.ts new file mode 100644 index 00000000..d610590a --- /dev/null +++ b/indexer/tests/unit/utils/get-nft-transfers.test.ts @@ -0,0 +1,198 @@ +import { getNftTransfers } from '../../../src/utils/transfers'; + +describe('getNftTransfers', () => { + it('should format this RECONCILE/MINT event as a transfer', async () => { + const reconcileEvent = { + params: [ + 't:LMh569P5t1ItN_4b1o3XK_to4IagVgJm89RZ559-sjw', + 1, + { account: '', current: 0, previous: 0 }, + { account: 'r:n_8aa0eb0be2f51f3f97699ca8d2589ef64a24dbcb.kadena', current: 1, previous: 0 }, + ], + name: 'RECONCILE', + module: { + namespace: 'marmalade-v2', + name: 'ledger', + }, + moduleHash: '7QV99opeC0tYI184ws9bMt4ory4l_j_AuYs-LJT2bV4', + orderIndex: 0, + }; + + const transactionDetailsAttributes = { + id: 1, + blockId: 1, + chainId: 1, + creationtime: '2025-08-21T18:58:50Z', + hash: '1234567890', + result: {}, + logs: '', + num_events: 1, + requestkey: 'abc123', + sender: '', + txid: '1234567890', + }; + + const output = getNftTransfers([reconcileEvent], transactionDetailsAttributes); + + const expectedTransfers = [ + { + amount: 1, + chainId: 1, + from_acct: '', + modulehash: '7QV99opeC0tYI184ws9bMt4ory4l_j_AuYs-LJT2bV4', + modulename: 'marmalade-v2.ledger', + requestkey: 'abc123', + to_acct: 'r:n_8aa0eb0be2f51f3f97699ca8d2589ef64a24dbcb.kadena', + hasTokenId: true, + tokenId: 't:LMh569P5t1ItN_4b1o3XK_to4IagVgJm89RZ559-sjw', + type: 'poly-fungible', + orderIndex: 0, + }, + ]; + expect(output).toEqual(expectedTransfers); + }); + + it('should format this RECONCILE/BURN event as a transfer', async () => { + const reconcileEvent = { + params: [ + 't:0WhZyzsJRo4zIUZbXeNh0brWjTgcfbeAPnS7V4FL8BE', + 1, + { + account: 'k:ad010254d356e5242cbe508276141f4f19fa06db2455a22ab8805733e3d65138', + current: 0, + previous: 1, + }, + { account: '', current: 0, previous: 0 }, + ], + name: 'RECONCILE', + module: { + namespace: 'marmalade', + name: 'ledger', + }, + moduleHash: 'KT2lURUtOUDl0eGNINnE5TVplcU50SXNXd1g2d3dYY2l', + orderIndex: 2, + }; + + const transactionDetailsAttributes = { + id: 1, + blockId: 1, + chainId: 1, + creationtime: '2025-08-21T18:58:50Z', + hash: '1234567890', + result: {}, + logs: '', + num_events: 1, + requestkey: 'abc123', + sender: '', + txid: '1234567890', + }; + + const output = getNftTransfers([reconcileEvent], transactionDetailsAttributes); + + const expectedTransfers = [ + { + amount: 1, + chainId: 1, + from_acct: 'k:ad010254d356e5242cbe508276141f4f19fa06db2455a22ab8805733e3d65138', + modulehash: 'KT2lURUtOUDl0eGNINnE5TVplcU50SXNXd1g2d3dYY2l', + modulename: 'marmalade.ledger', + requestkey: 'abc123', + to_acct: '', + hasTokenId: true, + tokenId: 't:0WhZyzsJRo4zIUZbXeNh0brWjTgcfbeAPnS7V4FL8BE', + type: 'poly-fungible', + orderIndex: 2, + }, + ]; + expect(output).toEqual(expectedTransfers); + }); + + it('should format this RECONCILE/TRANSFER event as a transfer', async () => { + const reconcileEvent = { + params: [ + 't:_sLgqsJyfOxfIduWkb_llroKrb7Vo-O6LmoDgigXgY8', + 1, + { + account: 'k:1b57695390163531852f7724313e3ef9ab4728425fead4d1d120444c33f1aa58', + current: 0, + previous: 1, + }, + { + account: 'k:85a29bcd001682d1be2927560db18f452e1e439b6ffd7db722a52a8b68558a4e', + current: 1, + previous: 0, + }, + ], + name: 'RECONCILE', + module: { + namespace: 'marmalade-v2', + name: 'ledger', + }, + moduleHash: 'rE7DU8jlQL9x_MPYuniZJf5ICBTAEHAIFQCB4blofP4', + orderIndex: 2, + }; + + const transactionDetailsAttributes = { + id: 1, + blockId: 1, + chainId: 1, + creationtime: '2025-08-21T18:58:50Z', + hash: '1234567890', + result: {}, + logs: '', + num_events: 1, + requestkey: 'abc123', + sender: '', + txid: '1234567890', + }; + + const output = getNftTransfers([reconcileEvent], transactionDetailsAttributes); + + const expectedTransfers = [ + { + amount: 1, + chainId: 1, + from_acct: 'k:1b57695390163531852f7724313e3ef9ab4728425fead4d1d120444c33f1aa58', + modulehash: 'rE7DU8jlQL9x_MPYuniZJf5ICBTAEHAIFQCB4blofP4', + modulename: 'marmalade-v2.ledger', + requestkey: 'abc123', + to_acct: 'k:85a29bcd001682d1be2927560db18f452e1e439b6ffd7db722a52a8b68558a4e', + hasTokenId: true, + tokenId: 't:_sLgqsJyfOxfIduWkb_llroKrb7Vo-O6LmoDgigXgY8', + type: 'poly-fungible', + orderIndex: 2, + }, + ]; + expect(output).toEqual(expectedTransfers); + }); + + it('should ignore this TRANSFER event', async () => { + const reconcileEvent = { + params: ['', 'k:e7f7130f359fb1f8c87873bf858a0e9cbc3c1059f62ae715ec72e760b055e9f3', 0.9440715], + name: 'TRANSFER', + module: { + namespace: '', + name: 'coin', + }, + moduleHash: 'rE7DU8jlQL9x_MPYuniZJf5ICBTAEHAIFQCB4blofP4', + orderIndex: 2, + }; + + const transactionDetailsAttributes = { + id: 1, + blockId: 1, + chainId: 1, + creationtime: '2025-08-21T18:58:50Z', + hash: '1234567890', + result: {}, + logs: '', + num_events: 1, + requestkey: 'abc123', + sender: '', + txid: '1234567890', + }; + + const output = getNftTransfers([reconcileEvent], transactionDetailsAttributes); + expect(output).toEqual([]); + }); +});