The balance lists and Home's ETH line rounded with toFixed(4), so 0.99996 ETH read 1.0000 there and 0.9999 on Send. Send's current balance and ConfirmTx's balance line kept the nonzero floor, so 0.00004 USDC read 0.0000 in the lists and 0.00004 on Send. All four now cut the stored decimal string with truncateAmount(), as the history rows do. balanceLine() takes the stored balance string instead of a parsed number, and escapes the quantity cell. The amounts, fees and allowances a screen asks the user to approve keep the floor of truncateAmountNeverZero(). Disclosure: for a holding below 0.0001, ConfirmTx shows the amount sent beside a balance that reads 0.0000. Model: opus-5-5
419 lines
16 KiB
JavaScript
419 lines
16 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 { bundledToken, resolveSymbol } = require("../../shared/tokenList");
|
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
|
const {
|
|
fetchRecentTransactions,
|
|
filterTransactions,
|
|
} = require("../../shared/transactions");
|
|
const { resolveEnsNames } = require("../../shared/ens");
|
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
|
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;
|
|
const chainId = currentNetwork().chainId;
|
|
const knownToken = bundledToken(tokenId, chainId);
|
|
currentSymbol =
|
|
tokenId === "ETH"
|
|
? nativeCurrency()
|
|
: resolveSymbol(
|
|
tokenId,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
chainId,
|
|
);
|
|
|
|
$("address-token-title").textContent =
|
|
wallet.name +
|
|
" \u2014 Address " +
|
|
(ai + 1) +
|
|
" \u2014 " +
|
|
displaySymbol(currentSymbol);
|
|
|
|
// 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"));
|
|
|
|
renderBalance();
|
|
|
|
// 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);
|
|
}
|
|
|
|
// The USD total and the balance line of the selected token. Drawn again by
|
|
// each balance refresh that returns while this screen is up
|
|
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
|
|
// screen as it is.
|
|
function renderBalance() {
|
|
const addr =
|
|
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
|
const tokenId = state.selectedToken;
|
|
let balance, price;
|
|
if (tokenId === "ETH") {
|
|
// null when no refresh has read the balance.
|
|
balance = addr.balance;
|
|
price = getPrice("ETH");
|
|
} else {
|
|
const id = tokenId.toLowerCase();
|
|
const tb = (addr.tokenBalances || []).find(
|
|
(t) => t.address.toLowerCase() === id,
|
|
);
|
|
const tracked = state.trackedTokens.find(
|
|
(t) => t.address.toLowerCase() === id,
|
|
);
|
|
// null when the scale is unknown, or when no refresh has read the
|
|
// token's balance: no quantity to show, and none to price.
|
|
// balanceLine() states that rather than printing 0.0000.
|
|
if (tb) balance = tb.balance;
|
|
else balance = tracked && tracked.balanceNotFetched ? null : "0";
|
|
price = getPrice(tokenId);
|
|
}
|
|
|
|
// USD total for this token only
|
|
const amount = unknownableAmount(balance);
|
|
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(
|
|
currentSymbol,
|
|
balance,
|
|
price,
|
|
);
|
|
}
|
|
|
|
async function loadTransactions(address, tokenId) {
|
|
const networkId = state.networkId;
|
|
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;
|
|
|
|
// 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(),
|
|
);
|
|
}
|
|
|
|
// 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))),
|
|
];
|
|
let names = new Map();
|
|
if (counterparties.length > 0) {
|
|
try {
|
|
names = await resolveEnsNames(
|
|
counterparties,
|
|
state.rpcUrl,
|
|
state.networkId,
|
|
ctx.pageClosed,
|
|
);
|
|
} catch {
|
|
// The rows then show no ENS names.
|
|
}
|
|
}
|
|
|
|
// If the network changed during the lookups above, or the screen now
|
|
// shows another address or token, nothing is saved or drawn. The page's
|
|
// network is compared after the read of storage, because a switch made
|
|
// on this page can land during that read. See
|
|
// src/shared/storedSettings.js.
|
|
if (
|
|
!(await storedNetworkIs(networkId)) ||
|
|
state.networkId !== networkId ||
|
|
$("address-token-line").dataset.full !== address ||
|
|
state.selectedToken !== tokenId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Persist any newly discovered fraud contracts, inside the save and
|
|
// holding the lock. Nothing is added unless this page and the stored
|
|
// record both still name the request's network.
|
|
if (result.newFraudContracts.length > 0) {
|
|
let saved = false;
|
|
await saveState((stored) => {
|
|
if (state.networkId !== networkId) return false;
|
|
if (stored.networkId !== networkId) return false;
|
|
for (const addr of result.newFraudContracts) {
|
|
if (!state.fraudContracts.includes(addr)) {
|
|
state.fraudContracts.push(addr);
|
|
}
|
|
}
|
|
saved = true;
|
|
});
|
|
// If the network changed while the save waited for the lock,
|
|
// nothing is drawn.
|
|
if (!saved) return;
|
|
}
|
|
|
|
loadedTxs = txs;
|
|
ensNameMap = names;
|
|
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);
|
|
// If the network changed, or the screen now shows another address or
|
|
// token, the failure is not shown.
|
|
if (
|
|
!(await storedNetworkIs(networkId)) ||
|
|
state.networkId !== networkId ||
|
|
$("address-token-line").dataset.full !== address ||
|
|
state.selectedToken !== tokenId
|
|
) {
|
|
return;
|
|
}
|
|
$("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", () => {
|
|
// AddressDetail's show() clears the token, but Back onto a drawn
|
|
// screen only unhides it. "Send" below may have left the dropdown on
|
|
// the token as well.
|
|
state.selectedToken = null;
|
|
$("send-token").value = "ETH";
|
|
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
|
|
];
|
|
// A null balance is one no refresh has read, so it is not refused.
|
|
if (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, renderBalance };
|