script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.
ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.
Two rules new to the recommended set are narrowed, and both would have cost
something to satisfy. no-useless-assignment is off for approval.js and
confirmTx.js only: it flags the `password = null` and `decryptedSecret =
null` wipes at 9 sites there, which are dead by construction — that is what
a best-effort wipe of decrypted key material is — and the rule's fix is to
delete the wipe. It stays on for the rest of the tree, so an ordinary dead
store elsewhere is still an error. preserve-caught-error is off tree-wide:
it would change what the wallet's error paths throw at 3 sites
(src/shared/balances.js 207 and 215, tests/e2e/firefox/run.js 131), and
adopting `{ cause }` is a decision of its own rather than a side effect of
turning a linter on, so new code is not held to it either pending that
decision.
Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283). driver.js's waitFor
had a plain dead store in its `last` initializer, which the newly scoped
no-useless-assignment catches; the initializer is dropped.
Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker, and a value set to anything else is now an error rather than a
silent fall-through to the docker path. The check stage takes a COPY --from=
lint dependency so a lint failure fails the whole build early rather than
racing it.
The lint stage roughly doubles the image build, which exposed script/test's
30s cap as marginal rather than a bound: on the first CI run to rebuild the
base stage cold it killed a healthy suite at 30.6s with nothing asserting
false. The cap is a guard against a hung suite, not a wall-clock budget, and
one a healthy suite can trip teaches "just run it again". It stays at 30s on
a host, where the suite runs in about 8s and REPO_POLICIES' figure holds,
and the Dockerfile raises it to 180s through AUTISTMASK_TEST_TIMEOUT for the
in-image run, which also pays a cold jest cache and shares the runner with
the rest of the build. script/test now names a timeout kill as one instead
of reporting it as a test failure, and skips the verbose rerun in that case,
which would only spend the same wall clock to be killed again.
No --fix anywhere in the lint path: make check remains non-mutating.
The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
383 lines
14 KiB
JavaScript
383 lines
14 KiB
JavaScript
// Address-token detail view: shows a single token's balance and
|
|
// filtered transactions for the selected address.
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
truncateMiddle,
|
|
balanceLine,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state, saveState } = 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;
|
|
|
|
function isoDate(timestamp) {
|
|
const d = new Date(timestamp * 1000);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
if (state.utcTimestamps) {
|
|
return (
|
|
d.getUTCFullYear() +
|
|
"-" +
|
|
pad(d.getUTCMonth() + 1) +
|
|
"-" +
|
|
pad(d.getUTCDate()) +
|
|
"T" +
|
|
pad(d.getUTCHours()) +
|
|
":" +
|
|
pad(d.getUTCMinutes()) +
|
|
":" +
|
|
pad(d.getUTCSeconds()) +
|
|
"Z"
|
|
);
|
|
}
|
|
const offsetMin = -d.getTimezoneOffset();
|
|
const sign = offsetMin >= 0 ? "+" : "-";
|
|
const absOff = Math.abs(offsetMin);
|
|
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
|
return (
|
|
d.getFullYear() +
|
|
"-" +
|
|
pad(d.getMonth() + 1) +
|
|
"-" +
|
|
pad(d.getDate()) +
|
|
"T" +
|
|
pad(d.getHours()) +
|
|
":" +
|
|
pad(d.getMinutes()) +
|
|
":" +
|
|
pad(d.getSeconds()) +
|
|
tzStr
|
|
);
|
|
}
|
|
|
|
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;
|
|
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
|
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 = resolveSymbol(
|
|
tokenId,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
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
|
|
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 * price : null;
|
|
const usdStr = formatUsd(usdVal);
|
|
$("address-token-usd-total").innerHTML = 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(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> ${tokenDecimals}</div>`;
|
|
if (tokenHolders != null)
|
|
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${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,
|
|
);
|
|
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,
|
|
);
|
|
} catch {
|
|
ensNameMap = new Map();
|
|
}
|
|
}
|
|
|
|
renderTransactions(txs);
|
|
} catch (e) {
|
|
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 (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);
|
|
const dirLabel = tx.directionLabel;
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + tx.symbol)
|
|
: escapeHtml(tx.symbol);
|
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
|
const displayAddr =
|
|
title || 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 += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
|
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").classList.add("hidden");
|
|
let staticHtml = `<div class="font-bold">${escapeHtml(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 };
|