Files
AutistMask/src/popup/views/helpers.js
T
clawbot bec20aa2bb
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
fix: say a contract creation has no recipient instead of a blank line (closes #250)
A transaction with no `to` showed a blank address, with a colour dot whose
colour was `undefined`, on the wait, success and error screens, the
transaction detail view and the history rows on Home, AddressDetail and
AddressToken. The approval screen showed "(contract creation)".

All of them now say "This transaction creates a new contract. It has no
recipient." The three history lists draw a row's counterparty lines through
one helper in helpers.js. A transaction with a real `to` is unchanged. The
new test drives each screen and list both ways.

Model: opus-5-5
2026-10-04 12:24:39 +02:00

683 lines
25 KiB
JavaScript

// Shared DOM helpers used by all views.
//
// Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string
// goes through escapeHtml(), whatever its provenance looks like today. The
// only interpolations left bare are markup FRAGMENTS this code just built
// (a rendered dot, an icon, a composed row), which escaping would turn into
// visible angle brackets, and locally computed numbers and loop indices.
// The distinction is meant to be greppable: an unescaped `${` next to a
// name that reads like data is a defect.
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { formatEther } = require("ethers");
const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [
"welcome",
"add-wallet",
"main",
"address",
"address-token",
"send",
"confirm-tx",
"wait-tx",
"success-tx",
"error-tx",
"receive",
"add-token",
"settings",
"delete-wallet-confirm",
"delete-wallet-lost-password",
"delete-address-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the
// state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
"state-recovery",
];
// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();
function onViewLeave(name, fn) {
viewLeaveHandlers.set(name, fn);
}
function $(id) {
return document.getElementById(id);
}
function showError(id, msg) {
const el = $(id);
el.textContent = msg;
el.style.visibility = "visible";
}
function hideError(id) {
const el = $(id);
el.textContent = "";
el.style.visibility = "hidden";
}
function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
el.classList.toggle("hidden", v !== name);
}
}
clearFlash();
state.currentView = name;
// A view's show() ends here, so this is where the Back path learns the
// view is no longer the blank template from index.html and must not be
// rendered a second time. See viewRouter.js.
markViewRendered(name);
saveState();
updateDebugBanner(name);
}
// Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
function updateDebugBanner(viewName) {
const debug = isDebug();
const net = currentNetwork();
const show = debug || net.isTestnet;
let banner = document.getElementById("debug-banner");
if (show) {
if (!banner) {
banner = document.createElement("div");
banner.id = "debug-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
}
// The view id is internal vocabulary; it helps while developing but
// means nothing to a user. Only a debug build appends it, gated on the
// compile-time DEBUG constant so a release build never shows it — not
// isDebug(), which is also true for a testnet or the runtime toggle.
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) {
banner.textContent = "[TESTNET]" + suffix;
} else {
banner.textContent = "DEBUG / INSECURE" + suffix;
}
} else if (banner) {
banner.remove();
}
}
// The banner shown when a save has failed, registered as the save-failure
// reporter by src/popup/index.js.
//
// Persistent and not dismissable, unlike showFlash(): what it says is true
// until the popup is closed, and a message that clears itself after two seconds
// is how the user goes on operating a wallet that is persisting nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
// because it hangs off document.body rather than off a view.
//
// Created on demand rather than authored in index.html, the same way
// updateDebugBanner() creates its own: it is absent from a popup where nothing
// has failed, which is the state that must not need markup to be in.
//
// textContent, never innerHTML: `detail` carries an error message, which may
// come from the browser's storage layer.
function showSaveFailureBanner(detail) {
let banner = document.getElementById("save-failure-banner");
if (!banner) {
banner = document.createElement("div");
banner.id = "save-failure-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
document.body.prepend(banner);
}
const message = (detail && (detail.message || detail.problem)) || detail;
banner.textContent =
"NOT SAVED — AutistMask could not write to storage, so recent" +
" changes are not stored. Close and reopen the popup; if this keeps" +
" happening, do not rely on anything you change now." +
(message ? " (" + String(message) + ")" : "");
}
// Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;
function setBackRenderer(fn) {
_renderBack = fn;
}
// Push the current view onto the navigation stack so goBack() can
// return to it. Call this before any forward navigation.
function pushCurrentView() {
if (state.currentView) {
state.viewStack.push(state.currentView);
}
}
// Pop the navigation stack and show the previous view. If the stack
// is empty, fall back to the main (home) view.
function goBack() {
let target;
if (state.viewStack.length > 0) {
target = state.viewStack.pop();
} else {
target = "main";
}
// A popped view is landed on, not navigated to. If the popup has been
// closed and reopened since the view was pushed, nothing has ever
// rendered it in this page load and its template is still blank, so it
// has to be rendered here rather than merely unhidden.
if (_renderBack && _renderBack(target)) return;
showView(target);
}
// Clear the entire navigation stack (used when resetting to root,
// e.g. after adding or deleting a wallet).
function clearViewStack() {
state.viewStack = [];
}
let flashTimer = null;
function clearFlash() {
if (flashTimer) {
clearTimeout(flashTimer);
flashTimer = null;
}
$("flash-msg").textContent = "";
$("flash-msg").title = "";
}
// The flash line reserves exactly one line, and a message that wrapped would
// push the screen below it down (README, No Layout Shift). So #flash-msg never
// wraps: text too long for the line is cut with an ellipsis, and the whole
// message is also put in the line's title. Write messages to fit, at most 50
// characters, so none is cut.
function showFlash(msg, duration = 2000) {
clearFlash();
$("flash-msg").textContent = msg;
$("flash-msg").title = msg;
flashTimer = setTimeout(clearFlash, duration);
}
// A stored token balance as a number, or null when there is no number in it.
// balances.js writes null for a holding whose scale nothing knows, and this
// keeps that null from becoming a zero one dereference later.
function unknownableAmount(balance) {
if (balance == null) return null;
const n = parseFloat(balance);
return Number.isFinite(n) ? n : null;
}
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
);
}
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
// so it is attacker-chosen markup until it has been through escapeHtml, and
// attacker-chosen length until it has been through displaySymbol. This is
// the row that issue #307 was reported against: every screen that lists a
// holding renders through here.
//
// `amount` is null for a holding whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
// print for it and no fiat value to derive from one, and printing 0.0000 for
// a real holding is the failure this whole rule exists to prevent, so the row
// says so instead.
function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
price && amount !== null
? formatUsd(amount * price) || " "
: " ";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row"
: "";
return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
`<span>${qty}</span>` +
`</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` +
`</div>`
);
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
"ETH",
);
const seen = new Set();
for (const t of addr.tokenBalances || []) {
// A holding below 0.000001 is not listed, tracked or not. A tracked
// token then gets the zero row below while showZero is on.
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding of an unstatable amount, not a holding
// of zero, so the show-zero setting has no say over it: hiding it
// would be asserting the zero nobody established. Anything that does
// not parse to a finite number is unknown for the same reason — the
// `|| "0"` this replaced turned both into a confident zero.
const bal = unknownableAmount(t.balance);
if (bal === 0 && !showZero) continue;
html += balanceLine(
t.symbol,
bal,
getPrice(t.symbol),
t.address.toLowerCase(),
);
seen.add(t.address.toLowerCase());
}
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
html += balanceLine(
t.symbol,
0,
getPrice(t.symbol),
t.address.toLowerCase(),
);
}
}
return html;
}
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about, except a token holding below 0.000001, which the balance list
// under the remove-address warning leaves out too. Deliberately unrounded —
// the rendered lines round to four decimals, so a dust balance displays as
// 0.0000 while still being real money at a real address. Callers that warn
// about holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) {
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding whose amount could not be stated —
// balances.js drops a row of zero base units before the scale is
// consulted, so a row that survived with no quantity is holding
// something. Warning about funds must err towards warning.
const bal = unknownableAmount(t.balance);
if (bal === null || bal > 0) return true;
}
return false;
}
// The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call
// site, and is now enforced once in renderAddressHtml().
const ADDRESS_MIN_DISPLAY_LEN = 32;
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
// README). Callers that need to display less should use a different UI
// approach rather than silently making addresses insecure.
function truncateMiddle(str, maxLen) {
if (str.length <= maxLen) return str;
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
if (removed > 10) {
maxLen = str.length - 10 + 1;
}
if (maxLen >= str.length) return str;
const half = Math.floor((maxLen - 1) / 2);
return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
}
// 16 colors evenly spaced around the hue wheel (22.5° apart),
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
const ADDRESS_COLORS = [
"#d92626",
"#d96926",
"#d9ac26",
"#c2d926",
"#80d926",
"#3dd926",
"#26d953",
"#26d996",
"#26d9d9",
"#2696d9",
"#2653d9",
"#3d26d9",
"#8026d9",
"#c226d9",
"#d926ac",
"#d92669",
];
function addressColor(address) {
const idx = parseInt(address.slice(2, 6), 16) % 16;
return ADDRESS_COLORS[idx];
}
function addressDotHtml(address) {
const color = addressColor(address);
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
}
// Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) {
const lower = address.toLowerCase();
for (let wi = 0; wi < wallets.length; wi++) {
const addrs = wallets[wi].addresses;
for (let ai = 0; ai < addrs.length; ai++) {
if (addrs[ai].address.toLowerCase() === lower) {
return wallets[wi].name + " \u2014 Address " + (ai + 1);
}
}
}
return null;
}
// What every recipient line and history row says for a transaction with no
// `to`. Such a transaction creates a contract, so there is no address to show,
// and a blank line on these screens reads as a rendering fault.
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
// amount alone and the contract creation sentence in place of the address.
function txCounterpartyHtml(address, nameHtml, amountHtml) {
if (!address) {
return (
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
);
}
return (
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
`<div class="am-address">${escapeHtml(address)}</div>`
);
}
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
// Delegates to renderAddressHtml for consistent output.
function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen });
}
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";
}
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) {
return explorerUrl("address", address);
}
// The URL still has to be escaped on the way into href="...": encoding
// governs what the URL means, escaping governs whether it stays inside the
// attribute.
function etherscanLinkHtml(url) {
return (
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>`
);
}
// Render a copyable text span with dashed underline affordance.
// The caller must attach click handlers via attachCopyHandlers() or
// manually wire up [data-copy] elements after inserting the HTML.
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
// Attach click-to-copy handlers to all [data-copy] elements within
// a container. Safe to call multiple times on the same container.
function attachCopyHandlers(container) {
const root =
typeof container === "string"
? document.getElementById(container)
: container;
if (!root) return;
root.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
// Unified address rendering.
//
// Two stacked rows, in this order:
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
// Address 2") and the explorer link icon. Optional ENS name below it.
// 2. The address itself, alone on a full-width row that never wraps
// (see .am-address in styles/main.css).
//
// The split is the point. Everything used to sit on one line: dot, address
// and link together, with `break-all` to let the address fold when the line
// ran out. In the wallet list, where the row also carried [info] and [x],
// it ran out every time — the bug in #380 — and a folded address is a
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
// now, so all 42 characters fit at every nesting depth the popup uses and
// nothing has to be dropped or folded to make room.
//
// Options object:
// title — wallet title string (from addressTitle)
// ensName — ENS name string
// maxLen — if set, truncate address display. Floored at 32 characters
// here rather than by the caller: no view passes it any more
// (every address row is wide enough for all 42 characters),
// so a floor that lived in the callers would have gone away
// with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
html += `<div class="flex items-center">${dot}`;
if (title) {
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
}
html += `${extLink}</div>`;
if (ensName) {
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
}
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
return html;
}
function flashCopyFeedback(el) {
if (!el) return;
el.classList.remove("copy-flash-fade");
el.classList.add("copy-flash-active");
setTimeout(() => {
el.classList.remove("copy-flash-active");
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
}
module.exports = {
VIEWS,
$,
showError,
hideError,
showView,
onViewLeave,
updateDebugBanner,
showSaveFailureBanner,
setBackRenderer,
pushCurrentView,
goBack,
clearViewStack,
showFlash,
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
formatFee,
addressColor,
addressDotHtml,
escapeHtml,
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
explorerUrl,
EXT_ICON,
truncateMiddle,
isoDate,
timeAgo,
};