// Balance fetching: ETH balances via RPC, ERC-20 token balances via // Blockscout, ENS reverse lookup via RPC. const { JsonRpcProvider, Network, Contract, formatEther, formatUnits, } = require("ethers"); const { ERC20_ABI } = require("./constants"); const { log, debugFetch } = require("./log"); const { deriveAddressFromXpub } = require("./wallet"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); // Use a static network to skip auto-detection (which can fail and cause // "could not coalesce error" on some RPC endpoints like Cloudflare). const mainnet = Network.from("mainnet"); function getProvider(rpcUrl) { return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet }); } function formatBalance(wei) { const eth = formatEther(wei); const parts = eth.split("."); if (parts.length === 1) return eth + ".0"; const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; return parts[0] + "." + dec; } function formatTokenBalance(raw, decimals) { const val = formatUnits(raw, decimals); const parts = val.split("."); if (parts.length === 1) return val + ".0"; const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; return parts[0] + "." + dec; } // Fetch token balances for a single address from Blockscout. // Returns [{ address, symbol, decimals, balance }]. // Filters out spam: only shows tokens that are in the known token list, // explicitly tracked by the user, or have >= 1000 holders. async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) { try { const resp = await debugFetch( blockscoutUrl + "/addresses/" + address + "/token-balances", ); if (!resp.ok) { log.errorf("blockscout token-balances:", resp.status); return null; } const items = await resp.json(); if (!Array.isArray(items)) return null; const trackedSet = new Set( (trackedTokens || []).map((t) => t.address.toLowerCase()), ); const balances = []; for (const item of items) { if (item.token?.type !== "ERC-20") continue; const decimals = parseInt(item.token.decimals || "18", 10); const bal = formatTokenBalance(item.value || "0", decimals); if (bal === "0.0") continue; const tokenAddr = (item.token.address_hash || "").toLowerCase(); const holders = parseInt(item.token.holders_count || "0", 10); const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr); const hasEnoughHolders = holders >= 1000; // Skip spam tokens the user never asked to see if (!isKnown && !isTracked && !hasEnoughHolders) continue; // Skip tokens spoofing a known symbol from a different address const sym = (item.token.symbol || "").toUpperCase(); const legitAddr = KNOWN_SYMBOLS.get(sym); if ( legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddr ) continue; balances.push({ address: item.token.address_hash, symbol: item.token.symbol || "???", decimals: decimals, balance: bal, holders: holders, }); } return balances; } catch (e) { log.errorf("fetchTokenBalances failed:", e.message); return null; } } // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) { log.debugf("refreshBalances start, rpc:", rpcUrl); const provider = getProvider(rpcUrl); const updates = []; for (const wallet of wallets) { for (const addr of wallet.addresses) { // ETH balance updates.push( provider .getBalance(addr.address) .then((bal) => { addr.balance = formatBalance(bal); log.debugf("ETH balance", addr.address, addr.balance); }) .catch((e) => { log.errorf( "ETH balance failed", addr.address, e.shortMessage || e.message, ); }), ); // ENS reverse lookup updates.push( provider .lookupAddress(addr.address) .then((name) => { addr.ensName = name || null; }) .catch(() => { addr.ensName = null; }), ); // ERC-20 token balances via Blockscout updates.push( fetchTokenBalances( addr.address, blockscoutUrl, trackedTokens, ).then((balances) => { if (balances !== null) { addr.tokenBalances = balances; log.debugf( "Token balances", addr.address, balances.length, "tokens", ); } }), ); } } await Promise.all(updates); log.debugf("refreshBalances done"); } // Look up token metadata from its contract. // Calls symbol() and decimals() to verify it implements ERC-20. async function lookupTokenInfo(contractAddress, rpcUrl) { log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl); const provider = getProvider(rpcUrl); const contract = new Contract(contractAddress, ERC20_ABI, provider); let name, symbol, decimals; try { symbol = await contract.symbol(); log.debugf("symbol() =", symbol); } catch (e) { log.errorf("symbol() failed:", e.shortMessage || e.message); throw new Error("Not a valid ERC-20 token (symbol() failed)."); } try { decimals = await contract.decimals(); log.debugf("decimals() =", decimals); } catch (e) { log.errorf("decimals() failed:", e.shortMessage || e.message); throw new Error("Not a valid ERC-20 token (decimals() failed)."); } try { name = await contract.name(); log.debugf("name() =", name); } catch (e) { log.warnf("name() failed, using symbol as name:", e.message); name = symbol; } // Truncate to prevent storage of excessively long values from RPC name = String(name).slice(0, 64); symbol = String(symbol).slice(0, 12); log.infof("Token resolved:", symbol, "decimals", Number(decimals)); return { name, symbol, decimals: Number(decimals) }; } // Derive HD addresses starting from index 0 and check for on-chain activity. // Checks gapLimit addresses in parallel per batch. Stops when an entire // batch has no used addresses (i.e. gapLimit consecutive empty addresses). // Returns { addresses: [{ address, index }], nextIndex }. async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) { log.debugf("scanForAddresses start, gapLimit:", gapLimit); const provider = getProvider(rpcUrl); const used = []; let checked = 0; let checkUpTo = gapLimit; while (checked < checkUpTo) { const batch = []; for (let i = checked; i < checkUpTo; i++) { const addr = deriveAddressFromXpub(xpub, i); batch.push({ addr, index: i }); } const results = await Promise.all( batch.map(async ({ addr, index }) => { try { const [balance, txCount] = await Promise.all([ provider.getBalance(addr), provider.getTransactionCount(addr), ]); return { addr, index, isUsed: balance > 0n || txCount > 0 }; } catch (e) { log.errorf( "scanForAddresses check failed", addr, e.shortMessage || e.message, ); return { addr, index, isUsed: false }; } }), ); checked = checkUpTo; for (const r of results) { if (r.isUsed) { used.push({ address: r.addr, index: r.index }); log.debugf("scanForAddresses used", r.addr, "index:", r.index); checkUpTo = Math.max(checkUpTo, r.index + 1 + gapLimit); } } } used.sort((a, b) => a.index - b.index); const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1; log.infof( "scanForAddresses done, found:", used.length, "nextIndex:", nextIndex, ); return { addresses: used, nextIndex }; } module.exports = { refreshBalances, lookupTokenInfo, getProvider, scanForAddresses, };