// 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 makeBlockie = require("ethereum-blockies-base64"); const { truncateAmountNeverZero, isBelowOneMillionth, } = require("../../shared/amountDisplay"); const { resolveTokenSymbol } = require("../../shared/approvalAmount"); 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, never by showView() (see there), but 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"; } // Set when src/popup/index.js passes the recovery screen to showView(), and // never cleared. Kept in memory for this popup's life, never in // state.currentView, which is saved: a popup opened later must not inherit it. let stateRecoveryShown = false; function showView(name) { // The recovery screen, once up, is never replaced: work still running // when it went up, such as a transaction wait, must not take the user off // it or clear what they exported or typed there // (https://git.eeqj.de/sneak/AutistMask/issues/373). if (stateRecoveryShown) return; const leaving = state.currentView; if (leaving && leaving !== name) { const onLeave = viewLeaveHandlers.get(leaving); if (onLeave) onLeave(); } // Passed here only so the screen it replaces is left like any other; // stateRecovery.show() raises it, and it is never the current view. if (name === "state-recovery") { stateRecoveryShown = true; return; } 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; } // The active network's native token symbol, `ETH` on mainnet and `SepoliaETH` // on Sepolia, as src/shared/networks.js names it. The wallet's balances and // the Send and confirmation screens, which send on the active network, label a // native amount with this; a transaction already made or requested is labelled // by its own chain id, through nativeCurrencyByChainId() in networks.js. The // "ETH" that state.selectedToken and txInfo.token hold is the native token's // id, not its label, and stays "ETH" on every network. function nativeCurrency() { return currentNetwork().nativeCurrency; } // The symbol shown for a token line, resolved from the bundled list, the // tokens the user tracks, and the explorer's report — the same chain the // amount line's scale comes from. Null when no source names one, so the token // lines keep saying `Unknown token` for a token nothing knows. function tokenLabel(address) { return resolveTokenSymbol(address, { trackedTokens: state.trackedTokens, wallets: state.wallets, }); } // A network fee in wei as the confirmation and approval screens both show it: // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // native currency of the network the fee is paid on, 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, symbol) { const eth = formatEther(wei); const ethPrice = getPrice("ETH"); const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; return ( truncateAmountNeverZero(eth) + " " + symbol + (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 ( `