A balance refresh that returns now draws the balances on AddressDetail or AddressToken again when that screen is up, not only the wallet list. Their transaction lists are left alone. Every add path on AddToken and SettingsAddToken tracks a token through trackToken(), which gives the tracked record `balanceNotFetched: true`. While the field is set, the token's line reads `quantity unknown` instead of 0.0000, and Send reads its balance as unknown. refreshBalances() now resolves with the tracked tokens whose balance it read at every address. The popup's refresh deletes the field from those tokens in the stored record and leaves the rest of the stored list as it is. Removing the token removes the field, and each network keeps its own records. Model: opus-5-5
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
txCounterpartyHtml,
|
|
HISTORY_OFF_TEXT,
|
|
addressTitle,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
pushCurrentView,
|
|
isoDate,
|
|
timeAgo,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
|
const { formatAddressTotal, getAddressValue } = 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 exportPrivkey = require("./exportPrivkey");
|
|
const { walletDefect } = require("../../shared/walletDefects");
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
|
// send and the private-key export path check it before asking for a password,
|
|
// so a wallet whose key getSignerForAddress refuses says so instead of failing
|
|
// after the user has typed one in.
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
let ctx;
|
|
|
|
function show() {
|
|
state.selectedToken = null;
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
const addr = wallet.addresses[state.selectedAddress];
|
|
const ai = state.selectedAddress;
|
|
$("address-title").textContent =
|
|
wallet.name + " \u2014 Address " + (ai + 1);
|
|
const blockieEl = $("address-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);
|
|
const addrTitle = addressTitle(addr.address, state.wallets);
|
|
$("address-line").innerHTML = renderAddressHtml(addr.address, {
|
|
title: addrTitle,
|
|
ensName: addr.ensName,
|
|
});
|
|
$("address-line").dataset.full = addr.address;
|
|
attachCopyHandlers($("address-line"));
|
|
const ensEl = $("address-ens");
|
|
// ENS is now shown inside renderAddressHtml, hide the separate element
|
|
ensEl.classList.add("hidden");
|
|
renderBalances();
|
|
// Only Blockscout reports the tokens the user has not tracked.
|
|
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
renderSendTokenSelect(addr);
|
|
$("tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
|
showView("address");
|
|
loadTransactions(addr.address);
|
|
}
|
|
|
|
// The USD total and the balance list. 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 renderBalances() {
|
|
const addr =
|
|
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
|
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
|
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
|
$("address-balances").innerHTML = balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
$("address-balances")
|
|
.querySelectorAll(".balance-row")
|
|
.forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
state.selectedToken = row.dataset.token;
|
|
ctx.showAddressToken();
|
|
});
|
|
});
|
|
}
|
|
|
|
let loadedTxs = [];
|
|
|
|
let ensNameMap = new Map();
|
|
|
|
async function loadTransactions(address) {
|
|
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,
|
|
});
|
|
const txs = result.transactions;
|
|
|
|
// Collect ALL unique addresses (from + to) for ENS resolution so
|
|
// that reverse lookups work for every displayed address, not just
|
|
// the ones that were originally entered as ENS names.
|
|
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, nothing is saved or drawn. See
|
|
// src/shared/storedSettings.js.
|
|
if (
|
|
state.networkId !== networkId ||
|
|
!(await storedNetworkIs(networkId)) ||
|
|
$("address-line").dataset.full !== address
|
|
) {
|
|
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,
|
|
// the failure is not shown.
|
|
if (
|
|
state.networkId !== networkId ||
|
|
!(await storedNetworkIs(networkId)) ||
|
|
$("address-line").dataset.full !== address
|
|
) {
|
|
return;
|
|
}
|
|
$("tx-list").innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
|
|
function renderTransactions(txs) {
|
|
const list = $("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) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address instead of the contract address (see issue #55).
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? 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;
|
|
|
|
$("btn-address-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
|
|
$("btn-send").addEventListener("click", () => {
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
const addr =
|
|
state.wallets[state.selectedWallet].addresses[
|
|
state.selectedAddress
|
|
];
|
|
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
|
showFlash("Cannot send \u2014 zero balance.");
|
|
return;
|
|
}
|
|
$("send-to").value = "";
|
|
$("send-amount").value = "";
|
|
$("send-token-dropdown").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-receive").addEventListener("click", () => {
|
|
ctx.showReceive();
|
|
});
|
|
|
|
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
|
|
|
// More menu dropdown
|
|
const moreBtn = $("btn-more-menu");
|
|
const moreDropdown = $("more-menu-dropdown");
|
|
moreBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = !moreDropdown.classList.toggle("hidden");
|
|
moreBtn.classList.toggle("bg-fg", isOpen);
|
|
moreBtn.classList.toggle("text-bg", isOpen);
|
|
});
|
|
document.addEventListener("click", () => {
|
|
moreDropdown.classList.add("hidden");
|
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
|
});
|
|
moreDropdown.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$("btn-export-privkey").addEventListener("click", () => {
|
|
moreDropdown.classList.add("hidden");
|
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
|
// This address's private key can be derived from the stored key,
|
|
// but export goes through getSignerForAddress, which refuses a key
|
|
// that is not a master key. Without this the export screen would
|
|
// take a password and then refuse.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
// No pushCurrentView() here: exportPrivkey.show() can return
|
|
// without navigating, so it does its own push.
|
|
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
|
});
|
|
|
|
exportPrivkey.init();
|
|
}
|
|
|
|
module.exports = { init, show, renderBalances };
|