Home, AddressDetail and AddressToken remember which network is active before they ask Blockscout for the transaction history. Each screen drops the answer if another network is active on the page or in storage when the answer lands. AddressDetail and AddressToken also drop an answer that lands once the screen shows another address. AddressToken also drops an answer that lands once the screen shows another token. A dropped answer draws no rows and saves no fraud contracts. A failed request goes through the same checks. A dropped failure shows no message. Holding the lock, the save adds new fraud contracts only if the page and the stored record still name the request's network. storedNetworkIs() reads the stored network, in src/shared/storedSettings.js. Model: opus-5-5
504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
isoDate,
|
|
timeAgo,
|
|
addressDotHtml,
|
|
txCounterpartyHtml,
|
|
HISTORY_OFF_TEXT,
|
|
addressTitle,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
nativeCurrency,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const {
|
|
state,
|
|
saveState,
|
|
currentAddress,
|
|
currentNetwork,
|
|
} = require("../../shared/state");
|
|
const { notify } = require("../../shared/browserApi");
|
|
const {
|
|
updateSendBalance,
|
|
renderSendTokenSelect,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
|
const { canRemoveAddress } = require("../../shared/walletDelete");
|
|
const {
|
|
walletDefect,
|
|
walletDefectHtml,
|
|
} = require("../../shared/walletDefects");
|
|
const {
|
|
formatUsd,
|
|
formatAddressTotal,
|
|
getPrice,
|
|
getAddressValue,
|
|
} = require("../../shared/prices");
|
|
const {
|
|
fetchRecentTransactions,
|
|
filterTransactions,
|
|
} = require("../../shared/transactions");
|
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
|
const { log } = require("../../shared/log");
|
|
|
|
function findActiveAddr() {
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
if (a.address === state.activeAddress) return a;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderTotalValue() {
|
|
const el = $("total-value");
|
|
const subEl = $("total-value-sub");
|
|
const priceEl = $("eth-price-display");
|
|
if (!el) return;
|
|
|
|
const ethPrice = getPrice("ETH");
|
|
if (priceEl) {
|
|
priceEl.innerHTML = ethPrice
|
|
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
|
: " ";
|
|
}
|
|
|
|
const addr = findActiveAddr();
|
|
if (!addr) {
|
|
el.innerHTML = " ";
|
|
if (subEl) subEl.innerHTML = " ";
|
|
return;
|
|
}
|
|
const ethBal = parseFloat(addr.balance || "0");
|
|
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
|
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
|
el.textContent = ethStr + ethUsd;
|
|
|
|
if (subEl) {
|
|
subEl.innerHTML =
|
|
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
|
}
|
|
}
|
|
|
|
function renderActiveAddress() {
|
|
const el = $("active-address-display");
|
|
if (!el) return;
|
|
if (state.activeAddress) {
|
|
el.innerHTML = renderAddressHtml(state.activeAddress);
|
|
attachCopyHandlers(el);
|
|
} else {
|
|
el.textContent = "";
|
|
}
|
|
}
|
|
|
|
let homeTxs = [];
|
|
|
|
function renderHomeTxList(ctx) {
|
|
const list = $("home-tx-list");
|
|
if (!list) return;
|
|
if (!state.useBlockscout) {
|
|
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
|
return;
|
|
}
|
|
if (homeTxs.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 homeTxs) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address (the one that initiated the swap) instead of the
|
|
// contract address which is not useful in the list view.
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? tx.to
|
|
: tx.from;
|
|
// directionLabel is the explorer's own method name for a contract
|
|
// call, title-cased — attacker-chosen for an attacker's contract.
|
|
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, when it is one of our own addresses,
|
|
// names it on the line above rather than replacing it.
|
|
const title = addressTitle(counterparty, state.wallets);
|
|
const titleStr = title ? escapeHtml(title) : "";
|
|
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="home-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, titleStr, amountStr);
|
|
html += `</div>`;
|
|
i++;
|
|
}
|
|
list.innerHTML = html;
|
|
list.querySelectorAll(".home-tx-row").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const idx = parseInt(row.dataset.tx, 10);
|
|
const tx = homeTxs[idx];
|
|
// Set selectedWallet/selectedAddress so back navigation works
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (
|
|
let ai = 0;
|
|
ai < state.wallets[wi].addresses.length;
|
|
ai++
|
|
) {
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (
|
|
addr.toLowerCase() === tx.from.toLowerCase() ||
|
|
addr.toLowerCase() === tx.to.toLowerCase()
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
state.selectedToken = null;
|
|
ctx.showTransactionDetail(tx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadHomeTxs(ctx) {
|
|
const allAddresses = [];
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
allAddresses.push(a.address);
|
|
}
|
|
}
|
|
if (allAddresses.length === 0) return;
|
|
|
|
const networkId = state.networkId;
|
|
const filters = {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
|
hideFraudContracts: state.hideFraudContracts,
|
|
hideDustTransactions: state.hideDustTransactions,
|
|
dustThresholdGwei: state.dustThresholdGwei,
|
|
fraudContracts: state.fraudContracts,
|
|
};
|
|
|
|
try {
|
|
const fetches = allAddresses.map((addr) =>
|
|
fetchRecentTransactions(
|
|
addr,
|
|
state.blockscoutUrl,
|
|
currentNetwork().chainId,
|
|
),
|
|
);
|
|
const results = await Promise.all(fetches);
|
|
|
|
// If the network changed during the lookups, nothing is saved or
|
|
// drawn. See src/shared/storedSettings.js.
|
|
if (
|
|
state.networkId !== networkId ||
|
|
!(await storedNetworkIs(networkId))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Merge, deduplicate by hash, filter, sort, take 25
|
|
const seen = new Set();
|
|
let merged = [];
|
|
for (const txs of results) {
|
|
for (const tx of txs) {
|
|
if (seen.has(tx.hash)) continue;
|
|
seen.add(tx.hash);
|
|
merged.push(tx);
|
|
}
|
|
}
|
|
|
|
const filtered = filterTransactions(merged, filters);
|
|
|
|
// 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 (filtered.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 filtered.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;
|
|
}
|
|
|
|
merged = filtered.transactions;
|
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
|
homeTxs = merged.slice(0, 25);
|
|
renderHomeTxList(ctx);
|
|
} catch (e) {
|
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
// src/popup/index.js.
|
|
if (ctx.pageClosed.aborted) return;
|
|
log.errorf("loadHomeTxs failed:", e.message);
|
|
// If the network changed, the failure is not shown.
|
|
if (
|
|
state.networkId !== networkId ||
|
|
!(await storedNetworkIs(networkId))
|
|
) {
|
|
return;
|
|
}
|
|
const list = $("home-tx-list");
|
|
if (list) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// The wallet list markup. Pure: it reads state and returns a string, so the
|
|
// list can be asserted on without a DOM.
|
|
function walletListHtml() {
|
|
let html = "";
|
|
state.wallets.forEach((wallet, wi) => {
|
|
const defect = walletDefect(wallet);
|
|
html += `<div>`;
|
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
|
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
|
// No "+" on a defective wallet: deriving another address from that
|
|
// xpub would only add one more address the key does not produce
|
|
// under the standard path.
|
|
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
|
|
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
|
}
|
|
html += `</div>`;
|
|
html += walletDefectHtml(wallet);
|
|
|
|
wallet.addresses.forEach((addr, ai) => {
|
|
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
|
const isActive = state.activeAddress === addr.address;
|
|
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
|
// Only where a wallet can spare the address: a wallet holding a
|
|
// single address has no remove control, because its last address
|
|
// is never removable.
|
|
const removeBtn = canRemoveAddress(wallet)
|
|
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
|
: "";
|
|
const dot = addressDotHtml(addr.address);
|
|
const titleBold = isActive ? "font-bold" : "";
|
|
// [info] and [x] ride on the "Address N" line, which was empty
|
|
// to its right, so the address below gets the row to itself.
|
|
// They used to sit beside the address and take about a third of
|
|
// the width off it, which is what made a 42-character address
|
|
// fold onto a second line here and nowhere else (#380).
|
|
html += `<div class="flex text-xs items-center justify-between">`;
|
|
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
|
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
|
html += `</div>`;
|
|
if (addr.ensName) {
|
|
// An ENS reverse record is whatever the name owner set it
|
|
// to; renderAddressHtml() escapes its own copy of this and
|
|
// this list was the one that did not.
|
|
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
|
|
}
|
|
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
|
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
|
html += balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
html += `</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
});
|
|
return html;
|
|
}
|
|
|
|
function render(ctx) {
|
|
const container = $("wallet-list");
|
|
// Only Blockscout reports the tokens the user has not tracked.
|
|
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
if (state.wallets.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = walletListHtml();
|
|
|
|
container.querySelectorAll(".address-row").forEach((row) => {
|
|
row.addEventListener("click", async () => {
|
|
const wi = parseInt(row.dataset.wallet, 10);
|
|
const ai = parseInt(row.dataset.address, 10);
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (state.activeAddress !== addr) {
|
|
state.activeAddress = addr;
|
|
await saveState();
|
|
render(ctx);
|
|
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-addr-info").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
state.selectedWallet = parseInt(btn.dataset.wallet, 10);
|
|
state.selectedAddress = parseInt(btn.dataset.address, 10);
|
|
ctx.showAddressDetail();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
ctx.showDeleteAddress(
|
|
parseInt(btn.dataset.wallet, 10),
|
|
parseInt(btn.dataset.address, 10),
|
|
);
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-add-address").forEach((btn) => {
|
|
btn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(btn.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const newAddr = deriveAddressFromXpub(
|
|
wallet.xpub,
|
|
wallet.nextIndex,
|
|
);
|
|
wallet.addresses.push({
|
|
address: newAddr,
|
|
index: wallet.nextIndex,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
});
|
|
wallet.nextIndex++;
|
|
await saveState();
|
|
render(ctx);
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".wallet-name").forEach((span) => {
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(span.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.value = wallet.name;
|
|
input.className =
|
|
"font-bold border border-border p-0 bg-bg text-fg";
|
|
input.style.width = "100%";
|
|
const save = async () => {
|
|
const val = input.value.trim();
|
|
if (val && val !== wallet.name) {
|
|
wallet.name = val;
|
|
await saveState();
|
|
}
|
|
render(ctx);
|
|
};
|
|
input.addEventListener("blur", save);
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") input.blur();
|
|
if (ev.key === "Escape") {
|
|
input.value = wallet.name;
|
|
input.blur();
|
|
}
|
|
});
|
|
span.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
});
|
|
});
|
|
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
loadHomeTxs(ctx);
|
|
}
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Call
|
|
// after selectActiveAddress().
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
function selectActiveAddress() {
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
|
|
if (
|
|
state.wallets[wi].addresses[ai].address === state.activeAddress
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function init(ctx) {
|
|
$("btn-add-wallet-bottom").addEventListener("click", ctx.showAddWalletView);
|
|
|
|
$("btn-main-send").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
// Before the balance check and before any password is asked for: this
|
|
// wallet cannot sign at all, so the send screen is a dead end.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
const addr = currentAddress();
|
|
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");
|
|
renderSendTokenSelect(addr);
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-main-receive").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, render, walletListHtml };
|