import { Connection, PublicKey, ParsedTransactionWithMeta, ConfirmedSignatureInfo, TokenBalance } from '@solana/web3.js'; import { TRANSACTION_TYPE, TransactionType } from '../constant'; export interface SVMTransactionHistoryItem { hash: string; timestamp: number | null | undefined; status: 'success' | 'failed' | 'pending'; fee: number; type: TransactionType; from: string; to?: string; slot: number; amount?: number; token?: string; memo?: string; } export interface TransactionHistoryOptions { limit?: number; before?: string; until?: string; } /** * Fetches and parses transaction history for a Solana wallet address * @param connection - Solana RPC connection * @param walletAddress - Public key of the wallet (string or PublicKey) * @param options - Optional parameters for pagination and filtering * @returns Array of parsed transaction history items */ export async function getSVMTransactionHistory( connection: Connection, walletAddress: PublicKey, options: TransactionHistoryOptions = {} ): Promise { const { limit = 50, before, until } = options; const publicKey = typeof walletAddress === 'string' ? new PublicKey(walletAddress) : walletAddress; try { // Fetch signature info const signatures = await connection.getSignaturesForAddress(publicKey, { limit, before, until, }); console.log(`Found ${signatures.length} transactions`); // Fetch and parse transactions in batches to avoid rate limits const transactions = await fetchTransactionsInBatches( connection, signatures, 5 // batch size ); // Parse transactions into a user-friendly format const history: SVMTransactionHistoryItem[] = []; for (let i = 0; i < transactions.length; i++) { const tx = transactions[i]; const sigInfo = signatures[i]; if (!tx) { // Transaction might be null if it's not available history.push({ hash: sigInfo.signature, timestamp: sigInfo.blockTime, slot: sigInfo.slot, status: sigInfo.err ? 'failed' : 'success', fee: 0, type: TRANSACTION_TYPE.UNKNOWN, from: walletAddress.toBase58() }); continue; } const parsed = parseTransaction(tx, publicKey.toBase58()); history.push({ hash: sigInfo.signature, timestamp: sigInfo.blockTime, slot: sigInfo.slot, status: tx.meta?.err ? 'failed' : 'success', fee: parsed.fee ?? 0, type: parsed.type ?? TRANSACTION_TYPE.UNKNOWN, from: parsed.from ?? walletAddress.toBase58(), ...parsed, }); } return history; } catch (error) { console.error('Error fetching transaction history:', error); throw error; } } /** * Fetches transactions in batches to avoid overwhelming the RPC */ async function fetchTransactionsInBatches( connection: Connection, signatures: ConfirmedSignatureInfo[], batchSize: number ): Promise<(ParsedTransactionWithMeta | null)[]> { const transactions: (ParsedTransactionWithMeta | null)[] = []; for (let i = 0; i < signatures.length; i += batchSize) { const batch = signatures.slice(i, i + batchSize); const batchPromises = batch.map(sig => connection.getParsedTransaction(sig.signature, { maxSupportedTransactionVersion: 0, }) ); const batchResults = await Promise.all(batchPromises); transactions.push(...batchResults); // Small delay to avoid rate limiting if (i + batchSize < signatures.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } return transactions; } /** * Parses a transaction into a simplified format */ function parseTransaction( tx: ParsedTransactionWithMeta, walletAddress: string ): Partial { const fee = tx.meta?.fee || 0; // Try to determine transaction type and extract relevant info const instructions = tx.transaction.message.instructions; // Check for token transfers if (tx.meta?.preTokenBalances && tx.meta?.postTokenBalances) { // console.log('tx.meta: ', tx.meta); const tokenTransfer = findTokenTransfer( tx.meta.preTokenBalances, tx.meta.postTokenBalances, walletAddress ); if (tokenTransfer) { return { fee, type: TRANSACTION_TYPE.TOKEN_TRANSFER, from: tokenTransfer.from, to: tokenTransfer.to, amount: tokenTransfer.amount, token: tokenTransfer.mint, }; } } // Check for SOL transfers if (tx.meta?.preBalances && tx.meta?.postBalances) { const solTransfer = findSolTransfer( tx.meta.preBalances, tx.meta.postBalances, tx.transaction.message.accountKeys, walletAddress ); if (solTransfer) { return { fee, type: TRANSACTION_TYPE.NATIVE_TRANSFER, from: solTransfer.from, to: solTransfer.to, amount: solTransfer.amount, token: 'SOL', }; } } // Check for memo const memo = extractMemo(instructions); // Determine general type based on instructions const type = determineTransactionType(instructions); return { fee, type, memo, }; } /** * Finds token transfers in the transaction */ function findTokenTransfer( preBalances: TokenBalance[], postBalances: TokenBalance[], walletAddress: string ): { from: string; to: string; amount: number; mint: string } | null { for (let i = 0; i < postBalances.length; i++) { const post = postBalances[i] const pre = preBalances.find( p => p.accountIndex === post.accountIndex ); if (!pre) continue; const preAmount = parseFloat(pre.uiTokenAmount.uiAmountString || "0"); const postAmount = parseFloat(post.uiTokenAmount.uiAmountString || "0"); const diff = postAmount - preAmount; const from = diff < 0 ? post.owner ?? "unknown" : postBalances[i + 1]?.owner ?? "unknown"; let to = diff > 0 ? post.owner ?? "unknown" : postBalances[i + 1]?.owner ?? "unknown"; if (to === "unknown") { to = diff > 0 ? post.owner ?? "unknown" : postBalances[i - 1]?.owner ?? "unknown" } if (from && to) { if (Math.abs(diff) > 0) { return { from, to, amount: Math.abs(diff), mint: post.mint, }; } } } return null; } /** * Finds SOL transfers in the transaction */ function findSolTransfer( preBalances: number[], postBalances: number[], accountKeys: any[], walletAddress: string ): { from: string; to: string; amount: number } | null { for (let i = 0; i < preBalances.length; i++) { const diff = postBalances[i] - preBalances[i]; const account = accountKeys[i]; const accountPubkey = typeof account === 'string' ? account : account.pubkey.toBase58(); if (Math.abs(diff) > 5000 && accountPubkey === walletAddress) { // Ignore fee-only changes return { from: diff < 0 ? accountPubkey : 'unknown', to: diff > 0 ? accountPubkey : 'unknown', amount: Math.abs(diff) / 1e9, // Convert lamports to SOL }; } } return null; } /** * Extracts memo from transaction instructions */ function extractMemo(instructions: any[]): string | undefined { for (const instruction of instructions) { if (instruction.program === 'spl-memo' || instruction.programId?.toBase58() === 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') { return instruction.parsed || instruction.data; } } return undefined; } /** * Determines the general type of transaction */ function determineTransactionType(instructions: any[]): TransactionType { if (instructions.length === 0) return TRANSACTION_TYPE.UNKNOWN; const programs = instructions.map(i => i.program || i.programId?.toBase58()); if (programs.includes('spl-token')) return TRANSACTION_TYPE.TOKEN_INTERACTIONS; if (programs.includes('system')) return TRANSACTION_TYPE.SYSTEM; if (programs.some(p => p?.includes('Swap'))) return TRANSACTION_TYPE.SWAP; if (programs.some(p => p?.includes('Stake'))) return TRANSACTION_TYPE.STAKING; return TRANSACTION_TYPE.PROGRAM_INTERACTION; }