Files
AutistMask/src/popup/views/helpers.js
T
clawbot ca9cd438d9
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: transaction screens name a token from the tokens of the transaction's network (closes #629)
decodeCalldata() looked a token's symbol and decimals up in the active
network's tracked tokens and balances, whatever chain id it was given, so
TransactionDetail and TxApproval showed a token tracked on another network
by address only, with its amount in base units. The lookup now uses what the
wallet knows of the tokens on the transaction's network: the active
network's tracked tokens and balances, or for another network the tokens
tracked on it and no balances. TxApproval's token labels, the symbol it
hands the wait, success and error screens, and SuccessTx's decoded token
label use the same lookup.

Model: opus-5-5
2026-10-20 04:16:04 +02:00

816 lines
32 KiB
JavaScript

// Helpers the views in this directory share: showing a view and going back,
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
//
// 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 { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
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",
"approve-network",
"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, after the settings debug toggle changes, and
// after a network switch in Settings.
// 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.
//
// An entry for the view already showing is skipped: landing on it would
// make Back seem to do nothing. Settings, the recovery phrase or delete
// wallet screen, then the gear leaves Settings under Settings, because that
// screen takes itself off the stack when left, and a reopened popup cuts it
// off the restored stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/481).
function goBack() {
const stack = state.viewStack;
while (stack.length > 0 && stack[stack.length - 1] === state.currentView) {
stack.pop();
}
let target;
if (stack.length > 0) {
target = stack.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 screen, which sends on the active network, label a native amount
// with this; the confirmation screen, and a transaction already made or
// requested, label it by the transaction's 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;
}
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork();
if (net && net.id === state.networkId) {
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources 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, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
}
// 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 on the network with `chainId`, or on the active
// network when no chain id is given. The USD value is of the exact fee, not of
// the truncated figure.
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH", chainId);
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
? escapeHtml(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 w-[42ch] max-w-full">` +
`<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(
nativeCurrency(),
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 name of a wallet's key material, which is also its only backup: the
// recovery phrase of an HD wallet, the private key of a key wallet, the
// extended private key of an xprv wallet. DeleteWallet,
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
function keyMaterialName(wallet) {
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
if (wallet.type === "key") return "private key";
return "extended private key";
}
// 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.
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
// written out in the source, so the class name cannot be put together at
// runtime.
const ADDRESS_COLORS = [
"bg-[#d92626]",
"bg-[#d96926]",
"bg-[#d9ac26]",
"bg-[#c2d926]",
"bg-[#80d926]",
"bg-[#3dd926]",
"bg-[#26d953]",
"bg-[#26d996]",
"bg-[#26d9d9]",
"bg-[#2696d9]",
"bg-[#2653d9]",
"bg-[#3d26d9]",
"bg-[#8026d9]",
"bg-[#c226d9]",
"bg-[#d926ac]",
"bg-[#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 class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`;
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
}
// 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 });
}
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
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 class="inline-block w-[10px] h-[10px] ml-[4px] 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>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id,
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
// use the network their transaction's chain id names, and the popup can reopen
// onto them after Settings switched the network. The active network's are
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
// or its defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) {
const net = networkByChainId(chainId);
if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
}
const remembered = state.networkEndpoints[net.id] || {};
return {
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
};
}
// The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; 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, chainId) {
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address, chainId) {
return explorerUrl("address", address, chainId);
}
// 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
// chainId — the network whose explorer the link opens; the active
// network's when absent
//
// 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, chainId } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address, chainId);
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,
keyMaterialName,
unknownableAmount,
nativeCurrency,
tokenSourcesByChainId,
tokenLabel,
formatFee,
addressDotHtml,
blockieHtml,
escapeHtml,
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
endpointsByChainId,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
timeAgo,
};