import { createPublicClient, http, type PublicClient, type Address, type Hash, type Transaction, type TransactionReceipt, formatEther, formatUnits, decodeEventLog, parseAbi } from 'viem'; import { TRANSACTION_TYPE, TransactionType } from '../constant'; import { HelperAPI, RouteScanAPI } from '../helpers'; // Mapping of chainId to RouteScan network name const CHAIN_ID_TO_NETWORK: Record = { 1: 'mainnet', // Ethereum Mainnet 5: 'testnet', // Goerli 11155111: 'testnet', // Sepolia 137: 'mainnet', // Polygon 80001: 'testnet', // Polygon Mumbai 56: 'mainnet', // BSC 97: 'testnet', // BSC Testnet 43114: 'mainnet', // Avalanche 43113: 'testnet', // Avalanche Fuji 42161: 'mainnet', // Arbitrum 421611: 'testnet', // Arbitrum Testnet 10: 'mainnet', // Optimism 420: 'testnet', // Optimism Goerli 8453: 'mainnet', // Base 84531: 'testnet', // Base Goerli 16661: 'mainnet', // 0G Newton Testnet (using mainnet for simplicity) }; export interface EVMTransactionHistoryItem { hash: string; timestamp: number | null; status: 'success' | 'failed' | 'pending'; fee: string; // in ETH type: TransactionType; from: string; to: string | null; blockNumber: bigint; gasUsed: bigint; gasPrice: string; // in gwei value: string; // in ETH method?: string; tokenTransfers?: TokenTransfer[]; nftTransfers?: NFTTransfer[]; } export interface TokenTransfer { type: 'ERC20'; from: string; to: string; amount: string; tokenAddress: string; tokenSymbol?: string; tokenDecimals?: number; } export interface NFTTransfer { type: 'ERC721' | 'ERC1155'; from: string; to: string; tokenId: string; amount?: string; // for ERC1155 tokenAddress: string; collectionName?: string; } export interface TransactionHistoryOptions { startBlock?: bigint; endBlock?: bigint; includeTokenTransfers?: boolean; includeNFTTransfers?: boolean; } // ERC20 Transfer event signature const ERC20_TRANSFER_EVENT = parseAbi([ 'event Transfer(address indexed from, address indexed to, uint256 value)' ]); // ERC721 Transfer event signature const ERC721_TRANSFER_EVENT = parseAbi([ 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)' ]); // ERC1155 TransferSingle event signature const ERC1155_TRANSFER_SINGLE_EVENT = parseAbi([ 'event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)' ]); // ERC1155 TransferBatch event signature const ERC1155_TRANSFER_BATCH_EVENT = parseAbi([ 'event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)' ]); /** * Fetches and parses transaction history for an EVM wallet address * @param client - Viem public client * @param walletAddress - Ethereum address * @param options - Optional parameters for filtering and features * @returns Array of parsed transaction history items */ export async function getEVMTransactionHistory( client: PublicClient, walletAddress: Address, options: TransactionHistoryOptions = {} ): Promise { // const { // startBlock = 0n, // endBlock, // includeTokenTransfers = true, // includeNFTTransfers = true, // } = options; // try { // const currentBlock = await client.getBlockNumber(); // const toBlock = endBlock || currentBlock; // // For wallet UI, we typically want recent transactions // // Start scanning from current block backwards // const scanStartBlock = startBlock > 0n ? startBlock : (currentBlock > 5000n ? currentBlock - 5000n : 0n); // console.log(`Fetching recent transactions from block ${scanStartBlock} to ${toBlock}`); // // Get transaction hashes for the address (max 15 transactions) // const txHashes = await getRecentTransactionHashes( // client, // walletAddress, // scanStartBlock, // toBlock, // 15 // max transactions to fetch // ); // console.log(`Found ${txHashes.length} unique transactions`); // // Fetch full transaction details in batches // const transactions = await fetchTransactionsInBatches(client, txHashes, 10); // // Parse each transaction // const history: EVMTransactionHistoryItem[] = []; // for (const { tx, receipt, block } of transactions) { // if (!tx || !receipt) continue; // const parsed = await parseEVMTransaction( // client, // tx, // receipt, // block?.timestamp || null, // walletAddress, // includeTokenTransfers, // includeNFTTransfers // ); // history.push(parsed); // } // // Sort by block number (newest first) // history.sort((a, b) => Number(b.blockNumber - a.blockNumber)); // return history; // } catch (error) { // console.error('Error fetching EVM transaction history:', error); // throw error; // } if (client.chain?.id === undefined) throw new Error("Chain Id is Undefined") const chainId = client.chain.id; const network = CHAIN_ID_TO_NETWORK[chainId]; // Try RouteScan first if network mapping exists if (network) { try { console.log(`Fetching transaction history from RouteScan for chain ${chainId} (${network})`); const routeScanResponse = await RouteScanAPI.getTxList( network, chainId, walletAddress, 0, 99999999, 1, 20, // limit to 20 recent transactions 'desc' // newest first ); if (routeScanResponse.status === '1' && routeScanResponse.result) { console.log(`RouteScan returned ${routeScanResponse.result.length} transactions`); // Convert RouteScan format to our EVMTransactionHistoryItem format return routeScanResponse.result.map(tx => ({ hash: tx.hash, timestamp: parseInt(tx.timeStamp), status: tx.txreceipt_status === '1' ? 'success' as const : 'failed' as const, fee: formatEther(BigInt(tx.gasUsed) * BigInt(tx.gasPrice)), type: determineTransactionTypeFromInput(tx.input, tx.value), from: tx.from, to: tx.to || null, blockNumber: BigInt(tx.blockNumber), gasUsed: BigInt(tx.gasUsed), gasPrice: formatUnits(BigInt(tx.gasPrice), 9), // gwei value: formatEther(BigInt(tx.value)), method: tx.methodId || undefined, // Token and NFT transfers would need additional API calls // We'll skip them for now to keep it simple })); } } catch (error) { console.warn('RouteScan failed, falling back to HelperAPI:', error); } } // Fallback to HelperAPI console.log(`Fetching transaction history from HelperAPI for chain ${chainId}`); return await HelperAPI.getTransactionHistory(walletAddress, "EVM", chainId); } /** * Determine transaction type from input data and value (simpler version for RouteScan data) */ function determineTransactionTypeFromInput(input: string, value: string): TransactionType { if (input === '0x' || input === '') { return BigInt(value) > 0n ? TRANSACTION_TYPE.NATIVE_TRANSFER : TRANSACTION_TYPE.CONTRACT_INTERACTION; } const methodSig = input.slice(0, 10); if (methodSig === '0xa9059cbb') return TRANSACTION_TYPE.TOKEN_TRANSFER; // ERC20 transfer if (methodSig === '0x23b872dd') return TRANSACTION_TYPE.TOKEN_TRANSFER; // ERC20 transferFrom if (methodSig === '0x42842e0e') return TRANSACTION_TYPE.NFT_TRANSFER; // ERC721 safeTransferFrom if (methodSig === '0xf242432a') return TRANSACTION_TYPE.NFT_TRANSFER; // ERC1155 safeTransferFrom if (BigInt(value) > 0n) return TRANSACTION_TYPE.NATIVE_TRANSFER; return TRANSACTION_TYPE.CONTRACT_INTERACTION; } /** * Gets recent transaction hashes by scanning blocks backwards * Uses batched parallel requests for better performance */ async function getRecentTransactionHashes( client: PublicClient, address: Address, startBlock: bigint, endBlock: bigint, maxTransactions: number = 15, batchSize: number = 9 ): Promise { const txHashes: Hash[] = []; const seenHashes = new Map(); const addressLower = address.toLowerCase(); let currentBlock = endBlock; let blocksScanned = 0n; console.log(`Scanning blocks backwards from ${endBlock}...`); // Iterate backwards in batches while (currentBlock >= startBlock && txHashes.length < maxTransactions) { // Create batch of block numbers to fetch const blockNumbers: bigint[] = []; for (let i = 0; i < batchSize && currentBlock >= startBlock; i++) { blockNumbers.push(currentBlock); currentBlock--; } try { // Fetch all blocks in parallel const blocks = await Promise.all( blockNumbers.map(blockNumber => client.getBlock({ blockNumber, includeTransactions: true, }).catch(error => { console.error(`Error fetching block ${blockNumber}:`, error); return null; }) ) ); blocks.length console.log('blocks.length: ', blocks.length); // Process blocks in order (newest to oldest) for (const block of blocks) { if (!block || !block.transactions) continue; for (let i = 0; i < block.transactions.length && txHashes.length < maxTransactions; i++) { const tx = block.transactions[i]; if (typeof tx === 'object') { // Check if wallet is sender or receiver const isSender = tx.from.toLowerCase() === addressLower; const isReceiver = tx.to?.toLowerCase() === addressLower; if ((isSender || isReceiver) && !seenHashes.has(tx.hash)) { seenHashes.set(tx.hash, true); txHashes.push(tx.hash); } } else { console.log("Transaction is not an object: ", tx); } } // Early exit if we found enough transactions if (txHashes.length >= maxTransactions) break; } blocksScanned += BigInt(blockNumbers.length); // Log progress if (blocksScanned % 50n === 0n || blocksScanned < 50n) { console.log(`Scanned ${blocksScanned} blocks, found ${txHashes.length} transactions`); } } catch (error) { console.error(`Error fetching block batch:`, error); continue; } } console.log(`Found ${txHashes.length} transactions after scanning ${blocksScanned} blocks`); return txHashes; } /** * Alternative function that uses Etherscan-like API * This is the recommended approach for production use */ export async function getEVMTransactionHistoryWithAPI( client: PublicClient, walletAddress: Address, apiEndpoint: string, apiKey: string, options: TransactionHistoryOptions = {} ): Promise { const { includeTokenTransfers = true, includeNFTTransfers = true } = options; try { // Fetch normal transactions const normalTxResponse = await fetch( `${apiEndpoint}?module=account&action=txlist&address=${walletAddress}&startblock=0&endblock=99999999&sort=desc&apikey=${apiKey}` ); const normalTxData = await normalTxResponse.json(); const history: EVMTransactionHistoryItem[] = []; if (normalTxData.status === '1' && normalTxData.result) { for (const tx of normalTxData.result.slice(0, 50)) { // Limit to 50 most recent const receipt = await client.getTransactionReceipt({ hash: tx.hash as Hash }); const parsed = await parseEVMTransaction( client, { hash: tx.hash as Hash, from: tx.from as Address, to: tx.to as Address | null, value: BigInt(tx.value), blockNumber: BigInt(tx.blockNumber), input: tx.input as Hash, nonce: parseInt(tx.nonce), gas: BigInt(tx.gas), gasPrice: tx.gasPrice ? BigInt(tx.gasPrice) : undefined, } as Transaction, receipt, BigInt(tx.timeStamp), walletAddress, includeTokenTransfers, includeNFTTransfers ); history.push(parsed); } } return history; } catch (error) { console.error('Error fetching EVM transaction history with API:', error); throw error; } } /** * Fetches transaction hashes for an address by iterating through blocks * Efficient for recent transactions (last 10-15 txs) */ async function getTransactionHashesByAddress( client: PublicClient, address: Address, startBlock: bigint, endBlock: bigint, direction: 'from' | 'to', maxTransactions: number = 15 ): Promise { const txHashes: Hash[] = []; const addressLower = address.toLowerCase(); let currentBlock = endBlock; console.log(`Scanning blocks backwards from ${endBlock} to ${startBlock}...`); // Iterate backwards from most recent block while (currentBlock >= startBlock && txHashes.length < maxTransactions) { try { const block = await client.getBlock({ blockNumber: currentBlock, includeTransactions: true, }); if (block.transactions) { for (const tx of block.transactions) { // Check if transaction matches the direction filter if (typeof tx === 'object') { const matchesDirection = (direction === 'from' && tx.from.toLowerCase() === addressLower) || (direction === 'to' && tx.to?.toLowerCase() === addressLower) || (direction === 'from' && tx.from.toLowerCase() === addressLower) || (direction === 'to' && tx.to?.toLowerCase() === addressLower); if (matchesDirection) { txHashes.push(tx.hash); // Stop if we've found enough transactions if (txHashes.length >= maxTransactions) { break; } } } } } currentBlock--; // Log progress every 100 blocks if ((endBlock - currentBlock) % 100n === 0n) { console.log(`Scanned ${endBlock - currentBlock} blocks, found ${txHashes.length} transactions`); } } catch (error) { console.error(`Error fetching block ${currentBlock}:`, error); currentBlock--; continue; } } console.log(`Found ${txHashes.length} transactions after scanning ${endBlock - currentBlock} blocks`); return txHashes; } /** * Fetches full transaction details in batches */ async function fetchTransactionsInBatches( client: PublicClient, hashes: Hash[], batchSize: number ): Promise> { const results = []; for (let i = 0; i < hashes.length; i += batchSize) { const batch = hashes.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map(async (hash) => { try { const [tx, receipt] = await Promise.all([ client.getTransaction({ hash }), client.getTransactionReceipt({ hash }), ]); let block = null; if (tx?.blockNumber) { block = await client.getBlock({ blockNumber: tx.blockNumber }); } return { tx, receipt, block }; } catch (error) { console.error(`Error fetching transaction ${hash}:`, error); return { tx: null, receipt: null, block: null }; } }) ); results.push(...batchResults); // Small delay to avoid rate limiting if (i + batchSize < hashes.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } return results; } /** * Parses a single EVM transaction */ async function parseEVMTransaction( client: PublicClient, tx: Transaction, receipt: TransactionReceipt, timestamp: bigint | null, walletAddress: Address, includeTokenTransfers: boolean, includeNFTTransfers: boolean ): Promise { const gasUsed = receipt.gasUsed; const effectiveGasPrice = receipt.effectiveGasPrice || tx.gasPrice || 0n; const fee = formatEther(gasUsed * effectiveGasPrice); const gasPrice = formatUnits(effectiveGasPrice, 9); // gwei // Determine transaction type const type = determineTransactionType(tx, receipt); // Extract method signature const method = tx.input && tx.input.length >= 10 ? tx.input.slice(0, 10) : undefined; // Parse token transfers from logs let tokenTransfers: TokenTransfer[] = []; let nftTransfers: NFTTransfer[] = []; if (includeTokenTransfers || includeNFTTransfers) { const transfers = await parseTransferLogs( client, receipt.logs, walletAddress, includeTokenTransfers, includeNFTTransfers ); tokenTransfers = transfers.tokens; nftTransfers = transfers.nfts; } return { hash: tx.hash, timestamp: timestamp ? Number(timestamp) : null, blockNumber: tx.blockNumber || 0n, status: receipt.status === 'success' ? 'success' : 'failed', fee, gasUsed, gasPrice, type, from: tx.from, to: tx.to || null, value: formatEther(tx.value || 0n), method, tokenTransfers: tokenTransfers.length > 0 ? tokenTransfers : undefined, nftTransfers: nftTransfers.length > 0 ? nftTransfers : undefined, }; } /** * Determines the transaction type */ function determineTransactionType(tx: Transaction, receipt: TransactionReceipt): TransactionType { // Contract creation if (!tx.to) return TRANSACTION_TYPE.CONTRACT_CREATION; // Check if it's a token/NFT transfer based on method signature const methodSig = tx.input?.slice(0, 10); if (methodSig === '0xa9059cbb') return TRANSACTION_TYPE.TOKEN_TRANSFER; // ERC20 transfer if (methodSig === '0x23b872dd') return TRANSACTION_TYPE.TOKEN_TRANSFER; // ERC20 transferFrom if (methodSig === '0x42842e0e') return TRANSACTION_TYPE.NFT_TRANSFER; // ERC721 safeTransferFrom if (methodSig === '0xf242432a') return TRANSACTION_TYPE.NFT_TRANSFER; // ERC1155 safeTransferFrom // Check value if (tx.value && tx.value > 0n) return TRANSACTION_TYPE.NATIVE_TRANSFER; // Check logs for common patterns if (receipt.logs.some(log => (log as any).topics[0]?.includes('Swap'))) return TRANSACTION_TYPE.SWAP; if (receipt.logs.some(log => (log as any).topics[0]?.includes('Deposit'))) return TRANSACTION_TYPE.DEPOSIT; if (receipt.logs.some(log => (log as any).topics[0]?.includes('Withdraw'))) return TRANSACTION_TYPE.WITHDRAWAL; return TRANSACTION_TYPE.CONTRACT_INTERACTION; } /** * Parses transfer events from transaction logs */ async function parseTransferLogs( client: PublicClient, logs: TransactionReceipt['logs'], walletAddress: Address, includeTokenTransfers: boolean, includeNFTTransfers: boolean ): Promise<{ tokens: TokenTransfer[]; nfts: NFTTransfer[] }> { const tokens: TokenTransfer[] = []; const nfts: NFTTransfer[] = []; for (const log of logs) { try { // Try ERC20 Transfer if (includeTokenTransfers && (log as any).topics.length === 3) { try { const decoded = decodeEventLog({ abi: ERC20_TRANSFER_EVENT, data: log.data, topics: (log as any).topics, }) as any if (decoded.eventName === 'Transfer') { const { from, to, value } = decoded.args as any; // Only include if wallet is involved if (from.toLowerCase() === walletAddress.toLowerCase() || to.toLowerCase() === walletAddress.toLowerCase()) { // Try to get token info (this might fail for non-standard tokens) let decimals = 18; try { decimals = await client.readContract({ address: log.address, abi: parseAbi(['function decimals() view returns (uint8)']), functionName: 'decimals', authorizationList: undefined }) } catch { } tokens.push({ type: 'ERC20', from, to, amount: formatUnits(value, decimals), tokenAddress: log.address, tokenDecimals: decimals, }); } } } catch { } } // Try ERC721 Transfer (has indexed tokenId) if (includeNFTTransfers && (log as any).topics.length === 4) { try { const decoded = decodeEventLog({ abi: ERC721_TRANSFER_EVENT, data: log.data, topics: (log as any).topics, }) as any if (decoded.eventName === 'Transfer') { const { from, to, tokenId } = decoded.args as any; if (from.toLowerCase() === walletAddress.toLowerCase() || to.toLowerCase() === walletAddress.toLowerCase()) { nfts.push({ type: 'ERC721', from, to, tokenId: tokenId.toString(), tokenAddress: log.address, }); } } } catch { } } // Try ERC1155 TransferSingle if (includeNFTTransfers) { try { const decoded = decodeEventLog({ abi: ERC1155_TRANSFER_SINGLE_EVENT, data: log.data, topics: (log as any).topics, }) as any if (decoded.eventName === 'TransferSingle') { const { from, to, id, value } = decoded.args as any; if (from.toLowerCase() === walletAddress.toLowerCase() || to.toLowerCase() === walletAddress.toLowerCase()) { nfts.push({ type: 'ERC1155', from, to, tokenId: id.toString(), amount: value.toString(), tokenAddress: log.address, }); } } } catch { } } } catch (error) { // Skip logs that don't match our patterns continue; } } return { tokens, nfts }; }