// 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"); // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // over the address, not part of its identity. Every address comparison in // this file goes through this helper, so an address arriving in checksummed // or upper-case form can never be read as a different address. function normalizeAddress(addr) { return (addr || "").toLowerCase(); } 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 = normalizeAddress(from) === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; if (toIsContract && method && method !== "transfer") { const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to)); 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 = normalizeAddress(from) === 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: normalizeAddress( tt.token?.address_hash || tt.token?.address || "", ), holders: parseInt(tt.token?.holders_count || "0", 10), }; } // True when a parsed native entry moved no ETH. Contract-call entries have // their amount fields blanked by parseTx, so they are never judged here. function movedNoEther(tx) { if (tx.direction === "contract") return false; return BigInt(tx.rawAmount || "0") === BigInt(0); } // Merge parsed normal transactions with parsed ERC-20 token transfers into // one row per distinct value movement. Pure: it reads only its arguments // and returns a new list sorted newest block first. // // The merge key is the transaction hash for the native entry and // hash + token contract for each token transfer, so: // // - A display-level contract call (a swap and friends, direction // "contract") absorbs every token leg of its hash into the single // native entry, because the legs are hops of one operation rather // than separate movements the user made. // - Otherwise each distinct token contract in the transaction keeps its // own row, so a hash carrying several genuine transfers stays several // rows. // - The native entry of such a transaction is dropped when it moved no // ETH and at least one token transfer shares its hash: that entry is // the ERC-20 call itself, already represented by the token row. A // native entry that moved ETH survives alongside the token rows, since // the ETH and the tokens are two real movements, and a zero-value // native transaction with no token transfer on its hash survives too. function mergeTransactions(txs, tokenTransfers) { const byKey = new Map(); // Entries are copied so consolidation never writes through to the // caller's objects. for (const tx of txs) { byKey.set(tx.hash, { ...tx }); } const absorbedHashes = new Set(); for (const parsed of tokenTransfers) { const existing = byKey.get(parsed.hash); if (existing && existing.direction === "contract") { // For contract calls (swaps), consolidate into the original // tx entry. Prefer the "received" transfer (swap output) // for the display amount. If no received transfer exists, // fall back to the first "sent" transfer (swap input). const isReceived = parsed.direction === "received"; const needsAmount = !existing.exactValue; if (isReceived || needsAmount) { existing.value = parsed.value; existing.exactValue = parsed.exactValue; existing.rawAmount = parsed.rawAmount; existing.rawUnit = parsed.rawUnit; existing.symbol = parsed.symbol; existing.contractAddress = parsed.contractAddress; existing.holders = parsed.holders; } // Keep the original tx's from/to (the user's address and the // contract they called), not the token transfer's from/to // which may be a router or Permit2 contract. continue; } if (existing && movedNoEther(existing)) { absorbedHashes.add(parsed.hash); } // Every other token transfer gets its own entry. byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), { ...parsed, }); } for (const hash of absorbedHashes) { byKey.delete(hash); } const merged = [...byKey.values()]; merged.sort((a, b) => b.blockNumber - a.blockNumber); return merged; } async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { log.debugf("fetchRecentTransactions", address); const addrLower = normalizeAddress(address); 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 txs = mergeTransactions( (txJson.items || []).map((tx) => parseTx(tx, addrLower)), (ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)), ); 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 normalizeAddress(tx.contractAddress) !== normalizeAddress(legit); } // Pure filter function. Takes raw transactions and filter settings, // returns { transactions, newFraudContracts }. function filterTransactions(txs, filters = {}) { const fraudSet = new Set( (filters.fraudContracts || []).map(normalizeAddress), ); // The dust threshold defaults only when it is unset (nullish): a // threshold of 0 is a real value meaning "hide nothing", since no // transaction has a value below 0 gwei. It is therefore equivalent to // clearing the hide-dust checkbox, and the two controls cannot override // each other in either direction. const dustThresholdGwei = filters.dustThresholdGwei ?? 100000; const newFraud = []; const filtered = []; for (const tx of txs) { const contract = normalizeAddress(tx.contractAddress); // Always filter spoofed known symbols and record the fraud contract if (isSpoofedSymbol(tx)) { if (contract && !fraudSet.has(contract)) { fraudSet.add(contract); newFraud.push(contract); } continue; } // Filter fraud contracts if setting is on if (filters.hideFraudContracts && contract && fraudSet.has(contract)) { 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 < dustThresholdGwei ) { continue; } filtered.push(tx); } return { transactions: filtered, newFraudContracts: newFraud }; } module.exports = { fetchRecentTransactions, filterTransactions, mergeTransactions, };