// Address-token detail view: shows a single token's balance and // filtered transactions for the selected address. const { $, showView, showFlash, addressDotHtml, escapeHtml, truncateMiddle, balanceLine, } = require("./helpers"); const { state, currentAddress, saveState } = require("../../shared/state"); const { formatUsd, getPrice, getAddressValueUsd, } = require("../../shared/prices"); const { fetchRecentTransactions, filterTransactions, } = require("../../shared/transactions"); const { resolveEnsNames } = require("../../shared/ens"); const { updateSendBalance, renderSendTokenSelect } = require("./send"); const { log } = require("../../shared/log"); const QRCode = require("qrcode"); const makeBlockie = require("ethereum-blockies-base64"); const EXT_ICON = `` + `` + `` + `` + ``; function etherscanAddressLink(address) { return `https://etherscan.io/address/${address}`; } function etherscanTxLink(hash) { return `https://etherscan.io/tx/${hash}`; } 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; 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 = tb ? tb.symbol : "?"; 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; $("address-token-usd-total").textContent = formatUsd(usdVal); // Single token balance line (no tokenId — not clickable here) $("address-token-balance").innerHTML = balanceLine(symbol, amount, price); // 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 unique counterparty addresses for ENS resolution const counterparties = [ ...new Set( txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)), ), ]; 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 dirLabel = tx.direction === "sent" ? "Sent" : "Received"; const amountStr = escapeHtml(tx.value + " " + tx.symbol); const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10)); const displayAddr = 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); showTxDetail(loadedTxs[idx]); }); }); } function copyableHtml(text, extraClass) { const cls = "underline decoration-dashed cursor-pointer" + (extraClass ? " " + extraClass : ""); return `${escapeHtml(text)}`; } function blockieHtml(address) { const src = makeBlockie(address); return ``; } function txDetailAddressHtml(address) { const ensName = ensNameMap.get(address) || null; const blockie = blockieHtml(address); const dot = addressDotHtml(address); const link = etherscanAddressLink(address); const extLink = `${EXT_ICON}`; let html = `
${blockie}
`; if (ensName) { html += `
${dot}` + copyableHtml(ensName, "") + extLink + `
` + `
` + copyableHtml(address, "break-all") + `
`; } else { html += `
${dot}` + copyableHtml(address, "break-all") + extLink + `
`; } return html; } function txDetailHashHtml(hash) { const link = etherscanTxLink(hash); const extLink = `${EXT_ICON}`; return copyableHtml(hash, "break-all") + extLink; } function showTxDetail(tx) { $("tx-detail-hash").innerHTML = txDetailHashHtml(tx.hash); $("tx-detail-from").innerHTML = txDetailAddressHtml(tx.from); $("tx-detail-to").innerHTML = txDetailAddressHtml(tx.to); $("tx-detail-value").textContent = tx.value + " " + tx.symbol; $("tx-detail-time").textContent = isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; showView("transaction"); // Attach copy handlers document .getElementById("view-transaction") .querySelectorAll("[data-copy]") .forEach((el) => { el.onclick = () => { navigator.clipboard.writeText(el.dataset.copy); showFlash("Copied!"); }; }); } function init(ctx) { $("address-token-full").addEventListener("click", () => { const addr = $("address-token-full").dataset.full; if (addr) { navigator.clipboard.writeText(addr); 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; } $("send-token").disabled = true; updateSendBalance(); showView("send"); }); $("btn-address-token-receive").addEventListener("click", () => { const addr = currentAddress(); const address = addr ? addr.address : ""; $("receive-address").textContent = address; if (address) { QRCode.toCanvas($("receive-qr"), address, { width: 200, margin: 2, color: { dark: "#000000", light: "#ffffff" }, }); } const warningEl = $("receive-erc20-warning"); if (state.selectedToken && state.selectedToken !== "ETH") { warningEl.textContent = "This is an ERC-20 token. Only send " + currentSymbol + " on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss."; warningEl.classList.remove("hidden"); } else { warningEl.classList.add("hidden"); } showView("receive"); }); } module.exports = { init, show };