Out of the box the wallet sent the user's addresses to Blockscout and each recipient to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate those requests. Both are off in a new install and offered unticked on Welcome and in Settings. While one is off, the screens that relied on it say so in a plain line. Each gated request reads its setting from storage before it asks and when the answer lands, and drops a late answer. An open popup takes another page's change and draws its screens again. While "Use Blockscout" is off, no address keeps an untracked token, and no screen names a token from Blockscout's report. ENS lookups ask only the RPC endpoint. Model: opus-5-5
352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
// Address-token detail view: shows a single token's balance and
|
|
// filtered transactions for the selected address.
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
txCounterpartyHtml,
|
|
HISTORY_OFF_TEXT,
|
|
addressTitle,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
nativeCurrency,
|
|
balanceLine,
|
|
unknownableAmount,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
pushCurrentView,
|
|
isoDate,
|
|
timeAgo,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
|
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
|
const { formatUsd, getPrice } = 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");
|
|
const { walletDefect } = require("../../shared/walletDefects");
|
|
|
|
let ctx;
|
|
|
|
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 = nativeCurrency();
|
|
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,
|
|
);
|
|
// null when the scale is unknown: no quantity to show, and none to
|
|
// price. balanceLine() states that rather than printing 0.0000.
|
|
amount = tb ? unknownableAmount(tb.balance) : 0;
|
|
price = getPrice(symbol);
|
|
}
|
|
|
|
currentSymbol = symbol;
|
|
|
|
$("address-token-title").textContent =
|
|
wallet.name +
|
|
" \u2014 Address " +
|
|
(ai + 1) +
|
|
" \u2014 " +
|
|
displaySymbol(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
|
|
const addrTitle = addressTitle(addr.address, state.wallets);
|
|
$("address-token-line").innerHTML = renderAddressHtml(addr.address, {
|
|
title: addrTitle,
|
|
ensName: addr.ensName,
|
|
});
|
|
$("address-token-line").dataset.full = addr.address;
|
|
attachCopyHandlers($("address-token-line"));
|
|
|
|
// USD total for this token only
|
|
const usdVal = price && amount !== null ? amount * price : null;
|
|
const usdStr = formatUsd(usdVal);
|
|
$("address-token-usd-total").innerHTML = escapeHtml(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(displaySymbol(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 projectUrl = knownToken && knownToken.url ? knownToken.url : null;
|
|
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
|
|
infoHtml += `<div class="mb-2">${renderAddressHtml(tokenId)}</div>`;
|
|
if (tokenName)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
|
|
if (tokenSymbol)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
|
if (tokenDecimals != null)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
|
if (tokenHolders != null)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
|
if (projectUrl)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
|
contractInfo.innerHTML = infoHtml;
|
|
attachCopyHandlers(contractInfo);
|
|
contractInfo.classList.remove("hidden");
|
|
} else {
|
|
contractInfo.innerHTML = "";
|
|
contractInfo.classList.add("hidden");
|
|
}
|
|
|
|
// Transactions
|
|
$("address-token-tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
|
showView("address-token");
|
|
loadTransactions(addr.address, tokenId);
|
|
}
|
|
|
|
async function loadTransactions(address, tokenId) {
|
|
try {
|
|
const rawTxs = await fetchRecentTransactions(
|
|
address,
|
|
state.blockscoutUrl,
|
|
currentNetwork().chainId,
|
|
);
|
|
const result = filterTransactions(rawTxs, {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
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,
|
|
state.networkId,
|
|
ctx.pageClosed,
|
|
);
|
|
} catch {
|
|
ensNameMap = new Map();
|
|
}
|
|
}
|
|
|
|
renderTransactions(txs);
|
|
} catch (e) {
|
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
// src/popup/index.js.
|
|
if (ctx.pageClosed.aborted) return;
|
|
log.errorf("loadTransactions failed:", e.message);
|
|
$("address-token-tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
|
|
function renderTransactions(txs) {
|
|
const list = $("address-token-tx-list");
|
|
if (!state.useBlockscout) {
|
|
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
|
return;
|
|
}
|
|
if (txs.length === 0) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
|
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);
|
|
// The explorer's method name for a contract call, title-cased.
|
|
const dirLabel = escapeHtml(tx.directionLabel);
|
|
const sym = displaySymbol(tx.symbol);
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + sym)
|
|
: escapeHtml(sym);
|
|
// The counterparty used to be squeezed in beside the amount and
|
|
// truncated to whatever was left over. It gets its own row now and
|
|
// is shown whole; the title or ENS name, where there is one, names
|
|
// it on the line above rather than replacing it.
|
|
const nameStr = escapeHtml(title || ensName || "");
|
|
const err = tx.isError ? " (failed)" : "";
|
|
const opacity = tx.isError ? " opacity-50" : "";
|
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
|
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
|
html += `</div>`;
|
|
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-contract-info").addEventListener("click", (e) => {
|
|
const copyEl = e.target.closest("[data-copy]");
|
|
if (copyEl) {
|
|
navigator.clipboard.writeText(copyEl.dataset.copy);
|
|
showFlash("Copied!");
|
|
flashCopyFeedback(copyEl);
|
|
}
|
|
});
|
|
|
|
$("btn-address-token-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
|
|
$("btn-address-token-send").addEventListener("click", () => {
|
|
const defect = walletDefect(state.wallets[state.selectedWallet]);
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
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-dropdown").classList.add("hidden");
|
|
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
|
|
if (tokenId !== "ETH") {
|
|
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
|
|
}
|
|
$("send-token-static").innerHTML = staticHtml;
|
|
$("send-token-static").classList.remove("hidden");
|
|
attachCopyHandlers($("send-token-static"));
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-address-token-receive").addEventListener("click", () => {
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show };
|