A user who forgot their password but held their recovery phrase was permanently locked out: deletion was password-gated and re-importing the phrase was refused as a duplicate. Their only escape was destroying extension storage through browser internals, taking every other wallet with it. DeleteWallet gains an "I have lost my password" route that destroys the stored secret after the wallet's name is typed back. No password gate was added: requiring one to discard a secret protects nothing, since an attacker who wants destruction can uninstall the extension, and the only person it stops is the legitimate user who lost it. The screen is excluded from RESTORABLE_VIEWS and registers an onViewLeave cleanup. Deletion was chosen over re-import because a key wallet is duplicate-checked by address rather than xpub, so an xpub-only relaxation would leave that user still wedged; because re-import makes the user retype their recovery phrase into a live popup merely to change a password; and because it reaches no end state that delete-then-import plus scanForAddresses() does not. The attacker argument did not decide it — re-import clears the "no worse than the phrase alone" bar. All three AddWallet password hints now state the password cannot be recovered or reset and name that mode's only backup, the xprv mode correctly claiming no recovery phrase. deleteAddress.js no longer tells the user that deleting a wallet asks for a password, which this change made false. The typed confirmation collapses internal whitespace on both sides: a wallet renamed with two spaces displays with one, so the string a user could see and type could never match, making the confirmation untypable on the one screen whose purpose is un-wedging a stuck user. Measured, not reasoned, after review found the first reserve twice too large and pushing the Import button below the fold: #btn-add-wallet-confirm bottom 628.13 -> 580.13 at 360x600, scrollHeight 636 -> 600, hint box 48px identical across all three tabs and on re-entry. make check 40 suites / 828 tests, test-e2e 55/55, test-e2e-firefox 8/8.
540 lines
18 KiB
JavaScript
540 lines
18 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 { 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",
|
|
];
|
|
|
|
// 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);
|
|
}
|
|
const suffix = 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();
|
|
}
|
|
}
|
|
|
|
// 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 = "";
|
|
}
|
|
|
|
function showFlash(msg, duration = 2000) {
|
|
clearFlash();
|
|
$("flash-msg").textContent = msg;
|
|
flashTimer = setTimeout(() => {
|
|
$("flash-msg").textContent = "";
|
|
flashTimer = null;
|
|
}, duration);
|
|
}
|
|
|
|
// 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.
|
|
function balanceLine(symbol, amount, price, tokenId) {
|
|
const qty = amount.toFixed(4);
|
|
const usd = price ? 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 || []) {
|
|
const bal = parseFloat(t.balance || "0");
|
|
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. 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 (parseFloat(t.balance || "0") > 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Produces consistent HTML for any Ethereum address:
|
|
// • Color dot
|
|
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
|
// • Optional ENS name shown bold above address
|
|
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
|
// • Etherscan external link icon
|
|
//
|
|
// Options object:
|
|
// title — wallet title string (from addressTitle)
|
|
// ensName — ENS name string
|
|
// maxLen — if set, truncate address display (min 32 chars enforced)
|
|
// 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, maxLen) : address;
|
|
const link = etherscanAddressUrl(address);
|
|
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
|
|
|
let html = "";
|
|
if (title) {
|
|
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
|
}
|
|
if (ensName) {
|
|
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
|
}
|
|
if (title || ensName) {
|
|
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
|
} else {
|
|
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</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,
|
|
setBackRenderer,
|
|
pushCurrentView,
|
|
goBack,
|
|
clearViewStack,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
balanceLine,
|
|
balanceLinesForAddress,
|
|
addressHoldsFunds,
|
|
addressColor,
|
|
addressDotHtml,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
addressTitle,
|
|
formatAddressHtml,
|
|
renderAddressHtml,
|
|
copyableHtml,
|
|
attachCopyHandlers,
|
|
etherscanAddressUrl,
|
|
etherscanLinkHtml,
|
|
explorerUrl,
|
|
EXT_ICON,
|
|
truncateMiddle,
|
|
isoDate,
|
|
timeAgo,
|
|
};
|