Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.
The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
393 lines
14 KiB
JavaScript
393 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,
|
|
displaySymbol,
|
|
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 " +
|
|
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 * 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(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> ${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,
|
|
state.networkId,
|
|
);
|
|
} 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);
|
|
// 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);
|
|
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(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 };
|