All checks were successful
check / check (push) Successful in 26s
Address rows on Home now carry an [x] control on wallets that derive their addresses from an extended key and hold more than one; it opens a confirmation screen before anything is removed. Removing an address destroys nothing, and the copy says so: the address stays derivable from key material the wallet still holds, any funds at it stay where they are, and importing the recovery phrase brings it back. That is also why the screen is not password-gated, unlike delete-wallet — a password gates the disclosure or destruction of a secret, and this does neither. A balance is surfaced as a warning line, never as a refusal. The state transition lives next to the wallet one in src/shared/walletDelete.js and shares its address comparison, site-permission cleanup and broadcast, so the rules match one level down: the last address of a wallet is never removable, the selection moves only when it was the address removed, an index after the splice is decremented, a selection in another wallet is untouched, and AUTISTMASK_ACTIVE_CHANGED is broadcast when the active address moves so a connected site stops being told about an address the user removed. The wallet's derivation counter is a high-water mark and is not rewound, so "+" derives a fresh index rather than handing back the address just removed.
482 lines
14 KiB
JavaScript
482 lines
14 KiB
JavaScript
// Shared DOM helpers used by all views.
|
|
|
|
const { isDebug } = require("../../shared/log");
|
|
const {
|
|
formatUsd,
|
|
getPrice,
|
|
getAddressValueUsd,
|
|
} = require("../../shared/prices");
|
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
|
|
|
// 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-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;
|
|
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 to re-render the main/home view when navigating back to it.
|
|
// Set once by index.js via setRenderMain().
|
|
let _renderMain = null;
|
|
|
|
function setRenderMain(fn) {
|
|
_renderMain = 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";
|
|
}
|
|
if (target === "main" && _renderMain) {
|
|
_renderMain();
|
|
}
|
|
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);
|
|
}
|
|
|
|
function balanceLine(symbol, amount, price, tokenId) {
|
|
const qty = amount.toFixed(4);
|
|
const usd = price ? formatUsd(amount * price) || " " : " ";
|
|
const tokenAttr = tokenId ? ` data-token="${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>${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;
|
|
}
|
|
|
|
// 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>`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
const div = document.createElement("div");
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// 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>`;
|
|
|
|
function etherscanAddressUrl(address) {
|
|
return `${currentNetwork().explorerUrl}/address/${address}`;
|
|
}
|
|
|
|
function etherscanLinkHtml(url) {
|
|
return (
|
|
`<a href="${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,
|
|
setRenderMain,
|
|
pushCurrentView,
|
|
goBack,
|
|
clearViewStack,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
balanceLine,
|
|
balanceLinesForAddress,
|
|
addressColor,
|
|
addressDotHtml,
|
|
escapeHtml,
|
|
addressTitle,
|
|
formatAddressHtml,
|
|
renderAddressHtml,
|
|
copyableHtml,
|
|
attachCopyHandlers,
|
|
etherscanAddressUrl,
|
|
etherscanLinkHtml,
|
|
EXT_ICON,
|
|
truncateMiddle,
|
|
isoDate,
|
|
timeAgo,
|
|
};
|