// Transaction history fetching via Blockscout v2 API. // Fetches normal transactions and ERC-20 token transfers, // merges them, and returns the most recent entries. // // Filtering is separated from fetching: fetchRecentTransactions returns // raw parsed data including token metadata, and filterTransactions is // a pure function that applies anti-poisoning heuristics. const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); function formatTxValue(val) { const parts = val.split("."); if (parts.length === 1) return val + ".0000"; const dec = (parts[1] + "0000").slice(0, 4); return parts[0] + "." + dec; } function parseTx(tx, addrLower) { const from = tx.from?.hash || ""; const to = tx.to?.hash || ""; const rawWei = tx.value || "0"; const toIsContract = tx.to?.is_contract || false; const method = tx.method || null; // For contract calls, produce a meaningful label instead of "0.0000 ETH" let symbol = "ETH"; let value = formatTxValue(formatEther(rawWei)); let exactValue = formatEther(rawWei); let rawAmount = rawWei; let rawUnit = "wei"; let direction = from.toLowerCase() === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; if (toIsContract && method && method !== "transfer") { const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); if (token) { symbol = token.symbol; } // Map known DEX methods to "Swap" for cleaner display const SWAP_METHODS = new Set([ "execute", "swap", "swapExactTokensForTokens", "swapTokensForExactTokens", "swapExactETHForTokens", "swapTokensForExactETH", "swapExactTokensForETH", "swapETHForExactTokens", "multicall", ]); const label = SWAP_METHODS.has(method) ? "Swap" : method.charAt(0).toUpperCase() + method.slice(1); direction = "contract"; directionLabel = label; value = ""; exactValue = ""; rawAmount = ""; rawUnit = ""; } return { hash: tx.hash, blockNumber: tx.block_number, timestamp: Math.floor(new Date(tx.timestamp).getTime() / 1000), from: from, to: to, value: value, exactValue: exactValue, rawAmount: rawAmount, rawUnit: rawUnit, valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))), symbol: symbol, direction: direction, directionLabel: directionLabel, isError: tx.status !== "ok", contractAddress: null, holders: null, isContractCall: toIsContract, method: method, }; } function parseTokenTransfer(tt, addrLower) { const from = tt.from?.hash || ""; const to = tt.to?.hash || ""; const decimals = parseInt(tt.total?.decimals || "18", 10); const rawVal = tt.total?.value || "0"; const direction = from.toLowerCase() === addrLower ? "sent" : "received"; const sym = tt.token?.symbol || "?"; return { hash: tt.transaction_hash, blockNumber: tt.block_number, timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000), from: from, to: to, value: formatTxValue(formatUnits(rawVal, decimals)), exactValue: formatUnits(rawVal, decimals), rawAmount: rawVal, rawUnit: sym + " base units (10^-" + decimals + ")", valueGwei: null, symbol: sym, direction: direction, directionLabel: direction === "sent" ? "Sent" : "Received", isError: false, contractAddress: ( tt.token?.address_hash || tt.token?.address || "" ).toLowerCase(), holders: parseInt(tt.token?.holders_count || "0", 10), }; } async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { log.debugf("fetchRecentTransactions", address); const addrLower = address.toLowerCase(); const [txResp, ttResp] = await Promise.all([ debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), debugFetch( blockscoutUrl + "/addresses/" + address + "/token-transfers?type=ERC-20", ), ]); if (!txResp.ok) { log.errorf( "blockscout transactions:", txResp.status, txResp.statusText, ); } if (!ttResp.ok) { log.errorf( "blockscout token-transfers:", ttResp.status, ttResp.statusText, ); } const txJson = txResp.ok ? await txResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {}; const txsByHash = new Map(); for (const tx of txJson.items || []) { txsByHash.set(tx.hash, parseTx(tx, addrLower)); } // When a token transfer shares a hash with a normal tx, the normal tx // is the contract call (0 ETH) and the token transfer has the real // amount and symbol. A single transaction (e.g. a swap) can produce // multiple token transfers (one per token involved), so we key token // transfers by hash + contract address to keep all of them. We also // preserve contract-call metadata (direction, label, method) from the // matching normal tx so swaps display correctly. for (const tt of ttJson.items || []) { const parsed = parseTokenTransfer(tt, addrLower); const existing = txsByHash.get(parsed.hash); if (existing && existing.direction === "contract") { parsed.direction = "contract"; parsed.directionLabel = existing.directionLabel; parsed.isContractCall = true; parsed.method = existing.method; // Remove the bare-hash normal tx so it doesn't appear as a // duplicate with empty value; token transfers replace it. txsByHash.delete(parsed.hash); } // Use composite key so multiple token transfers per tx are kept. const ttKey = parsed.hash + ":" + (parsed.contractAddress || ""); txsByHash.set(ttKey, parsed); } const txs = [...txsByHash.values()]; txs.sort((a, b) => b.blockNumber - a.blockNumber); const result = txs.slice(0, count); log.debugf("fetchRecentTransactions done, count:", result.length); return result; } // Check if a token transfer is spoofing a known symbol. // Returns true if the symbol matches a known token but the contract // address doesn't match the legitimate one. function isSpoofedSymbol(tx) { if (!tx.contractAddress) return false; const symbol = (tx.symbol || "").toUpperCase(); if (!KNOWN_SYMBOLS.has(symbol)) return false; const legit = KNOWN_SYMBOLS.get(symbol); if (legit === null) return true; // "ETH" as ERC-20 is always fake return tx.contractAddress !== legit; } // Pure filter function. Takes raw transactions and filter settings, // returns { transactions, newFraudContracts }. function filterTransactions(txs, filters = {}) { const fraudSet = new Set( (filters.fraudContracts || []).map((a) => a.toLowerCase()), ); const newFraud = []; const filtered = []; for (const tx of txs) { // Always filter spoofed known symbols and record the fraud contract if (isSpoofedSymbol(tx)) { if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { fraudSet.add(tx.contractAddress); newFraud.push(tx.contractAddress); } continue; } // Filter fraud contracts if setting is on if ( filters.hideFraudContracts && tx.contractAddress && fraudSet.has(tx.contractAddress) ) { continue; } // Filter low-holder tokens (<1000) if setting is on if ( filters.hideLowHolderTokens && tx.contractAddress && tx.holders !== null && tx.holders < 1000 ) { continue; } // Filter dust transactions (below gwei threshold) if setting is on. // Contract calls (approve, transfer, etc.) often have 0 ETH value // and should never be filtered as dust. if ( filters.hideDustTransactions && !tx.isContractCall && tx.valueGwei !== null && tx.valueGwei < (filters.dustThresholdGwei || 100000) ) { continue; } filtered.push(tx); } return { transactions: filtered, newFraudContracts: newFraud }; } async function hasTransactionHistory(address, blockscoutUrl) { try { const resp = await debugFetch(blockscoutUrl + "/addresses/" + address); if (!resp.ok) { // If Blockscout returns 404, the address has never been seen on-chain. if (resp.status === 404) return false; log.errorf( "blockscout address check:", resp.status, resp.statusText, ); return null; // unknown } const data = await resp.json(); // Blockscout v2 address endpoint returns tx counts. // An address with no history may still exist (e.g. received ETH once // but shows 0 outgoing). We check both transactions_count and // token_transfers_count to be thorough. const txCount = (parseInt(data.transactions_count, 10) || 0) + (parseInt(data.token_transfers_count, 10) || 0); return txCount > 0; } catch (e) { log.errorf("hasTransactionHistory error:", e.message); return null; // unknown, don't block the user } } module.exports = { fetchRecentTransactions, filterTransactions, hasTransactionHistory, };