// Address-token detail view: shows a single token's balance and // filtered transactions for the selected address. const { $, showView, showFlash, addressDotHtml, addressTitle, escapeHtml, truncateMiddle, balanceLine, } = require("./helpers"); const { state, currentAddress, saveState } = require("../../shared/state"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { formatUsd, getPrice, getAddressValueUsd, } = require("../../shared/prices"); const { fetchRecentTransactions, filterTransactions, } = require("../../shared/transactions"); const { resolveEnsNames } = require("../../shared/ens"); const { updateSendBalance, renderSendTokenSelect, resetSendValidation, } = require("./send"); const { log } = require("../../shared/log"); const makeBlockie = require("ethereum-blockies-base64"); let ctx; const EXT_ICON = `` + `` + `` + `` + ``; function etherscanAddressLink(address) { return `https://etherscan.io/address/${address}`; } function isoDate(timestamp) { const d = new Date(timestamp * 1000); const pad = (n) => String(n).padStart(2, "0"); return ( d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()) ); } function timeAgo(timestamp) { const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return seconds + " seconds ago"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago"; const hours = Math.floor(minutes / 60); if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago"; const days = Math.floor(hours / 24); if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago"; const months = Math.floor(days / 30); if (months < 12) return months + " month" + (months !== 1 ? "s" : "") + " ago"; const years = Math.floor(days / 365); return years + " year" + (years !== 1 ? "s" : "") + " ago"; } let loadedTxs = []; let ensNameMap = new Map(); let currentSymbol = null; function show() { const wallet = state.wallets[state.selectedWallet]; const addr = wallet.addresses[state.selectedAddress]; const ai = state.selectedAddress; const tokenId = state.selectedToken; // Determine token symbol and balance let symbol, amount, price; const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); if (tokenId === "ETH") { symbol = "ETH"; amount = parseFloat(addr.balance || "0"); price = getPrice("ETH"); } else { const tb = (addr.tokenBalances || []).find( (t) => t.address.toLowerCase() === tokenId.toLowerCase(), ); symbol = resolveSymbol( tokenId, addr.tokenBalances, state.trackedTokens, ); amount = tb ? parseFloat(tb.balance || "0") : 0; price = getPrice(symbol); } currentSymbol = symbol; $("address-token-title").textContent = wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + symbol; // Blockie const blockieEl = $("address-token-jazzicon"); blockieEl.innerHTML = ""; const img = document.createElement("img"); img.src = makeBlockie(addr.address); img.width = 48; img.height = 48; img.style.imageRendering = "pixelated"; img.style.borderRadius = "50%"; blockieEl.appendChild(img); // Address line $("address-token-dot").innerHTML = addressDotHtml(addr.address); $("address-token-full").dataset.full = addr.address; $("address-token-full").textContent = addr.address; const addrLink = etherscanAddressLink(addr.address); $("address-token-etherscan-link").innerHTML = `${EXT_ICON}`; // USD total for this token only const usdVal = price ? amount * price : 0; const usdStr = formatUsd(usdVal); $("address-token-usd-total").innerHTML = usdStr || " "; // Single token balance line (no tokenId — not clickable here) $("address-token-balance").innerHTML = balanceLine(symbol, amount, price); // Token contract details (ERC-20 only) const contractInfo = $("address-token-contract-info"); if (tokenId !== "ETH") { const tb = (addr.tokenBalances || []).find( (t) => t.address.toLowerCase() === tokenId.toLowerCase(), ); const tracked = (state.trackedTokens || []).find( (t) => t.address.toLowerCase() === tokenId.toLowerCase(), ); const rawName = (tb && tb.name) || (tracked && tracked.name) || (knownToken && knownToken.name) || null; const rawSymbol = (tb && tb.symbol) || (tracked && tracked.symbol) || (knownToken && knownToken.symbol) || null; const tokenName = rawName ? escapeHtml(rawName) : null; const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null; const tokenDecimals = tb && tb.decimals != null ? tb.decimals : tracked && tracked.decimals != null ? tracked.decimals : knownToken && knownToken.decimals != null ? knownToken.decimals : null; const tokenHolders = tb && tb.holders != null ? tb.holders : null; const dot = addressDotHtml(tokenId); const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`; const projectUrl = knownToken && knownToken.url ? knownToken.url : null; let infoHtml = `
Contract Address
`; infoHtml += `
${dot}` + `${escapeHtml(tokenId)}` + `${EXT_ICON}` + `
`; if (tokenName) infoHtml += `
Name: ${tokenName}
`; if (tokenSymbol) infoHtml += `
Symbol: ${tokenSymbol}
`; if (tokenDecimals != null) infoHtml += `
Decimals: ${tokenDecimals}
`; if (tokenHolders != null) infoHtml += `
Holders: ${Number(tokenHolders).toLocaleString()}
`; if (projectUrl) infoHtml += `
Website: ${escapeHtml(projectUrl)}
`; contractInfo.innerHTML = infoHtml; contractInfo.classList.remove("hidden"); } else { contractInfo.innerHTML = ""; contractInfo.classList.add("hidden"); } // Transactions $("address-token-tx-list").innerHTML = '
Loading...
'; showView("address-token"); loadTransactions(addr.address, tokenId); } async function loadTransactions(address, tokenId) { try { const rawTxs = await fetchRecentTransactions( address, state.blockscoutUrl, ); const result = filterTransactions(rawTxs, { hideLowHolderTokens: state.hideLowHolderTokens, hideFraudContracts: state.hideFraudContracts, hideDustTransactions: state.hideDustTransactions, dustThresholdGwei: state.dustThresholdGwei, fraudContracts: state.fraudContracts, }); let txs = result.transactions; // Persist any newly discovered fraud contracts if (result.newFraudContracts.length > 0) { for (const addr of result.newFraudContracts) { if (!state.fraudContracts.includes(addr)) { state.fraudContracts.push(addr); } } await saveState(); } // Filter to this token only if (tokenId === "ETH") { txs = txs.filter((tx) => tx.contractAddress === null); } else { txs = txs.filter( (tx) => tx.contractAddress && tx.contractAddress.toLowerCase() === tokenId.toLowerCase(), ); } loadedTxs = txs; // Collect ALL unique addresses for ENS resolution so reverse // lookups work for every displayed address. const counterparties = [ ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ]; if (counterparties.length > 0) { try { ensNameMap = await resolveEnsNames( counterparties, state.rpcUrl, ); } catch { ensNameMap = new Map(); } } renderTransactions(txs); } catch (e) { log.errorf("loadTransactions failed:", e.message); $("address-token-tx-list").innerHTML = '
Failed to load transactions.
'; } } function renderTransactions(txs) { const list = $("address-token-tx-list"); if (txs.length === 0) { list.innerHTML = '
No transactions found.
'; return; } let html = ""; let i = 0; for (const tx of txs) { const counterparty = tx.direction === "sent" ? tx.to : tx.from; const ensName = ensNameMap.get(counterparty) || null; const title = addressTitle(counterparty, state.wallets); const dirLabel = tx.directionLabel; const amountStr = tx.value ? escapeHtml(tx.value + " " + tx.symbol) : escapeHtml(tx.symbol); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const displayAddr = title || ensName || truncateMiddle(counterparty, maxAddr); const addrStr = escapeHtml(displayAddr); const dot = addressDotHtml(counterparty); const err = tx.isError ? " (failed)" : ""; const opacity = tx.isError ? " opacity:0.5;" : ""; const ago = escapeHtml(timeAgo(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp)); html += `
`; html += `
${ago}${dirLabel}${err}
`; html += `
${dot}${addrStr}${amountStr}
`; html += `
`; i++; } list.innerHTML = html; list.querySelectorAll(".tx-row").forEach((row) => { row.addEventListener("click", () => { const idx = parseInt(row.dataset.tx, 10); const tx = loadedTxs[idx]; tx.fromEns = ensNameMap.get(tx.from) || null; tx.toEns = ensNameMap.get(tx.to) || null; ctx.showTransactionDetail(tx); }); }); } function init(_ctx) { ctx = _ctx; $("address-token-full").addEventListener("click", () => { const addr = $("address-token-full").dataset.full; if (addr) { navigator.clipboard.writeText(addr); showFlash("Copied!"); } }); $("address-token-contract-info").addEventListener("click", (e) => { const copyEl = e.target.closest("[data-copy]"); if (copyEl) { navigator.clipboard.writeText(copyEl.dataset.copy); showFlash("Copied!"); } }); $("btn-address-token-back").addEventListener("click", () => { ctx.showAddressDetail(); }); $("btn-address-token-send").addEventListener("click", () => { const addr = state.wallets[state.selectedWallet].addresses[ state.selectedAddress ]; if (!addr.balance || parseFloat(addr.balance) === 0) { if (state.selectedToken === "ETH") { showFlash("Cannot send \u2014 zero balance."); return; } } renderSendTokenSelect(addr); $("send-to").value = ""; $("send-amount").value = ""; const tokenId = state.selectedToken; if (tokenId === "ETH") { $("send-token").value = "ETH"; } else { $("send-token").value = tokenId; } // Hide dropdown, show static token display $("send-token").classList.add("hidden"); let staticHtml = `
${escapeHtml(currentSymbol)}
`; if (tokenId !== "ETH") { const dot = addressDotHtml(tokenId); const link = `https://etherscan.io/token/${tokenId}`; const extLink = `${EXT_ICON}`; staticHtml += `
${dot}` + `${escapeHtml(tokenId)}` + extLink + `
`; } $("send-token-static").innerHTML = staticHtml; $("send-token-static").classList.remove("hidden"); // Attach copy handler for the contract address const copyEl = $("send-token-static").querySelector("[data-copy]"); if (copyEl) { copyEl.addEventListener("click", () => { navigator.clipboard.writeText(copyEl.dataset.copy); showFlash("Copied!"); }); } updateSendBalance(); resetSendValidation(); showView("send"); }); $("btn-address-token-receive").addEventListener("click", () => { ctx.showReceive(); }); } module.exports = { init, show };