theseus/bundled-addons/aegis/panel.js
Local Dev b85605416e Aegis: the PIN is checked by the host, and guessing ends at five
The panel fetched the PIN blob and decrypted it itself, then reported its
own failures. The lockout therefore counted only what a well-behaved panel
chose to report, a 15-minute timer handed out five more guesses forever,
and anything able to run in the panel could take the blob and search the
million PINs offline in minutes.

The blob now never leaves index.js: pinSet builds it after checking the
master password against the vault, pinUnwrap counts each guess before
trying it, and five wrong guesses switch the PIN off until the master
password is entered. The panel keeps its PIN pads and only sends digits.
A blob from an older build (200k iterations) is re-made at 600k under a
fresh salt on the next correct PIN. Only the topmost PIN pad listens to
typed digits, so two stacked pads cannot both take one entry.
2026-10-04 02:17:26 +02:00

5971 lines
308 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Aegis wallet panel. All state comes from activate() via window.silentmode.
// This file renders the multi-wallet picker, per-chain views, and collects
// input; it never touches keys or the vault.
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
// Which surface is this? The sidebar panel and the full-screen tab are the
// SAME file — an add-on's own tab is handed a window.silentmode with the
// same invoke/on surface, and main dispatches it as from:"panel", so
// everything below works identically in both. The flag only drives layout.
const SURFACE = (function () {
try { return new URL(location.href).searchParams.get("surface") || "panel"; }
catch (e) { return "panel"; }
})();
if (SURFACE === "web") document.documentElement.dataset.surface = "web";
let state = null; // full state (all wallets + selected)
let tab = "receive";
let unit = null; // "big" | "small" — chain-dependent
let sendMax = false;
let planTimer = null;
// 0.8.0: mode toggles for the Send + Receive tabs. "send"/"receive" is
// the normal flow; "consolidate" swaps the tab body for the inline
// batch-consolidate picker. Session-scoped — resets to normal on reload.
let sendMode = "send";
let rcvMode = "receive";
// Cached inline consolidate render tokens — rebuilt on demand, reused
// across paints while the mode is active.
let consolidateInlineHost = null;
let lastPlan = null;
let settingsFilled = false;
// Selected asset for the Send tab. `null` = native coin. Otherwise a
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
let sendAsset = null;
// Wallet strip's view mode. "coins" is the six-column ticker/chain summary;
// "addresses" replaces it inline with the per-address list under one coin
// group. Toggled via the group row click / the back arrow in the inline
// header. Cleared whenever a fresh render is triggered by a wallet change
// so the strip snaps back to the summary.
let stripView = { mode: "coins", groupKey: null };
// Cached security state ({ hasPin, requirePinForSending, requirePinForReveal }).
// Populated on startup and refreshed after any pin/security invoke — used by
// the lock screen (PIN vs. password), the Settings General card, and the
// reveal gate. requirePinForReveal defaults TRUE, here and in the host, so a
// failed read never lands on the permissive setting.
let securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true };
let securityLoaded = false;
// Cached session config: whether the vault stays unlocked across Theseus
// restarts (safeStorage-backed) and how many idle minutes trigger an
// auto-lock. Populated on boot; refreshed after each Settings edit.
let sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true };
let sessionLoaded = false;
let idleTimer = null;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
// Truncate a label to at most `n` visible chars, appending an ellipsis
// when clipped. Used by the inline coin list so long user labels don't
// blow out the row width; the full name stays available via title="".
const shortLabel = (s, n) => {
const t = String(s ?? "").trim();
const cap = Math.max(1, n || 7);
return t.length > cap ? t.slice(0, cap) + "…" : t;
};
// CashAddr / BIP-173 / Cashtokens all prefix the mainnet or testnet name
// before the payload ("bitcoincash:qz…", "bchtest:qp…", "bchreg:qr…").
// The prefix is the same on every row of a coin drilldown so showing it
// there is redundant noise — strip for display, keep in the tooltip and
// the clipboard so the full canonical form is one hover / one click away.
// Non-BCH addresses (ETH 0x…, TRX T…, base58 SOL) pass through unchanged.
const stripAddrPrefix = (addr) => {
if (!addr) return "";
const s = String(addr);
const i = s.indexOf(":");
if (i < 0) return s;
const p = s.slice(0, i).toLowerCase();
return (p === "bitcoincash" || p === "bchtest" || p === "bchreg") ? s.slice(i + 1) : s;
};
const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } };
const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {});
const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, "");
// ---- coin logos ------------------------------------------------------------
// Inline SVGs so the header, wallet picker and settings surface all render
// the same mark. Sized by the container via width/height attributes.
function logoSvg(logo, size) {
const s = size || 20;
if (logo === "bch") {
// All coin marks below are the canonical SVGs from
// github.com/spothq/cryptocurrency-icons — the permissive-licensed
// set most wallets, exchanges, and explorers standardised on, so
// Aegis's logos match what users see everywhere else. Inline so
// panel load doesn't fetch anything.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin Cash" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" fill="#8dc351" r="16"/><path d="M21.207 10.534c-.776-1.972-2.722-2.15-4.988-1.71l-.807-2.813-1.712.491.786 2.74c-.45.128-.908.27-1.363.41l-.79-2.758-1.711.49.805 2.813c-.368.114-.73.226-1.085.328l-.003-.01-2.362.677.525 1.83s1.258-.388 1.243-.358c.694-.199 1.035.139 1.2.468l.92 3.204c.047-.013.11-.029.184-.04l-.181.052 1.287 4.49c.032.227.004.612-.48.752.027.013-1.246.356-1.246.356l.247 2.143 2.228-.64c.415-.117.825-.227 1.226-.34l.817 2.845 1.71-.49-.807-2.815a65.74 65.74 0 001.372-.38l.802 2.803 1.713-.491-.814-2.84c2.831-.991 4.638-2.294 4.113-5.07-.422-2.234-1.724-2.912-3.471-2.836.848-.79 1.213-1.858.642-3.3zm-.65 6.77c.61 2.127-3.1 2.929-4.26 3.263l-1.081-3.77c1.16-.333 4.704-1.71 5.34.508zm-2.322-5.09c.554 1.935-2.547 2.58-3.514 2.857l-.98-3.419c.966-.277 3.915-1.455 4.494.563z" fill="#fff" fill-rule="nonzero"/></g></svg>`;
}
if (logo === "trx") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Tron" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#EF0027" cx="16" cy="16" r="16"/><path d="M21.932 9.913L7.5 7.257l7.595 19.112 10.583-12.894-3.746-3.562zm-.232 1.17l2.208 2.099-6.038 1.093 3.83-3.192zm-5.142 2.973l-6.364-5.278 10.402 1.914-4.038 3.364zm-.453.934l-1.038 8.58L9.472 9.487l6.633 5.502zm.96.455l6.687-1.21-7.67 9.343.983-8.133z" fill="#FFF"/></g></svg>`;
}
if (logo === "sc") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Siacoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#20EE82"/><path fill="#FFF" d="M16 7.5a8.5 8.5 0 018.5 8.5v8.5H16a8.5 8.5 0 110-17zm5.1 13.6v-5.023c0-2.82-2.255-5.163-5.074-5.177a5.106 5.106 0 00-5.126 5.126c.014 2.819 2.358 5.074 5.177 5.074H21.1z"/></g></svg>`;
}
if (logo === "dgb") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="DigiByte" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#006AD2"/><path fill="#FFF" d="M12.368 25l.479-1.282-.85.084-.306.81c-.024.061-.044.125-.075.183-.067.125-.17.203-.313.204-.63.001-1.258 0-1.888-.001-.015 0-.03-.009-.063-.019l.402-1.085c-.733-.02-1.446-.032-2.156-.113.012-.133 4.062-10.345 4.223-10.652.04-.003.087-.01.135-.01h3.27c.033 0 .066 0 .098.002.331.025.515.305.4.623-.153.42-.315.838-.472 1.256l-2.058 5.474c-.021.056-.039.114-.065.19.058.003.103.009.148.007 3.096-.135 5.368-1.613 6.836-4.39a6.711 6.711 0 00.67-1.935c.073-.395.096-.791-.003-1.186a1.763 1.763 0 00-.698-1.03c-.468-.337-.994-.481-1.562-.484H7.5c.024-.06.035-.1.054-.136l1.388-2.501a.754.754 0 01.706-.418h5.866l.601-1.59h1.782c.044 0 .088-.003.13.003.127.02.2.12.181.25-.008.054-.028.106-.048.158-.123.331-.249.661-.372.992-.021.056-.038.113-.06.18h.805c.02-.043.04-.087.058-.132l.496-1.317c.05-.133.052-.134.185-.134.564 0 1.129-.002 1.693 0 .238.001.323.127.238.357-.135.369-.274.735-.412 1.102-.019.051-.036.103-.06.173.055.01.1.02.145.026.785.096 1.549.274 2.274.601.551.249 1.052.574 1.464 1.03.558.615.835 1.35.879 2.18.042.805-.105 1.581-.372 2.33-.632 1.775-1.53 3.388-2.83 4.747-.896.936-1.93 1.68-3.064 2.282-1.224.65-2.518 1.105-3.858 1.427-.12.03-.183.082-.224.2-.147.41-.303.818-.457 1.226-.095.25-.19.318-.452.318h-1.868z"/></g></svg>`;
}
if (logo === "btc") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#F7931A"/><path fill="#FFF" fill-rule="nonzero" d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z"/></g></svg>`;
}
if (logo === "eth") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Ethereum" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#627EEA"/><g fill="#FFF" fill-rule="nonzero"><path fill-opacity=".602" d="M16.498 4v8.87l7.497 3.35z"/><path d="M16.498 4L9 16.22l7.498-3.35z"/><path fill-opacity=".602" d="M16.498 21.968v6.027L24 17.616z"/><path d="M16.498 27.995v-6.028L9 17.616z"/><path fill-opacity=".2" d="M16.498 20.573l7.497-4.353-7.497-3.348z"/><path fill-opacity=".602" d="M9 16.22l7.498 4.353v-7.701z"/></g></g></svg>`;
}
if (logo === "sol") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Solana" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#66F9A1" cx="16" cy="16" r="16"/><path d="M9.925 19.687a.59.59 0 01.415-.17h14.366a.29.29 0 01.207.497l-2.838 2.815a.59.59 0 01-.415.171H7.294a.291.291 0 01-.207-.498l2.838-2.815zm0-10.517A.59.59 0 0110.34 9h14.366c.261 0 .392.314.207.498l-2.838 2.815a.59.59 0 01-.415.17H7.294a.291.291 0 01-.207-.497L9.925 9.17zm12.15 5.225a.59.59 0 00-.415-.17H7.294a.291.291 0 00-.207.498l2.838 2.815c.11.109.26.17.415.17h14.366a.291.291 0 00.207-.498l-2.838-2.815z" fill="#FFF"/></g></svg>`;
}
if (logo === "aegis") {
// Athena's aspis — hexagonal shield with a boss at center + four
// spoke marks. Same silhouette as the aegis.x hero SVG so the wallet
// and the marketing page read as one identity.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Aegis" style="vertical-align:middle;flex:none">
<polygon points="16,2 29,9 29,23 16,30 3,23 3,9" fill="none" stroke="#d6ff3d" stroke-width="2" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="4.3" fill="none" stroke="#d6ff3d" stroke-width="1.4"/>
<circle cx="16" cy="16" r="1.3" fill="#d6ff3d"/>
<path d="M16 10.5 v-2.4 M16 21.5 v2.4 M10.5 16 h-2.4 M21.5 16 h2.4" stroke="#d6ff3d" stroke-width="1.4" stroke-linecap="round"/>
</svg>`;
}
// Fallback = Aegis shield (rather than a "?"), so an unrecognised
// registry entry still looks intentional.
return logoSvg("aegis", s);
}
function testnetTag() { return `<span class="ttag">TEST</span>`; }
// Selected wallet convenience.
const sel = () => state && state.selected;
const chain = () => sel()?.chain || "";
const decimals = () => sel()?.meta?.decimals || 8;
const ticker = () => sel()?.meta?.ticker || "";
// Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at
// 10^24-scale routinely. Use BigInt for anything that arrives as a string.
function fmtBig(units, dec) {
const d = dec != null ? dec : decimals();
if (typeof units === "string" && /^-?\d+$/.test(units)) {
const neg = units.startsWith("-");
const raw = neg ? units.slice(1) : units;
const bi = BigInt(raw || "0");
const base = 10n ** BigInt(d);
const whole = (bi / base).toString();
let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, "");
// Show 8-digit precision at most for very small units; keep 2 dp minimum.
const cap = Math.min(d, 8);
if (frac.length > cap) frac = frac.slice(0, cap);
if (!frac) frac = "";
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
}
const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d);
return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
}
// Header balance. Groups the integer part and dims the fraction — the
// "98,230.02" treatment the reference wallets lead with — and by default
// shortens a long tail, because eight decimals of noise sit exactly where
// the eye lands.
//
// The shortening NEVER rounds and never collapses a non-zero balance to
// zeros, which is the way this idea normally goes wrong: a flat "2 decimal
// places" rule renders 0.00042 BCH as "0.00" and the wallet reads as empty.
// Instead it keeps two decimals minimum and then extends past any leading
// zeros so roughly four significant digits always survive, capped at what
// the amount actually has:
//
// 1204.23234145 -> 1,204.23 0.23234145 -> 0.2323
// 0.00012345 -> 0.0001234 0.00000001 -> 0.00000001 (1 sat, intact)
//
// It truncates rather than rounds, so the figure shown is never more than
// the wallet holds and sending the displayed amount always clears.
// An ellipsis marks the cut, hover shows the exact figure, and clicking
// pins full precision (remembered). Only this hero is abbreviated: Send,
// MAX, history and the wallet strip keep calling fmtBig directly, so every
// number you act on is exact.
const BAL_MIN_DP = 2, BAL_SIG_DP = 4;
let balFullText = "—";
let balFullPrecision = false;
try { balFullPrecision = localStorage.getItem("aegis/fullPrecision") === "1"; } catch (_e) {}
function setBalMain(text) {
balFullText = String(text == null ? "—" : text);
paintBalMain();
}
function paintBalMain() {
const el = $("balMain");
if (!el) return;
const m = /^(-?)(\d+)(\.\d+)?$/.exec(balFullText);
if (!m) {
el.textContent = balFullText;
el.removeAttribute("title");
el.classList.remove("balx");
return;
}
const group = (d) => d.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const head = m[1] + group(m[2]);
const fullFrac = m[3] ? m[3].slice(1) : "";
let frac = fullFrac, cut = false;
if (!balFullPrecision && fullFrac) {
// Whole units already carry the significant digits, so anything with a
// non-zero integer part only needs the two decimals. It is the amounts
// under 1 that have to dig past their leading zeros to keep any meaning.
const whole = m[2] !== "0";
const zeros = (/^0*/.exec(fullFrac) || [""])[0].length;
const want = whole ? BAL_MIN_DP : zeros + BAL_SIG_DP;
const keep = Math.max(BAL_MIN_DP, Math.min(fullFrac.length, want));
if (keep < fullFrac.length) { frac = fullFrac.slice(0, keep); cut = true; }
}
// Every interpolated piece is digits, a sign, a dot or a comma the regex
// vouched for, so innerHTML here cannot inject markup.
el.innerHTML = head + (frac
? '<span class="dec">.' + frac + (cut ? '<span class="more">' + String.fromCharCode(0x2026) + "</span>" : "") + "</span>"
: "");
const exact = head + (fullFrac ? "." + fullFrac : "");
const toggleable = cut || balFullPrecision;
el.title = cut ? exact + " — click for every decimal"
: (balFullPrecision ? exact + " — click to shorten" : exact);
el.classList.toggle("balx", toggleable);
}
$("balMain") && $("balMain").addEventListener("click", () => {
balFullPrecision = !balFullPrecision;
try { localStorage.setItem("aegis/fullPrecision", balFullPrecision ? "1" : "0"); } catch (_e) {}
paintBalMain();
});
function fmtSmall(units) {
if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return Number(units || 0).toLocaleString("en-US");
}
function smallUnitLabel() {
const c = chain();
if (c === "bch" || c === "dgb" || c === "btc") return "sat";
if (c === "trx") return "sun";
if (c === "sc") return "H";
if (c === "eth") return "wei";
if (c === "sol") return "lamports";
return "u";
}
// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet.
function explorerHref(base, id) {
const s = sel();
return base + id + (s?.explorerSuffix || "");
}
function bigUnitLabel() { return ticker(); }
// ---- fiat helpers ----------------------------------------------------------
// Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled
// or missing, fiat helpers return null and the caller renders nothing.
function priceFor(chain) {
if (!state?.prices?.enabled) return null;
return state.prices.prices?.[chain] ?? null;
}
// Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe
// for wide-decimals coins (SC=24, ETH=18) that overflow Number.
function usdOf(chain, units, decimals) {
const price = priceFor(chain);
if (price == null || !units) return null;
const d = Number(decimals) || 0;
if (typeof units === "string" && /^-?\d+$/.test(units)) {
// BigInt-safe: divide the units by 10^d first via BigInt, then use
// the fractional remainder as a Number multiplier for the last dp.
const neg = units.startsWith("-");
const abs = neg ? units.slice(1) : units;
const base = 10n ** BigInt(d);
const bi = BigInt(abs);
const whole = Number(bi / base);
const frac = Number(bi % base) / Number(base);
return (neg ? -1 : 1) * (whole + frac) * price;
}
const n = Number(units) / Math.pow(10, d);
return n * price;
}
// Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else
// grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the
// feed is enabled but hasn't returned yet.
function fmtFiat(usd) {
if (usd == null) return null;
if (usd === 0) return "$0.00";
const abs = Math.abs(usd);
// Sub-cent coins (SC ~ $0.0007, DGB ~ $0.005) get 3 significant digits so
// users see meaningful movement without the row screaming "< $0.01" at
// every wallet. Keeps trailing zeros trimmed: $0.000756, not $0.0007560.
if (abs < 0.01) {
const sig = usd.toPrecision(3);
const num = Number(sig);
if (num === 0) return "$0";
// Node.js's toPrecision returns e.g. "0.000756" for tiny numbers, "5.60e-4"
// for extreme. Normalise to a plain fixed string.
const s = /e/i.test(sig) ? num.toFixed(Math.max(0, -Math.floor(Math.log10(abs)) + 2)) : sig;
return "$" + s;
}
if (abs < 10) return "$" + usd.toFixed(2);
const int = Math.floor(usd);
const frac = Math.abs(usd - int).toFixed(2).slice(1);
return "$" + int.toLocaleString("en-US") + frac;
}
function fiatSkeleton() {
return state?.prices?.enabled ? "≈ $—" : null;
}
// ---- security: PIN ----------------------------------------------------------
// The pads below only collect six digits. The host (index.js pinUnwrap)
// holds the PIN blob, counts every guess before trying it, and after too
// many wrong ones switches the PIN off until the master password is entered.
// The panel never sees the blob, so it cannot be searched from here, and it
// cannot reset the counter.
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
async function pinNeedsMaster() {
try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; }
}
const PIN_MASTER_COPY = "Too many wrong PINs. Enter your master password; the PIN works again after that.";
const wrongPinCopy = (remaining) =>
`Wrong PIN. ${remaining} attempt${remaining === 1 ? "" : "s"} left, then Aegis asks for your master password.`;
async function refreshSecurityState() {
try {
securityState = await S.invoke("securityGet");
securityLoaded = true;
} catch { securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true }; securityLoaded = true; }
return securityState;
}
async function refreshSessionState() {
try {
sessionState = await S.invoke("sessionStatus");
sessionLoaded = true;
} catch {
sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true };
sessionLoaded = true;
}
return sessionState;
}
// Idle auto-lock. Any user gesture in the panel resets the timer; if the
// user stays quiet for `sessionState.idleMinutes`, Aegis invokes vaultLock
// so a walked-away laptop doesn't leave the wallet unlocked. Wired at
// boot; each config change bounces it via bindIdleAutoLock().
function bindIdleAutoLock() {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
const mins = Number(sessionState.idleMinutes) || 0;
if (mins <= 0) return;
const reset = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
// Only lock if the vault is actually open — no point calling lock
// while we're already on the unlock screen.
const s = sel();
if (!s || s.phase !== "ready") return;
try {
state = await S.invoke("vaultLock");
// Start the panel over rather than re-render it. Whatever was open
// goes with the page: a revealed key, an import form holding a seed
// phrase, a half-filled send, the password remembered for PIN
// enrolment. Painting a lock screen underneath left all of that on
// top of a "locked" wallet.
try { delete window.__aegisLastPw; } catch {}
location.reload();
} catch (e) { /* silent — user activity will retry */ }
}, mins * 60 * 1000);
};
reset();
// Reset on any deliberate gesture. Passive listeners so scrolling long
// wallet lists doesn't fight the idle timer.
const opts = { passive: true, capture: true };
const listener = () => reset();
["mousedown", "keydown", "touchstart", "focus", "click"].forEach((ev) => document.addEventListener(ev, listener, opts));
// Store the listener so a later bindIdleAutoLock doesn't stack duplicates.
if (bindIdleAutoLock._prev) {
for (const ev of ["mousedown", "keydown", "touchstart", "focus", "click"]) {
document.removeEventListener(ev, bindIdleAutoLock._prev, opts);
}
}
bindIdleAutoLock._prev = listener;
}
// ---- tabs ------------------------------------------------------------------
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
function showTab(name) {
tab = name;
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
if (name === "settings") { settingsFilled = false; fillSettings(); applySetSec(activeSetSec); }
if (name === "send") applyUnitPicker();
// Settings is the only tab that can be reached while the vault is
// locked. Re-run the full render() so the lock-screen overlay + chrome
// visibility stay in sync with whichever tab the user just picked.
render();
}
// ---- settings section nav (0.8.2) -----------------------------------------
// Settings grew tall enough (Security, Session, Master password, MultiSig,
// Wallet manage + 6 per-chain cards, Prices, Sites) that scrolling to any
// one was awkward. Segment the tab with a chip row: only one section is
// visible at a time, choice persists in localStorage so users land back
// where they left off.
let activeSetSec = "security";
try {
const saved = localStorage.getItem("aegis/setSec");
if (saved) activeSetSec = saved;
} catch (_e) {}
function applySetSec(name) {
activeSetSec = name || "security";
try { localStorage.setItem("aegis/setSec", activeSetSec); } catch (_e) {}
document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => {
b.classList.toggle("on", b.dataset.setsec === activeSetSec);
});
// A section can span more than one card (Security holds both the top
// Security card and the MultiSig card lower down), so toggle every
// matching body — hide non-matches.
document.querySelectorAll("[data-setsec-body]").forEach((el) => {
el.hidden = el.dataset.setsecBody !== activeSetSec;
});
}
document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => {
b.addEventListener("click", () => applySetSec(b.dataset.setsec));
});
applySetSec(activeSetSec);
// ---- wallet picker (two-step add) ------------------------------------------
$("pickerBtn").addEventListener("click", (e) => {
// 0.8.0: header is a CURRENCY PICKER. Clicking the wallet name/badge
// opens the browse pane in #drop (coin list → wallet list per coin
// → click a wallet to select it). The ✎ chip on the right opens the
// per-wallet manage modal (rename, path, remove), which is the
// dedicated "edit THIS wallet" affordance — different intent.
if (e.target && e.target.closest("#hAdd")) return;
if (e.target && e.target.closest("#hManage")) {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === state?.selectedWalletId);
if (w) openWalletManageModal(w);
return;
}
e.stopPropagation();
pickerTab = "browse";
const d = $("drop");
positionDropBelowTabs(d);
d.hidden = false;
fillPicker();
});
// 0.8.4: separate netchip row. Click jumps straight to the browse:chain
// view for the current wallet's chain — the network-chip strip at the
// top of that view is what actually switches networks.
// Open the same wallet as a full Theseus tab. CSS hides this chip on the
// full-screen surface, so it never offers to open a second copy of itself.
$("hFull") && $("hFull").addEventListener("click", async (e) => {
e.stopPropagation();
try { await S.invoke("openFullScreen"); }
catch (err) { showErr(cleanErr(err)); }
});
// Network indicator and network switch, merged into one always-visible
// control under the balance. Before this, the indicator was a chip in the
// status row and the real selector was a chip row inside the picker's coin
// drilldown: the half that told you where you were and the half that could
// move you were in different places, and the useful half was two clicks deep.
//
// A chain with a single network still renders its one chip. That chip is the
// indicator, and dropping it would make the header height jump as the user
// moves between chains.
function paintNetSelector(s) {
const host = $("hNetSel");
if (!host) return;
const chain = s && s.chain;
if (!chain) { host.hidden = true; host.innerHTML = ""; return; }
const mine = (state?.wallets || []).filter((w) => w.chain === chain);
const nets = orderNetworks(Array.from(new Set(mine.map((w) => w.network))));
if (!nets.length) { host.hidden = true; host.innerHTML = ""; return; }
host.hidden = false;
host.innerHTML = nets.map((n) => {
const peers = mine.filter((w) => w.network === n);
const lbl = networkLabelFor(chain, n, n);
// "Chipnet testnet TEST" says it twice. Only tag a testnet whose own
// label doesn't already announce it (Nile, Sepolia, devnet, signet).
const isTestnet = !!peers.find((w) => w.testnet) && !/test/i.test(lbl);
return `<button type="button" class="netselchip ${n === s.network ? "on" : ""}" data-netsel="${esc(n)}"
title="${n === s.network ? "You are on " + esc(lbl) : "Switch to " + esc(lbl)} — ${peers.length} wallet${peers.length === 1 ? "" : "s"}">
${esc(lbl)}${isTestnet ? " " + testnetTag() : ""}<span class="cnt">${peers.length}</span>
</button>`;
}).join("");
host.querySelectorAll("[data-netsel]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const net = b.dataset.netsel;
if (net === s.network) return;
const peers = mine.filter((w) => w.network === net);
if (!peers.length) return;
// Keep the picker's own per-chain network memory in step, so the two
// views never disagree about which network this chain is showing.
activeNetworkByChain.set(chain, net);
persistActiveNetworks();
// The wallet list has to come along. In the addresses drilldown its
// groupKey pins "<chain>:<network>", so without this the header says
// chipnet while the list below keeps showing mainnet addresses.
const subKey = `${chain}:${net}`;
if (stripView.mode === "addresses" && String(stripView.groupKey || "").split(":")[0] === chain) {
stripView = { mode: "addresses", groupKey: subKey };
}
// Land on whichever wallet this network was last left on, so the header
// switch and the row the strip highlights are the same wallet.
const remembered = activeWalletBySubgroup.get(subKey);
const target = peers.find((w) => w.id === remembered) || peers[0];
activeWalletBySubgroup.set(subKey, target.id);
try {
state = await S.invoke("selectWallet", { id: target.id });
settingsFilled = false;
render();
} catch (err) { aegisAlert("Could not switch network: " + cleanErr(err)); }
}));
}
// + Add and ⋯ More chips moved from the wallet strip into the header
// (0.6.31). Same handlers as before — fillPicker for the Add-only picker,
// openMoreMenu for Import/Connect/About. Each stopsPropagation so the
// outer pickerBtn click doesn't also fire "manage this wallet".
$("hAdd").addEventListener("click", (e) => {
e.stopPropagation();
// 0.7.1: header + opens the method chooser first (New / Import /
// Connect), then routes into the coin picker for the chosen method.
// Users who want to skip straight to "Add new" from another entry
// point (e.g. openMoreMenu) still set pickerTab = "add" directly.
pickerTab = "method";
const d = $("drop");
// 0.7.3: anchor the drop to the BOTTOM of the tabs bar (nav) so it
// opens over the wallet list + main content but leaves the wallet
// header (balance + selected wallet) AND the Receive/Send/History/
// Settings tabs visible above it. Measured at open time because
// header height varies with content (portfolio line, error banner).
positionDropBelowTabs(d);
d.hidden = false;
fillPicker();
});
function positionDropBelowTabs(dropEl) {
try {
const nav = document.querySelector("nav");
if (!nav) return;
const y = Math.round(nav.getBoundingClientRect().bottom);
if (y > 0) dropEl.style.top = y + "px";
} catch {}
}
// hMore chip removed in 0.7.2 — the compact method chooser under hAdd
// carries Create / Import / Connect / About, so a second right-corner
// button was redundant.
document.addEventListener("click", (e) => {
const d = $("drop");
if (d.hidden) return;
// Also whitelist the always-visible wallet strip so its + / ⋯ buttons —
// which run fillPicker() and detach themselves during the render — don't
// trigger the outer "click outside → close" logic. Before this whitelist
// the Add button appeared broken because the picker opened and immediately
// closed in the same event tick.
if (e.target.closest("#drop") || e.target.closest("#pickerBtn") || e.target.closest("#walletStrip")) return;
d.hidden = true;
});
// Which picker tab is showing. Persisted in the picker instance state so a
// user who opens the picker → picks Import → cancels → reopens returns to
// Wallets (the sane default).
let pickerTab = "wallets";
// 0.8.4: coin catalogue search query + one-shot preselected chain for the
// Add pane (set when the browse view routes into "add" for a specific
// unowned coin).
let browseQuery = "";
let pickerAddChain = null;
function fillPicker() {
const d = $("drop");
const wallets = state?.wallets || [];
const coins = state?.coins || [];
const rowsHtml = wallets.map((w) => {
const on = w.id === state.selectedWalletId ? "on" : "";
const totalUnits = w.balance ? (typeof w.balance.confirmed === "string"
? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString()
: (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0;
const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—";
const usd = usdOf(w.chain, totalUnits, w.decimals);
const fiat = fmtFiat(usd);
const fiatLine = fiat ? `<div class="fs">${esc(fiat)}</div>` : "";
const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`;
const importedTag = w.kind === "imported" ? ` <span class="ttag" style="background:rgba(214,255,61,.16);color:var(--acid,#d6ff3d)">IMPORTED</span>` : "";
// Derivation path under the balance — one of the most requested pieces of
// info for anyone verifying an address against another wallet. Legacy /
// isDefault wallets can't be removed (they gate legacy funds).
const pathLine = w.accountPath ? `<div class="s mono" style="font-size:10.5px;opacity:.7">${esc(w.accountPath)}</div>` : "";
const menu = w.isLegacy || w.isDefault
? `<span title="Default wallet — protects legacy funds; cannot be removed" style="padding:4px 8px;font-size:14px;color:var(--dim);cursor:not-allowed">🔒</span>`
: `<button class="btn sm" data-walletmenu="${esc(w.id)}" title="Manage wallet" style="padding:4px 8px;font-size:14px">⋯</button>`;
return `<div class="row ${on}" style="position:relative">
<div style="display:flex;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer" data-select="${esc(w.id)}">
${logoSvg(w.logo, 22)}
<div class="m"><div class="l">${esc(w.label)}${importedTag}</div><div class="s">${sub}</div>${pathLine}</div>
<div class="v"><div>${esc(bal)}</div>${fiatLine}</div>
</div>
${menu}
</div>`;
}).join("");
// "Add wallet" is a two-step flyout: first show coins, then that coin's
// networks. Nothing is created until the user clicks a specific network.
const coinRows = coins.map((c) => {
const testCount = c.networks.filter((n) => n.testnet).length;
const sub = c.networks.length > 1
? c.networks.map((n) => n.label).join(" · ")
: c.networks[0].label;
return `<div class="coinrow" data-coin="${esc(c.chain)}">
${logoSvg(c.logo, 22)}
<div class="m"><div class="l">${esc(c.label)}</div><div class="s">${esc(sub)}</div></div>
<div class="caret">▸</div>
</div>
<div class="netgroup" id="netgroup-${esc(c.chain)}" hidden>
${c.networks.map((n) => `<div class="netchoice" data-add="${esc(c.chain + ":" + n.id)}">
${esc(n.label)}${n.testnet ? " " + testnetTag() : ""}
</div>`).join("")}
</div>`;
}).join("");
// Three-tab layout: Add (create new) / Import (external) / Connect
// (WizardConnect pairing). The old Wallets tab is gone — the always-visible
// strip above the header owns switching, so the picker no longer needs to
// duplicate that list. Add-only when the picker opens from [+].
const bchWallets = wallets.filter((w) => w.chain === "bch");
const wcCount = Object.values(state?.wc || {}).reduce((n, arr) => n + (arr?.length || 0), 0);
// 0.7.1: picker is now a stepped wizard. First "screen" ("method") asks
// HOW the user wants to add a wallet — three cards — before picking a
// coin. Once a method is chosen, the coin picker (or import options)
// appear with a "← Back" chevron so users can revise the method
// without closing the picker.
if (pickerTab === "wallets") pickerTab = "method"; // migrate any stale default
// 0.8.0: browse mode — coin picker → wallet list. Entered by clicking
// the header. Two sub-states:
// pickerTab = "browse" → coin list (all chains user
// owns wallets for)
// pickerTab = "browse:<chain>" → wallet list for that chain,
// with a network-chip row at
// the top for switching.
if (pickerTab === "browse" || pickerTab.startsWith("browse:")) {
const focused = pickerTab.startsWith("browse:") ? pickerTab.slice(7) : null;
// Group user's wallets by chain — the browse view mirrors the strip's
// per-chain grouping but is triggered explicitly by clicking the
// header, and shows richer per-chain summaries (wallet count, total
// native balance, remembered active-network name).
const walletsByChain = new Map();
for (const w of wallets) {
if (!walletsByChain.has(w.chain)) walletsByChain.set(w.chain, []);
walletsByChain.get(w.chain).push(w);
}
if (!focused) {
// 0.8.4: full catalogue with search. Every supported chain is
// listed, whether the user already has a wallet on it or not.
// Rows for owned coins jump to that coin's wallet list; rows for
// unowned coins jump straight into the Add-wallet flow for that
// coin, so the header picker doubles as a fast on-ramp.
const q = String(browseQuery || "").trim().toLowerCase();
const matches = (c) => !q
|| String(c.chain || "").toLowerCase().includes(q)
|| String(c.label || "").toLowerCase().includes(q)
|| String(c.ticker || "").toLowerCase().includes(q)
|| String(c.short || "").toLowerCase().includes(q);
const ownedRows = [];
const otherRows = [];
for (const c of coins) {
if (!matches(c)) continue;
const ws = walletsByChain.get(c.chain) || [];
if (ws.length > 0) {
const first = ws[0];
const nets = Array.from(new Set(ws.map((w) => w.network)));
let active = activeNetworkByChain.get(c.chain);
if (!active || !nets.includes(active)) active = nets.includes("mainnet") ? "mainnet" : nets[0];
const netLbl = networkLabelFor(c.chain, active, active);
const totalUnits = sumGroupUnits(ws.filter((w) => w.network === active));
const dec = first.decimals || c.decimals || 8;
const bal = fmtBig(totalUnits || 0, dec) + " " + esc(first.ticker || c.ticker || c.chain.toUpperCase());
const usd = usdOf(c.chain, totalUnits || 0, dec);
const fiat = usd != null ? `<div class="fs">${esc(fmtFiat(usd))}</div>` : "";
const activeMark = ws.some((w) => w.id === state?.selectedWalletId) ? "on" : "";
ownedRows.push(`<div class="row ${activeMark}" data-browse-chain="${esc(c.chain)}" style="cursor:pointer">
${logoSvg(c.logo, 22)}
<div class="m">
<div class="l">${esc(c.label)} <span class="hint" style="font-weight:400">· ${ws.length} wallet${ws.length === 1 ? "" : "s"}${nets.length > 1 ? ` · ${esc(netLbl)}` : ""}</span></div>
<div class="s mono">${esc(c.ticker || c.chain)}</div>
</div>
<div class="v"><div>${bal}</div>${fiat}</div>
</div>`);
} else {
otherRows.push(`<div class="row unowned" data-browse-add="${esc(c.chain)}" style="cursor:pointer">
${logoSvg(c.logo, 22)}
<div class="m">
<div class="l">${esc(c.label)}</div>
<div class="s mono">${esc(c.ticker || c.chain)}</div>
</div>
<div class="v">+ Add</div>
</div>`);
}
}
// 0.9.3: the "Pick a coin" title row is gone — the search field's
// own placeholder already says what this pane is, so the title was
// a line of chrome restating it and pushing the list down.
d.innerHTML = `
<div class="pickersearch pickersearch-top">
<input id="pickerSearch" type="search" autocomplete="off" spellcheck="false"
placeholder="Search coins by name or ticker…" value="${esc(q)}">
<button class="closex" id="pickerClose" title="Close">✕</button>
</div>
<div class="droppane" style="padding:6px">
${ownedRows.length ? `<div class="catgroup">Your wallets</div>${ownedRows.join("")}` : ""}
${otherRows.length ? `<div class="catgroup" style="margin-top:6px">Add a new wallet</div>${otherRows.join("")}` : ""}
${(!ownedRows.length && !otherRows.length) ? `<div class="hint" style="padding:14px;text-align:center">No coins match "${esc(q)}".</div>` : ""}
</div>`;
d.querySelectorAll("[data-browse-chain]").forEach((row) => row.addEventListener("click", (e) => {
e.stopPropagation();
pickerTab = "browse:" + row.dataset.browseChain;
fillPicker();
}));
d.querySelectorAll("[data-browse-add]").forEach((row) => row.addEventListener("click", (e) => {
e.stopPropagation();
const chain = row.dataset.browseAdd;
// Offer the method chooser (New / Import / Connect) rather than
// dropping straight into create — "add a BCH wallet" is just as
// often "bring in the one I already have". pickerAddChain is
// module-level, so the coin stays preselected through whichever
// branch the user picks.
pickerTab = "method";
pickerAddChain = chain;
fillPicker();
}));
const searchEl = d.querySelector("#pickerSearch");
if (searchEl) {
searchEl.addEventListener("input", () => {
browseQuery = searchEl.value;
// Preserve caret across re-render.
const caret = searchEl.selectionStart;
fillPicker();
const s2 = d.querySelector("#pickerSearch");
if (s2) { s2.focus(); try { s2.setSelectionRange(caret, caret); } catch (_e) {} }
});
// Autofocus on first render, but not on every re-render — the
// re-focus above handles that. Guard with a data flag.
if (!searchEl.dataset.autof) { searchEl.dataset.autof = "1"; searchEl.focus(); }
}
const closeBtn = d.querySelector("#pickerClose");
if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; });
return;
}
// WALLETS: list under one chain, with a network-chip row on top.
const ws = walletsByChain.get(focused) || [];
const nets = Array.from(new Set(ws.map((w) => w.network)));
const ordered = orderNetworks(nets);
let active = activeNetworkByChain.get(focused);
if (!active || !ordered.includes(active)) active = ordered.includes("mainnet") ? "mainnet" : ordered[0];
const first = ws[0] || {};
const filtered = ws.filter((w) => w.network === active);
const dec = first.decimals || 8;
const ticker = first.ticker || focused.toUpperCase();
const netChips = ordered.length > 1
? `<div class="brnetrow">${ordered.map((n) => {
const on = n === active ? "on" : "";
const cnt = ws.filter((w) => w.network === n).length;
const lbl = networkLabelFor(focused, n, n);
return `<button class="brnet ${on}" data-browse-net="${esc(n)}">${esc(lbl)} <span class="hint">${cnt}</span></button>`;
}).join("")}</div>`
: "";
const rows = filtered.map((w) => {
const on = w.id === state?.selectedWalletId ? "on" : "";
const units = walletBalanceUnits(w);
const bal = fmtBig(units || 0, dec) + " " + esc(ticker);
const usd = usdOf(w.chain, units || 0, dec);
const fiat = usd != null ? `<div class="fs">${esc(fmtFiat(usd))}</div>` : "";
const importedTag = w.kind === "imported" ? ` <span class="ttag" style="background:rgba(214,255,61,.16);color:var(--acid,#d6ff3d)">IMPORTED</span>` : "";
const addrShort = w.address ? `${String(w.address).slice(0, 10)}…${String(w.address).slice(-6)}` : "";
return `<div class="row ${on}" data-browse-wid="${esc(w.id)}" style="cursor:pointer">
${logoSvg(w.logo, 22)}
<div class="m">
<div class="l">${esc(w.label)}${importedTag}</div>
<div class="s mono" title="${esc(w.address || "")}">${esc(addrShort)}</div>
</div>
<div class="v"><div>${bal}</div>${fiat}</div>
</div>`;
}).join("");
d.innerHTML = `
<div class="droptabs">
<button data-browse-back style="padding-left:6px;padding-right:10px">← ${esc(first.coinLabel || focused.toUpperCase())}</button>
<span style="flex:1"></span>
<button class="closex" id="pickerClose" title="Close">✕</button>
</div>
${netChips}
<div class="droppane" style="padding:6px">
${filtered.length ? rows : `<div class="hint" style="padding:14px;text-align:center">No ${esc(ticker)} wallet on this network yet.</div>`}
</div>`;
d.querySelectorAll("[data-browse-back]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); pickerTab = "browse"; fillPicker(); }));
d.querySelectorAll("[data-browse-net]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
activeNetworkByChain.set(focused, b.dataset.browseNet);
persistActiveNetworks();
fillPicker();
}));
d.querySelectorAll("[data-browse-wid]").forEach((row) => row.addEventListener("click", async (e) => {
e.stopPropagation();
const id = row.dataset.browseWid;
d.hidden = true;
pickerTab = "method"; // Reset so next + opens the add flow, not the wallet list.
try {
if (id !== state?.selectedWalletId) {
state = await S.invoke("selectWallet", { id });
settingsFilled = false;
render();
}
} catch (er) { showErr(cleanErr(er)); }
}));
const closeBtn = d.querySelector("#pickerClose");
if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; });
return;
}
if (pickerTab === "method") {
// Compact method chooser. Three "how" options + About sit as short
// one-line rows, so #drop keeps to about a third of the panel and the
// footer (aegis.x brand + version) stays visible below it. Was three
// large cards in 0.7.1; the tall layout was covering the footer.
d.innerHTML = `
<div class="droptabs">
<span style="flex:1;font-size:12.5px;color:var(--dim);padding-left:10px">How would you like to add a wallet?</span>
<button class="closex" id="pickerClose" title="Close">✕</button>
</div>
<div class="droppane" style="padding:4px">
<div class="coinrow" data-method="add" style="cursor:pointer">
<span style="font-size:18px;line-height:1;width:22px;text-align:center">+</span>
<div class="m"><div class="l">Create a new wallet</div><div class="s">Derived from your Theseus vault</div></div>
<div class="caret">›</div>
</div>
<div class="coinrow" data-method="import" style="cursor:pointer">
<span style="font-size:18px;line-height:1;width:22px;text-align:center">↓</span>
<div class="m"><div class="l">Import an existing wallet</div><div class="s">BIP39 mnemonic, WIF, or encrypted keystore</div></div>
<div class="caret">›</div>
</div>
<div class="coinrow" data-method="connect" style="cursor:pointer">
<span style="font-size:18px;line-height:1;width:22px;text-align:center">⚡</span>
<div class="m"><div class="l">Connect via WizardConnect${wcCount ? ` <span class="ttag">${wcCount} paired</span>` : ""}</div><div class="s">Pair a hardware / desktop signer over WC</div></div>
<div class="caret">›</div>
</div>
<hr>
<div class="coinrow" data-method="about" style="cursor:pointer">
<span style="font-size:18px;line-height:1;width:22px;text-align:center">🛡</span>
<div class="m"><div class="l">About Aegis · aegis.x</div><div class="s">Open the wallet's front-door site</div></div>
<div class="caret">›</div>
</div>
</div>`;
d.querySelectorAll("[data-method]").forEach((row) => row.addEventListener("click", (e) => {
e.stopPropagation();
const m = row.dataset.method;
if (m === "about") { d.hidden = true; openUrl("https://aegis.x/"); return; }
pickerTab = m;
fillPicker();
}));
const closeBtn = d.querySelector("#pickerClose");
if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; });
return;
}
const modeLabel = pickerTab === "add" ? "Create a new wallet"
: pickerTab === "import" ? "Import an existing wallet"
: "Connect via WizardConnect";
d.innerHTML = `
<div class="droptabs">
<button data-ptab="method" title="Back to method chooser" style="padding-left:6px;padding-right:10px">← ${esc(modeLabel)}</button>
<span style="flex:1"></span>
<button class="closex" id="pickerClose" title="Close">✕</button>
</div>
<div class="droppane" id="ppane-add" ${pickerTab === "add" ? "" : "hidden"}>
<div class="hint" style="padding:6px 8px 10px">Pick a coin, then a network. The wallet is derived from your Theseus vault — nothing to write down.</div>
${coinRows}
</div>
<div class="droppane" id="ppane-import" ${pickerTab === "import" ? "" : "hidden"}>
<div class="hint" style="padding:6px 8px 10px">Load an <b>existing</b> wallet by pasting its BIP39 mnemonic + derivation path, or a WIF private key. Key material is stored encrypted in Theseus's wallet-imports.enc.</div>
<div class="coinrow" id="picker-import-keystore" style="background:rgb(from var(--acid, #d6ff3d) r g b / .06);border:1px solid rgb(from var(--acid, #d6ff3d) r g b / .25);border-radius:8px">
${logoSvg("aegis", 22)}
<div class="m">
<div class="l">Bulk-import from encrypted keystore</div>
<div class="s">Deviant chipnet-keystore.json (or any <span class="mono">chipnet-keystore/2-encrypted</span> file) — master password unlocks all wallets in one go</div>
</div>
<div class="caret">›</div>
</div>
<div class="coinrow" id="picker-import-single">
${logoSvg("aegis", 22)}
<div class="m"><div class="l">Import a single wallet (any coin)</div><div class="s">BIP39 mnemonic + path, or a chain-native private key (WIF / hex / base58)</div></div>
<div class="caret">›</div>
</div>
</div>
<div class="droppane" id="ppane-connect" ${pickerTab === "connect" ? "" : "hidden"}>
${renderConnectPane(bchWallets)}
</div>`;
// Back-chevron routes to the method screen. stopPropagation is critical:
// the click re-renders innerHTML, so the tab element becomes detached,
// and the outer document handler (which hides the picker when a click
// lands outside #drop) then sees a disconnected target and dismisses
// the whole panel. Same reason the import row needs it below.
d.querySelectorAll("[data-ptab]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
pickerTab = b.dataset.ptab;
fillPicker();
}));
const closeBtn = d.querySelector("#pickerClose");
if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; });
if (pickerTab === "connect") wireConnectPane();
d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => {
d.hidden = true;
try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); }
catch (e) { showErr(cleanErr(e)); }
}));
// Per-row "⋯" menu — rename + remove. Removes call the same handler the
// Settings tab uses; a hard confirm gates any accidental click since the
// action is unrecoverable for the wallet's local metadata (funds stay
// on-chain; the pointer is what disappears).
d.querySelectorAll("[data-walletmenu]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const id = b.dataset.walletmenu;
const w = (state?.wallets || []).find((x) => x.id === id);
if (!w) return;
openWalletManageModal(w);
}));
d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => {
// Collapse other coins' network groups; toggle this one.
d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; });
d.querySelectorAll(".coinrow .caret").forEach((c) => { c.textContent = "▸"; });
const group = d.querySelector("#netgroup-" + r.dataset.coin);
group.hidden = !group.hidden;
r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾";
}));
// 0.8.4: if the browse view routed here with a preselected chain (user
// clicked "+ Add" on an unowned coin), expand that coin's network
// group AND scroll it into view so the user sees which networks
// exist without a second click.
if (pickerTab === "add" && pickerAddChain) {
const target = pickerAddChain;
pickerAddChain = null;
const row = d.querySelector(`.coinrow[data-coin="${target.replace(/["\\]/g, "")}"]`);
const group = d.querySelector(`#netgroup-${target.replace(/["\\]/g, "")}`);
if (row && group) {
group.hidden = false;
const caret = row.querySelector(".caret"); if (caret) caret.textContent = "▾";
row.scrollIntoView({ block: "nearest" });
}
}
d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => {
const [c, n] = r.dataset.add.split(":");
d.hidden = true;
try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); }
catch (e) { showErr(cleanErr(e)); }
}));
const impBtn = $("picker-import-single");
if (impBtn) impBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openImportModal(null); });
const impKs = $("picker-import-keystore");
if (impKs) impKs.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openKeystoreImportModal(); });
}
// 0.8.6: in-panel replacement for window.confirm(). The native dialog is
// chrome-owned, so it renders as a Theseus-branded OS box outside the
// sidebar — jarring next to the wallet's own UI, and it can't carry an
// icon or a danger-styled button. Resolves true/false like confirm(),
// so callers just `await` it.
// opts: { title, body, confirmLabel, cancelLabel, danger, icon }
function aegisConfirm(opts) {
const o = opts || {};
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
overlay.innerHTML = `
<div style="width:min(94vw,360px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:17px;line-height:1">${o.icon || (o.danger ? "⚠" : "🛡")}</span>
<div style="font-weight:600;flex:1">${esc(o.title || "Are you sure?")}</div>
</div>
${o.body ? `<div class="hint" style="margin-bottom:12px;line-height:1.5">${o.body}</div>` : ""}
<div class="actions" style="justify-content:flex-end;gap:6px">
${o.alertOnly ? "" : `<button class="btn" data-ac="no">${esc(o.cancelLabel || "Cancel")}</button>`}
<button class="btn ${o.danger ? "danger" : "primary"}" data-ac="yes">${esc(o.confirmLabel || "Confirm")}</button>
</div>
</div>`;
document.body.appendChild(overlay);
let done = false;
const finish = (val) => {
if (done) return;
done = true;
document.removeEventListener("keydown", onKey, true);
try { overlay.remove(); } catch {}
resolve(val);
};
const onKey = (e) => {
if (e.key === "Escape") { e.stopPropagation(); finish(false); }
else if (e.key === "Enter") {
// Enter means "the focused button". It used to mean yes even with
// focus on Cancel, so Tab → Enter removed the wallet.
e.preventDefault(); e.stopPropagation();
finish(document.activeElement?.dataset?.ac !== "no");
}
};
document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); });
overlay.querySelectorAll("[data-ac]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
finish(b.dataset.ac === "yes");
}));
// A destructive question opens on Cancel, like the host's own overlay.
const yes = overlay.querySelector('[data-ac="yes"]');
const no = overlay.querySelector('[data-ac="no"]');
if (o.danger && no) no.focus(); else if (yes) yes.focus();
});
}
// Pick-one-of-several sibling of aegisConfirm, for branches where the
// honest answer is a question rather than a yes/no. Resolves the chosen
// option's `id`, or null if dismissed.
// opts: { title, body, icon, options: [{id, label, hint, primary}] }
function aegisChoose(opts) {
const o = opts || {};
const options = Array.isArray(o.options) ? o.options : [];
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
overlay.innerHTML = `
<div style="width:min(94vw,360px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:17px;line-height:1">${o.icon || "+"}</span>
<div style="font-weight:600;flex:1">${esc(o.title || "Choose")}</div>
</div>
${o.body ? `<div class="hint" style="margin-bottom:12px;line-height:1.5">${o.body}</div>` : ""}
<div style="display:flex;flex-direction:column;gap:6px">
${options.map((op) => `
<button class="btn ${op.primary ? "primary" : ""}" data-ac="${esc(op.id)}"
style="text-align:left;padding:9px 11px;display:block;width:100%">
<div style="font-weight:600">${esc(op.label)}</div>
${op.hint ? `<div class="hint" style="margin-top:2px;font-weight:400">${esc(op.hint)}</div>` : ""}
</button>`).join("")}
</div>
<div class="actions" style="justify-content:flex-end;margin-top:10px">
<button class="btn" data-ac="">${esc(o.cancelLabel || "Cancel")}</button>
</div>
</div>`;
document.body.appendChild(overlay);
let done = false;
const finish = (val) => {
if (done) return;
done = true;
document.removeEventListener("keydown", onKey, true);
try { overlay.remove(); } catch {}
resolve(val);
};
const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); finish(null); } };
document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(null); });
overlay.querySelectorAll("[data-ac]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
finish(b.dataset.ac || null);
}));
const first = overlay.querySelector('[data-ac]:not([data-ac=""])');
if (first) first.focus();
});
}
// Single-button sibling of aegisConfirm, for the error notices that used
// window.alert(). Fire-and-forget: callers don't need the result, so it
// works from sync handlers too.
function aegisAlert(message, opts) {
const o = opts || {};
return aegisConfirm({
title: o.title || "Something went wrong",
icon: o.icon || "⚠",
body: esc(String(message == null ? "" : message)),
confirmLabel: o.confirmLabel || "OK",
cancelLabel: null,
alertOnly: true,
});
}
// Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label
// + category. Backend derives cashaddr and stores signer material in
// wallet-imports.enc (design §3.2). Modal is a plain overlay div injected
// into the panel body so it works over any tab.
// Manage-wallet modal: rename + derivation path + hard remove. Backend
// handlers already exist (renameWallet, setAccountPath, removeWallet); this
// just gives them a UI in the picker so users don't dive into per-wallet
// Settings for something they view as a top-level action.
function openWalletManageModal(w) {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:24px";
const canRemove = !(w.isDefault || w.isLegacy);
const canSetPath = ["bch", "btc", "dgb"].includes(w.chain);
// Only BCH imports can be promoted: the other imported adapters still
// throw "read-only" from plan(), so there is no sweep to run.
const canPromote = w.kind === "imported" && w.chain === "bch";
// Per-purpose defaults. WizardConnect is BCH-only and a WIF import has no
// xpub to pair with, so the row explains itself rather than offering a
// choice that must fail.
const roles = (state && state.roles) || {};
const isPay = roles.payments === w.id;
const isWc = roles.wizardconnect === w.id;
const wcOk = w.chain === "bch" && !w.wcBlocked;
const wcWhy = w.chain !== "bch"
? "WizardConnect pairs Bitcoin Cash wallets only."
: (w.wcBlocked || "");
overlay.innerHTML = `
<div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
${logoSvg(w.logo, 22)}
<div style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Manage: ${esc(w.label)}</div>
<button class="btn sm" id="mwClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " · testnet" : ""}</div>
<div class="field">
<div class="lbl">Label</div>
<input type="text" id="mwLabel" value="${esc(w.label || "")}" placeholder="Wallet name">
</div>
${canSetPath ? `<div class="field">
<div class="lbl">Derivation path (account)</div>
<input type="text" id="mwPath" value="${esc(w.accountPath || "")}" spellcheck="false" placeholder="m/44'/…">
<div class="hint">Advanced. Changing this switches to a different set of addresses under the same wallet seed.</div>
</div>` : ""}
<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:6px">Use this wallet for</div>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer">
<input type="checkbox" id="mwRolePay" ${isPay ? "checked" : ""}>
<span>Payments <span class="hint" style="font-weight:normal">— opens here when nothing else is picked</span></span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;${wcOk ? "" : "opacity:.55;cursor:default"}">
<input type="checkbox" id="mwRoleWc" ${isWc ? "checked" : ""} ${wcOk ? "" : "disabled"}>
<span>WizardConnect <span class="hint" style="font-weight:normal">— offered first when a site asks to pair</span></span>
</label>
${wcOk ? "" : `<div class="hint" style="margin-top:4px">${esc(wcWhy)}</div>`}
</div>
<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:4px">Secret key</div>
<div class="hint" style="margin-bottom:8px">Everything needed to spend this wallet elsewhere. Aegis asks for your PIN (or master password) first.</div>
<button class="btn" id="mwReveal" type="button">Show secret key…</button>
</div>
${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:4px">WizardConnect</div>
<div class="hint" style="margin-bottom:8px">This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.</div>
<button class="btn" id="mwPromote" type="button">Promote to HD wallet…</button>
</div>` : ""}
<div class="msg err" id="mwMsg" hidden></div>
<div class="actions" style="justify-content:space-between;margin-top:12px">
${canRemove ? `<button class="btn danger" id="mwRemove">Remove wallet</button>` : `<span class="hint">Default wallet — cannot be removed.</span>`}
<div style="display:flex;gap:6px">
<button class="btn" id="mwCancel">Cancel</button>
<button class="btn primary" id="mwSave">Save</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#mwClose").addEventListener("click", close);
overlay.querySelector("#mwCancel").addEventListener("click", close);
overlay.querySelector("#mwSave").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
const nextLabel = overlay.querySelector("#mwLabel").value.trim();
const nextPath = overlay.querySelector("#mwPath")?.value?.trim();
try {
if (nextLabel && nextLabel !== w.label) {
state = await S.invoke("renameWallet", { id: w.id, label: nextLabel });
}
if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) {
state = await S.invoke("setAccountPath", { id: w.id, accountPath: nextPath });
}
// Roles. Only send a change: unticking clears the role, but only when
// this wallet is the one currently holding it, so closing the modal on
// some other wallet can't unset someone else's default.
const wantPay = !!overlay.querySelector("#mwRolePay")?.checked;
const wantWc = !!overlay.querySelector("#mwRoleWc")?.checked;
if (wantPay !== isPay) {
state = await S.invoke("setWalletRole", { role: "payments", walletId: wantPay ? w.id : null });
}
if (wcOk && wantWc !== isWc) {
state = await S.invoke("setWalletRole", { role: "wizardconnect", walletId: wantWc ? w.id : null });
}
close();
fillPicker();
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
overlay.querySelector("#mwReveal").addEventListener("click", () => openRevealSecretModal(w));
// Promote: preview the sweep (costed without creating anything), confirm
// with the real numbers, then create + sweep in one host call. The confirm
// carries the amounts because this moves the wallet's entire balance.
if (canPromote) overlay.querySelector("#mwPromote").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
let pv;
try { pv = await S.invoke("promotePreview", { walletId: w.id }); }
catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; return; }
if (pv.error) { msg.textContent = pv.error; msg.hidden = false; return; }
const amt = (u) => fmtBig(u, pv.decimals);
const ok = await aegisConfirm({
title: "Promote to HD wallet?",
confirmLabel: "Create and sweep",
body: `Aegis will derive <b>${esc(pv.suggestedLabel)}</b> from your vault on ${esc(pv.networkLabel)}, then send this wallet's whole balance to it.`
+ `<br><br><b>${esc(amt(pv.net))} ${esc(pv.ticker)}</b> arrives · <b>${esc(amt(pv.fee))} ${esc(pv.ticker)}</b> miner fee.`
+ `<br><br>The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`,
});
if (!ok) return;
try {
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
close();
fillPicker();
render();
await aegisAlert(
`Sent ${amt(r.sent)} ${r.ticker} to "${r.newWalletLabel}". It can pair with WizardConnect once the sweep confirms.`
+ (r.txid ? ` Txid ${r.txid}` : ""),
{ title: "Promoted to HD wallet", icon: "✅", confirmLabel: "Done" });
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
if (canRemove) overlay.querySelector("#mwRemove").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
const ok = await aegisConfirm({
title: `Remove "${w.label}"?`,
danger: true,
confirmLabel: "Remove wallet",
body: `On-chain funds stay exactly where they are — this only unlinks the wallet from Aegis.<br><br>You can add it back later on the same coin + network to derive the same addresses ${w.kind === "imported" ? "(or re-import it, since this one was imported)" : "from your vault seed"}.`,
});
if (!ok) return;
try {
state = await S.invoke("removeWallet", { id: w.id });
close();
fillPicker();
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
}
// Master-key bulk import (MASTER-KEY-INTEGRATION.md §7.1).
// The user picks a chipnet-keystore/2-encrypted JSON file + types the master
// password. Decrypt runs entirely in the panel iframe via SubtleCrypto; the
// password never crosses IPC or the network. Preview shows cashaddr + label
// + category for each entry; user picks with checkboxes and hits Import.
// Rate limit: 5 fails / 60 s → 30 s lockout (§8.6). Chipnet-only (§8.7 —
// rejects `bitcoincash:` prefixes silently).
let keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 };
function hexToBytesU8(h) {
const s = String(h || "");
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
return out;
}
async function unlockKeystoreV2(encryptedJson, passphrase) {
if (encryptedJson.spec !== "chipnet-keystore/2-encrypted") {
throw new Error("wrong password"); // opaque — actual reason is bad file
}
const enc = new TextEncoder();
const salt = hexToBytesU8(encryptedJson.kdf.salt);
const iv = hexToBytesU8(encryptedJson.encryption.iv);
const cipherAll = hexToBytesU8(encryptedJson.ciphertext);
const passKey = await crypto.subtle.importKey("raw", enc.encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"]);
const aesKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: encryptedJson.kdf.iterations, hash: "SHA-256" },
passKey, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipherAll);
return JSON.parse(new TextDecoder().decode(ptBuf));
}
function openKeystoreImportModal() {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px";
overlay.innerHTML = `
<div style="width:min(94vw,420px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${logoSvg("aegis", 22)}
<div style="font-weight:600;flex:1">Bulk-import from encrypted keystore</div>
<button class="btn sm" id="ksClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">
Chipnet only. Master password never leaves this panel — it decrypts the file locally via WebCrypto. Every imported wallet lands in Theseus's <span class="mono">wallet-imports.enc</span>, no plaintext on disk.
</div>
<div class="field" id="ksFileField">
<div class="lbl">Keystore file</div>
<input type="file" id="ksFile" accept="application/json,.json" style="padding:6px 0">
<div class="hint">Typically <span class="mono">Deviant/Keys/chipnet-keystore.json</span>. Any <span class="mono">chipnet-keystore/2-encrypted</span> file works.</div>
</div>
<div class="field" id="ksPassField">
<div class="lbl">Master password</div>
<input type="password" id="ksPass" spellcheck="false" autocomplete="off">
</div>
<div id="ksPreview" hidden>
<div class="lbl" style="margin-top:6px">Select wallets to import</div>
<div class="hint" id="ksPreviewMeta" style="margin-bottom:6px"></div>
<div class="actions" style="margin:4px 0 8px 0">
<button class="btn sm" id="ksSelAll" type="button">Select all</button>
<button class="btn sm" id="ksSelNone" type="button">Clear</button>
<button class="btn sm" id="ksSelBns" type="button">Only bns</button>
<button class="btn sm" id="ksSelOps" type="button">Only operational</button>
</div>
<div id="ksList" class="serverlist" style="max-height:38vh;overflow-y:auto"></div>
</div>
<div class="msg err" id="ksMsg" hidden style="margin-top:8px"></div>
<div class="actions" style="justify-content:space-between;margin-top:10px">
<button class="btn" id="ksCancel" type="button">Cancel</button>
<div style="display:flex;gap:6px">
<button class="btn" id="ksUnlock" type="button">Unlock →</button>
<button class="btn primary" id="ksImport" type="button" hidden>Import selected</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#ksClose").addEventListener("click", close);
overlay.querySelector("#ksCancel").addEventListener("click", close);
// Loaded keystore file (parsed JSON) and the decrypted plaintext once
// the user unlocks it. Kept in this closure so nothing hits IPC.
let loadedFile = null;
let decrypted = null;
const setMsg = (t, cls = "err") => {
const el = overlay.querySelector("#ksMsg");
if (!t) { el.hidden = true; return; }
el.className = "msg " + cls; el.textContent = t; el.hidden = false;
};
overlay.querySelector("#ksFile").addEventListener("change", async (e) => {
setMsg("");
const file = e.target.files?.[0]; if (!file) { loadedFile = null; return; }
if (file.size > 512 * 1024) { setMsg("File is too large for a keystore (>512 KB)."); loadedFile = null; return; }
try {
const text = await file.text();
loadedFile = JSON.parse(text);
if (loadedFile?.spec !== "chipnet-keystore/2-encrypted") {
setMsg("File is not a chipnet-keystore/2-encrypted."); loadedFile = null; return;
}
} catch (er) { setMsg("File is not valid JSON."); loadedFile = null; }
});
overlay.querySelector("#ksUnlock").addEventListener("click", async () => {
setMsg("");
// Rate-limit check first (§8.6).
const now = Date.now();
if (keystoreUnlockFails.lockedUntil && now < keystoreUnlockFails.lockedUntil) {
const secs = Math.ceil((keystoreUnlockFails.lockedUntil - now) / 1000);
setMsg(`Too many failed attempts — try again in ${secs}s.`); return;
}
if (!loadedFile) { setMsg("Pick a keystore file first."); return; }
const pass = overlay.querySelector("#ksPass").value;
if (!pass) { setMsg("Enter the master password."); return; }
const btn = overlay.querySelector("#ksUnlock");
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Decrypting…";
try {
decrypted = await unlockKeystoreV2(loadedFile, pass);
// Reset failure counter on success (§8.6).
keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 };
renderKeystorePreview(overlay, decrypted);
} catch (err) {
// Opaque error (§8.5). Track failure for rate-limit.
if (!keystoreUnlockFails.firstAt || now - keystoreUnlockFails.firstAt > 60_000) {
keystoreUnlockFails = { count: 1, firstAt: now, lockedUntil: 0 };
} else {
keystoreUnlockFails.count++;
if (keystoreUnlockFails.count >= 5) {
keystoreUnlockFails.lockedUntil = now + 30_000;
setMsg("5 failed attempts. Locked for 30 seconds.");
}
}
if (!keystoreUnlockFails.lockedUntil) setMsg("Wrong password.");
} finally { btn.disabled = false; btn.textContent = orig; }
});
overlay.querySelector("#ksImport").addEventListener("click", async () => {
setMsg("");
const rows = [...overlay.querySelectorAll("[data-ksrow]")].filter((r) => r.querySelector("input[type=checkbox]").checked);
if (!rows.length) { setMsg("Select at least one wallet to import."); return; }
const btn = overlay.querySelector("#ksImport");
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Importing…";
let ok = 0, skipped = 0, errors = [];
for (const row of rows) {
const slug = row.dataset.ksrow;
const entry = decrypted?.wallets?.[slug];
if (!entry) { errors.push(`${slug}: missing in decrypted payload`); continue; }
// Chipnet-only guard (§8.7). Refuse mainnet.
if (String(entry.cashaddr || "").startsWith("bitcoincash:")) { skipped++; continue; }
if (!String(entry.cashaddr || "").startsWith("bchtest:")) { skipped++; continue; }
const spec = { chain: "bch", network: "chipnet", label: entry.label || slug,
category: entry.category || "operational",
source: entry.source || `keystore-bulk-import#${slug}` };
if (entry.wif) {
spec.wif = entry.wif;
} else if (entry.seed) {
// Deviant's keystore stores `seed` as either raw hex (fromMasterSeed
// path) or a BIP39 mnemonic (word list). Route based on shape.
const s = String(entry.seed).trim();
if (/^[0-9a-f]{64,128}$/i.test(s)) { spec.seedHex = s; spec.path = entry.path; }
else { spec.mnemonic = s; spec.path = entry.path; }
} else { errors.push(`${slug}: no wif or seed`); continue; }
try {
await S.invoke("importWallet", spec);
ok++;
} catch (er) {
const msg = cleanErr(er);
// Duplicate imports are non-errors: user re-ran on the same file.
if (/duplicate/i.test(msg)) { skipped++; continue; }
errors.push(`${slug}: ${msg}`);
}
}
btn.textContent = orig; btn.disabled = false;
if (errors.length) { setMsg(`Imported ${ok}, ${skipped} skipped (mainnet). ${errors.length} error(s): ${errors.slice(0, 3).join("; ")}${errors.length > 3 ? "…" : ""}`); }
else if (ok) {
// Session pw is dropped when the overlay closes; we don't hold it.
close();
// Refresh panel state so the wallet strip shows the new imports.
try { state = await S.invoke("state"); render(); } catch {}
} else { setMsg(`Nothing imported${skipped ? ` — ${skipped} mainnet entries skipped (chipnet-only)` : ""}.`); }
});
}
function renderKeystorePreview(overlay, plain) {
const wallets = plain?.wallets || {};
const entries = Object.entries(wallets).map(([slug, w]) => ({
slug, cashaddr: String(w.cashaddr || ""), label: w.label || slug,
category: w.category || "operational", kind: w.wif ? "wif" : (w.seed ? "seed" : "?"),
}));
const chipnet = entries.filter((e) => e.cashaddr.startsWith("bchtest:"));
const mainnet = entries.filter((e) => e.cashaddr.startsWith("bitcoincash:"));
const el = overlay.querySelector("#ksList");
el.innerHTML = chipnet.map((e) => `<label data-ksrow="${esc(e.slug)}">
<input type="checkbox" checked>
<span class="surl">
<div style="font-size:12px;color:var(--ink)">${esc(e.label)}
<span class="ttag" style="background:rgba(214,255,61,.16);color:var(--acid,#d6ff3d);text-transform:none">${esc(e.category)}</span>
<span class="hint" style="font-size:10.5px">· ${esc(e.kind.toUpperCase())}</span>
</div>
<div class="mono" style="font-size:10.5px;color:var(--dim)">${esc(e.cashaddr.slice(0, 32))}…${esc(e.cashaddr.slice(-6))}</div>
</span>
</label>`).join("");
const meta = `${chipnet.length} chipnet wallets available.` + (mainnet.length ? ` ${mainnet.length} mainnet entries hidden (chipnet-only import).` : "");
overlay.querySelector("#ksPreviewMeta").textContent = meta;
overlay.querySelector("#ksPreview").hidden = false;
overlay.querySelector("#ksUnlock").hidden = true;
overlay.querySelector("#ksImport").hidden = false;
overlay.querySelector("#ksFileField").style.display = "none";
overlay.querySelector("#ksPassField").style.display = "none";
overlay.querySelector("#ksSelAll").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = true));
overlay.querySelector("#ksSelNone").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = false));
overlay.querySelector("#ksSelBns").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => {
const cat = r.querySelector(".ttag")?.textContent || "";
r.querySelector("input[type=checkbox]").checked = cat === "bns" || cat === "bns-infra";
}));
overlay.querySelector("#ksSelOps").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => {
const cat = r.querySelector(".ttag")?.textContent || "";
r.querySelector("input[type=checkbox]").checked = cat === "operational";
}));
}
// Multi-chain import config — drives the form shape per coin. Every entry
// declares: label / logo / networks (with default derivation path) /
// key-material formats accepted / placeholder for the raw-key input.
const IMPORT_COIN_CONFIG = {
bch: {
label: "Bitcoin Cash", logo: "bch",
networks: [
// Chipnet prefills the BCH coin type, not BIP44's testnet 1'. Chipnet
// is a BCH testnet and the tooling around it (keystore exports,
// faucets, test wallets) overwhelmingly derives from 145'; prefilling
// 1' sent every chipnet seed import to an empty address. 1' is still
// one of the presets, and the scan finds whichever is real.
// NOTE: this is the IMPORT prefill only. Vault-derived chipnet wallets
// keep m/44'/1'/0' (chain-bch.js) — changing that would move the
// addresses of wallets that already exist.
{ id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/145'/0'/0/0", testnet: true },
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" },
],
},
btc: {
label: "Bitcoin", logo: "btc",
// Testnet3 is de facto abandoned (blocks stall for weeks, faucets
// dried up); Signet is Bitcoin's living testnet now. Only Signet is
// exposed to new imports. The testnet3 adapter is kept in
// lib/chain-btc.js so any wallet created on an earlier version still
// loads — it just no longer appears in the picker.
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/84'/0'/0'/0/0" },
{ id: "signet", label: "Signet", defaultPath: "m/84'/1'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" },
],
},
dgb: {
label: "DigiByte", logo: "dgb",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/84'/20'/0'/0/0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "L… / K… (base58check)" },
],
},
eth: {
label: "Ethereum", logo: "eth",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/60'/0'/0/0" },
{ id: "sepolia", label: "Sepolia", defaultPath: "m/44'/60'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" },
],
},
trx: {
label: "Tron", logo: "trx",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/195'/0'/0/0" },
{ id: "nile", label: "Nile testnet", defaultPath: "m/44'/195'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" },
],
},
sol: {
label: "Solana", logo: "sol",
networks: [
{ id: "mainnet", label: "Mainnet-beta", defaultPath: "m/44'/501'/0'/0'" },
{ id: "devnet", label: "Devnet", defaultPath: "m/44'/501'/0'/0'", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (hex)", placeholder: "32 or 64 bytes hex" },
{ id: "privB58", label: "Private key (base58)", placeholder: "Phantom / Solflare export" },
],
},
sc: {
label: "Siacoin", logo: "sc",
// Sia's walletd (v2) uses a 32-byte root seed and an integer index
// (KeyFromSeed layout) — no BIP44 path. Sia Central Lite / walletd
// both accept a 12-word BIP39 mnemonic that PBKDF2's down to the
// root; the raw 32-byte hex is the alternative that walletd's API
// itself takes. defaultPath doubles as the address index the import
// starts on (0 is standard for a fresh import).
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic (12 words)" },
{ id: "seedHex", label: "Seed hex (64 chars)", placeholder: "32-byte root, walletd-compatible" },
],
},
};
// ---- QR import (image file, never the camera) -------------------------------
//
// Reads a seed phrase or a private key out of a picture of a QR code. The
// file never leaves the panel: it is drawn to a canvas here and decoded by
// the vendored jsQR, with no upload and no camera permission.
//
// What comes back is CLASSIFIED, not trusted. It picks the field to fill and
// nothing else — no auto-submit, no auto-import. A QR is an opaque blob to
// the person holding it, and "scan this to restore your wallet" is a working
// phish; the user still reads what landed in the box and presses Import, and
// the real validation happens in the host handler either way.
const BIP39_LENGTHS = new Set([12, 15, 18, 21, 24]);
// A BIP39 English phrase is only lowercase a-z words, 3-8 letters each, in
// one of the defined lengths. Word membership is not checked here — the host
// handler does that when it derives; this only decides which box to fill.
function mnemonicIn(str) {
const words = String(str || "").toLowerCase().split(/\s+/).filter(Boolean);
if (!BIP39_LENGTHS.has(words.length)) return null;
if (!words.every((w) => /^[a-z]{3,8}$/.test(w))) return null;
return { value: words.join(" "), words: words.length };
}
function classifyScannedSecret(text) {
const s = String(text || "").trim();
if (!s) return { kind: "empty" };
const bare = mnemonicIn(s);
if (bare) return { kind: "mnemonic", value: bare.value, words: bare.words };
// WIF: base58, 51-52 chars. 5/K/L = BTC-family mainnet, 9/c = testnet.
if (/^[5KL9c][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return { kind: "wif", value: s };
// Raw 32-byte hex, with or without 0x.
if (/^(0x)?[0-9a-fA-F]{64}$/.test(s)) return { kind: "hex", value: s };
// Wrapped phrases. Several wallets export the seed inside an envelope —
// a version marker, the words, sometimes a derivation path, pipe- or
// comma-separated ("1|<words>|m/44'/145'/0'"). Split on every run of
// non-letters (keeping spaces, which separate the words) and look for a
// BIP39-shaped run in any piece. This is tolerant of the wrapper without
// being loose about what counts as a phrase: a URL or an address still
// breaks into single-word pieces and matches nothing.
for (const piece of s.split(/[^A-Za-z ]+/)) {
const hit = mnemonicIn(piece);
if (!hit) continue;
// A path anywhere in the payload is worth carrying over — pulled from
// the ORIGINAL string, since splitting on non-letters shreds it.
const path = (s.match(/m(?:\/\d+'?)+/) || [])[0] || null;
return { kind: "mnemonic", value: hit.value, words: hit.words, path, wrapped: true };
}
return { kind: "unknown", value: s };
}
// jsQR is 257 KB of vendored decoder and it is only needed when someone
// actually imports a QR image. Loading it from panel.html meant every panel
// open — every hide/show — parsed all of it before panel.js even started.
// Fetch it the first time it is wanted instead.
let jsqrLoad = null;
function loadJsQr() {
if (typeof jsQR === "function") return Promise.resolve();
if (jsqrLoad) return jsqrLoad;
jsqrLoad = new Promise((resolve, reject) => {
const el = document.createElement("script");
el.src = "lib/jsqr.js";
el.onload = () => (typeof jsQR === "function" ? resolve() : reject(new Error("the QR decoder loaded but exposed nothing")));
el.onerror = () => { jsqrLoad = null; reject(new Error("the QR decoder failed to load")); };
document.head.appendChild(el);
});
return jsqrLoad;
}
async function decodeQrFile(file) {
await loadJsQr();
const bitmap = await createImageBitmap(file);
try {
// Very large photos cost time and memory for no accuracy gain; jsQR wants
// pixels, not megapixels. Cap the long edge and let the browser scale.
const MAX = 1600;
const scale = Math.min(1, MAX / Math.max(bitmap.width, bitmap.height));
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const cv = document.createElement("canvas");
cv.width = w; cv.height = h;
const ctx = cv.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, w, h);
const img = ctx.getImageData(0, 0, w, h);
// Photographs often invert or sit on a dark background; try both.
const hit = jsQR(img.data, w, h, { inversionAttempts: "attemptBoth" });
return hit && hit.data ? hit.data : null;
} finally {
try { bitmap.close(); } catch (_e) {}
}
}
function wireQrImport(overlay) {
const btn = overlay.querySelector("#imQrBtn");
const input = overlay.querySelector("#imQrFile");
if (!btn || !input) return;
const msg = () => overlay.querySelector("#imMsg");
const say = (text, isErr) => {
const m = msg(); if (!m) return;
m.textContent = text;
m.className = isErr ? "msg err" : "msg";
m.hidden = false;
};
btn.addEventListener("click", (e) => { e.preventDefault(); input.value = ""; input.click(); });
input.addEventListener("change", async () => {
const file = input.files && input.files[0];
if (!file) return;
const prev = btn.textContent;
btn.textContent = "Reading…"; btn.disabled = true;
try {
const text = await decodeQrFile(file);
if (!text) { say("No QR code found in that image. A flat, well-lit crop of just the code works best.", true); return; }
const found = classifyScannedSecret(text);
if (found.kind === "mnemonic") {
const ta = overlay.querySelector("#imMnemonic");
if (ta) { ta.value = found.value; ta.dispatchEvent(new Event("input", { bubbles: true })); }
// A derivation path in the QR is worth surfacing, but it does not get
// to silently replace a path already in the box — that box is
// prefilled with this coin's default and may have been edited, and
// quietly changing which addresses get derived is the kind of thing
// that looks like lost funds. Fill it only when empty; otherwise say
// what the QR carried and let the user decide.
let pathNote = "";
if (found.path) {
const pf = overlay.querySelector("#imPath");
if (pf && !pf.value.trim()) {
pf.value = found.path;
pf.dispatchEvent(new Event("input", { bubbles: true }));
pathNote = ` Its derivation path ${found.path} went into the path box.`;
} else if (pf && pf.value.trim() !== found.path) {
pathNote = ` It also carried the path ${found.path} — the box says ${pf.value.trim()}, change it if that is wrong.`;
}
}
const unwrapped = found.wrapped ? " (unwrapped from the QR's own format)" : "";
say(`Read a ${found.words}-word phrase${unwrapped}.${pathNote} Check it, pick the coin and network, then press Import.`, false);
} else if (found.kind === "wif" || found.kind === "hex") {
const raw = overlay.querySelector("#imRaw");
if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); }
say(`Read a ${found.kind === "wif" ? "WIF private key" : "32-byte key"}. Switch Source to the matching option if it is not already, then press Import.`, false);
} else {
// Don't paste unrecognised payloads into a key field — show a short
// preview so the user can see it was, say, a URL, and stop there.
const peek = found.value.length > 90 ? found.value.slice(0, 90) + "…" : found.value;
say(`That QR decoded to something that is not a seed phrase or key: "${peek}"`, true);
}
} catch (err) {
say(cleanErr(err), true);
} finally {
btn.textContent = prev; btn.disabled = false;
input.value = "";
}
});
}
function openImportModal(initialChain) {
const chains = Object.keys(IMPORT_COIN_CONFIG);
let curChain = chains.includes(initialChain) ? initialChain : "bch";
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px";
// Check vault lock state up front. Imported wallets that mounted at
// startup can leave overallPhase === "ready" even when the vault is
// still locked (imports skip vault.derive) — that's what makes the
// main panel look unlocked while a fresh "wallet-imports-add" IPC
// fails with "password vault is locked". So we test the vault
// directly here and, if it's locked, surface an unlock form inside
// the modal rather than blindly submitting and showing red text.
const paintUnlockGate = (errText) => {
overlay.innerHTML = `
<div style="width:min(94vw,420px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<div style="font-weight:600;flex:1">🔒 Unlock the vault to import</div>
<button class="btn sm" id="imClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">Aegis stores imported key material in Theseus's encrypted vault (<span class="mono">wallet-imports.enc</span>). Enter your master password once to unlock it, then Aegis will remember the import form you were filling in.</div>
<div class="field">
<div class="lbl">Master password</div>
<input type="password" id="imUnlockPw" autocomplete="current-password" placeholder="…">
</div>
<div class="msg err" id="imUnlockMsg" ${errText ? "" : "hidden"} style="margin-top:8px">${esc(errText || "")}</div>
<div class="actions" style="justify-content:flex-end;margin-top:10px">
<button class="btn" id="imCancel">Cancel</button>
<button class="btn primary" id="imUnlockBtn">Unlock and continue</button>
</div>
</div>`;
overlay.querySelector("#imClose").addEventListener("click", close);
overlay.querySelector("#imCancel").addEventListener("click", close);
const pwInput = overlay.querySelector("#imUnlockPw");
pwInput.focus();
const doUnlock = async () => {
const pw = pwInput.value;
const err = overlay.querySelector("#imUnlockMsg");
if (!pw) { err.textContent = "Password required."; err.hidden = false; return; }
err.hidden = true;
try {
state = await S.invoke("vaultUnlock", { masterPassword: pw });
// Success — re-paint the modal as the actual import form.
paintImportForm();
} catch (e) {
err.textContent = cleanErr(e); err.hidden = false;
}
};
overlay.querySelector("#imUnlockBtn").addEventListener("click", doUnlock);
pwInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); });
};
const paintImportForm = () => {
overlay.innerHTML = `
<div style="width:min(94vw,420px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<span id="imHeaderLogo"></span>
<div style="font-weight:600;flex:1">Import a wallet</div>
<button class="btn sm" id="imClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.</div>
<div class="field">
<div class="lbl">Coin</div>
<select id="imCoin" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px">
${chains.map((c) => `<option value="${esc(c)}" ${c === curChain ? "selected" : ""}>${esc(IMPORT_COIN_CONFIG[c].label)}</option>`).join("")}
</select>
</div>
<div class="field">
<div class="lbl">Network</div>
<div id="imNetworkGroup" style="display:flex;gap:12px;flex-wrap:wrap;font-size:12.5px;margin-top:4px"></div>
</div>
<div class="field">
<div class="lbl">Source</div>
<div id="imFormatGroup" style="display:flex;gap:12px;flex-wrap:wrap;font-size:12.5px;margin-top:4px"></div>
</div>
<div class="field" id="imMnemonicField">
<div class="lbl" style="display:flex;align-items:center;gap:8px">
<span style="flex:1">Mnemonic (12/24 words)</span>
<!-- Image only, no camera. Theseus denies the camera by default
(blockCamera) and blanks device labels; a wallet should not be
the reason a privacy browser turns that off. A picture of the
QR needs no permission at all. -->
<button type="button" class="btn sm" id="imQrBtn" title="Read a seed phrase or key from a QR code image">Scan QR image</button>
<input type="file" id="imQrFile" accept="image/*" hidden>
</div>
<textarea id="imMnemonic" spellcheck="false" rows="2" style="font-family:ui-monospace,monospace;font-size:12px" placeholder="paste the seed phrase"></textarea>
<div class="lbl" id="imPathLabel" style="margin-top:6px">Derivation path</div>
<!-- One prefilled path was the whole bug: a seed from a wallet on a
different path imports a valid but empty address, reports 0, and
gives no way to tell a wrong path from an empty wallet. Presets
name the wallets each path belongs to; the scan below asks the
chain which one actually holds coins. -->
<select id="imPathPreset" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px;margin-bottom:6px" hidden></select>
<input type="text" id="imPath" spellcheck="false" placeholder="m/…">
<div id="imScanRow" style="margin-top:6px" hidden>
<button class="btn sm" id="imScanBtn" type="button">Check which path has my funds</button>
</div>
<div id="imScanOut"></div>
<div class="hint" id="imPathHint"></div>
</div>
<div class="field" id="imRawField" hidden>
<div class="lbl" id="imRawLabel">Private key</div>
<input type="text" id="imRaw" spellcheck="false" placeholder="">
</div>
<div class="field">
<div class="lbl">Label</div>
<input type="text" id="imLabel" placeholder="e.g. Trading wallet">
</div>
<div class="field">
<div class="lbl">Category</div>
<select id="imCategory" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px">
<option value="operational">operational</option>
<option value="bns">bns</option>
<option value="bns-infra">bns-infra</option>
<option value="chipnet-test">chipnet-test</option>
<option value="hd-general">hd-general</option>
<option value="primary">primary</option>
</select>
</div>
<div class="msg err" id="imMsg" hidden style="margin-top:8px"></div>
<div class="actions" style="justify-content:flex-end;margin-top:10px">
<button class="btn" id="imCancel">Cancel</button>
<button class="btn primary" id="imGo">Import</button>
</div>
</div>`;
overlay.querySelector("#imClose").addEventListener("click", close);
overlay.querySelector("#imCancel").addEventListener("click", close);
wireQrImport(overlay);
const netGroup = overlay.querySelector("#imNetworkGroup");
const fmtGroup = overlay.querySelector("#imFormatGroup");
const rawField = overlay.querySelector("#imRawField");
const mnField = overlay.querySelector("#imMnemonicField");
function paintChain() {
const cfg = IMPORT_COIN_CONFIG[curChain];
overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22);
netGroup.innerHTML = cfg.networks.map((n, i) => `<label><input type="radio" name="imNet" value="${esc(n.id)}" ${i === 0 ? "checked" : ""}> ${esc(n.label)}${n.testnet ? " " + testnetTag() : ""}</label>`).join("");
fmtGroup.innerHTML = cfg.formats.map((f, i) => `<label><input type="radio" name="imKind" value="${esc(f.id)}" ${i === 0 ? "checked" : ""}> ${esc(f.label)}</label>`).join("");
overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", () => {
updatePathDefault(true);
paintPathPresets();
}));
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", () => {
updateFormatFields();
paintPathPresets();
}));
updatePathDefault(true);
updateFormatFields();
paintPathPresets();
}
function updatePathDefault(force) {
const cfg = IMPORT_COIN_CONFIG[curChain];
const netId = overlay.querySelector('input[name="imNet"]:checked')?.value;
const net = cfg.networks.find((n) => n.id === netId) || cfg.networks[0];
const path = overlay.querySelector("#imPath");
if (force || !path.value.trim()) path.value = net.defaultPath;
// Sia doesn't have a BIP44 path — the same field carries the u64
// address index KeyFromSeed derives from. Rename the label + hint
// so users don't paste a bogus m/44'/… into the SC form.
const pathLbl = overlay.querySelector("#imPathLabel");
const isSia = curChain === "sc";
if (pathLbl) pathLbl.textContent = isSia ? "Address index" : "Derivation path";
overlay.querySelector("#imPathHint").textContent = isSia
? `Sia uses KeyFromSeed(seed, index) — default is ${net.defaultPath} for a fresh import.`
: `Default for ${net.label}: ${net.defaultPath}`;
}
function updateFormatFields() {
const cfg = IMPORT_COIN_CONFIG[curChain];
const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic";
const f = cfg.formats.find((x) => x.id === fmt) || cfg.formats[0];
mnField.hidden = fmt !== "mnemonic";
rawField.hidden = fmt === "mnemonic";
if (fmt !== "mnemonic") {
overlay.querySelector("#imRawLabel").textContent = f.label;
overlay.querySelector("#imRaw").placeholder = f.placeholder || "";
overlay.querySelector("#imRaw").value = "";
}
}
// Derivation-path presets + the "which path has my funds" scan. Both are
// mnemonic-only: a WIF carries its own single key and has no path.
const CUSTOM = "__custom__";
// Which master-key variant the chosen path belongs to (DigiByte only;
// null means BIP32's standard "Bitcoin seed").
let importHmacKey = null;
let lastCandidates = null;
async function paintPathPresets() {
const presetSel = overlay.querySelector("#imPathPreset");
const scanRow = overlay.querySelector("#imScanRow");
const out = overlay.querySelector("#imScanOut");
if (!presetSel || !scanRow) return;
out.innerHTML = "";
const netId = overlay.querySelector('input[name="imNet"]:checked')?.value || "";
const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic";
let cands = [];
if (fmt === "mnemonic") {
try { cands = (await S.invoke("seedPathCandidates", { chain: curChain, network: netId }))?.candidates || []; }
catch { cands = []; }
}
lastCandidates = cands;
if (!cands.length) { presetSel.hidden = true; scanRow.hidden = true; return; }
presetSel.hidden = false;
scanRow.hidden = false;
const cur = overlay.querySelector("#imPath").value.trim();
// The value is an index, not the path: DigiByte lists the same path
// twice under two different master keys, so the path alone is not a
// unique choice.
presetSel.innerHTML = cands.map((c, i) =>
`<option value="${i}" ${c.path === cur && !c.hmacKey ? "selected" : ""}>${esc(c.path)} — ${esc(c.note)}</option>`
).join("") + `<option value="${CUSTOM}" ${cands.some((c) => c.path === cur) ? "" : "selected"}>Custom…</option>`;
presetSel.onchange = () => {
if (presetSel.value === CUSTOM) { overlay.querySelector("#imPath").focus(); return; }
const c = cands[Number(presetSel.value)];
if (!c) return;
overlay.querySelector("#imPath").value = c.path;
importHmacKey = c.hmacKey || null;
};
// Typing by hand flips the select to Custom rather than leaving it
// pointing at a preset the field no longer matches.
overlay.querySelector("#imPath").oninput = () => {
const v = overlay.querySelector("#imPath").value.trim();
const i = cands.findIndex((c) => c.path === v && !c.hmacKey);
presetSel.value = i >= 0 ? String(i) : CUSTOM;
// Typing a path by hand means the standard master key unless a scan
// result sets it again.
if (i >= 0) importHmacKey = null;
};
}
async function runPathScan() {
const out = overlay.querySelector("#imScanOut");
const btn = overlay.querySelector("#imScanBtn");
const mnemonic = overlay.querySelector("#imMnemonic").value.trim();
if (!mnemonic) {
out.innerHTML = `<div class="msg err" style="margin-top:6px">Paste the seed phrase first — the scan derives addresses from it to ask the chain which path has history.</div>`;
return;
}
const netId = overlay.querySelector('input[name="imNet"]:checked')?.value || "";
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Scanning…";
out.innerHTML = `<div class="hint" style="margin-top:6px">Deriving candidates and querying the network…</div>`;
try {
const r = await S.invoke("scanSeedPaths", { chain: curChain, network: netId, mnemonic });
const any = r.results.some((x) => x.balance > 0 || x.txCount > 0);
const rows = r.results.map((x) => {
const funded = x.balance > 0;
const amt = fmtBig(x.balance || 0, r.decimals);
const tag = funded
? `<b style="color:var(--acid)">${esc(amt)} ${esc(r.ticker)}</b>`
: x.txCount > 0 ? `<span class="hint">no balance, ${x.txCount} past tx</span>`
: `<span class="hint">nothing</span>`;
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center;margin-top:4px">
<div>
<div class="mono" style="font-size:12px">${esc(x.path)}${x.hmacKey ? ` <span class="ttag">${esc(x.hmacKey)}</span>` : ""}</div>
<div class="hint">${esc(x.note)} · ${tag}</div>
</div>
<button class="btn sm ${funded ? "primary" : ""}" data-usepath="${esc(x.path)}" data-usehmac="${esc(x.hmacKey || "")}">Use</button>
</div>`;
}).join("");
out.innerHTML = (any
? `<div class="hint" style="margin-top:6px">Scanned ${r.results.length} paths, ${r.results[0]?.scanned || 0} addresses each.</div>`
: `<div class="msg err" style="margin-top:6px">None of these paths has any history on ${esc(r.network)}. Either this seed has never been used here, or its wallet uses a path Aegis does not list — type it in by hand and re-scan.</div>`)
+ rows;
out.querySelectorAll("[data-usepath]").forEach((b) => b.addEventListener("click", () => {
overlay.querySelector("#imPath").value = b.dataset.usepath;
importHmacKey = b.dataset.usehmac || null;
const ps = overlay.querySelector("#imPathPreset");
if (ps) {
const i = (lastCandidates || []).findIndex((c) => c.path === b.dataset.usepath && (c.hmacKey || "") === (b.dataset.usehmac || ""));
ps.value = i >= 0 ? String(i) : CUSTOM;
}
flash(b, "Set");
}));
} catch (e) {
out.innerHTML = `<div class="msg err" style="margin-top:6px">${esc(cleanErr(e))}</div>`;
} finally { btn.disabled = false; btn.textContent = old; }
}
overlay.querySelector("#imScanBtn").addEventListener("click", runPathScan);
overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); });
paintChain();
overlay.querySelector("#imGo").addEventListener("click", async () => {
const msg = overlay.querySelector("#imMsg"); msg.hidden = true;
const chain = curChain;
const network = overlay.querySelector('input[name="imNet"]:checked')?.value;
const kind = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic";
const label = overlay.querySelector("#imLabel").value.trim();
const category = overlay.querySelector("#imCategory").value;
if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; }
const payload = { chain, network, label, category };
if (kind === "mnemonic") {
payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim();
payload.path = overlay.querySelector("#imPath").value.trim();
if (importHmacKey) payload.hmacKey = importHmacKey;
if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; }
// Sia has no BIP44 path — the "path" field carries a u64 address
// index instead. Rename before sending so the addon reads it via
// p.index (mnemonic path field still populates for other chains).
if (chain === "sc") {
payload.index = payload.path || "0";
delete payload.path;
}
} else if (kind === "wif") {
payload.wif = overlay.querySelector("#imRaw").value.trim();
if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; }
} else if (kind === "privHex") {
payload.privHex = overlay.querySelector("#imRaw").value.trim();
if (!payload.privHex) { msg.textContent = "Private key hex required."; msg.hidden = false; return; }
} else if (kind === "privB58") {
payload.privB58 = overlay.querySelector("#imRaw").value.trim();
if (!payload.privB58) { msg.textContent = "Private key base58 required."; msg.hidden = false; return; }
} else if (kind === "seedHex") {
payload.seedHex = overlay.querySelector("#imRaw").value.trim();
if (!payload.seedHex) { msg.textContent = "Seed hex required."; msg.hidden = false; return; }
// Same rename as the mnemonic branch — SC's "path" input carries
// the address index. The path field defaults to "0" per config.
if (chain === "sc") {
payload.index = "0"; // Raw-hex form has no path input; use 0.
}
}
try {
state = await S.invoke("importWallet", payload);
close();
render();
} catch (e) {
const errText = cleanErr(e);
// Race case: vault got locked between the modal opening and the
// submit hitting Theseus (idle-lock, or user unlocked but the
// imports subsystem didn't get its own credential). Route the
// user through the unlock gate instead of the raw error text.
if (/vault is locked|password vault is locked/i.test(errText)) {
paintUnlockGate("Vault locked while you were filling in the form. Unlock again to save this import.");
return;
}
msg.textContent = errText; msg.hidden = false;
}
});
};
// Bootstrap: attach the overlay first, then pick which face to show.
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
S.invoke("vaultStatus").then((st) => {
if (st && st.unlocked) paintImportForm();
else paintUnlockGate(null);
}).catch(() => {
// If we can't even reach vaultStatus, assume unlocked and let the
// downstream submit surface the real error.
paintImportForm();
});
}
// ---- Consolidation modal ---------------------------------------------------
// Opens a batch send-max flow from every same-chain/same-network sibling
// into the currently-selected wallet. Preview first, per-source checkboxes,
// then a single PIN gate (if enabled) before the batch fires.
function openConsolidateModal() {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px";
overlay.innerHTML = `
<div style="width:min(94vw,460px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="font-weight:600;flex:1">⇢ Consolidate balances</div>
<button class="btn sm" id="conClose" type="button">✕</button>
</div>
<div class="hint" id="conIntro" style="margin-bottom:10px">Every wallet you tick is swept via send-max into the currently-selected wallet. Same chain + same network only — nothing crosses networks or coins.</div>
<div class="field" style="margin-bottom:6px">
<div class="lbl">Destination</div>
<div id="conDest" class="mono" style="padding:6px 8px;background:rgba(255,255,255,.04);border-radius:6px;font-size:12.5px;overflow-wrap:anywhere">Loading…</div>
</div>
<div id="conBody" style="margin-top:10px"></div>
<div class="msg err" id="conMsg" hidden style="margin-top:8px"></div>
<div class="actions" style="justify-content:space-between;margin-top:10px;align-items:center">
<label style="font-size:12px;display:inline-flex;align-items:center;gap:6px;color:var(--dim)">
<input type="checkbox" id="conSelectAll" checked> select all eligible
</label>
<div style="display:flex;gap:8px">
<button class="btn" id="conCancel">Close</button>
<button class="btn primary" id="conGo" disabled>Consolidate</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#conClose").addEventListener("click", close);
overlay.querySelector("#conCancel").addEventListener("click", close);
let preview = null;
const setBusy = (on, t) => {
const btn = overlay.querySelector("#conGo");
btn.disabled = !!on || !eligibleCount();
btn.textContent = t || "Consolidate";
};
const eligibleCount = () => {
if (!preview) return 0;
return overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked').length;
};
const paintPreview = () => {
const dest = overlay.querySelector("#conDest");
dest.textContent = preview.destinationLabel + " — " + shortenAddress(preview.destinationAddress);
const body = overlay.querySelector("#conBody");
if (!preview.sources.length) {
body.innerHTML = `<div class="hint" style="padding:12px;text-align:center">No other ${esc(preview.ticker || preview.chain.toUpperCase())} wallets on ${esc(preview.network)}. Add or import one first, then come back.</div>`;
overlay.querySelector("#conSelectAll").disabled = true;
return;
}
const dec = preview.decimals;
const rows = preview.sources.map((s) => {
const bal = fmtBig(s.balance, dec) + " " + esc(preview.ticker);
const net = s.net != null ? fmtBig(s.net, dec) + " " + esc(preview.ticker) : "—";
const fee = s.fee != null ? (preview.chain === "bch" ? `${s.fee} sat` : fmtBig(s.fee, dec) + " " + esc(preview.ticker)) : "—";
const err = s.error ? `<div class="hint" style="color:#f6768a;margin-top:2px;overflow-wrap:anywhere">${esc(s.error)}</div>` : "";
const check = s.eligible
? `<input type="checkbox" data-conwid="${esc(s.walletId)}" checked>`
: `<input type="checkbox" disabled>`;
const opacity = s.eligible ? "" : "opacity:.55";
return `<div class="tx" style="grid-template-columns:auto 1fr auto;align-items:start;gap:10px;padding:8px;border-radius:6px;background:rgba(255,255,255,.02);${opacity}">
<label style="display:flex;align-items:center;height:100%">${check}</label>
<div style="min-width:0">
<div style="font-weight:600">${esc(s.label)}</div>
<div class="hint mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(s.address || "—")}</div>
<div class="hint" style="margin-top:2px">Balance <b>${bal}</b> · Fee ${fee}</div>
${err}
</div>
<div style="text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap">
<div>${net}</div>
<div class="hint">will land</div>
</div>
</div>`;
}).join("");
// Summary line at the bottom.
body.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">${rows}</div>
<div id="conSummary" class="hint" style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line)"></div>`;
const refreshSummary = () => {
const eligible = preview.sources.filter((s) => s.eligible);
const checked = new Set(Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked')).map((c) => c.dataset.conwid));
const sum = (getter) => eligible.filter((s) => checked.has(s.walletId)).reduce((a, s) => a + BigInt(getter(s) || "0"), 0n);
const totalNet = sum((s) => s.net);
const totalFee = sum((s) => s.fee);
const totalBal = sum((s) => s.balance);
overlay.querySelector("#conSummary").innerHTML = `
<b>${checked.size}</b> wallet${checked.size === 1 ? "" : "s"} selected · Moving <b>${esc(fmtBig(totalBal.toString(), dec))}</b> ${esc(preview.ticker)}
(net <b>${esc(fmtBig(totalNet.toString(), dec))}</b> ${esc(preview.ticker)} after ${esc(fmtBig(totalFee.toString(), dec))} in fees)
`;
overlay.querySelector("#conGo").disabled = checked.size === 0;
};
overlay.querySelectorAll('input[type="checkbox"][data-conwid]').forEach((cb) => cb.addEventListener("change", () => {
// Uncheck master when any individual comes off.
const master = overlay.querySelector("#conSelectAll");
const boxes = Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:not(:disabled)'));
master.checked = boxes.length > 0 && boxes.every((b) => b.checked);
refreshSummary();
}));
overlay.querySelector("#conSelectAll").addEventListener("change", (e) => {
const on = !!e.target.checked;
overlay.querySelectorAll('input[type="checkbox"][data-conwid]:not(:disabled)').forEach((cb) => { cb.checked = on; });
refreshSummary();
});
refreshSummary();
};
const loadPreview = async () => {
try {
preview = await S.invoke("consolidatePreview");
paintPreview();
} catch (e) {
overlay.querySelector("#conBody").innerHTML = `<div class="hint" style="color:#f6768a;padding:12px">Preview failed: ${esc(cleanErr(e))}</div>`;
}
};
overlay.querySelector("#conGo").addEventListener("click", async () => {
const checked = Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked')).map((c) => c.dataset.conwid);
if (!checked.length) return;
const msg = overlay.querySelector("#conMsg"); msg.hidden = true;
// PIN gate fires ONCE for the whole batch — a batch send-max operation
// is a single user intent.
if (!await pinGate("transaction", `Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`)) {
msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return;
}
setBusy(true, "Sending…");
try {
const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
renderResult(res);
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
setBusy(false);
}
});
const renderResult = (res) => {
const ok = res.results.filter((r) => r.ok);
const bad = res.results.filter((r) => !r.ok);
const explorer = sel()?.explorerTx || "";
const okRows = ok.map((r) => {
const link = explorer && r.txid ? `<a class="link" data-conurl="${esc(explorerHref(explorer, r.txid))}">${esc(r.txid.slice(0, 16))}…</a>` : (r.txid || "");
return `<div class="tx" style="grid-template-columns:1fr auto;padding:6px 8px;background:rgba(214,255,61,.06);border-radius:6px">
<div><div style="font-weight:600">${esc(r.label)}</div><div class="hint">Sent — ${link}</div></div>
<div style="color:var(--acid,#d6ff3d);font-size:16px">✓</div>
</div>`;
}).join("");
const badRows = bad.map((r) => `<div class="tx" style="grid-template-columns:1fr auto;padding:6px 8px;background:rgba(246,118,138,.06);border-radius:6px">
<div><div style="font-weight:600">${esc(r.label)}</div><div class="hint" style="color:#f6768a;overflow-wrap:anywhere">${esc(r.error || "unknown error")}</div></div>
<div style="color:#f6768a;font-size:16px">⚠</div>
</div>`).join("");
overlay.querySelector("#conIntro").textContent = ok.length
? `${ok.length} broadcast · ${bad.length} skipped/failed. Balances update as the network confirms.`
: "Nothing broadcast — see per-source errors below.";
overlay.querySelector("#conBody").innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">${okRows}${badRows}</div>`;
overlay.querySelector("#conSelectAll").disabled = true;
overlay.querySelector("#conGo").hidden = true;
overlay.querySelector("#conCancel").textContent = "Done";
overlay.querySelectorAll("[data-conurl]").forEach((a) => a.addEventListener("click", (e) => {
e.preventDefault(); openUrl(a.dataset.conurl);
}));
};
loadPreview();
}
function shortenAddress(a) {
const s = String(a || "");
if (s.length <= 20) return s;
return s.slice(0, 12) + "…" + s.slice(-6);
}
// 0.8.0: inline consolidate view rendered into the Send/Receive tab body
// when the user flips the mode toggle to Consolidate. Same preview + batch
// mechanics as openConsolidateModal, but without the outer overlay so the
// tab feels like a native alternate mode rather than an interrupting modal.
async function renderConsolidateInline(hostEl) {
if (!hostEl) return;
hostEl.innerHTML = `<div class="hint" style="padding:12px;text-align:center">Loading…</div>`;
let preview;
try { preview = await S.invoke("consolidatePreview"); }
catch (e) { hostEl.innerHTML = `<div class="hint" style="color:#f6768a;padding:12px">Preview failed: ${esc(cleanErr(e))}</div>`; return; }
const dec = preview.decimals;
const rows = preview.sources.map((s) => {
const bal = fmtBig(s.balance, dec) + " " + esc(preview.ticker);
const net = s.net != null ? fmtBig(s.net, dec) + " " + esc(preview.ticker) : "—";
const fee = s.fee != null ? (preview.chain === "bch" ? `${s.fee} sat` : fmtBig(s.fee, dec) + " " + esc(preview.ticker)) : "—";
const err = s.error ? `<div class="hint" style="color:#f6768a;margin-top:2px;overflow-wrap:anywhere">${esc(s.error)}</div>` : "";
const check = s.eligible ? `<input type="checkbox" data-inconwid="${esc(s.walletId)}" checked>` : `<input type="checkbox" disabled>`;
const opacity = s.eligible ? "" : "opacity:.55";
return `<div class="tx" style="grid-template-columns:auto 1fr auto;align-items:start;gap:10px;padding:8px;border-radius:6px;background:rgba(255,255,255,.02);${opacity}">
<label style="display:flex;align-items:center;height:100%">${check}</label>
<div style="min-width:0">
<div style="font-weight:600">${esc(s.label)}</div>
<div class="hint mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(s.address || "—")}</div>
<div class="hint" style="margin-top:2px">Balance <b>${bal}</b> · Fee ${fee}</div>
${err}
</div>
<div style="text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap">
<div>${net}</div>
<div class="hint">will land</div>
</div>
</div>`;
}).join("");
hostEl.innerHTML = `
<div class="hint" style="margin-bottom:10px">Sweep same-network siblings into <b>${esc(preview.destinationLabel)}</b> (${esc(shortenAddress(preview.destinationAddress))}).</div>
<div id="inconBody" style="display:flex;flex-direction:column;gap:6px">${preview.sources.length ? rows : `<div class="hint" style="padding:12px;text-align:center">No other wallets to sweep.</div>`}</div>
<div id="inconSummary" class="hint" style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line)"></div>
<div class="msg err" id="inconMsg" hidden style="margin-top:8px"></div>
<div class="actions" style="justify-content:space-between;margin-top:10px;align-items:center">
<label style="font-size:12px;display:inline-flex;align-items:center;gap:6px;color:var(--dim)">
<input type="checkbox" id="inconSelectAll" checked> select all eligible
</label>
<button class="btn primary" id="inconGo">Consolidate</button>
</div>`;
const eligible = preview.sources.filter((s) => s.eligible);
const refreshSummary = () => {
const checked = new Set(Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:checked')).map((c) => c.dataset.inconwid));
const sum = (getter) => eligible.filter((s) => checked.has(s.walletId)).reduce((a, s) => a + BigInt(getter(s) || "0"), 0n);
const totalNet = sum((s) => s.net);
const totalFee = sum((s) => s.fee);
const totalBal = sum((s) => s.balance);
hostEl.querySelector("#inconSummary").innerHTML = `<b>${checked.size}</b> selected · Moving <b>${esc(fmtBig(totalBal.toString(), dec))}</b> ${esc(preview.ticker)} (net <b>${esc(fmtBig(totalNet.toString(), dec))}</b> after ${esc(fmtBig(totalFee.toString(), dec))} fees)`;
hostEl.querySelector("#inconGo").disabled = checked.size === 0;
};
hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]').forEach((cb) => cb.addEventListener("change", () => {
const master = hostEl.querySelector("#inconSelectAll");
const boxes = Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:not(:disabled)'));
master.checked = boxes.length > 0 && boxes.every((b) => b.checked);
refreshSummary();
}));
hostEl.querySelector("#inconSelectAll").addEventListener("change", (e) => {
const on = !!e.target.checked;
hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:not(:disabled)').forEach((cb) => { cb.checked = on; });
refreshSummary();
});
refreshSummary();
hostEl.querySelector("#inconGo").addEventListener("click", async () => {
const checked = Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:checked')).map((c) => c.dataset.inconwid);
if (!checked.length) return;
const msg = hostEl.querySelector("#inconMsg"); msg.hidden = true;
if (!await pinGate("transaction", `Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`)) {
msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return;
}
const go = hostEl.querySelector("#inconGo");
go.disabled = true; go.textContent = "Sending…";
try {
const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
const okCount = res.results.filter((r) => r.ok).length;
const badCount = res.results.length - okCount;
const explorer = sel()?.explorerTx || "";
const rowsResult = res.results.map((r) => {
if (r.ok) {
const link = explorer && r.txid ? `<a class="link" data-inconurl="${esc(explorerHref(explorer, r.txid))}">${esc(r.txid.slice(0, 16))}…</a>` : (r.txid || "");
return `<div class="tx" style="grid-template-columns:1fr auto;padding:6px 8px;background:rgba(214,255,61,.06);border-radius:6px">
<div><div style="font-weight:600">${esc(r.label)}</div><div class="hint">Sent — ${link}</div></div>
<div style="color:var(--acid,#d6ff3d);font-size:16px">✓</div>
</div>`;
}
return `<div class="tx" style="grid-template-columns:1fr auto;padding:6px 8px;background:rgba(246,118,138,.06);border-radius:6px">
<div><div style="font-weight:600">${esc(r.label)}</div><div class="hint" style="color:#f6768a;overflow-wrap:anywhere">${esc(r.error || "unknown error")}</div></div>
<div style="color:#f6768a;font-size:16px">⚠</div>
</div>`;
}).join("");
hostEl.innerHTML = `<div class="hint" style="margin-bottom:10px">${okCount} broadcast · ${badCount} skipped/failed.</div>
<div style="display:flex;flex-direction:column;gap:6px">${rowsResult}</div>`;
hostEl.querySelectorAll("[data-inconurl]").forEach((a) => a.addEventListener("click", (e) => { e.preventDefault(); openUrl(a.dataset.inconurl); }));
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
go.disabled = false; go.textContent = "Consolidate";
}
});
}
function paintSendMode() {
const normal = $("sendNormal"); const cons = $("sendConsolidate");
if (!normal || !cons) return;
const buttons = document.querySelectorAll("[data-send-mode]");
buttons.forEach((b) => b.classList.toggle("on", b.dataset.sendMode === sendMode));
normal.hidden = sendMode !== "send";
cons.hidden = sendMode !== "consolidate";
if (sendMode === "consolidate") {
const host = $("sendConsolidateInline");
if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); }
}
}
// Which half of the Receive pane is showing. Persistent: the choice sticks
// across re-renders and across wallet switches, because someone comparing
// token balances between wallets should not be knocked back to the QR on
// every click.
const RCV_VIEWS = ["address", "assets", "nfts"];
let rcvView = "address";
try {
const saved = localStorage.getItem("aegis/rcvView");
if (RCV_VIEWS.includes(saved)) rcvView = saved;
} catch (_e) {}
function paintRcvView() {
const addr = $("rcvAddressPane"), assets = $("rcvAssetsPane"), nfts = $("rcvNftsPane");
if (!addr || !assets || !nfts) return;
document.querySelectorAll("[data-rcv-view]").forEach((b) =>
b.classList.toggle("on", b.dataset.rcvView === rcvView));
addr.hidden = rcvView !== "address";
assets.hidden = rcvView !== "assets";
nfts.hidden = rcvView !== "nfts";
// The QR's backing store is sized from its rendered box, so one drawn
// while its pane was hidden comes out blank. Redraw on reveal — and note
// renderReceive only redraws when the address CHANGES, so switching back
// to an unchanged address would otherwise never repaint it.
if (rcvView === "address") {
const a = sel()?.address || "";
if (a) { try { drawQr(qrPayload(chain(), a, sel()?.network)); } catch (_e) {} }
}
}
function paintRcvMode() {
const normal = $("rcvNormal"); const cons = $("rcvConsolidate");
if (!normal || !cons) return;
normal.hidden = rcvMode !== "receive";
cons.hidden = rcvMode !== "consolidate";
if (rcvMode === "consolidate") {
const host = $("rcvConsolidateInline");
if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); }
}
}
// A paired dapp's relay status, from RelayStatus.status. Worth showing:
// "reconnecting" is the difference between "the dapp will see my next
// signature" and "this pairing is dead and I should re-pair", and that is
// invisible otherwise. "connected" is the normal case, so it stays quiet.
function wcStatusTag(status) {
const s = String(status || "");
if (!s || s === "connected") return "";
const label = s === "reconnecting" ? "RECONNECTING"
: s === "session_deleted" ? "SESSION GONE"
: s === "disconnected" ? "OFFLINE" : s.toUpperCase();
return `<span class="ttag" style="background:rgba(224,179,65,.18);color:#e0b341">${esc(label)}</span>`;
}
// Content of the Connect pane in the picker — WizardConnect pairing lives
// here so users can paste a wiz:// URI without diving into per-wallet
// Settings. If no BCH wallet is ready, we show a gate instead of the form.
function renderConnectPane(bchWallets) {
const readyBch = bchWallets.filter((w) => w.phase === "ready");
if (!readyBch.length) {
// 0.8.8: the locked branch used to be a dead end — it told the user to
// unlock the vault but gave them nothing to click, and the panel chrome
// stays visible whenever an imported wallet is mounted (those skip the
// vault), so this is reachable without the lock screen ever showing.
// Inline the same unlock form the lock screen uses.
const locked = bchWallets.length > 0;
return `<div class="hint" style="padding:14px">
<div style="margin-bottom:6px"><b>WizardConnect</b> pairs Aegis with a BCH dapp (Cauldron, Moria, or any site built on the SDK).</div>
${locked ? `
<div style="margin-bottom:10px">WizardConnect signs with your BCH keys, so the password vault has to be unlocked first.</div>
<div class="field">
<input type="password" id="pkConnectUnlockPw" placeholder="Master password" autocomplete="current-password">
</div>
<div class="actions"><button class="btn primary" id="pkConnectUnlockBtn">Unlock vault</button></div>
<div class="msg err" id="pkConnectUnlockMsg" hidden></div>
` : `<div>Add a BCH wallet first via the Add tab, then come back.</div>`}
</div>`;
}
// Wallets with no derivable seed (WIF single-key imports) can't pair at
// all, so they're disabled rather than silently failing on Connect.
const pairable = readyBch.filter((w) => !w.wcBlocked);
const blocked = readyBch.filter((w) => w.wcBlocked);
// Same precedence as the page-initiated pairing in index.js — the wallet
// nominated for WizardConnect, else whatever the panel is showing. Two
// different rules here and there is how a pairing surprises someone.
const wcRole = (state && state.roles && state.roles.wizardconnect) || null;
const preferId = (pairable.find((w) => w.id === wcRole) || pairable.find((w) => w.id === state.selectedWalletId) || pairable[0] || {}).id || null;
const opt = (w) => `<option value="${esc(w.id)}" ${w.id === preferId ? "selected" : ""} ${w.wcBlocked ? "disabled" : ""}>${esc(w.label)} · ${esc(w.networkLabel)}${w.id === wcRole ? " ★" : ""}${w.wcBlocked ? ` — can't pair (${esc(w.wcBlockedShort || "unsupported")})` : ""}</option>`;
// Pairable first, under a heading, whenever anything is blocked. In
// registry order a bulk keystore import puts fifteen unpairable WIF
// wallets ahead of the two chipnet ones that pair fine, and a list whose
// visible rows are all greyed-out testnet entries reads as "chipnet isn't
// supported" — which is not true and never was. Grouping keeps the
// reasons visible without letting them bury the working options.
const options = blocked.length
? `<optgroup label="Can pair">${pairable.map(opt).join("")}</optgroup>`
+ `<optgroup label="Cannot pair">${blocked.map(opt).join("")}</optgroup>`
: pairable.map(opt).join("");
// Greying an option out with "can't pair" and giving no reason anywhere on
// the page reads as a broken wallet rather than a property of how it was
// added. Show the reasons whenever ANY wallet is blocked — the old note
// only appeared when nothing at all could pair, so a user with one good
// mainnet wallet and ten WIF-imported chipnet ones saw no explanation.
const reasons = [...new Set(blocked.map((w) => w.wcBlocked))].map(esc).join(" ");
const blockedNote = !blocked.length
? ""
: !pairable.length
? `<div class="msg err" style="margin-bottom:8px">${reasons}</div>`
: `<div class="hint" style="margin-bottom:8px">${blocked.length} of ${readyBch.length} BCH wallets can't pair. ${reasons}</div>`;
// Flatten all connected dapps (across BCH wallets) into one list — the
// user thinks "my dapps", not "dapps per wallet".
const rows = [];
for (const w of readyBch) {
const conns = state?.wc?.[w.id] || [];
for (const c of conns) rows.push({ ...c, walletId: w.id, walletLabel: w.label });
}
const rowsHtml = rows.length
? rows.map((c) => `<div class="tx" style="grid-template-columns:auto 1fr auto;cursor:default;align-items:center;margin-top:6px">
<div>${c.dappIcon ? `<img src="${esc(c.dappIcon)}" style="width:18px;height:18px;border-radius:4px" onerror="this.hidden=true">` : ""}</div>
<div><div>${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}</div><div class="hint">on <b>${esc(c.walletLabel)}</b> · <span class="mono">${esc((c.uri || "").slice(0, 40))}…</span></div></div>
<button class="btn sm" data-wcpick="${esc(c.walletId)}|${esc(c.id)}">Disconnect</button>
</div>`).join("")
: `<div class="hint" style="padding:6px 8px">No dapps paired yet.</div>`;
return `<div style="padding:6px">
<div class="hint" style="margin-bottom:8px">Dapps that support Aegis hand the pairing over with one click. Otherwise open the dapp's Connect dialog and press <b>Scan page</b>, or paste its <span class="mono">wiz://</span> code below. Aegis signs every request after your approval.</div>
${blockedNote}
<div class="field">
<div class="lbl">Sign with</div>
<select id="pkConnectWallet" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px">${options}</select>
</div>
<div class="field">
<input type="text" id="pkConnectUri" spellcheck="false" placeholder="wiz://?p=…&s=…">
</div>
<div class="actions" style="gap:6px">
<button class="btn primary" id="pkConnectBtn">Connect</button>
<button class="btn" id="pkConnectScanBtn" title="Look for a wiz:// pairing code on the dapp tab you have open">Scan page</button>
</div>
<div class="msg err" id="pkConnectMsg" hidden></div>
<div class="lbl" style="margin-top:14px">Paired dapps</div>
${rowsHtml}
</div>`;
}
function wireConnectPane() {
// Locked-vault branch: unlock in place, then re-render the pane so the
// pairing form replaces the gate without the user reopening the picker.
const unlockBtn = document.getElementById("pkConnectUnlockBtn");
if (unlockBtn) {
const doUnlock = async () => {
const pwEl = document.getElementById("pkConnectUnlockPw");
const msg = document.getElementById("pkConnectUnlockMsg");
msg.hidden = true;
const pw = pwEl.value;
if (!pw) return;
try {
state = await S.invoke("vaultUnlock", { masterPassword: pw });
pwEl.value = "";
render();
fillPicker();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
unlockBtn.addEventListener("click", (e) => { e.stopPropagation(); doUnlock(); });
const pwEl = document.getElementById("pkConnectUnlockPw");
if (pwEl) {
pwEl.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") doUnlock(); });
try { pwEl.focus(); } catch {}
}
return;
}
const btn = document.getElementById("pkConnectBtn"); if (!btn) return;
btn.addEventListener("click", async () => {
const walletId = document.getElementById("pkConnectWallet").value;
const uri = document.getElementById("pkConnectUri").value.trim();
const msg = document.getElementById("pkConnectMsg"); msg.hidden = true;
if (!uri) return;
try {
state = await S.invoke("wcConnect", { walletId, uri });
document.getElementById("pkConnectUri").value = "";
fillPicker();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
const scanBtn = document.getElementById("pkConnectScanBtn");
if (scanBtn) scanBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const msg = document.getElementById("pkConnectMsg"); msg.hidden = true;
const field = document.getElementById("pkConnectUri");
const prev = scanBtn.textContent;
scanBtn.textContent = "Scanning…"; scanBtn.disabled = true;
try {
const res = await S.invoke("wcScanPage");
const uris = res?.uris || [];
if (!uris.length) {
// A dapp drops its pairing code from the DOM once it is connected,
// so the commonest reason a scan comes up empty is that the page is
// ALREADY paired. Telling that user to "open the Connect dialog"
// sends them looking for a dialog the dapp will not show again.
const alreadyPaired = Object.entries(state?.wc || {})
.flatMap(([wid, conns]) => (conns || []).map((c) => ({ ...c, walletId: wid })));
const nameOf = (c) => c.dappName || c.label || "a dapp";
const walletLabel = (wid) => (state?.wallets || []).find((w) => w.id === wid)?.label || wid;
const where = res?.origin ? ` on ${res.origin}` : " on the open tab";
msg.textContent = alreadyPaired.length
? `No wiz:// pairing code found${where}. Aegis already has ${nameOf(alreadyPaired[0])} paired on ${walletLabel(alreadyPaired[0].walletId)} — a dapp removes its code once it is connected, so if this page shows itself as connected there is nothing left to scan. To pair a different wallet, disconnect on both sides first.`
: `No wiz:// pairing code found${where}. Open the dapp's Connect dialog first, then scan again.`;
msg.hidden = false;
return;
}
// Fill the field rather than pairing outright: the user still picks
// which wallet signs, and still presses Connect. A scan that silently
// paired would be a click with a much larger consequence than the
// button implies.
field.value = uris[0];
msg.textContent = uris.length > 1
? `Found ${uris.length} codes on ${res.origin || "the page"} — filled the first. Press Connect to pair.`
: `Found a pairing code on ${res.origin || "the page"}. Press Connect to pair.`;
msg.classList.remove("err");
msg.hidden = false;
} catch (err) {
msg.textContent = cleanErr(err);
msg.classList.add("err");
msg.hidden = false;
} finally {
scanBtn.textContent = prev; scanBtn.disabled = false;
}
});
document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => {
const [walletId, connId] = b.dataset.wcpick.split("|");
try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); }
catch (e) { const m = document.getElementById("pkConnectMsg"); m.textContent = cleanErr(e); m.hidden = false; }
}));
}
// Always-visible wallet strip at the top of the panel. Each existing wallet
// is a chip (click to switch). Trailing [+] opens the Add-only picker;
// trailing [⋯] opens Import / Connect / Manage. Existing wallets are NEVER
// duplicated inside the picker — the picker is for creation flows only now.
// Which coin groups are collapsed in the wallet strip. Persisted per-user in
// panel-scoped session state; not durable across restarts because the picker
// already opens on the currently-selected wallet's group (auto-expand below).
const collapsedGroups = new Set();
// Per-chain "which network is showing" pointer. Rows grouped by chain
// alone (BCH, BTC, ETH, …); this map picks which subnetwork's wallets
// the row surfaces. Missing entry → pickDefaultNetwork() below prefers
// mainnet when present, falls back to the first wallet's network.
// 0.8.0: persisted to localStorage under aegis/activeNetworks so a user
// who prefers Sepolia on ETH stays on Sepolia across reloads. Fresh
// installs (no persisted entry) still fall back to mainnet — a
// last-selected-when-known, mainnet-otherwise policy.
const activeNetworkByChain = new Map(
(function () {
try {
const raw = localStorage.getItem("aegis/activeNetworks");
const j = raw ? JSON.parse(raw) : null;
return j && typeof j === "object" ? Object.entries(j) : [];
} catch { return []; }
})(),
);
function persistActiveNetworks() {
try {
const obj = {}; for (const [k, v] of activeNetworkByChain) obj[k] = v;
localStorage.setItem("aegis/activeNetworks", JSON.stringify(obj));
} catch {}
}
// 0.7.5: analogous pointer for "which specific wallet is active" within
// a chain+network bucket. Row click uses this to select the right wallet
// instead of always drilling into the address list, and the count pill
// (▾) is what opens the picker to change it. Keyed as "<chain>:<network>".
const activeWalletBySubgroup = new Map();
// Legacy per-chain+network key, kept because stripView.groupKey (inline
// address view) still uses it, and reorderWallets writes wallet order
// grouped by it below.
function subgroupKeyFor(w) { return `${w.chain}:${w.network}`; }
// Short network suffix. Used both as the pill next to the ticker (when
// the active network is not mainnet) and inside the network-picker
// dropdown. Empty string means "call this network Mainnet in the menu"
// and skip the pill on the row itself.
const NETWORK_SHORT = {
"bch:mainnet": "", "bch:chipnet": "Chipnet",
"btc:mainnet": "", "btc:testnet3": "Testnet", "btc:signet": "Signet",
"eth:mainnet": "", "eth:sepolia": "Sepolia",
"trx:mainnet": "", "trx:nile": "Nile",
"sol:mainnet": "", "sol:devnet": "Devnet",
"dgb:mainnet": "",
"sc:mainnet": "",
};
function isTestnetNetwork(chain, network) {
return network !== "mainnet";
}
function networkLabelFor(chain, network, fallback) {
const key = `${chain}:${network}`;
const short = NETWORK_SHORT[key];
if (short !== undefined) return short || "Mainnet";
return fallback || network || "Mainnet";
}
// Sort order inside the network dropdown: mainnet first, then the rest
// in the order they appeared in the wallet list. Keeps the natural
// primary-first reading while never surprising the user with alpha sort.
function orderNetworks(nets) {
const out = [];
if (nets.includes("mainnet")) out.push("mainnet");
for (const n of nets) if (n !== "mainnet" && !out.includes(n)) out.push(n);
return out;
}
function pickDefaultNetwork(nets) {
if (nets.includes("mainnet")) return "mainnet";
return nets[0];
}
// Meta for a chain-level group. Depends on which subnetwork is active,
// so pass that in explicitly (renderWalletStrip has already resolved it).
function chainMetaFor(sampleWallet, activeNet) {
const w = sampleWallet;
const short = networkLabelFor(w.chain, activeNet, w.networkLabel);
const isMainnet = activeNet === "mainnet";
const isChipnet = w.chain === "bch" && activeNet === "chipnet";
return {
coinName: isMainnet ? w.ticker : `${w.ticker} ${short}`,
ticker: w.ticker,
networkShort: isMainnet ? "" : short,
networkLabel: short,
logo: w.logo,
testnet: !isMainnet,
chipnet: isChipnet,
chain: w.chain,
activeNetwork: activeNet,
};
}
function walletBalanceUnits(w) {
if (!w.balance) return 0;
if (typeof w.balance.confirmed === "string") {
return (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString();
}
return (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0);
}
// Sum a group's balances into a single native-unit amount + fiat. BigInt-
// safe for SC/ETH-scale decimals (24, 18) via string paths.
function sumGroupUnits(gw) {
let bigTotal = null;
let numTotal = 0;
for (const w of gw) {
if (!w.balance) continue;
if (typeof w.balance.confirmed === "string" || typeof w.balance.unconfirmed === "string") {
const u = BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0");
bigTotal = (bigTotal == null ? u : bigTotal + u);
} else {
numTotal += (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0);
}
}
if (bigTotal != null) return bigTotal.toString();
return numTotal;
}
function renderWalletStrip() {
const el = $("walletStrip"); if (!el) return;
const wallets = state?.wallets || [];
const selId = state?.selectedWalletId;
// Bucket wallets by chain, then by network inside each chain. Order
// within a chain follows first-seen wallet, but the strip renders the
// active subnetwork's slice — see activeNetworkByChain above.
const chainGroups = new Map();
for (const w of wallets) {
if (!chainGroups.has(w.chain)) {
chainGroups.set(w.chain, { chain: w.chain, byNet: new Map(), all: [] });
}
const g = chainGroups.get(w.chain);
if (!g.byNet.has(w.network)) g.byNet.set(w.network, []);
g.byNet.get(w.network).push(w);
g.all.push(w);
}
// Inline addresses view still keys off `chain:network` — it lists the
// wallets under one specific subnetwork, not the whole chain — so its
// logic below stays subgroup-scoped.
if (stripView.mode === "addresses" && stripView.groupKey) {
const [subChain, subNet] = stripView.groupKey.split(":");
const cg = chainGroups.get(subChain);
const gw = cg?.byNet.get(subNet) || null;
if (!gw || !gw.length) { stripView = { mode: "coins", groupKey: null }; }
else {
const meta = chainMetaFor(gw[0], subNet);
return renderInlineCoinList(el, stripView.groupKey, { meta, wallets: gw });
}
}
const rows = [];
for (const [chain, cg] of chainGroups) {
const nets = orderNetworks(Array.from(cg.byNet.keys()));
let active = activeNetworkByChain.get(chain);
if (!active || !nets.includes(active)) active = pickDefaultNetwork(nets);
const gw = cg.byNet.get(active) || [];
const meta = chainMetaFor(gw[0], active);
const subKey = `${chain}:${active}`;
const groupHasSel = gw.some((w) => w.id === selId);
const unitPrice = priceFor(chain);
const priceTxt = unitPrice != null ? fmtFiat(unitPrice) : "—";
const totalUnits = sumGroupUnits(gw);
const decimals = gw[0].decimals || 8;
// Always render a number — 0 balances read as "0", not "—". Users
// seeing a dash next to a coin they just added assume Aegis failed
// to fetch; a clean "0" makes the "adapter connected, wallet just
// empty" state obvious. The em-dash still shows before the first
// fetch resolves, when the adapter hasn't emitted at all.
const totalNative = fmtBig(totalUnits || 0, decimals);
const totalUsd = usdOf(chain, totalUnits || 0, decimals);
const totalFiat = totalUsd != null ? fmtFiat(totalUsd) : "";
// Network pill sits inline with the ticker when the active network
// isn't mainnet. Chipnet gets the acid tint (BCH's friendly
// testnet); every other testnet uses amber. Mainnet renders no pill
// so the row stays visually quiet.
let pill = "";
if (meta.testnet) {
const cls = meta.chipnet ? "wchipnet" : "wtestnet";
const tip = `${meta.networkLabel} — testnet, coins have no market value`;
pill = `<span class="wnetpill ${cls}" title="${esc(tip)}">${esc(meta.networkLabel)}</span>`;
}
// 0.16.0: no ▾ network dropdown on the coin row. The coin view already
// carries real network chips (data-browse-net — they filter the list and
// show a per-network count), so a popover menu doing the same job was a
// second way to do one thing, and the only one that hid its options
// behind a click. Chips are the single network control now; the ticker
// is plain text again.
const multiNet = false;
const chevron = "";
const nameCls = "wcname";
const nameTitle = meta.coinName;
// Single wallet under the active network → click selects it. Multi-
// wallet: click selects the "active" wallet for this bucket (defaults
// to the currently-selected one if it's in the group, otherwise the
// first). The wgcount chip becomes a ▾ dropdown trigger that opens
// the inline addresses list — that's how you swap the active wallet.
const single = gw.length === 1;
// Pick the wallet the row will select on click. Precedence: previously
// selected in this bucket → globally selected wallet (if it's in gw)
// → first wallet in the group. Result is what the "active" pointer
// stores AND what the row's data-wstripid points at, so click always
// lands on a valid entry.
let activeWalletId = activeWalletBySubgroup.get(subKey) || null;
if (activeWalletId && !gw.some((w) => w.id === activeWalletId)) activeWalletId = null;
if (!activeWalletId && gw.some((w) => w.id === selId)) activeWalletId = selId;
if (!activeWalletId) activeWalletId = gw[0].id;
activeWalletBySubgroup.set(subKey, activeWalletId);
const walletId = activeWalletId;
const clickAction = `data-wstripid="${esc(walletId)}"`;
// Count chip carries the ▾ to signal the picker; single-wallet rows
// still get no chip.
const walletsChip = single ? "" : `<span class="wgcount wgpick" data-openlist="${esc(subKey)}" title="Choose a different ${esc(meta.ticker)} wallet">${gw.length} <span class="wgchev">▾</span></span>`;
// Actions row: on multi-wallet rows the ✎ / 🗑 target the ACTIVE
// wallet (not "the group") so the buttons still do something specific
// without needing a second click.
//
// 0.8.8: the second button used to be ⚙, which just selected the wallet
// and opened the global Settings tab — the same destination for every
// coin, so it read as a per-coin control that wasn't one. Removing a
// wallet is the action people actually wanted there.
// Same single-⋯ shape as the drilldown row: one control for "change
// this wallet", opening the manage modal that already holds rename,
// derivation path and remove. 0.9.3 merged the drilldown but left
// this row with the old ✎ + 🗑 pair, so the two views disagreed.
const activeW = gw.find((w) => w.id === walletId);
const canRemove = !(activeW?.isDefault || activeW?.isLegacy);
const rowMenu = canRemove
? `<button class="wact" data-wedit="${esc(walletId)}" title="Rename, derivation path, or remove this ${esc(meta.ticker)} wallet">⋯</button>`
: `<span class="wact" title="Default wallet — protects legacy funds; cannot be removed" style="opacity:.35;cursor:not-allowed">🔒</span>`;
rows.push(`<div class="wrow ${groupHasSel ? "on" : ""}" ${clickAction} data-groupkey="${esc(subKey)}" title="${esc(meta.coinName)} — open addresses" draggable="true" data-chain="${esc(chain)}">
<span class="wcell wclogo">${logoSvg(meta.logo, 16)}</span>
<span class="wcell ${nameCls}" data-netpicker="${esc(chain)}" title="${esc(nameTitle)}">
<span class="wtline">
<span class="wtck">${esc(meta.ticker)}</span>
${chevron}
${pill}
${walletsChip}
</span>
<span class="wcprice">${esc(priceTxt)}</span>
</span>
<span class="wcell"></span>
<span class="wcell wcamt">
<span class="wnative">${esc(totalNative)}</span>
${totalFiat ? `<span class="wfiat">${esc(totalFiat)}</span>` : ""}
</span>
<span class="wcell wcact">
${rowMenu}
</span>
</div>`);
}
// + Add / ⋯ More moved to the header's picker-actions in 0.6.31 — the
// strip now starts directly with coin rows, no waddwrap taking up space
// for buttons the user was already reaching for at the top of the panel.
el.innerHTML = rows.join("");
// Row body / ticker cell click → select single wallet or open list modal.
// We use event delegation via .wrow: check target inside for wact
// buttons AND the network picker first (they have their own handling)
// before doing the row action, so pressing ✎ or ⚙ or the ▾ chevron
// never accidentally re-selects the wallet.
el.querySelectorAll(".wrow").forEach((row) => row.addEventListener("click", async (e) => {
if (e.target.closest(".wact")) return;
if (e.target.closest(".wcname.wswitchable")) return;
// The count chip is now a picker trigger — clicks there open the
// address list without also firing the row-select.
const pickChip = e.target.closest(".wgpick[data-openlist]");
if (pickChip) {
e.stopPropagation();
stripView = { mode: "addresses", groupKey: pickChip.dataset.openlist };
renderWalletStrip();
return;
}
// 0.8.8: clicking a coin opens that coin's page (addresses + assets)
// instead of only flipping the selection and leaving the list in place.
// Selecting still happens, so Send/Receive/History follow the coin the
// user just opened — but the strip now navigates, which is what a row
// with a balance and a chevron looks like it should do.
if (row.dataset.openlist) {
stripView = { mode: "addresses", groupKey: row.dataset.openlist };
renderWalletStrip();
return;
}
if (row.dataset.wstripid) {
const id = row.dataset.wstripid;
const key = row.dataset.groupkey || null;
try {
if (id !== selId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; }
if (key) stripView = { mode: "addresses", groupKey: key };
render();
} catch (er) { showErr(cleanErr(er)); }
}
}));
// Ticker click on a multi-network chain → pop the network dropdown.
el.querySelectorAll(".wcname.wswitchable").forEach((cell) => cell.addEventListener("click", (e) => {
e.stopPropagation();
const chain = cell.dataset.netpicker;
const cg = chainGroups.get(chain); if (!cg) return;
openNetworkPicker(cell, chain, cg);
}));
el.querySelectorAll(".wact[data-wedit]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === b.dataset.wedit);
if (w) openWalletManageModal(w);
}));
el.querySelectorAll(".wact[data-openlist]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
stripView = { mode: "addresses", groupKey: b.dataset.openlist };
renderWalletStrip();
}));
// Drag & drop to reorder chains. Wallets sharing a chain stay contiguous
// regardless of subnetwork — every wallet under BCH moves as one block,
// mainnet + chipnet together — because the strip now presents one row
// per chain. The reorder is optimistic-persistent: we call reorderWallets,
// the addon writes storage, and the returned state re-renders the strip
// in the new order.
wireStripDragDrop(el, chainGroups);
}
// Anchored dropdown letting the user switch which subnetwork of a chain
// is showing on that chain's row. Every network under the chain gets a
// menu row with its own summed total, so the user can see all the
// balances before flipping. Only one menu can be open at a time.
let netMenuEl = null;
let netMenuDismiss = null;
function closeNetworkPicker() {
if (netMenuEl && netMenuEl.parentNode) netMenuEl.parentNode.removeChild(netMenuEl);
netMenuEl = null;
if (netMenuDismiss) {
document.removeEventListener("mousedown", netMenuDismiss, true);
document.removeEventListener("keydown", netMenuDismiss, true);
netMenuDismiss = null;
}
}
function openNetworkPicker(anchorEl, chain, chainGroup) {
closeNetworkPicker();
const nets = orderNetworks(Array.from(chainGroup.byNet.keys()));
let active = activeNetworkByChain.get(chain);
if (!active || !nets.includes(active)) active = pickDefaultNetwork(nets);
const items = nets.map((n) => {
const gw = chainGroup.byNet.get(n) || [];
const decimals = gw[0]?.decimals || 8;
const units = sumGroupUnits(gw);
const native = fmtBig(units || 0, decimals);
const isTest = n !== "mainnet";
// Mainnet is the chain itself — labelling it "Mainnet" reads as
// redundant next to the ticker. Show the plain ticker instead
// (e.g. "BCH"), and reserve the specific-network name for the
// testnets that need disambiguation (Chipnet / Sepolia / Nile / …).
const ticker = gw[0]?.ticker || chain.toUpperCase();
const label = isTest ? networkLabelFor(chain, n, n) : ticker;
const cls = isTest ? (chain === "bch" && n === "chipnet" ? "wchipnet" : "wtestnet") : "";
const on = n === active ? "on" : "";
return `<div class="nmitem ${on}" data-net="${esc(n)}">
<span class="nmname">
<span class="nmnet ${cls}">${esc(label)}</span>
<span class="nmcount">${gw.length}</span>
</span>
<span class="nmamt">${esc(native)} ${esc(ticker)}</span>
</div>`;
}).join("");
netMenuEl = document.createElement("div");
netMenuEl.className = "netmenu";
netMenuEl.innerHTML = items;
document.body.appendChild(netMenuEl);
const r = anchorEl.getBoundingClientRect();
const mr = netMenuEl.getBoundingClientRect();
const maxLeft = window.innerWidth - mr.width - 8;
const left = Math.max(8, Math.min(maxLeft, r.left));
const top = r.bottom + 4;
netMenuEl.style.left = left + "px";
netMenuEl.style.top = top + "px";
netMenuEl.querySelectorAll(".nmitem").forEach((it) => it.addEventListener("click", (e) => {
e.stopPropagation();
const n = it.dataset.net;
closeNetworkPicker();
if (!n || n === active) return;
activeNetworkByChain.set(chain, n);
persistActiveNetworks();
renderWalletStrip();
}));
netMenuDismiss = (e) => {
if (e.type === "keydown" && e.key !== "Escape") return;
if (e.type === "mousedown" && netMenuEl && netMenuEl.contains(e.target)) return;
closeNetworkPicker();
};
// Defer wiring so the click that opened the menu doesn't immediately close it.
setTimeout(() => {
document.addEventListener("mousedown", netMenuDismiss, true);
document.addEventListener("keydown", netMenuDismiss, true);
}, 0);
}
function wireStripDragDrop(el, chainGroups) {
const chainKeys = Array.from(chainGroups.keys());
let dragChain = null;
el.querySelectorAll(".wrow[draggable=true]").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragChain = row.dataset.chain || null;
if (!dragChain) return;
row.classList.add("dragging");
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragChain); } catch {}
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after"));
dragChain = null;
});
row.addEventListener("dragover", (e) => {
if (!dragChain || row.dataset.chain === dragChain) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = "move"; } catch {}
const rect = row.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after"));
row.classList.add(before ? "drop-before" : "drop-after");
});
row.addEventListener("dragleave", () => {
row.classList.remove("drop-before", "drop-after");
});
row.addEventListener("drop", async (e) => {
e.preventDefault();
const targetChain = row.dataset.chain;
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (!dragChain || !targetChain || dragChain === targetChain) return;
const next = chainKeys.filter((k) => k !== dragChain);
const at = next.indexOf(targetChain);
next.splice(before ? at : at + 1, 0, dragChain);
// Flatten chain order to a wallet ID list. Inside each chain,
// mainnet wallets come first followed by testnets, matching the
// dropdown's own order. Individual wallets keep their existing
// relative order inside each subnetwork.
const walletOrder = [];
for (const k of next) {
const cg = chainGroups.get(k); if (!cg) continue;
const nets = orderNetworks(Array.from(cg.byNet.keys()));
for (const n of nets) for (const w of cg.byNet.get(n)) walletOrder.push(w.id);
}
try { state = await S.invoke("reorderWallets", { order: walletOrder }); render(); }
catch (er) { showErr(cleanErr(er)); }
});
});
}
// Copy glyph as inline SVG. The 📋 emoji has no glyph in this platform's
// font stack, so it rendered as a tofu box that read like a stray
// character stuck to the balance beside it.
const COPY_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg>`;
const CHECK_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
// Inline replacement for the modal address list. Rendered directly into
// the wallet strip element when stripView.mode === "addresses". Header
// row has a back arrow (returns to the coins summary) and the coin's
// name/logo; each body row is icon · label · copy · amount · one ⋯ that
// opens the manage modal (rename / path / remove).
// Assets live in the Receive tab's Assets card, not here — see 0.9.2.
function renderInlineCoinList(el, groupKey, group) {
const { meta, wallets: gw } = group;
const selId = state?.selectedWalletId;
const chain = gw[0]?.chain;
const decimals = gw[0]?.decimals || 8;
const unitPrice = priceFor(chain);
const priceTxt = unitPrice != null ? fmtFiat(unitPrice) : "—";
const totalUnits = sumGroupUnits(gw);
const totalNative = fmtBig(totalUnits || 0, decimals);
const totalUsd = usdOf(chain, totalUnits || 0, decimals);
const totalFiat = totalUsd != null ? fmtFiat(totalUsd) : "";
const multiAddr = gw.length > 1;
const rows = gw.map((w) => {
const on = w.id === selId ? "on" : "";
const units = walletBalanceUnits(w);
// Always render a numeric balance — see the same rationale in the
// coins summary render. 0 reads as "0", not "—".
const bal = fmtBig(units || 0, w.decimals);
const usd = usdOf(w.chain, units || 0, w.decimals);
const fiat = usd != null ? fmtFiat(usd) : "";
// Address is the row's primary identifier — mono, ellipsised in
// whatever flex space is left after the fixed cells. The BCH
// "bitcoincash:" / "bchtest:" / "bchreg:" prefix is stripped for the
// in-row display (it's identical on every row of a drilldown and
// burns 8-9 chars of a fixed column), but the tooltip and clipboard
// carry the full canonical form so the truncation is display-only.
const fullAddr = w.address ? String(w.address) : "";
const displayAddr = stripAddrPrefix(fullAddr);
// Label preview capped at ~8 visible chars in JS; the CSS pill's
// fixed 68px column handles final ellipsis for oddball wide glyphs.
const shortName = shortLabel(w.label || "", 8);
// Fiat is dropped from the row to keep everything on one line; the
// aggregate coin fiat still shows in the drilldown header above.
// 0.9.2: the per-address asset list added in 0.8.8 duplicated the
// Receive tab's Assets card, so it's gone — assets live in one place.
// The truncated address is gone too: the full one sits at the top of
// Receive, and a shortened copy here was a second, less useful
// rendering of the same string. The row now carries identity (name)
// and, only when there is more than one address to compare, balance.
// 0.9.3: one row, one shape — icon · label · amount · one button.
// ✎ and 🗑 were two controls for what is really one idea ("change
// this wallet"), and the manage modal already holds rename,
// derivation path AND remove. A single ⋯ opens it, which also stops
// Remove sitting one stray click away from Rename.
const locked = w.isDefault || w.isLegacy;
const rowMenu = locked
? `<span class="wact" title="Default wallet — protects legacy funds; cannot be removed" style="opacity:.35;cursor:not-allowed">🔒</span>`
: `<button class="wact" data-lpedit="${esc(w.id)}" title="Rename, derivation path, or remove this wallet">⋯</button>`;
// Role badges, so which wallet pays and which one dapps get is legible
// from the list instead of only from inside the manage modal. They share
// the label's grid column via a flex wrapper: the label keeps its
// ellipsis, the badge stays whole.
const roles = (state && state.roles) || {};
const badges = [
roles.payments === w.id ? `<span class="rolebadge pay" title="Default wallet for payments">pay</span>` : "",
roles.wizardconnect === w.id ? `<span class="rolebadge" title="Offered first when a site asks to pair over WizardConnect">wc</span>` : "",
].join("");
const labelCell = `<span class="walabel walabel-wide" title="${esc(w.label || "")}">${esc(w.label || shortName || "—")}</span>`;
return `<div class="warow ${on}" data-listpick="${esc(w.id)}" title="${esc(w.label || fullAddr)}">
<span class="wcell">${logoSvg(meta.logo, 14)}</span>
${badges
? `<span style="display:flex;align-items:center;gap:4px;min-width:0">${labelCell}${badges}</span>`
: labelCell}
${fullAddr ? `<button class="wacopy" data-lpcopy="${esc(fullAddr)}" title="Copy full address (${esc(fullAddr)})">${COPY_ICON}</button>` : `<span></span>`}
<span class="waamt"><span>${esc(bal)}</span><span class="watkr">${esc(meta.ticker)}</span></span>
<span style="display:inline-flex;gap:2px;justify-self:end;align-items:center">${rowMenu}</span>
</div>`;
}).join("");
// Surface any adapter errors from the wallets in this group. If a fetch
// is failing (RPC unreachable, CORS block, rate limit) the display would
// silently show 0 without this — which is exactly what "why does my
// funded wallet still say 0" feels like from the user side.
const errs = gw.filter((w) => w.error).map((w) => `${shortLabel(w.label || w.address || "?", 6)}: ${w.error}`);
const errLine = errs.length ? `<div class="wcoinerr">⚠ ${esc(errs.join(" · "))}</div>` : "";
el.innerHTML = `
<div class="wcoinhead">
<span class="wctitle">${logoSvg(meta.logo, 16)} ${esc(meta.coinName)}<span class="wcount">· ${gw.length} address${gw.length === 1 ? "" : "es"}</span></span>
<button class="wback" id="stripRefresh" title="Refresh balances now">↻</button>
<!-- One way out, not two. "← Back" and this ✕ ran the same handler and
carried the same tooltip, so the row spent its left edge on a
duplicate of the control at its right edge. -->
<button class="wback" id="stripBackX" title="Back to coin list">✕</button>
</div>
<div class="wcoinsub">
<span class="wprice">${esc(priceTxt)}<span class="wsep" style="margin-left:4px">/ ${esc(meta.ticker)}</span></span>
${multiAddr ? `<span class="wsep">·</span>
<span class="wtot">${esc(totalNative)} ${esc(meta.ticker)}</span>
${totalFiat ? `<span class="wtotfiat">(${esc(totalFiat)})</span>` : ""}` : ""}
</div>
${errLine}
<div class="walist">${rows}</div>
<div class="waddwrap">
<button id="stripAddMore" title="Add another ${esc(meta.coinName)} wallet">+ Add another ${esc(meta.ticker)}</button>
</div>`;
const back = () => { stripView = { mode: "coins", groupKey: null }; renderWalletStrip(); };
el.querySelector("#stripBackX").addEventListener("click", back);
// Manual refresh: force every wallet under this coin+network to
// re-poll now. Handy when a testnet faucet just delivered or a
// mainnet transfer is expected to have landed.
const refreshBtn = el.querySelector("#stripRefresh");
if (refreshBtn) refreshBtn.addEventListener("click", async () => {
if (refreshBtn.dataset.spinning === "1") return;
refreshBtn.dataset.spinning = "1";
const prev = refreshBtn.textContent;
refreshBtn.textContent = "…";
// Say what happened. A tooltip-only result is indistinguishable from the
// button doing nothing, which is how this one came to be reported as
// broken even when it worked: balances that were already current changed
// nothing on screen.
let mark = null;
try {
const r = await S.invoke("refreshChain", { chain: gw[0]?.chain, network: gw[0]?.network });
const failed = (r?.results || []).filter((x) => !x.ok);
if (failed.length) {
refreshBtn.title = `Refresh failed on ${failed.length} of ${r.results.length}: ` + failed.map((f) => f.error).join("; ");
mark = "⚠";
} else {
refreshBtn.title = `Refreshed ${r.results.length} wallet${r.results.length === 1 ? "" : "s"}`;
mark = "✓";
}
} catch (e) {
refreshBtn.title = "Refresh failed: " + cleanErr(e);
mark = "⚠";
} finally {
delete refreshBtn.dataset.spinning;
if (mark) {
refreshBtn.textContent = mark;
setTimeout(() => { if (refreshBtn.isConnected) refreshBtn.textContent = prev; }, 1200);
} else refreshBtn.textContent = prev;
}
});
el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => {
if (e.target.closest(".wact")) return;
if (e.target.closest(".wacopy")) return;
const id = row.dataset.listpick;
// Record it as this network's current wallet, so switching away on the
// header's network chips and back returns here rather than to whichever
// wallet happens to be first.
const picked = (state?.wallets || []).find((w) => w.id === id);
if (picked) activeWalletBySubgroup.set(`${picked.chain}:${picked.network}`, id);
try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); }
catch (er) { showErr(cleanErr(er)); }
}));
// Address copy chip. Uses navigator.clipboard when available (the addon
// panel runs under file:// but Electron gives it clipboard access), and
// falls back to a textarea+execCommand for older stacks. Visual "copied"
// flash lasts ~1s so the user sees the click landed.
el.querySelectorAll(".wacopy[data-lpcopy]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const addr = b.dataset.lpcopy || "";
if (!addr) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(addr);
else {
const ta = document.createElement("textarea");
ta.value = addr; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
// innerHTML, not textContent — the button holds an inline SVG now,
// and assigning textContent would destroy it and leave a blank
// square once the confirmation timed out.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
}));
el.querySelectorAll("[data-lpedit]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit);
if (w) openWalletManageModal(w);
}));
// Removing a wallet now happens inside the manage modal (the row's ⋯),
// so the row no longer emits its own remove button. The modal calls
// render() itself, which redraws the strip — including falling back to
// the coin list when the last address under a coin goes away.
el.querySelector("#stripAddMore").addEventListener("click", async () => {
// This used to create a fresh wallet immediately, on the reasoning
// that being inside a coin's address list made the intent
// unambiguous. It doesn't: "add another BCH wallet" is just as often
// "bring in the one I already have somewhere else". Creating instead
// of importing is not a harmless guess either — the user ends up with
// an empty new address and has to work out why their funds aren't
// there. So ask.
const first = gw[0];
const pick = await aegisChoose({
title: `Add another ${meta.ticker} wallet`,
// meta.coinName already carries the network ("TRX Nile"), so naming
// networkLabel again read "TRX Nile · Nile testnet".
body: `On <b>${esc(meta.coinName)}</b>.`,
options: [
{ id: "create", label: "Create a new wallet", hint: "Derived from your Theseus vault — nothing to write down", primary: true },
{ id: "import", label: "Import an existing wallet", hint: "BIP39 mnemonic, or a chain-native private key" },
],
});
if (!pick) return;
if (pick === "import") { openImportModal(first.chain); return; }
try {
state = await S.invoke("addWallet", { chain: first.chain, network: first.network });
settingsFilled = false; render();
} catch (er) { showErr(cleanErr(er)); }
});
}
// Small popover for the "⋯" chip on the strip. Lists Import / Connect /
// Manage / About without cluttering the strip itself.
function openMoreMenu() {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:flex-start;justify-content:center;z-index:99998;padding-top:60px";
// "Manage current wallet" removed in 0.6.31 — the header's dedicated ✎
// chip already opens the same modal, and the header row itself remains
// a click target for the same thing. Two identical entry points were
// fine when they were the only path; three would just be clutter.
overlay.innerHTML = `
<div style="width:min(94vw,300px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:6px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<button class="row" data-mm="import" style="width:100%;display:flex;align-items:center;gap:10px;padding:9px 10px;background:transparent;border:0;color:var(--ink);cursor:pointer;font:inherit;text-align:left">↓ Import an existing wallet</button>
<button class="row" data-mm="connect" style="width:100%;display:flex;align-items:center;gap:10px;padding:9px 10px;background:transparent;border:0;color:var(--ink);cursor:pointer;font:inherit;text-align:left">⚡ Connect via WizardConnect</button>
<hr style="border:0;border-top:1px solid var(--line);margin:4px 0">
<button class="row" data-mm="about" style="width:100%;display:flex;align-items:center;gap:10px;padding:9px 10px;background:transparent;border:0;color:var(--dim);cursor:pointer;font:inherit;text-align:left;font-size:12px">About Aegis · aegis.x</button>
</div>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll("[data-mm]").forEach((b) => b.addEventListener("click", () => {
const action = b.dataset.mm;
close();
if (action === "import") openImportModal(null);
if (action === "connect") { pickerTab = "connect"; const d=$("drop"); d.hidden=false; fillPicker(); }
if (action === "about") openUrl("https://aegis.x/");
}));
}
function showErr(text) {
const box = $("gate");
box.hidden = false; box.innerHTML = `<div class="big">⚠</div><div>${esc(text)}</div>`;
setTimeout(() => { if (state?.selected?.phase === "ready") { box.hidden = true; } }, 3500);
}
// ---- render ----------------------------------------------------------------
// Populate the full-panel lock screen with either a master-password form
// (nosetup / no-PIN locked) or a PIN pad (locked with a PIN configured).
// PIN mode falls back to master-password via a link at the bottom so a
// forgotten PIN never locks the user out of their own vault.
function renderLockScreen(phase) {
const title = $("lockTitle");
const sub = $("lockSub");
const body = $("lockBody");
if (phase === "nosetup") {
title.textContent = "Set up Aegis";
if (body.dataset.mode === "setup") return;
body.dataset.mode = "setup";
sub.textContent = "Pick a master password — every Aegis wallet is derived from it. The same master password on another machine recreates the same addresses.";
body.innerHTML = `
<div class="lockform">
<input type="password" id="gateSetupPw" placeholder="Master password (4+ chars)" autocomplete="new-password">
<input type="password" id="gateSetupPw2" placeholder="Confirm master password" autocomplete="new-password">
<textarea id="gateSetupMnemonic" placeholder="BIP39 mnemonic — optional, 12 or 24 words" rows="2" spellcheck="false" style="font-family:ui-monospace,monospace;font-size:12px"></textarea>
<div class="hint">Optional. Paste a mnemonic to derive your vault from an existing seed (Ariadne mobile, another Theseus profile). Leave empty for a fresh independent seed.</div>
<div class="actions" style="justify-content:center;margin-top:6px">
<button class="btn primary" id="gateSetupBtn">Create vault</button>
</div>
<div class="msg err" id="gateSetupMsg" hidden></div>
</div>`;
const doSetup = async () => {
const pw = $("gateSetupPw").value;
const pw2 = $("gateSetupPw2").value;
const mnemonic = $("gateSetupMnemonic").value.trim();
const msg = $("gateSetupMsg"); msg.hidden = true;
if (!pw || pw.length < 4) { msg.textContent = "Master password must be 4+ characters."; msg.hidden = false; return; }
if (pw !== pw2) { msg.textContent = "Master passwords don't match."; msg.hidden = false; return; }
const seedSource = mnemonic ? { kind: "mnemonic", mnemonic } : { kind: "random" };
try {
state = await S.invoke("vaultSetup", { masterPassword: pw, seedSource });
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
$("gateSetupBtn").addEventListener("click", doSetup);
return;
}
// Locked phase. Two shapes:
// 1) PIN configured → 6-digit pad. Falls back to master-password
// entry if the user clicks "Use master password".
// 2) No PIN → master-password entry directly.
const hasPin = !!securityState?.hasPin;
const forcePw = body.dataset.forcePw === "1";
title.textContent = "Unlock Aegis";
sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet.";
if (hasPin && !forcePw && !securityState?.pinRequireMaster) {
// render() runs on every state push — balance polls fire it every couple
// of seconds — and this used to rebuild body.innerHTML each time, wiping
// the pad DOM and its digit buffer out from under someone mid-entry.
// That is the "resets after two digits" report: the reset is timed to the
// poll, not to the keypress count. Rebuild only when the mode changes.
if (body.dataset.mode === "pin") return;
body.dataset.mode = "pin";
body.innerHTML = `
<div class="pinpad" id="lockPinPad">
<div class="pindots" id="lockPinDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pinkeys" id="lockPinKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
<button data-k="0">0</button>
<button class="util" data-k="back">⌫</button>
</div>
<div class="pinerr" id="lockPinErr"></div>
</div>
<div class="altline"><a id="lockUsePw">Use master password instead</a> · <a id="lockGoSettingsFromPin">Settings</a></div>`;
setupPinPad({
dots: body.querySelector("#lockPinDots"),
keys: body.querySelector("#lockPinKeys"),
err: body.querySelector("#lockPinErr"),
onComplete: async (pin) => {
let r;
try { r = await pinTry(pin); }
catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; }
if (!r.ok) {
$("lockPinErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
if (r.requireMaster) { body.dataset.forcePw = "1"; setTimeout(() => renderLockScreen("locked"), 1200); }
return "reset";
}
try {
state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword });
render();
return "ok";
} catch (e) {
$("lockPinErr").textContent = cleanErr(e);
return "reset";
}
},
});
$("lockUsePw").addEventListener("click", () => { body.dataset.forcePw = "1"; renderLockScreen("locked"); });
if ($("lockGoSettingsFromPin")) $("lockGoSettingsFromPin").addEventListener("click", () => showTab("settings"));
return;
}
// Master-password entry. Guarded for the same reason as the pad above:
// rebuilding on every state push erased a half-typed password.
if (body.dataset.mode === "pw") return;
body.dataset.mode = "pw";
body.innerHTML = `
<div class="lockform">
<input type="password" id="gateUnlockPw" placeholder="Master password" autocomplete="current-password" autofocus>
<div class="actions" style="justify-content:center">
<button class="btn primary" id="gateUnlockBtn">Unlock</button>
</div>
<div class="msg err" id="gateUnlockMsg" hidden></div>
</div>
<div class="altline">
${hasPin ? `<a id="lockUsePin">Use PIN instead</a> · ` : ""}<a id="lockGoSettings">Settings</a>
</div>`;
const doUnlock = async () => {
const pw = $("gateUnlockPw").value;
const msg = $("gateUnlockMsg"); msg.hidden = true;
if (!pw) return;
try {
state = await S.invoke("vaultUnlock", { masterPassword: pw });
// Remember the master password for a moment so the user can, right
// after unlock, enroll a PIN without re-typing it. Cleared as soon
// as the panel navigates or reloads.
window.__aegisLastPw = pw;
setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000);
// Empty the field and let the form be rebuilt next time. It stayed
// filled behind the unlocked wallet, so after an idle lock or Sign out
// the password was sitting in the box for anyone to press Unlock.
try { $("gateUnlockPw").value = ""; } catch {}
body.dataset.mode = "";
body.dataset.forcePw = "";
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
$("gateUnlockBtn").addEventListener("click", doUnlock);
$("gateUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); });
try { $("gateUnlockPw").focus(); } catch {}
if (hasPin && $("lockUsePin")) $("lockUsePin").addEventListener("click", () => { body.dataset.forcePw = ""; renderLockScreen("locked"); });
if ($("lockGoSettings")) $("lockGoSettings").addEventListener("click", () => showTab("settings"));
}
// Wire up a PIN pad instance. `onComplete(pin)` runs when 6 digits are
// typed and must return "ok" (leave state) or "reset" (clear back to
// empty). Rendered by renderLockScreen for the unlock flow and by the
// PIN modal helper for set / verify flows.
// IMPORTANT: pass `dots`/`keys`/`err` as elements scoped to the pad's own
// container, never via getElementById. Each pad's markup hard-codes its ids,
// so two pads alive at once (easy: a load-time gate plus a transaction gate)
// are duplicate ids — a global lookup then returns the FIRST one, and this
// function binds a second click handler to the wrong pad's buttons. The
// symptom is a pad that appends two digits per press and resets after three.
function setupPinPad({ dots, keys, err, onComplete }) {
let buf = "";
const paint = () => {
const nodes = dots.querySelectorAll(".pindot");
nodes.forEach((n, i) => n.classList.toggle("on", i < buf.length));
};
keys.querySelectorAll("button[data-k]").forEach((b) => b.addEventListener("click", async () => {
const k = b.dataset.k;
if (err) err.textContent = "";
if (k === "clear") { buf = ""; paint(); return; }
if (k === "back") { buf = buf.slice(0, -1); paint(); return; }
if (buf.length >= 6) return;
buf += k;
paint();
if (buf.length === 6) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
// Always forget the digits. A pad that kept its six after "ok" was
// dead on the next lock and held the PIN in memory until then.
buf = ""; paint();
}
}
}));
// Keyboard fallback — some users prefer typing 6 digits fast.
const keyHandler = async (e) => {
if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; }
// Only while this pad is actually on screen, and never for keys typed
// into a field. The lock-screen pad stays in the DOM (hidden) after
// unlock, so six digits typed into the amount box counted as a PIN
// attempt — and five such amounts locked the PIN for 15 minutes.
if (dots.offsetParent === null) return;
// Two pads can be visible at once (a dapp's PIN request over a send's
// pad): only the topmost one listens, or six digits would complete both
// and a wrong PIN would cost two attempts.
const topModal = [...document.querySelectorAll(".pinmodal")].pop();
if (topModal && !topModal.contains(dots)) return;
const tgt = e.target;
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = "";
if (/^[0-9]$/.test(e.key)) {
if (buf.length >= 6) return;
buf += e.key; paint();
if (buf.length === 6) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
buf = ""; paint();
}
}
} else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); }
else if (e.key === "Escape") { buf = ""; paint(); }
};
document.addEventListener("keydown", keyHandler);
}
function render() {
if (!state) return;
noteSelectedWallet();
const s = sel();
const ready = s && s.phase === "ready";
const phase = s?.phase;
// Locked / no-setup take over the whole panel — the wallet strip, tab
// bar and per-wallet views would show either nothing or partial data,
// so we hide them behind an opaque overlay until the vault is open.
const fullLock = phase === "locked" || phase === "nosetup";
// Settings is the only always-usable tab (fiat prices, connected sites
// — nothing needs a live wallet). Every other tab is gated.
const onSettings = tab === "settings";
// An owed PIN hides everything, Settings included: unlike the vault lock
// (where Settings must stay reachable to configure a PIN in the first
// place), the PIN is already configured here and letting Settings through
// would be a way around the door.
if (pinGateBlocked) { paintPinDoor(); return; }
$("tabs").hidden = !(ready || onSettings);
const gate = $("gate");
gate.hidden = ready || onSettings || fullLock;
if (onSettings) fillSettings();
const lockScreen = $("lockScreen");
const showLock = fullLock && !onSettings;
lockScreen.hidden = !showLock;
// Hide the rest of the panel behind the lock overlay. Settings stays
// open even while locked (users can set up PIN policy without unlocking
// first), so a lock override does NOT hide the tab bar when the user
// has clicked into Settings.
const hideChrome = fullLock && !onSettings;
document.querySelector("header").hidden = hideChrome;
document.querySelector("nav").hidden = hideChrome;
$("walletStrip").hidden = hideChrome;
// Re-drawing the strip after unhiding keeps the coins/addresses view
// in sync with the current wallet set.
if (!hideChrome) renderWalletStrip();
// Header: replace the badge slot with the coin's SVG and show
// <wallet label> <coin · network + optional TEST tag>
$("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22);
$("hLabel").textContent = s?.label || "Aegis Wallet";
// 0.25.0: the network name moved out of this row into the selector chips
// under the balance. What is left here is connection state, which only
// earns attention when it is bad.
$("hNetRow").hidden = !s?.meta;
paintNetSelector(s);
if (showLock) {
renderLockScreen(phase);
}
if (!ready && !fullLock) {
const copy = {
error: ["⚠", "This wallet could not start.", s?.error || ""],
empty: ["🛡", "No wallets yet.", "Aegis can derive a fresh wallet from your Theseus password vault — nothing extra to write down — or import one you already have from its seed phrase or private key."],
}[phase] || ["…", "Starting…", ""];
let form = "";
if (phase === "empty") {
form = `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>`;
}
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>${form}`;
if (phase === "empty") {
const btn = $("gateAddWallet");
if (btn) btn.addEventListener("click", () => {
const d = $("drop");
// First-run is the MOST important place to offer import: someone
// arriving with an existing seed who is handed a create-only flow
// ends up staring at an empty wallet wondering where their coins
// went. Method chooser, not straight to create.
pickerTab = "method";
d.hidden = false;
fillPicker();
});
}
}
const dot = $("dot");
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
$("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || ""));
if (ready) {
// Sia-specific gate: adapter is up, keys are derived, but no walletd URL
// means no balance / history until the user configures one in Settings.
if (chain() === "sc" && s.needsWalletdUrl) {
setBalMain("—"); $("balTicker").textContent = s.meta.ticker;
$("netlbl").textContent = "point Aegis at a walletd node in Settings";
$("tabs").hidden = true;
gate.hidden = false;
gate.innerHTML = `<div class="big">🗝</div><div><b>Point Aegis at a walletd node</b></div><div class="hint" style="margin-top:8px">Settings › Sia › walletd URL. Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode works.</div>`;
return;
}
const total = balanceSum(s.balance);
setBalMain(fmtBig(total));
$("balTicker").textContent = s.meta.ticker;
const uc = s.balance?.unconfirmed;
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
// Fiat under the native amount (opt-in, might be null while loading).
const usd = usdOf(chain(), total, decimals());
const fiat = fmtFiat(usd) || fiatSkeleton();
$("balFiat").textContent = fiat || "";
$("balFiat").hidden = !fiat;
} else {
setBalMain("—"); $("balTicker").textContent = "";
$("balFiat").hidden = true;
}
renderPortfolio();
if (!ready) return;
const addr = s.address || "";
if ($("addr").textContent !== addr) {
$("addr").textContent = addr;
drawQr(qrPayload(chain(), addr, sel()?.network));
}
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
$("nextAddr").hidden = chain() !== "bch";
$("openFaucet").hidden = !s.faucet;
// Render SPL tokens list (SOL wallets only). Sending a token clicks
// through to the Send tab with that asset pre-picked.
renderTokens();
applyUnitPicker();
$("feeField").hidden = chain() !== "bch";
// OP_RETURN memo is BCH-only (added 0.6.36). Every other chain hides
// the field completely so the Send tab stays consistent.
const memoEl = $("memoField"); if (memoEl) memoEl.hidden = chain() !== "bch";
renderHistory();
// 0.8.0: consolidate is a MODE TOGGLE on Send + Receive, not a chip.
// Show the toggle when there's at least one same-network sibling; the
// count sits inside the button label. paintSendMode() / paintRcvMode()
// swap the body between normal and consolidate views.
const cur = sel();
const others = (state?.wallets || []).filter((w) =>
cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId,
);
const showToggle = others.length > 0;
paintSendFrom(cur, others);
// Send keeps its toggle. Receive's became a button in the address actions
// (0.18.0), so it is shown/counted the same way but is not a toggle.
for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvConsolidateBtn", "rcvConsolidateCount"]]) {
const wrap = $(wrapId); if (!wrap) continue;
wrap.hidden = !showToggle;
if (showToggle) {
const c = $(cntId); if (c) c.textContent = `${others.length}`;
}
}
// Reset to normal mode when the toggle disappears (no siblings left).
if (!showToggle) { sendMode = "send"; rcvMode = "receive"; }
paintSendMode();
paintRcvMode();
paintRcvView();
}
// Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N
// wallets" under the header. Only rendered when prices are on AND there are
// two or more wallets (a single wallet's fiat already sits in #balFiat).
function renderPortfolio() {
const el = $("portfolio");
const wallets = state?.wallets || [];
// Show whenever prices are on and at least one wallet exists — the single-
// wallet case still benefits from a portfolio row when the balance-line
// fiat is elided (e.g. header hidden during pane switches).
if (!state?.prices?.enabled || !wallets.length) { el.hidden = true; return; }
let total = 0, priced = 0;
for (const w of wallets) {
const b = w.balance;
if (!b) continue;
const units = (typeof b.confirmed === "string")
? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString()
: (b.confirmed || 0) + (b.unconfirmed || 0);
const usd = usdOf(w.chain, units, w.decimals);
if (usd != null) { total += usd; priced++; }
}
if (!priced) {
el.hidden = false;
el.innerHTML = `Portfolio: <b>${esc(fiatSkeleton() || "—")}</b>`;
return;
}
const noun = wallets.length === 1 ? "wallet" : "wallets";
el.hidden = false;
el.innerHTML = `Portfolio: <b>${esc(fmtFiat(total))}</b> across ${wallets.length} ${noun}`;
}
// Assets is a chip now, not a card that disappears when the count is zero.
// The count rides on the chip and an empty wallet says so in the pane, so
// "this wallet holds nothing" is a visible answer rather than a missing
// section the user has to infer — which is exactly how 46 CashTokens managed
// to look like a working, empty wallet before 0.15.0.
function setAssetsCount(n) {
const chip = $("rcvAssetsCount");
if (chip) chip.textContent = n ? String(n) : "";
const card = $("tokensCard");
if (card) card.hidden = false;
if (!n) {
const el = $("tokensList");
if (el) el.innerHTML = `<div class="hint" style="padding:4px 2px">No assets held by this wallet.</div>`;
const cnt = $("tokensCount");
if (cnt) cnt.textContent = "";
}
}
// Certificates chip. Counts individual certificates, not categories — "3"
// should mean three things you hold, which is the question the chip is
// answering. A chain with no non-fungible concept lands here with 0 and says
// so, rather than the chip vanishing on some coins and not others.
function setCertsCount(n) {
const chip = $("rcvNftsCount");
if (chip) chip.textContent = n ? String(n) : "";
const card = $("nftsCard");
if (card) card.hidden = false;
if (!n) {
const el = $("nftsList");
if (el) el.innerHTML = `<div class="hint" style="padding:4px 2px">No certificates held by this wallet.</div>`;
const cnt = $("nftsCount");
if (cnt) cnt.textContent = "";
}
}
// One row per certificate: which category it belongs to, its commitment (the
// payload that makes it specific), and its capability — minting / mutable /
// none is the difference between a certificate that can still issue others
// and one that is fixed, which is worth seeing at a glance.
function renderCerts(balances, metaMap) {
const rows = [];
for (const cat of Object.keys(balances || {})) {
const b = balances[cat];
const list = Array.isArray(b?.nfts) ? b.nfts : [];
for (const n of list) rows.push({ cat, ...n });
}
setCertsCount(rows.length);
if (!rows.length) return;
const el = $("nftsList");
if (!el) return;
const cntEl = $("nftsCount");
if (cntEl) cntEl.textContent = `· ${rows.length}`;
el.innerHTML = rows.map((r) => {
const meta = metaMap?.[r.cat] || null;
const named = meta?.name || meta?.symbol || null;
const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6));
// "immutable" is the quiet default — it describes most certificates and
// tagging every row with it would say nothing. Only the capabilities
// that change what the holder can DO get a tag. ("none" is Electrum's
// word for the same thing; lib/wallet.js normalises it to immutable, but
// accept both so an older cached snapshot still reads correctly.)
const cap = String(r.capabilityLabel || "").toLowerCase();
const capTag = cap && cap !== "none" && cap !== "immutable"
? ` <span class="ttag" title="This certificate can ${cap === "minting" ? "issue more of its category" : "be altered by its holder"}">${esc(cap.toUpperCase())}</span>`
: "";
// A commitment is arbitrary bytes; it is the only thing distinguishing
// two certificates of the same category, so show it rather than hide it.
const commit = String(r.commitmentHex || "");
const commitTxt = commit
? (commit.length > 24 ? commit.slice(0, 24) + "…" : commit)
: "no commitment";
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div>
<div>${esc(title)}${capTag}</div>
<div class="hint mono">${esc(commitTxt)}</div>
</div>
<div class="hint mono" style="align-self:center;text-align:right">${named ? esc(r.cat.slice(0, 8)) + "…" : ""}</div>
</div>`;
}).join("");
}
// Name a token yourself. Self-minted categories publish nothing — no
// registry entry, and usually not even an OP_RETURN in the genesis — so
// without this they can only ever read as a hex string. Clearing both fields
// deletes the local name and lets any registry entry show through again.
function openTokenLabelModal(category, meta) {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
const isLocal = !!meta?.local;
overlay.innerHTML = `
<div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="font-weight:600;flex:1">Name this token</div>
<button class="btn sm" id="tlClose" type="button">✕</button>
</div>
<div class="hint mono" style="margin-bottom:10px;word-break:break-all">${esc(category)}</div>
<div class="field">
<div class="lbl">Name</div>
<input type="text" id="tlName" maxlength="40" placeholder="e.g. Game.X credit" value="${esc(isLocal ? (meta?.name || "") : "")}">
</div>
<div class="field">
<div class="lbl">Ticker</div>
<input type="text" id="tlSymbol" maxlength="12" placeholder="e.g. GMX" value="${esc(isLocal ? (meta?.symbol || "") : "")}">
</div>
<div class="field">
<div class="lbl">Decimals</div>
<input type="text" id="tlDecimals" inputmode="numeric" placeholder="0" value="${esc(isLocal && Number.isFinite(meta?.decimals) ? String(meta.decimals) : "")}">
<div class="hint">How many decimal places the amount should be shown with. Leave blank for whole units.</div>
</div>
<div class="hint" style="margin-top:8px">Stored on this device only. It takes precedence over any BCMR registry; clear both fields to remove it.</div>
<div class="msg err" id="tlMsg" hidden style="margin-top:8px"></div>
<div class="actions" style="justify-content:space-between;margin-top:12px">
${isLocal ? `<button class="btn danger" id="tlClear">Remove name</button>` : `<span></span>`}
<div style="display:flex;gap:6px">
<button class="btn" id="tlCancel">Cancel</button>
<button class="btn primary" id="tlSave">Save</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch (_e) {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#tlClose").addEventListener("click", close);
overlay.querySelector("#tlCancel").addEventListener("click", close);
const save = async (clear) => {
const msg = overlay.querySelector("#tlMsg"); msg.hidden = true;
const dRaw = overlay.querySelector("#tlDecimals").value.trim();
if (!clear && dRaw && !/^\d{1,2}$/.test(dRaw)) {
msg.textContent = "Decimals must be a whole number between 0 and 18."; msg.hidden = false; return;
}
try {
await S.invoke("setTokenLabel", {
category,
name: clear ? "" : overlay.querySelector("#tlName").value,
symbol: clear ? "" : overlay.querySelector("#tlSymbol").value,
decimals: clear || !dRaw ? undefined : Number(dRaw),
});
close();
renderTokens();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
overlay.querySelector("#tlSave").addEventListener("click", () => save(false));
if (isLocal) overlay.querySelector("#tlClear").addEventListener("click", () => save(true));
overlay.querySelector("#tlName").focus();
}
function renderTokens() {
const s = sel();
const card = $("tokensCard");
const el = $("tokensList");
// Certificates are BCH-only today; every other chain lands on 0 and says
// so. Overwritten below for BCH.
if (chain() !== "bch") setCertsCount(0);
// SOL wallets: SPL tokens with a Send button (existing flow).
if (chain() === "sol") {
const tokens = s?.tokens || [];
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.";
if (!tokens.length) return;
el.innerHTML = tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const bal = fmtTokenAmount(t.balance, dec);
return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default">
<div><div>${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + '</span>' : ""}</div><div class="hint mono">${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}</div></div>
<div class="amt2 in" style="align-self:center">${esc(bal)}</div>
<button class="btn sm" data-mint="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${dec}" style="align-self:center">Send</button>
</div>`;
}).join("");
el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => {
sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) };
showTab("send");
}));
return;
}
// BCH wallets: CashTokens. Read-only display in 0.7.0 (spend ships in
// 0.7.1). Categories come pre-serialised from the wallet (fungible is
// a decimal string; NFTs are per-UTXO). Names/symbols/decimals/icons
// come from BCMR, fetched async; the first paint uses raw category hex.
if (chain() === "bch") {
const balances = s?.tokenBalances || {};
const allCats = Object.keys(balances);
// Assets = categories carrying a fungible balance. Certificates live in
// their own pane, so a category that is purely non-fungible does not
// take up a row here saying "1 NFT".
const cats = allCats.filter((c) => String(balances[c]?.fungible || "0") !== "0");
setAssetsCount(cats.length);
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "Fungible CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings.";
const draw = (metaMap) => {
// setAssetsCount already wrote the "no assets" line when there are no
// fungible categories; joining an empty list would wipe it. Common
// case now that purely-non-fungible categories live in the other pane.
if (!cats.length) { renderCerts(balances, metaMap); return; }
el.innerHTML = cats.map((cat) => {
const b = balances[cat];
const meta = metaMap?.[cat] || null;
const decimals = meta && Number.isFinite(meta.decimals) ? meta.decimals : 0;
const fungibleRaw = String(b.fungible || "0");
const showFungible = fungibleRaw !== "0";
const name = meta?.name || meta?.symbol || null;
const symbol = meta?.symbol || "";
const icon = meta?.iconUri || "";
const iconHtml = icon ? `<img src="${esc(icon)}" alt="" style="width:18px;height:18px;border-radius:4px;vertical-align:middle;margin-right:6px" onerror="this.style.display='none'">` : "";
const catShort = cat.slice(0, 10) + "…" + cat.slice(-6);
const fungibleTxt = showFungible ? fmtTokenAmount(fungibleRaw, decimals) + (symbol ? " " + symbol : "") : "";
const amountLine = fungibleTxt || "—";
const localTag = meta?.local ? ` <span class="ttag" title="A name you set — no registry knows this token">YOURS</span>` : "";
// Named: name on top, category beneath. Unnamed: the category IS the
// only identity, so it goes on top and the sub-line says what it is
// rather than repeating the same hex twice.
const utxoTxt = b.utxoCount ? `${b.utxoCount} UTXO${b.utxoCount === 1 ? "" : "s"}` : "";
const nameLine = name
? `${iconHtml}<b>${esc(name)}</b>${symbol && name !== symbol ? ` <span class="hint">${esc(symbol)}</span>` : ""}${localTag}`
: `${iconHtml}<span class="mono">${esc(catShort)}</span>`;
const subLine = name
? `<span class="mono">${esc(catShort)}</span>`
: ["unnamed token", utxoTxt].filter(Boolean).join(" · ");
// ✎ per row: these tokens often publish no metadata at all, so
// naming one locally is the only way it will ever read as anything
// but a hex string.
return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default;align-items:center">
<div><div>${nameLine}</div><div class="hint mono">${subLine}</div></div>
<div class="amt2 in" style="align-self:center;text-align:right">${esc(amountLine)}</div>
<button class="wact" data-tokname="${esc(cat)}" title="Name this token locally" style="align-self:center">✎</button>
</div>`;
}).join("");
el.querySelectorAll("[data-tokname]").forEach((b) => b.addEventListener("click", () => {
openTokenLabelModal(b.dataset.tokname, metaMap?.[b.dataset.tokname] || null);
}));
renderCerts(balances, metaMap);
};
// Paint immediately with whatever we know synchronously so the row
// set doesn't wait for the network. Then run the async lookup and
// redraw with names + icons.
draw({});
if (!allCats.length) return;
S.invoke("tokenMetadata", { categories: allCats }).then((res) => {
if (chain() !== "bch") return; // user switched away mid-fetch
// 0.17.0: the reply carries the map under .meta plus whether any
// registry actually answered. The old shape was the bare map and the
// failure path was .catch(() => {}), so two dead default registries
// went unnoticed — every token looked simply unregistered.
const map = res && res.meta ? res.meta : (res || {});
draw(map);
if (hintEl && res && res.registriesAnswered === false) {
hintEl.textContent = res.registries
? `No configured BCMR registry answered, so names are unavailable — check the registry list in Settings, or name a token yourself with ✎.`
: `No BCMR registry configured, so names are unavailable — add one in Settings, or name a token yourself with ✎.`;
}
}).catch((e) => {
if (hintEl) hintEl.textContent = `Token names unavailable: ${cleanErr(e)}. You can still name a token yourself with ✎.`;
});
return;
}
// Every other account-model chain (TRX, ETH, and anything added later)
// renders from the same `tokens` shape the adapters already return, so
// this is keyed on the DATA rather than on a list of chain ids — a new
// chain gets the asset list for free instead of silently falling
// through to a hidden card the way ETH did until 0.9.3.
{
const tokens = s?.tokens || [];
if (!tokens.length) { setAssetsCount(0); return; }
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
const kindLabel = chain() === "trx" ? "TRC20" : chain() === "eth" ? "ERC20" : "Tokens";
if (hintEl) hintEl.textContent = `${kindLabel} tokens held by this wallet. Read-only in this build — open the explorer from the header to move them.`;
// A symbol claimed by more than one contract is the lookalike pattern:
// spam mints borrow a trusted ticker so a careless send lands on the
// wrong contract. We can't tell which is genuine, so we don't guess —
// we mark every member of the clash and let the user check the address.
const symCount = new Map();
for (const t of tokens) {
const k = (t.symbol || "").toLowerCase();
if (t.known && k) symCount.set(k, (symCount.get(k) || 0) + 1);
}
const nativeUnits = walletBalanceUnits(sel()) || 0;
const nativeRow = `<div class="tx asset native" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div><div><b>${esc(s.meta?.ticker || chain().toUpperCase())}</b> <span class="hint">${esc(s.meta?.coinLabel || "")}</span></div>
<div class="hint">native</div></div>
<div class="amt2 in" style="align-self:center;text-align:right">${esc(fmtBig(nativeUnits, s.decimals))}</div>
</div>`;
const cnt = $("tokensCount");
if (cnt) cnt.textContent = `· ${tokens.length + 1}`;
el.innerHTML = nativeRow + tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const short = String(t.mint || "");
const addrLine = `${esc(short.slice(0, 10))}…${esc(short.slice(-6))}`;
const clash = t.known && symCount.get((t.symbol || "").toLowerCase()) > 1;
// Unknown contracts have no decimals, so a raw integer would be a
// misleading "balance". Say so instead of inventing a number.
const title = t.known
? `${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + "</span>" : ""}${clash ? ' <span class="ttag tdupe" title="More than one contract here uses this symbol — check the address before sending">LOOK-ALIKE</span>' : ""}`
: `<span class="hint">Unknown token</span>`;
const amount = t.known
? `<span title="${esc(fmtTokenAmountExact(t.balance, dec))} ${esc(t.symbol)}">${esc(fmtTokenAmount(t.balance, dec))}</span>`
: `<span class="hint mono" title="No registry entry for this contract — raw on-chain amount, decimals unknown">${esc(t.balance)} raw</span>`;
return `<div class="tx asset" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div><div>${title}</div><div class="hint mono" title="${esc(short)}">${addrLine}</div></div>
<div class="amt2 in" style="align-self:center;text-align:right">${amount}</div>
</div>`;
}).join("");
}
}
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.
function fmtTokenAmount(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
const group = (w) => w.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
if (d === 0) return (neg ? "-" : "") + group(abs);
const pad = abs.padStart(d + 1, "0");
const whole = pad.slice(0, pad.length - d);
let frac = pad.slice(pad.length - d).replace(/0+$/, "");
// An 18-decimal token renders as 60000000.000000005435817984 without a
// cap — 26 digits of noise for a balance whose meaningful part is the
// first few. Keep enough to distinguish small amounts, drop the rest;
// the exact value stays available in the row's title attribute.
const CAP = 8;
let truncated = false;
if (frac.length > CAP) { frac = frac.slice(0, CAP).replace(/0+$/, ""); truncated = true; }
return (neg ? "-" : "") + group(whole) + (frac ? "." + frac : "") + (truncated && !frac ? "…" : "");
}
// Exact, ungrouped rendering for tooltips — what fmtTokenAmount hides.
function fmtTokenAmountExact(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
if (d === 0) return (neg ? "-" : "") + abs;
const pad = abs.padStart(d + 1, "0");
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return (neg ? "-" : "") + pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
// ---- Send: which address the money leaves from -----------------------------
// Aegis models one address per wallet entry, so "send from" is the same
// question as "which of this coin's wallets". Picking one switches the panel
// selection rather than carrying a separate source: planSend and send resolve
// the wallet host-side from the selection, and a second notion of "current"
// would be two answers to one question, with the approval dialog free to
// disagree with the form.
function paintSendFrom(cur, others) {
const field = $("sendFromField"), box = $("sendFrom"), hint = $("sendFromHint");
if (!field || !box) return;
if (!cur || !others.length) { field.hidden = true; return; }
// List order, not "current first" — the order matches the wallet list the
// user already knows, so the same address sits in the same place each time.
const mine = (state?.wallets || []).filter((w) => w.chain === cur.chain && w.network === cur.network);
const roles = (state && state.roles) || {};
// Rebuild only when the options or balances actually change, so a re-render
// mid-typing doesn't reset a select under the cursor.
const key = mine.map((w) => `${w.id}:${walletBalanceUnits(w) || 0}:${roles.payments === w.id ? "p" : ""}`).join("|");
if (box.dataset.key !== key) {
box.dataset.key = key;
box.innerHTML = mine.map((w) => {
const bal = fmtBig(walletBalanceUnits(w) || 0, w.decimals);
const a = stripAddrPrefix(String(w.address || ""));
const tail = a ? " · " + (a.length > 16 ? a.slice(0, 8) + "…" + a.slice(-5) : a) : "";
const star = roles.payments === w.id ? " ★" : "";
return `<option value="${esc(w.id)}">${esc((w.label || "wallet") + tail)} — ${esc(bal)} ${esc(w.ticker)}${star}</option>`;
}).join("");
box.onchange = async () => {
const id = box.value;
if (!id || id === state.selectedWalletId) return;
try {
state = await S.invoke("selectWallet", { id });
settingsFilled = false;
// A different source means the costed plan is about other UTXOs.
lastPlan = null;
render();
schedulePlan();
} catch (e) {
const m = $("sendMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
}
};
}
box.value = cur.id;
const payW = mine.find((w) => roles.payments === w.id);
if (hint) {
hint.textContent = payW
? (payW.id === cur.id ? "Your default wallet for payments." : `★ ${payW.label} is your default for payments.`)
: "Pick a default for payments in a wallet's ⋯ menu.";
}
field.hidden = false;
}
function applyUnitPicker() {
const s = sel(); if (!s) return;
if (!unit) unit = "big";
// ---- SPL asset picker (SOL wallets with tokens) ---------------------
const tokens = (chain() === "sol" && s.tokens) || [];
const assetField = $("sendAssetField");
if (tokens.length) {
assetField.hidden = false;
const sel_ = $("sendAsset");
// Rebuild whenever the asset set changes so a new token appears.
const key = tokens.map((t) => t.mint).join("|");
if (sel_.dataset.key !== key) {
sel_.dataset.key = key;
sel_.innerHTML = `<option value="">SOL — native</option>` + tokens.map((t) =>
`<option value="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${Number(t.decimals) || 0}">${esc(t.symbol)}${t.name ? " · " + esc(t.name) : ""}</option>`
).join("");
sel_.onchange = () => {
const opt = sel_.options[sel_.selectedIndex];
sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null;
applyUnitPicker(); schedulePlan();
};
}
// Reflect the current sendAsset back into the select.
sel_.value = sendAsset ? sendAsset.mint : "";
} else {
assetField.hidden = true;
sendAsset = null;
}
const isToken = sendAsset != null;
const big = isToken ? sendAsset.symbol : bigUnitLabel();
const small = isToken ? "raw" : smallUnitLabel();
$("unitPicker").innerHTML =
`<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` +
`<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`;
$("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u)));
$("sendTo").placeholder = ({
bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…",
btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)",
trx: "T… (base58check, 34 chars)",
sc: "addr1… (76-hex + checksum)",
dgb: "dgb1q… (or D… / S… depending on family)",
eth: "0x… (40 hex chars, EIP-55)",
sol: "base58 public key (32 bytes)",
})[chain()] || "recipient address";
$("sendAmt").placeholder = unit === "big" ? "0.00" : "0";
}
function setUnit(u) {
if (u === unit) return;
const s = amountUnits();
unit = u;
applyUnitPicker();
// Exact conversion in the decimals of what is being sent. fmtBig() caps at
// 8 places and always used the chain's decimals, so 1 wei became "0" and a
// 6-decimal token amount shrank 1000× on a round trip.
if (s) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(s), amountDecimals()) : String(s);
updateSendFiatPreview();
}
function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); }
// <amount-parser> — pure functions, exercised by the sandbox harness.
// What the user typed → { whole, frac } digit strings. A wallet must never
// guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
// and Number() accepted "1e3", "0x10" and "-0.5".
function parseAmountText(text) {
let raw = String(text == null ? "" : text).trim().replace(/\s/g, "");
if (!raw) return { empty: true };
if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) {
raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
} else if (/^\d{1,3},\d{3}$/.test(raw)) {
return { error: `"${raw}" could mean ${raw.replace(",", "")} or ${raw.replace(",", ".")} — write it without the comma, or with a dot` };
} else if (/^\d*,\d+$/.test(raw)) {
raw = raw.replace(",", "."); // decimal comma: 0,5
}
if (!/^(\d+\.?\d*|\.\d+)$/.test(raw)) return { error: "Enter a plain number, like 0.5" };
const [w, f = ""] = raw.split(".");
return { whole: (w || "0").replace(/^0+(?=\d)/, ""), frac: f };
}
// → smallest units as a decimal string, or { error }. `unit` is "big" (coins)
// or "small" (sats / wei / raw token units).
function amountToUnits(text, decimalsN, unit) {
const p = parseAmountText(text);
if (p.empty) return { units: "0", empty: true };
if (p.error) return { error: p.error };
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
if (unit === "small") {
if (/[1-9]/.test(p.frac)) return { error: "The smallest unit cannot have decimals" };
return { units: BigInt(p.whole).toString() };
}
if (/[1-9]/.test(p.frac.slice(d))) return { error: `At most ${d} decimal place${d === 1 ? "" : "s"} here` };
const frac = (p.frac + "0".repeat(d)).slice(0, d);
return { units: (BigInt(p.whole) * (10n ** BigInt(d)) + BigInt(frac || "0")).toString() };
}
// Exact inverse for filling the field: units → "1.5" with no grouping.
function unitsToDecimalText(units, decimalsN) {
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
const s = String(units).replace(/^0+(?=\d)/, "");
if (!/^\d+$/.test(s)) return "";
if (d === 0) return s;
const pad = s.padStart(d + 1, "0");
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
// </amount-parser>
// Why the amount in the field is not usable, or null. Set by amountUnits().
let amountError = null;
function amountUnits() {
amountError = null;
const d = amountDecimals();
const r = amountToUnits($("sendAmt").value, d, unit);
if (r.error) { amountError = r.error; return 0; }
if (r.empty) return 0;
// SPL tokens and 18/24-decimal coins travel as decimal strings; the 8–9
// decimal chains keep their Number contract with the host, bounded so a
// value past 2^53 is refused instead of silently rounded.
const bigDecimals = sendAsset != null || d > 15;
if (bigDecimals) return r.units;
if (BigInt(r.units) > BigInt(Number.MAX_SAFE_INTEGER)) { amountError = "That amount is too large"; return 0; }
return Number(r.units);
}
// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere).
function balanceSum(b) {
if (!b) return 0;
if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") {
return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString();
}
return (b.confirmed || 0) + (b.unconfirmed || 0);
}
// ---- history ---------------------------------------------------------------
// History asset filter (0.9.2). Both on by default — these narrow a
// combined feed, they don't opt into one.
let histShowNative = true;
let histShowTokens = true;
let histFilterWired = false;
function wireHistFilter() {
if (histFilterWired) return;
const n = $("histNative"), t = $("histTokens");
if (!n || !t) return;
histFilterWired = true;
// Unchecking both would leave an empty list with no hint why, so the
// last one standing stays checked.
const onChange = () => {
if (!n.checked && !t.checked) { n.checked = true; }
histShowNative = n.checked;
histShowTokens = t.checked;
renderHistory();
};
n.addEventListener("change", onChange);
t.addEventListener("change", onChange);
}
function renderHistory() {
const s = sel();
const all = s?.history || [];
const el = $("txlist");
// Only offer the filter when there is actually something to filter.
const hasTokens = all.some((t) => t.asset);
const filterEl = $("histFilter");
if (filterEl) {
filterEl.hidden = !hasTokens;
const nativeLbl = $("histNativeLbl");
if (nativeLbl) nativeLbl.textContent = s?.meta?.ticker || "Coin";
}
const list = hasTokens
? all.filter((t) => (t.asset ? histShowTokens : histShowNative))
: all;
const cntEl = $("histCount");
if (cntEl) cntEl.textContent = hasTokens ? `${list.length} of ${all.length}` : "";
if (!list.length) {
el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : (all.length ? "Nothing matches this filter." : "No transactions yet.")}</div>`;
return;
}
const dec = s?.decimals ?? 8;
el.innerHTML = list.map((t) => {
// `delta` arrives in three shapes now: a number (UTXO chains), a decimal
// STRING (ETH — 18 decimals of wei overflows a JS number, so it must
// stay exact), or null (Solana, where the signature feed carries no
// amount and fetching one per tx would be 25 extra round trips a poll).
// Treating null as 0 would render "+ —", claiming a receive we cannot
// actually verify, so unknown amounts get their own neutral branch.
const known = t.delta != null;
const neg = known && String(t.delta).trim().startsWith("-");
const inc = known ? !neg : null;
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
const who = inc === null ? "" : inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : "");
const what = (inc === null ? (t.kind || "Transaction") : inc ? "Received" : "Sent") + (who ? " " + who : "");
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf")
: (t.status === "failed" ? "failed" : t.status === "pending" ? "pending" : "unconfirmed");
// Strip the sign as text rather than via Math.abs so a big-decimal
// string keeps every digit.
const magnitude = known ? String(t.delta).trim().replace(/^[-+]/, "") : "";
const isZero = known && /^0*$/.test(magnitude);
// A token row carries its OWN decimals and ticker — rendering a USDT
// amount against the chain's 6-decimal TRX scale (or an 18-decimal
// ERC-20 against 18 for ETH) would be off by orders of magnitude.
const rowDec = t.asset ? (t.assetDecimals ?? 0) : dec;
const rowTicker = t.asset ? ` ${t.asset}` : "";
const amountHtml = !known ? "—"
: isZero ? "—"
: `${inc ? "+" : "−"}${esc(fmtTokenAmount(magnitude, rowDec))}${esc(rowTicker)}`;
const icon = inc === null ? "·" : inc ? "↓" : "↑";
const iconCls = inc === null ? "" : inc ? "in" : "out";
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
<div class="ic ${iconCls}">${icon}</div>
<div class="what">${esc(what)}</div>
<div class="amt2 ${inc ? "in" : ""}">${amountHtml}</div>
<div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}</div>
<div class="conf ${t.confirmations > 0 ? (t.status === "failed" ? "pending" : "") : "pending"}">${esc(conf)}</div>
</div>`;
}).join("");
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid))));
wireHistFilter();
}
function shortAddr(a) {
if (!a) return "";
const s = String(a).replace(/^bitcoincash:|^bchtest:/, "");
return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4));
}
// ---- QR --------------------------------------------------------------------
// Coin-scheme URI so wallet apps that scan know which chain the payment is
// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681
// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed
// URI scheme exists).
function qrPayload(chain, address, network) {
if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, "");
if (chain === "btc") return "bitcoin:" + address; // BIP21
if (chain === "dgb") return "digibyte:" + address;
if (chain === "eth") return "ethereum:" + address;
if (chain === "sol") return "solana:" + address;
if (chain === "trx") return "tron:" + address;
return String(address);
}
function drawQr(text) {
const cv = $("qr");
const g = cv.getContext("2d");
let q;
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
const px = (q.size + 2) * scale;
// Backing store only. The DISPLAY size belongs to the user's dragged
// preference — setting cv.style here would reset the panel to its
// intrinsic size on every redraw (i.e. whenever the address changed).
cv.width = cv.height = px;
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
g.fillStyle = "#000";
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
applyQrSize();
}
// ---- receive actions -------------------------------------------------------
// 0.9.8: the QR is always visible and the panel is drag-resizable, which
// replaces the 0.9.2 show/hide toggle — a size the user sets once is a
// better answer than a binary, and it means the QR button no longer
// competes with Copy for the one row that gets used.
let applyQrSize = () => {};
(function wireQrResize() {
const grip = $("qrGrip"), wrap = $("qrWrap");
if (!grip || !wrap) return;
const MIN = 90, MAX = 420;
const clamp = (v) => Math.max(MIN, Math.min(MAX, Math.round(v)));
let size = 200;
try {
const saved = Number(localStorage.getItem("aegis/qrSize"));
if (Number.isFinite(saved) && saved > 0) size = clamp(saved);
} catch {}
const apply = () => {
// Cap against the actual panel width too — a size dragged wide on a
// roomy sidebar must not overflow when the sidebar is narrowed later.
const room = Math.max(MIN, (wrap.clientWidth || MAX) - 24);
const px = Math.min(size, room);
const cv = $("qr");
if (cv) { cv.style.width = px + "px"; cv.style.height = px + "px"; }
};
applyQrSize = apply;
apply();
window.addEventListener("resize", apply);
let startY = 0, startSize = 0, active = false;
const onMove = (e) => {
if (!active) return;
e.preventDefault();
size = clamp(startSize + (e.clientY - startY));
apply();
};
const onUp = () => {
if (!active) return;
active = false;
grip.classList.remove("dragging");
try { grip.releasePointerCapture?.(grip._pid); } catch {}
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
grip.addEventListener("pointerdown", (e) => {
active = true; startY = e.clientY;
const cv = $("qr");
startSize = cv ? (parseInt(cv.style.width, 10) || 200) : 200;
grip._pid = e.pointerId;
grip.classList.add("dragging");
try { grip.setPointerCapture(e.pointerId); } catch {}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
e.preventDefault();
});
// Keyboard affordance — a drag-only control is unusable without a mouse.
grip.tabIndex = 0;
grip.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 24 : 8;
if (e.key === "ArrowUp") { size = clamp(size - step); }
else if (e.key === "ArrowDown") { size = clamp(size + step); }
else return;
e.preventDefault(); apply();
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
});
})();
// The inline copy button holds an SVG; set it once at load.
(function paintCopyIcon() {
const b = $("copyAddr");
if (b && !b.firstElementChild) b.innerHTML = COPY_ICON;
})();
$("copyAddr").addEventListener("click", async () => {
const b = $("copyAddr");
try {
await navigator.clipboard.writeText(sel().address);
// innerHTML, not flash() — flash() swaps textContent, which would
// delete the inline SVG and leave an empty square behind.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
});
$("nextAddr").addEventListener("click", async () => {
try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); }
// The old handler discarded the error and flashed a bare "Failed", so
// neither the user nor a maintainer could tell WHY — which is why
// "next unused address doesn't work" had no diagnosable cause. Show it.
catch (e) { showErr("Next address: " + cleanErr(e)); }
});
$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address)));
$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet));
function flash(btn, text) {
const old = btn.textContent; btn.textContent = text;
setTimeout(() => { btn.textContent = old; }, 1200);
}
// ---- send ------------------------------------------------------------------
$("sendMax").addEventListener("click", () => {
// A token has no "max mode": its fee is paid in the coin, so Max is just
// the whole token balance, written into the field exactly. The toggle
// below did nothing for tokens — the plan never received it, and Send
// went out with whatever sat in the disabled field.
if (sendAsset) {
const t = (sel()?.tokens || []).find((x) => x.mint === sendAsset.mint);
sendMax = false;
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
if (t) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(t.balance), amountDecimals()) : String(t.balance);
updateSendFiatPreview();
schedulePlan();
return;
}
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
if ($("sendMemo")) $("sendMemo").addEventListener("input", () => schedulePlan());
// 0.7.7 legacy chips — hidden in 0.8.0 but kept for graceful transition;
// clicking still opens the standalone modal for anyone with muscle memory.
if ($("consolidateChip")) $("consolidateChip").addEventListener("click", () => openConsolidateModal());
if ($("consolidateChipRcv")) $("consolidateChipRcv").addEventListener("click", () => openConsolidateModal());
// 0.8.0 mode-toggle wiring. One class="modetoggle" element per tab —
// clicking a segment flips the mode variable and repaints the body via
// paintSendMode / paintRcvMode. Freshly-rendered inline hosts get their
// consolidate view populated on first switch.
document.querySelectorAll("[data-send-mode]").forEach((b) => b.addEventListener("click", () => {
sendMode = b.dataset.sendMode;
consolidateInlineHost = null; // force re-render on next switch
paintSendMode();
}));
// Consolidate, now an address action rather than a view toggle. Entering the
// view forces a re-render of the inline host so the preview is costed fresh
// each time, the same thing the old toggle did.
if ($("rcvConsolidateBtn")) $("rcvConsolidateBtn").addEventListener("click", () => {
rcvMode = "consolidate";
consolidateInlineHost = null;
paintRcvMode();
});
if ($("rcvConsolidateBack")) $("rcvConsolidateBack").addEventListener("click", () => {
rcvMode = "receive";
consolidateInlineHost = null;
paintRcvMode();
paintRcvView();
});
// Address & QR / Assets — the persistent pair inside the Receive body.
document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => {
rcvView = RCV_VIEWS.includes(b.dataset.rcvView) ? b.dataset.rcvView : "address";
try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {}
paintRcvView();
}));
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => {
if (id === "sendAmt" && sendMax) return;
if (id === "sendAmt") updateSendFiatPreview();
schedulePlan();
}));
// Live ≈$ preview beside the Amount label, updated on every keystroke. Off
// when prices are disabled or the input is empty, so a quiet form stays quiet.
function updateSendFiatPreview() {
const el = $("sendAmtFiat"); if (!el) return;
const s = sel(); if (!s || sendAsset) { el.hidden = true; return; }
const units = amountUnits();
if (!units || amountError || !state?.prices?.enabled) { el.hidden = true; return; }
const usd = usdOf(chain(), units, decimals());
const txt = fmtFiat(usd);
el.textContent = txt ? "≈ " + txt : "";
el.hidden = !txt;
}
function schedulePlan() {
// The summary on screen is stale from the first keystroke: Send stays off
// until a plan for exactly what is in the form has come back.
lastPlan = null; lastPlanReq = null;
const b = $("sendBtn"); if (b) b.disabled = true;
clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250);
}
// The request a plan was made for. Send submits THIS, never a fresh read of
// the form, so what is signed is what the summary showed.
let lastPlanReq = null;
let planSeq = 0;
function currentSendReq() {
const amount = amountUnits();
const base = { walletId: state?.selectedWalletId || null, to: $("sendTo").value.trim() };
if (sendAsset) return { ...base, mint: sendAsset.mint, amount };
return {
...base, amount,
feeRate: chain() === "bch" ? Number($("feeRate").value) : undefined,
sendMax,
memo: chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "",
};
}
// Selecting another wallet invalidates everything the Send form worked out.
// The fields used to survive the switch: the button stayed live on the old
// wallet's plan, Max swept the new wallet under the old summary, and a
// number typed in sats was re-read as wei.
let sendFormWalletId = null;
function noteSelectedWallet() {
const id = state?.selectedWalletId || null;
if (id === sendFormWalletId) return;
const prev = (state?.wallets || []).find((w) => w.id === sendFormWalletId);
const cur = (state?.wallets || []).find((w) => w.id === id);
const first = sendFormWalletId === null;
sendFormWalletId = id;
if (first) return;
lastPlan = null; lastPlanReq = null; planSeq++;
clearTimeout(planTimer);
sendAsset = null;
if (sendMax) {
sendMax = false;
try { $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendAmt").value = ""; } catch {}
}
// Another coin means the address and the number mean something else.
if (!prev || !cur || prev.chain !== cur.chain || prev.network !== cur.network) {
try { $("sendTo").value = ""; $("sendAmt").value = ""; if ($("sendMemo")) $("sendMemo").value = ""; } catch {}
unit = "big";
try { applyUnitPicker(); } catch {}
}
try {
$("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendMsg").hidden = true;
} catch {}
if ($("sendTo")?.value && $("sendAmt")?.value) schedulePlan();
}
async function updatePlan() {
const seq = ++planSeq;
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; lastPlanReq = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
const req = currentSendReq();
if (amountError && !sendMax) { msg.className = "msg err"; msg.textContent = amountError; msg.hidden = false; return; }
if (!to || (!sendMax && !req.amount)) return;
try {
if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals.
const p = await S.invoke("planTokenSend", { mint: req.mint, to, amount: req.amount });
if (seq !== planSeq) return; // a newer plan is on its way
lastPlan = { _token: true, ...p }; lastPlanReq = req;
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
$("sendBtn").disabled = false;
return;
}
const p = await S.invoke("planSend", { to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
if (seq !== planSeq) return; // a newer plan is on its way
lastPlan = p; lastPlanReq = req;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker();
$("sumFee").textContent = chain() === "bch"
? fmtSmall(p.fee) + " " + smallUnitLabel()
: fmtBig(p.fee) + " " + ticker();
$("sumTotal").textContent = fmtBig(p.total) + " " + ticker();
if (sendMax) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(p.recipients[0].value), amountDecimals()) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
if (seq !== planSeq) return;
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
if (!lastPlan || !lastPlanReq) return;
const msg = $("sendMsg"); msg.hidden = true;
// The form must still say what the summary was computed for. Max rewrites
// the amount field itself, so it is compared without the amount.
const now = currentSendReq();
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const strip = (r) => (r.sendMax ? { ...r, amount: null } : r);
if (!same(strip(now), strip(lastPlanReq))) {
await updatePlan();
if (lastPlan) { msg.className = "msg err"; msg.textContent = "The form changed — check the updated summary, then press Send again."; msg.hidden = false; }
return;
}
const req = lastPlanReq;
// PIN gate. Whether a transaction needs one is the policy's call, not a
// single flag's — and if the user only asked for a PIN at startup, this
// check passes without a prompt.
if (!await pinGate("transaction", "Confirm this send with your PIN.")) {
msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return;
}
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
const isToken = !!(req.mint && lastPlan._token);
const r = isToken
? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount })
: await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
// A broadcast that returned no txid is still a broadcast: never report
// it as a failure and leave a filled form inviting a second send.
const txid = r && typeof r.txid === "string" ? r.txid : "";
msg.className = "msg ok";
if (txid) {
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(txid)}">${esc(txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, txid)));
} else {
msg.textContent = "Sent. The network did not return a transaction id — check History before sending again.";
}
msg.hidden = false;
lastPlanReq = null;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false;
if ($("sendMemo")) $("sendMemo").value = "";
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
lastPlan = null;
} catch (e) {
const t = cleanErr(e);
if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; }
$("sendBtn").disabled = !lastPlan;
} finally { $("sendBtn").textContent = "Send"; }
});
// ---- settings --------------------------------------------------------------
function fillSettings() {
// Global settings (fiat prices, connected sites) render even when there
// is no active wallet / the vault is locked.
renderPricesSetting();
renderSites();
renderGeneralSecurity();
const s = sel();
const chainSetup = !!s && s.phase === "ready";
$("walletManage").hidden = !chainSetup;
if (!chainSetup) {
$("bchSettings").hidden = true;
$("trxSettings").hidden = true;
$("scSettings").hidden = true;
$("dgbSettings").hidden = true;
$("btcSettings").hidden = true;
$("ethSettings").hidden = true;
$("solSettings").hidden = true;
return;
}
$("bchSettings").hidden = chain() !== "bch";
$("trxSettings").hidden = chain() !== "trx";
$("scSettings").hidden = chain() !== "sc";
$("dgbSettings").hidden = chain() !== "dgb";
$("btcSettings").hidden = chain() !== "btc";
$("ethSettings").hidden = chain() !== "eth";
$("solSettings").hidden = chain() !== "sol";
$("removeBtn").disabled = !!s.isLegacy && s.chain === "bch";
$("removeHint").textContent = (s.isLegacy && s.chain === "bch")
? "The default BCH wallet cannot be removed (it protects legacy funds)."
: (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : "");
$("renameLabel").value = s.label || "";
if (chain() === "bch") {
if (!settingsFilled) {
$("setPath").value = s.accountPath || "";
renderServerCheckboxes();
settingsFilled = true;
}
$("bchServersRow").hidden = s.network !== "mainnet";
$("serverHint").textContent = s.network !== "mainnet"
? "Chipnet uses bundled defaults in this build."
: (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
$("purpose").textContent = "silentmode/addons/" + (s.purpose || "");
// WC pairing needs the vault-derived signer path. Imported wallets
// don't have one yet (M.1b), so we hide the paste field + surface a
// clear explanation in its place — otherwise the user hits an opaque
// "wc: wallet not ready" error from the addon.
const wcImported = s.kind === "imported";
if ($("wcImportedNotice")) $("wcImportedNotice").hidden = !wcImported;
if ($("wcInputs")) $("wcInputs").hidden = wcImported;
renderWcSites();
} else if (chain() === "trx") {
$("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
} else if (chain() === "sc") {
if (!settingsFilled) {
$("setWalletdUrl").value = s.walletdUrl || "";
settingsFilled = true;
}
$("scPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("scRecovery").innerHTML = "";
} else if (chain() === "dgb") {
if (!settingsFilled) {
fillFamilyPicker("Dgb", s);
settingsFilled = true;
}
$("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("dgbRecovery").innerHTML = "";
} else if (chain() === "btc") {
if (!settingsFilled) {
fillFamilyPicker("Btc", s);
settingsFilled = true;
}
$("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("btcRecovery").innerHTML = "";
} else if (chain() === "eth") {
if (!settingsFilled) {
$("setEthRpcUrl").value = s.rpcUrl || "";
settingsFilled = true;
}
$("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("ethRecovery").innerHTML = "";
} else if (chain() === "sol") {
if (!settingsFilled) {
$("setSolRpcUrl").value = s.rpcUrl || "";
settingsFilled = true;
}
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("solRecovery").innerHTML = "";
}
}
// Reflect the current price feed state into the Settings toggle + status
// line. Called from fillSettings() and whenever fresh state arrives.
// General security card — PIN state + "Require PIN for sending" toggle.
// Loads (or refreshes) securityState on demand. Shown even when the vault
// is locked so users on the Settings tab can flip the require-pin policy
// before unlocking.
async function renderGeneralSecurity() {
if (!securityLoaded) await refreshSecurityState();
if (!sessionLoaded) await refreshSessionState();
const hasPin = !!securityState.hasPin;
const set = $("gsPinSet"), chg = $("gsPinChange"), rm = $("gsPinRemove");
const hint = $("pinStatusHint");
if (set) set.hidden = hasPin;
if (chg) chg.hidden = !hasPin;
if (rm) rm.hidden = !hasPin;
if (hint) hint.textContent = hasPin
? "On — Aegis accepts a 6-digit PIN as an alias for your master password."
: "Off — Aegis asks for the master password every time.";
// "Ask for PIN" triggers. Meaningless without a PIN to ask for.
const onLine = $("gsPinOnLine");
if (onLine) onLine.hidden = !hasPin;
const pol = securityState.pinOn || {};
for (const [id, key] of PIN_ON_FIELDS) {
const box = $(id);
if (box) box.checked = !!pol[key];
}
// Both PIN policies are meaningless without a PIN to use.
const revLine = $("gsRequirePinRevealLine"), rpr = $("gsRequirePinReveal");
if (revLine) revLine.hidden = !hasPin;
if (rpr) rpr.checked = !!securityState.requirePinForReveal;
renderSessionSettings();
}
// Session card: reflects lockOnClose + idleMinutes + safeStorage
// availability into the toggles. When the OS keystore isn't available
// (rare — mainly stripped Linux setups), lock-on-close is forced on and
// the toggle is disabled with a clear hint.
function renderSessionSettings() {
const lc = $("gsLockOnClose");
const im = $("gsIdleMinutes");
const hint = $("gsSessionHint");
if (!lc || !im) return;
const canRemember = !!sessionState.safeStorageAvailable;
lc.checked = !!sessionState.lockOnClose;
lc.disabled = !canRemember;
im.value = String(sessionState.idleMinutes || 0);
if (hint) {
if (!canRemember) {
hint.textContent = "OS keystore unavailable on this machine — Aegis can't remember the unlock across restarts. Master-password entry on every launch.";
} else if (sessionState.lockOnClose) {
hint.textContent = "On — Aegis asks for the master password (or PIN) every time Theseus starts.";
} else {
hint.textContent = "Off — Aegis stays signed in across Theseus restarts. Master password is stored in the OS keystore under this user only.";
}
}
}
// Modal helper that captures a PIN via the same 6-digit pad used on the
// lock screen. Returns the entered PIN (string of 6 digits) or null if
// the user closes without confirming. `confirm` mode double-prompts and
// only resolves when both entries match.
function openPinModal({ title, subtitle, mode }) {
return new Promise((resolve) => {
const first = { pin: null };
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
<div class="pincard">
<h2 id="pmTitle">${esc(title)}</h2>
<div class="pinsub" id="pmSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="pmDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pinkeys" id="pmKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
<button data-k="0">0</button>
<button class="util" data-k="back">⌫</button>
</div>
<div class="pinerr" id="pmErr"></div>
</div>
<div class="pinactions">
<button class="btn" id="pmCancel" type="button">Cancel</button>
</div>
</div>`;
document.body.appendChild(wrap);
const close = (val) => { try { wrap.remove(); } catch {} resolve(val); };
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); });
wrap.querySelector("#pmCancel").addEventListener("click", () => close(null));
setupPinPad({
// Scoped to this overlay, not looked up by global id — see the note
// on setupPinPad about duplicate ids across simultaneous pads.
dots: wrap.querySelector("#pmDots"), keys: wrap.querySelector("#pmKeys"), err: wrap.querySelector("#pmErr"),
onComplete: async (pin) => {
if (mode === "confirm" && first.pin == null) {
first.pin = pin;
$("pmSub").textContent = "Re-enter to confirm";
return "reset";
}
if (mode === "confirm" && first.pin !== pin) {
$("pmErr").textContent = "PINs don't match. Start again.";
first.pin = null;
$("pmSub").textContent = subtitle || "";
return "reset";
}
close(pin);
return "ok";
},
});
});
}
$("gsPinSet") && $("gsPinSet").addEventListener("click", async () => {
await handlePinSet();
});
$("gsPinChange") && $("gsPinChange").addEventListener("click", async () => {
await handlePinSet(true);
});
$("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => {
const ok = await aegisConfirm({
title: "Remove the quick-access PIN?",
danger: true,
confirmLabel: "Remove PIN",
body: "You'll have to type the master password on every unlock again.",
});
if (!ok) return;
try {
await S.invoke("pinBlobClear");
// Every trigger depends on there being a PIN, so clear them all rather
// than leaving a policy armed against a credential that no longer exists.
await S.invoke("securitySet", { pinOn: { restart: false, launch: false, interval: false, transaction: false } });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) { aegisAlert("Could not remove PIN: " + cleanErr(e)); }
});
// Checkbox id → policy key, used by both the paint and the wiring below.
const PIN_ON_FIELDS = [
["gsPinOnRestart", "restart"],
["gsPinOnLaunch", "launch"],
["gsPinOnInterval", "interval"],
["gsPinOnTransaction", "transaction"],
];
for (const [id, key] of PIN_ON_FIELDS) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", async () => {
const want = box.checked;
try {
securityState = await S.invoke("securitySet", { pinOn: { [key]: want } });
// Ticking a trigger should not fire it retroactively: the user is
// sitting in Settings having just proved whatever got them here.
// The host only records a gate against proof it verified, so this
// works when the password is still at hand (just unlocked); otherwise
// the new trigger simply asks once at its next occasion.
if (want && window.__aegisLastPw) { try { await S.invoke("pinGateSatisfied", { masterPassword: window.__aegisLastPw }); } catch {} }
renderGeneralSecurity();
} catch (e) {
box.checked = !want;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
}
$("gsRequirePinReveal") && $("gsRequirePinReveal").addEventListener("change", async () => {
const on = $("gsRequirePinReveal").checked;
try {
securityState = await S.invoke("securitySet", { requirePinForReveal: on });
renderGeneralSecurity();
} catch (e) {
$("gsRequirePinReveal").checked = !on;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
$("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => {
// Route through the addon so it can pass the section slug back to
// Theseus (main-process gates section-hint validation).
S.invoke("openSettings", { section: "passwords" }).catch(() => {});
});
// Session controls: Lock-on-close toggle + Idle-lock dropdown + Sign out.
// Turning "Lock on close" OFF is the "stay signed in" opt-in — we need
// the master password once to seed the OS keystore. Turning it back ON
// wipes the stored blob and reverts to the classic every-launch prompt.
$("gsLockOnClose") && $("gsLockOnClose").addEventListener("change", async () => {
const on = $("gsLockOnClose").checked;
try {
if (!on) {
const pw = window.__aegisLastPw || await promptMasterPassword({
title: "Stay signed in",
subtitle: "Aegis needs your master password once to encrypt it into the OS keystore. It never touches disk in plaintext.",
});
if (!pw) { $("gsLockOnClose").checked = true; return; }
sessionState = await S.invoke("sessionEnable", { masterPassword: pw });
// Drop the buffered password immediately — safeStorage now holds it.
try { delete window.__aegisLastPw; } catch {}
} else {
sessionState = await S.invoke("sessionDisable");
}
renderSessionSettings();
bindIdleAutoLock();
} catch (e) {
$("gsLockOnClose").checked = !on;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
$("gsIdleMinutes") && $("gsIdleMinutes").addEventListener("change", async () => {
const mins = Number($("gsIdleMinutes").value) || 0;
try {
sessionState = await S.invoke("sessionConfigSet", { idleMinutes: mins });
renderSessionSettings();
bindIdleAutoLock();
} catch (e) { aegisAlert("Could not save idle timeout: " + cleanErr(e)); }
});
$("gsSignOut") && $("gsSignOut").addEventListener("click", async () => {
const ok = await aegisConfirm({
title: "Sign out of Aegis?",
icon: "🔒",
confirmLabel: "Sign out",
body: "The vault will re-lock and you'll need the master password (or PIN) to open it again.",
});
if (!ok) return;
try {
state = await S.invoke("vaultLock");
stripView = { mode: "coins", groupKey: null };
render();
// Session blob was cleared server-side; refresh our cached view.
sessionState = await S.invoke("sessionStatus");
renderSessionSettings();
} catch (e) { aegisAlert("Could not sign out: " + cleanErr(e)); }
});
// Setting or changing a PIN needs the master password to encrypt against.
// If the panel has one buffered from a recent unlock (window.__aegisLastPw)
// we use it silently; otherwise we ask, verify via a fresh vaultUnlock, and
// then proceed with the PIN capture flow.
async function handlePinSet(replacing) {
let masterPw = window.__aegisLastPw || null;
if (!masterPw) {
masterPw = await promptMasterPassword({
title: replacing ? "Confirm master password" : "Set up quick-access PIN",
subtitle: replacing
? "We need the master password once to re-encrypt the PIN under a fresh key."
: "The PIN is an alias for your master password. Enter the master password once to bind them.",
});
if (!masterPw) return;
}
const pin = await openPinModal({
title: replacing ? "Choose a new PIN" : "Choose a PIN",
subtitle: "Six digits",
mode: "confirm",
});
if (!pin) return;
try {
await S.invoke("pinSet", { pin, masterPassword: masterPw });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) {
aegisAlert("Could not save PIN: " + cleanErr(e));
} finally {
// Drop the buffered password sooner rather than later — we only kept
// it around to enroll a PIN without a re-prompt.
try { delete window.__aegisLastPw; } catch {}
}
}
// Small modal that captures the master password + verifies it via a
// vaultUnlock roundtrip. Resolves with the password string on success or
// null on cancel / failure. Used both by PIN enrollment (from Settings)
// and by the PIN approval gate when the user chose to fall back.
function promptMasterPassword({ title, subtitle }) {
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
<div class="pincard">
<h2>${esc(title || "Confirm master password")}</h2>
<div class="pinsub">${esc(subtitle || "")}</div>
<div style="display:flex;flex-direction:column;gap:8px">
<input type="password" id="pmpPw" placeholder="Master password" autocomplete="current-password" autofocus>
<div class="msg err" id="pmpErr" hidden></div>
</div>
<div class="pinactions">
<button class="btn" id="pmpCancel" type="button">Cancel</button>
<button class="btn primary" id="pmpOk" type="button">Confirm</button>
</div>
</div>`;
document.body.appendChild(wrap);
const done = (v) => { try { wrap.remove(); } catch {} resolve(v); };
wrap.querySelector("#pmpCancel").addEventListener("click", () => done(null));
const submit = async () => {
const pw = $("pmpPw").value;
const err = $("pmpErr"); err.hidden = true;
if (!pw) return;
try {
// Re-unlock the vault to confirm the password is correct. Idempotent —
// if the vault is already open, calling unlock again is a no-op.
state = await S.invoke("vaultUnlock", { masterPassword: pw });
done(pw);
} catch (e) { err.textContent = cleanErr(e); err.hidden = false; }
};
wrap.querySelector("#pmpOk").addEventListener("click", submit);
$("pmpPw").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
try { $("pmpPw").focus(); } catch {}
});
}
// What each secret form actually IS, and where it can actually be restored.
// This copy matters more than it looks: none of these are a BIP39 recovery
// phrase, because Aegis never stores one. An import converts the words to a
// seed and throws the words away; a vault wallet is HKDF(vault root, purpose)
// and never had words. Someone who writes down what we show here and believes
// it is a 12-word phrase has not backed anything up, so each form says what
// it is in its own name and the note says what to do with it.
const SECRET_FORMS = {
wif: {
title: "Private key (WIF)",
note: "This single key controls this one address and nothing else. Any Bitcoin Cash wallet that accepts a WIF key can import it.",
},
privhex: {
title: "Private key (hex)",
note: "This single key controls this one account. Most ETH / TRX / SOL wallets accept a raw hex private key.",
},
seed: {
title: "Wallet seed (hex)",
note: "There is no word list to show. You imported a recovery phrase, and Aegis converted it to this seed and discarded the words — that conversion is one-way, so the phrase cannot be recovered from here or from anywhere else in Aegis. Keep the original phrase wherever you first wrote it down. This seed plus the derivation path below is a complete backup of the wallet, and Aegis can take it back under Add → Import → Seed (hex).",
},
vault: {
title: "Wallet key (hex)",
note: "This wallet has no recovery phrase of its own — it is derived from your Theseus vault, so your real backup is the vault's master password. The key below restores this one wallet via Add → Import → Seed (hex), and the account xprv, where shown, is accepted by most other HD wallets.",
},
};
// Reveal one wallet's secret. Gated by authorizeForSecret, hidden until
// asked for a second time, and never left on screen after the modal closes.
async function openRevealSecretModal(w) {
// Two shapes reach here: a wallet summary from the manage modal, which
// keys the id as `id`, and state.selected from the Settings buttons, which
// keys it as `walletId`. Accept both rather than silently doing nothing.
const walletId = w && (w.id || w.walletId);
if (!walletId) return;
const pw = await authorizeForSecret(`Reveal the secret key for "${(w && w.label) || "this wallet"}".`);
if (!pw) return;
let r;
try { r = await S.invoke("revealSecret", { walletId, masterPassword: pw }); }
catch (e) { await aegisAlert(cleanErr(e), { title: "Could not reveal", icon: "⚠️" }); return; }
const form = SECRET_FORMS[r.form] || { title: "Secret", note: "" };
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px";
const field = (label, value, danger) => value
? `<div class="lbl" style="margin-top:8px">${esc(label)}</div>
<div class="mono" style="word-break:break-all;${danger ? "color:var(--danger)" : ""}">${esc(value)}</div>`
: "";
overlay.innerHTML = `
<div style="width:min(94vw,420px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="font-weight:600;flex:1">${esc(form.title)}</div>
<button class="btn sm" id="rvClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">${esc(r.label || "")} · ${esc(r.coinLabel || "")} · ${esc(r.networkLabel || "")}</div>
<div class="msg err" style="margin-bottom:10px">Anyone who sees this can spend everything in this wallet. Nobody legitimate will ever ask you for it — not support, not Silent Mode.</div>
<div class="hint" style="margin-bottom:10px">${esc(form.note)}</div>
<div id="rvHidden">
<div class="actions"><button class="btn danger" id="rvShow" type="button">Show the key</button></div>
</div>
<div id="rvShown" hidden>
${field(form.title, r.secret, true)}
${field("Derivation path", r.path || r.accountPath, false)}
${field("Account private key (xprv)", r.xprv, true)}
${field("Account xpub (safe to share)", r.xpub, false)}
${field("Address", r.address, false)}
<div class="actions" style="margin-top:12px;gap:6px">
<button class="btn" id="rvCopy" type="button">Copy key</button>
<button class="btn" id="rvHide" type="button">Hide</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
// Wipe the rendered secret out of the DOM on the way out rather than
// relying on the node being dropped — the modal is the only place it
// exists in the renderer, so clearing it is cheap and exact.
const close = () => {
try { overlay.querySelector("#rvShown").innerHTML = ""; } catch {}
try { overlay.remove(); } catch {}
};
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#rvClose").addEventListener("click", close);
overlay.querySelector("#rvShow").addEventListener("click", () => {
overlay.querySelector("#rvHidden").hidden = true;
overlay.querySelector("#rvShown").hidden = false;
});
overlay.querySelector("#rvHide").addEventListener("click", close);
overlay.querySelector("#rvCopy").addEventListener("click", async (e) => {
try { await navigator.clipboard.writeText(r.secret); flash(e.currentTarget, "Copied"); }
catch { await aegisAlert("Could not reach the clipboard."); }
});
}
// Ask for the PIN if the configured policy says this event needs it. The
// host decides — the panel reloads whenever it is hidden and shown, so it
// cannot be what remembers that a gate was already cleared.
//
// Returns true to proceed, false if the user cancelled or failed. A passing
// check is recorded, which is what stops Aegis asking again until one of the
// user's triggers fires. Any failure to reach the host is treated as "ask",
// since the safe direction for a lock is closed.
async function pinGate(event, subtitle) {
let st;
try { st = await S.invoke("pinGateStatus", { event }); }
catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return true;
// verifyPinInteractively resolves to what the PIN unwrapped — the vault
// master password — and the host checks that itself before it records the
// gate or lets a transaction through. A PIN the host could not verify
// does not count, so a failure here is a failed gate.
const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (!proof) return false;
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); }
catch { return false; }
return true;
}
// A dapp transaction is waiting on the PIN ("ask on every transaction").
// The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
// and the pending transaction goes through.
async function answerPinRequest(req) {
if (!req || pinGateBlocked) return;
const where = String(req.origin || "A site").slice(0, 80);
const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
if (!proof) return;
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* host keeps waiting, then refuses */ }
}
const PIN_GATE_COPY = {
restart: "Theseus restarted — confirm your PIN to use this wallet.",
launch: "Confirm your PIN to open the wallet.",
interval: "It has been a while — confirm your PIN to carry on.",
transaction: "Confirm this transaction with your PIN.",
};
// Gate the panel itself on load. Runs once per panel script load, which is
// also once per hide/show, so "on each wallet launch" is simply this check
// with that trigger enabled.
//
// This must finish BEFORE the first render(). Asking afterwards was the
// original complaint in a new costume: the wallet painted, balances and all,
// and the pad then appeared on top of a panel the user could already read.
// `pinGateBlocked` keeps the chrome hidden for as long as the PIN is owed,
// so a cancelled or failed prompt leaves a closed door rather than an open
// wallet.
let pinGateChecked = false;
let pinGateBlocked = false;
async function pinGateOnLoad() {
if (pinGateChecked) return;
pinGateChecked = true;
if (!securityLoaded) await refreshSecurityState();
if (!securityState.hasPin) return;
// Nothing to protect yet if the vault is locked — the master-password gate
// is already in the way, and stacking a PIN prompt on top of it is two
// locks on one door.
if (state && (state.overallPhase === "locked" || state.overallPhase === "nosetup")) return;
let st;
try { st = await S.invoke("pinGateStatus", { event: "panel-load" }); }
catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return;
pinGateBlocked = true;
paintPinDoor(st.reason);
// Keep asking until it is satisfied. Cancelling does not open the wallet;
// it leaves the door shut with a button to try again, which is the only
// honest outcome for "a PIN is required here".
let proof = null;
for (;;) {
proof = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (proof) break;
const again = await aegisConfirm({
title: "PIN required",
icon: "🔒",
confirmLabel: "Enter PIN",
cancelLabel: "Leave locked",
body: "Aegis stays locked until the PIN is entered. You can also unlock with your master password from Settings.",
});
if (!again) return; // stays blocked; door remains shut
}
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* re-asks next load */ }
pinGateBlocked = false;
render();
}
// The closed door shown behind the pad, so there is never a moment where the
// wallet is readable but unauthenticated.
function paintPinDoor(reason) {
const g = $("gate");
if (!g) return;
document.querySelector("header").hidden = true;
document.querySelector("nav").hidden = true;
const strip = $("walletStrip"); if (strip) strip.hidden = true;
const ls = $("lockScreen"); if (ls) ls.hidden = true;
g.hidden = false;
g.innerHTML = `<div class="big">🔒</div><div>${esc(PIN_GATE_COPY[reason] || "Confirm with your PIN.")}</div>`;
}
// How many wrong PINs before a sensitive reveal stops asking for the PIN and
// asks for the master password instead. Lower than the host's limit (5) on
// purpose: someone fumbling their own PIN gets a way through before the PIN
// is switched off, and someone guessing is pushed onto the credential that
// is actually hard to guess. The host counter is NOT reset on the way
// across, so guesses still accumulate toward that limit.
const REVEAL_PIN_MAX_FAILS = 3;
// Prove entitlement to see a secret, and hand back the master password —
// which is what the host verifies before it parts with anything. The PIN blob
// wraps that same password, so both routes end at the same proof and the host
// never has to take the panel's word for it.
//
// Returns the master password, or null if the user backed out.
async function authorizeForSecret(subtitle) {
if (!securityLoaded) await refreshSecurityState();
const usePin = securityState.requirePinForReveal && securityState.hasPin;
if (!usePin) {
// No PIN configured, or the user turned the PIN prompt off. Either way
// the master password is the gate — never nothing.
return promptMasterPassword({ title: "Confirm master password", subtitle });
}
if (await pinNeedsMaster()) {
// The PIN is switched off after too many wrong guesses, but the password
// is a separate credential: the strikes stop PIN guessing, they do not
// lock the owner out.
return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
}
const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled
if (pin === "__fallback__") {
return promptMasterPassword({
title: "Confirm master password",
subtitle: `${REVEAL_PIN_MAX_FAILS} wrong PIN attempts. ${subtitle || ""}`.trim(),
});
}
return pin;
}
// PIN pad that resolves with the DECRYPTED MASTER PASSWORD on success, null
// on cancel, or "__fallback__" once the user has burned REVEAL_PIN_MAX_FAILS
// attempts. Separate from verifyPinInteractively because that one only
// answers yes/no and throws the password away.
function capturePinForSecret(subtitle) {
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
<div class="pincard">
<h2>Confirm with PIN</h2>
<div class="pinsub" id="rsSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="rsDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pinkeys" id="rsKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
<button data-k="0">0</button>
<button class="util" data-k="back">⌫</button>
</div>
<div class="pinerr" id="rsErr"></div>
</div>
<div class="pinactions">
<button class="btn" id="rsCancel" type="button">Cancel</button>
<button class="btn" id="rsUsePw" type="button">Use master password</button>
</div>
</div>`;
document.body.appendChild(wrap);
const done = (v) => { try { wrap.remove(); } catch {} resolve(v); };
wrap.querySelector("#rsCancel").addEventListener("click", () => done(null));
wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__"));
let tries = 0;
setupPinPad({
dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
onComplete: async (pin) => {
let r;
try { r = await pinTry(pin); }
catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
if (r.ok) { done(r.masterPassword); return "ok"; }
// Every guess here also counts toward the host's limit.
tries++;
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
},
});
});
}
// Ask the user to prove they know the PIN. Uses the same lockout counter
// as the unlock flow so an attacker can't drain guesses via a spammed
// Send button. Returns true on match, false on cancel / lockout / bad PIN.
// Only one PIN prompt at a time. Two concurrent prompts are asking the same
// question, so the second joins the first instead of stacking a second pad
// over it — which is also how duplicate pad ids came to collide.
let pinPromptInFlight = null;
function verifyPinInteractively(subtitle) {
if (pinPromptInFlight) return pinPromptInFlight;
pinPromptInFlight = verifyPinInteractivelyOnce(subtitle)
.finally(() => { pinPromptInFlight = null; });
return pinPromptInFlight;
}
async function verifyPinInteractivelyOnce(subtitle) {
// The PIN is off after too many wrong guesses; the master password is the
// same proof (it is what the PIN unwraps), and the host checks it.
if (await pinNeedsMaster()) {
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
return pw || false;
}
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
<div class="pincard">
<h2>Confirm with PIN</h2>
<div class="pinsub" id="vpSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="vpDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pinkeys" id="vpKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
<button data-k="0">0</button>
<button class="util" data-k="back">⌫</button>
</div>
<div class="pinerr" id="vpErr"></div>
</div>
<div class="pinactions">
<button class="btn" id="vpCancel" type="button">Cancel</button>
</div>
</div>`;
document.body.appendChild(wrap);
const done = (v) => { try { wrap.remove(); } catch {} resolve(v); };
wrap.querySelector("#vpCancel").addEventListener("click", () => done(false));
setupPinPad({
dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
onComplete: async (pin) => {
let r;
try { r = await pinTry(pin); }
catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; }
// The unwrapped master password is truthy for every existing caller;
// the gate hands it to the host as proof (see pinGate).
if (r.ok) { done(r.masterPassword || true); return "ok"; }
$("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
if (r.requireMaster) {
setTimeout(async () => {
try { wrap.remove(); } catch {}
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY });
resolve(pw || false);
}, 1200);
return "ok";
}
return "reset";
},
});
});
}
function renderPricesSetting() {
const p = state?.prices;
const toggle = $("pricesToggle");
if (!toggle) return;
toggle.checked = !!p?.enabled;
$("refreshPrices").hidden = !p?.enabled;
const st = $("pricesStatus");
const sourcesEl = $("pricesSources");
const paintStatus = () => {
if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; }
if (p.loading) { st.textContent = "Fetching…"; return; }
if (p.error) { st.textContent = "Error: " + p.error; return; }
if (p.fetchedAt) {
const secs = Math.round((Date.now() - p.fetchedAt) / 1000);
const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`;
st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`;
return;
}
st.textContent = "Enabled — first fetch pending.";
};
paintStatus();
// Per-source status: name → up/down + last fetch age. Renders even when
// disabled so users can see WHICH oracles will be polled once they flip
// the switch. On a down source we surface the error text.
if (sourcesEl) {
const sources = Array.isArray(p?.sources) ? p.sources : [];
const status = p?.sourceStatus || {};
if (!sources.length) { sourcesEl.innerHTML = ""; }
else {
const rows = sources.map((s) => {
const st = status[s.id];
let tag = `<span style="color:var(--dim)">idle</span>`;
if (st) {
if (st.ok) {
const covers = Object.keys(st.prices || {}).length;
const age = Math.round((Date.now() - (st.at || Date.now())) / 1000);
tag = `<span style="color:var(--acid, #d6ff3d)">✓ ${covers} coin${covers === 1 ? "" : "s"}${age > 5 ? ` · ${age < 60 ? age + "s" : Math.round(age / 60) + "m"}` : ""}</span>`;
} else {
tag = `<span style="color:#f6768a" title="${esc(st.error || "")}">⚠ down</span>`;
}
}
return `<div style="display:flex;justify-content:space-between;gap:8px;padding:2px 0"><span>${esc(s.label)} <span style="color:var(--dim)">· ${esc(s.origin)}</span></span>${tag}</div>`;
});
sourcesEl.innerHTML = rows.join("");
}
}
}
// "Who can see the wallet". The list is the sites the user enabled by hand;
// sites with a saved connection stay visible regardless and are listed under
// Connected sites above.
async function renderInjectPolicy() {
const box = $("injectOnlyAllowed");
if (!box) return;
let st;
try { st = await S.invoke("injectPolicyGet"); } catch { return; }
box.checked = st.mode === "allowed";
box.disabled = !st.supported;
$("injectUnsupported").hidden = !!st.supported;
$("injectAllowBox").hidden = !(st.supported && st.mode === "allowed");
const list = $("injectSites");
list.innerHTML = (st.origins || []).length
? st.origins.map((o) => `<div class="k" style="overflow-wrap:anywhere">${esc(o)}</div><div class="v"><button class="btn sm" data-injremove="${esc(o)}" type="button">Remove</button></div>`).join("")
: `<div class="hint">No sites enabled yet. Open the site in a tab, then press the button above.</div>`;
list.querySelectorAll("[data-injremove]").forEach((b) => b.addEventListener("click", async () => {
try { await S.invoke("injectRemoveSite", { origin: b.dataset.injremove }); } catch {}
renderInjectPolicy();
}));
}
if ($("injectOnlyAllowed")) {
$("injectOnlyAllowed").addEventListener("change", async (e) => {
try { await S.invoke("injectPolicySet", { mode: e.target.checked ? "allowed" : "all" }); }
catch (err) { e.target.checked = !e.target.checked; aegisAlert("Could not save setting: " + cleanErr(err)); }
renderInjectPolicy();
});
$("injectEnableCurrent").addEventListener("click", async () => {
const msg = $("injectMsg");
try {
const r = await S.invoke("injectAllowSite", {});
msg.className = "msg ok"; msg.textContent = `Enabled on ${r.origin}. Reload that page to use the wallet there.`;
} catch (err) { msg.className = "msg err"; msg.textContent = cleanErr(err); }
msg.hidden = false;
renderInjectPolicy();
});
}
async function renderSites() {
renderInjectPolicy();
let perms = {};
try { perms = await S.invoke("permissions"); } catch {}
const origins = Object.keys(perms).filter((o) => {
const p = perms[o];
return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress) || (p.eth && p.eth.readAddress) || (p.sol && p.sol.readAddress));
});
const el = $("sites");
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
el.innerHTML = origins.map((o) => {
const p = perms[o]; const what = [];
if (p.readAddress) what.push("BCH address");
if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`);
if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address");
if (p.eth && p.eth.readAddress) what.push("EVM address" + (p.eth.chainId ? ` (chain ${p.eth.chainId})` : ""));
if (p.sol && p.sol.readAddress) what.push("Solana address");
// A plain "Connect" lasts until Theseus restarts; only "Always allow" is stored.
if (p.session) what.push("this session only");
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
}).join("");
el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => {
try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {}
}));
}
$("applySettings").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
const path = $("setPath").value.trim();
if (path && path !== (sel().accountPath || "")) {
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path });
}
// Server list is now saved on-checkbox-tick via saveServerCheckboxes(),
// so Apply doesn't need to re-collect. Still refresh the pane so any
// path change reflects immediately.
settingsFilled = false; fillSettings(); render();
flash($("applySettings"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
// Preset BCH mainnet Electrum servers users are likely to have heard of.
// Kept in sync with chain-bch.js's defaultServers so a fresh install with no
// custom pick behaves like this list. Order = suggested-priority.
const BCH_KNOWN_SERVERS = [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
"wss://electroncash.dk:50004",
"wss://fulcrum.jettscythe.xyz:50004",
];
function renderServerCheckboxes() {
const el = $("setServersList"); if (!el) return;
// The current list is the union of user-picked + presets; distinguish so we
// can render "custom" rows with a remove button while presets stay stable.
const current = new Set((state?.bchServers?.list || []).map(String));
const rows = [];
for (const url of BCH_KNOWN_SERVERS) {
rows.push({ url, checked: current.has(url), custom: false });
}
// Any picked URL that isn't in the presets list is treated as user-added.
for (const url of current) {
if (!BCH_KNOWN_SERVERS.includes(url)) rows.push({ url, checked: true, custom: true });
}
el.innerHTML = rows.map((r) => `<label>
<input type="checkbox" data-server="${esc(r.url)}" ${r.checked ? "checked" : ""}>
<span class="surl">${esc(r.url)}</span>
${r.custom ? `<button class="sremove" data-remove="${esc(r.url)}" title="Remove custom server">✕</button>` : ""}
</label>`).join("");
el.querySelectorAll("input[type=checkbox]").forEach((cb) => cb.addEventListener("change", saveServerCheckboxes));
el.querySelectorAll("[data-remove]").forEach((b) => b.addEventListener("click", async (e) => {
e.preventDefault();
const list = collectServerCheckboxes().filter((u) => u !== b.dataset.remove);
try { state = await S.invoke("setBchServers", { servers: list }); settingsFilled = false; fillSettings(); render(); }
catch (er) { $("settingsMsg").textContent = cleanErr(er); $("settingsMsg").hidden = false; }
}));
}
function collectServerCheckboxes() {
const el = $("setServersList");
if (!el) return [];
return [...el.querySelectorAll("input[type=checkbox]")]
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.server);
}
async function saveServerCheckboxes() {
const list = collectServerCheckboxes();
try {
state = await S.invoke("setBchServers", { servers: list });
// Don't rebuild the whole settings pane on every checkbox tick — just
// refresh the hint line so the "connected to …" text stays current.
if (state?.selected?.chain === "bch") {
const s = state.selected;
$("serverHint").textContent = (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
}
} catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
}
$("addCustomServer").addEventListener("click", async () => {
const input = $("setServersCustom");
const url = input.value.trim();
if (!/^wss?:\/\/[^/\s]+$/i.test(url)) {
$("settingsMsg").textContent = "Server must look like wss://host:port"; $("settingsMsg").hidden = false; return;
}
const list = [...new Set([...collectServerCheckboxes(), url])];
try {
state = await S.invoke("setBchServers", { servers: list });
input.value = "";
settingsFilled = false; fillSettings(); render();
} catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
$("resetServers").addEventListener("click", async () => {
try { state = await S.invoke("setBchServers", { servers: [] }); settingsFilled = false; fillSettings(); render(); }
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
$("renameBtn").addEventListener("click", async () => {
const label = $("renameLabel").value.trim();
if (!label) return;
try { state = await S.invoke("renameWallet", { id: state.selectedWalletId, label }); render(); flash($("renameBtn"), "Renamed"); }
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
$("removeBtn").addEventListener("click", async () => {
const s = sel(); if (!s || s.isLegacy) return;
const ok = await aegisConfirm({
title: `Remove "${s.label}"?`,
danger: true,
confirmLabel: "Remove wallet",
body: "The on-chain address stays exactly where it is; the wallet is only unlinked from Aegis.<br><br>You can add it back later by creating a new wallet on the same coin + network.",
});
if (!ok) return;
try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); }
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
$("showXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); }
catch (e) { $("recovery").textContent = cleanErr(e); }
});
// Every "show the secret" button goes through the one gated path. They used
// to call recovery({reveal:true}), which handed back the xprv behind nothing
// but an approval click and refused imported wallets outright.
$("showXprv").addEventListener("click", () => openRevealSecretModal(sel()));
function recoveryHtml(r) {
let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`;
if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`;
return h;
}
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => {
if (b.dataset.tab !== "settings") {
$("recovery").innerHTML = "";
$("scRecovery").innerHTML = "";
$("dgbRecovery").innerHTML = "";
$("btcRecovery").innerHTML = "";
$("ethRecovery").innerHTML = "";
$("solRecovery").innerHTML = "";
}
}));
// Sia-specific settings.
$("applyWalletdUrl").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setWalletdUrl", { id: state.selectedWalletId, walletdUrl: $("setWalletdUrl").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("showScSeed").addEventListener("click", () => openRevealSecretModal(sel()));
// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc":
// the DOM IDs are #set<Prefix>Family + #set<Prefix>Path.
function fillFamilyPicker(prefix, s) {
const families = s.meta?.addressFamilies || [];
const current = String(s.accountPath || "");
let currentId = families.find((f) => f.defaultAccountPath === current)?.id;
if (!currentId) {
const m = /^m\/(\d+)'/.exec(current);
const purpose = m ? Number(m[1]) : null;
currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id;
}
$(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || "";
$(`set${prefix}Family`).innerHTML = families.map((f) =>
`<option value="${esc(f.id)}" data-path="${esc(f.defaultAccountPath)}" ${f.id === currentId ? "selected" : ""}>${esc(f.label)}</option>`
).join("");
}
// Any family select → auto-fill the sibling path input.
document.addEventListener("change", (e) => {
const t = e.target;
if (!t) return;
const m = /^set(Dgb|Btc)Family$/.exec(t.id || "");
if (!m) return;
const opt = t.options[t.selectedIndex];
if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path;
});
$("applyDgbPath").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setDgbPath").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("applyBtcPath").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("showBtcXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
});
$("showBtcXprv").addEventListener("click", () => openRevealSecretModal(sel()));
// ETH / SOL: RPC URL.
$("applyEthRpc").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setEthRpcUrl").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applyEthRpc"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("applySolRpc").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setSolRpcUrl").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("showEthKey").addEventListener("click", () => openRevealSecretModal(sel()));
$("showSolKey").addEventListener("click", () => openRevealSecretModal(sel()));
$("showDgbXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
});
$("showDgbXprv").addEventListener("click", () => openRevealSecretModal(sel()));
// ---- WizardConnect (BCH only) ---------------------------------------------
function renderWcSites() {
const el = $("wcSites"); if (!el) return;
const walletId = state?.selectedWalletId;
const conns = (state?.wc && state.wc[walletId]) || [];
if (!conns.length) { el.innerHTML = `<div class="hint">No dapps paired yet.</div>`; return; }
el.innerHTML = conns.map((c) => {
const label = c.dappName || c.label || "(pairing…)";
const iconHtml = c.dappIcon ? `<img src="${esc(c.dappIcon)}" style="width:18px;height:18px;border-radius:4px" onerror="this.hidden=true">` : "";
return `<div class="tx" style="grid-template-columns:auto 1fr auto;cursor:default;align-items:center">
<div>${iconHtml}</div>
<div><div>${esc(label)} ${wcStatusTag(c.status)}</div><div class="hint mono">${esc((c.uri || "").slice(0, 46))}…</div></div>
<button class="btn sm" data-wcconn="${esc(c.id)}">Disconnect</button>
</div>`;
}).join("");
el.querySelectorAll("button[data-wcconn]").forEach((b) => b.addEventListener("click", async () => {
try { state = await S.invoke("wcDisconnect", { walletId, connId: b.dataset.wcconn }); render(); }
catch (e) { const m = $("wcMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; }
}));
}
$("wcConnectBtn").addEventListener("click", async () => {
const walletId = state?.selectedWalletId;
const uri = $("wcUri").value.trim();
const m = $("wcMsg"); m.hidden = true;
if (!uri) return;
try {
state = await S.invoke("wcConnect", { walletId, uri });
$("wcUri").value = "";
m.className = "msg ok"; m.textContent = "Pairing…"; m.hidden = false;
render();
} catch (e) {
m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
}
});
// ---- prices toggle ---------------------------------------------------------
$("pricesToggle").addEventListener("change", async () => {
const on = $("pricesToggle").checked;
try {
state = await S.invoke("setPricesEnabled", { enabled: on });
render(); if (tab === "settings") renderPricesSetting();
} catch (e) {
// Roll the checkbox back if the host rejected the change.
$("pricesToggle").checked = !on;
$("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false;
}
});
// pricesSource select is a hidden legacy element in 0.6.36+ — the picker
// was removed when pricing moved to multi-source majority-rule. No change
// handler needed; kept the DOM node so panel.js code that reads .value
// doesn't NPE mid-migration.
$("refreshPrices").addEventListener("click", async () => {
try {
await S.invoke("refreshPrices");
// The host emits a state event on completion; the render will pick it up.
renderPricesSetting();
} catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
// ---- boot ------------------------------------------------------------------
S.on("state", (s) => {
state = s;
render();
// render() returns early while a PIN is owed; don't fill a hidden Settings
// tab behind the door either.
if (tab === "settings" && !pinGateBlocked) fillSettings();
});
S.on("pinRequest", (req) => { answerPinRequest(req); });
(async () => {
// Load security + session state first so the very first render() knows
// whether to paint the PIN pad on the lock screen and what idle-lock
// timer to arm once the vault is open.
try { await refreshSecurityState(); } catch {}
try { await refreshSessionState(); } catch {}
try { state = await S.invoke("state"); }
catch (e) {
$("gate").hidden = false;
$("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`;
return;
}
// Settle the PIN BEFORE the first paint. Awaited on purpose: rendering the
// wallet first and prompting afterwards is exactly the behaviour being
// fixed, and a fire-and-forget call also let a second prompt open on top
// of this one.
await pinGateOnLoad();
render();
bindIdleAutoLock();
// Opened because a dapp transaction is waiting for the PIN? Answer it.
try { answerPinRequest(await S.invoke("pinRequestPending")); } catch {}
})();
// Persistent footer: aegis.x brand link + version marker + update check.
// The check button hits the OTA manifest and compares versions client-side;
// when a newer one is advertised, the pill turns into an "Update to vX.Y.Z"
// chip. Clicking that chip fires the addon-message "requestUpdate" which
// runs the same check + apply flow used by Settings > Extensions > Aegis
// (falls back to opening Settings for pre-0.3.47 Theseus that lacks the
// panel-facing apply path).
const OTA_URL = "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json";
let footerCurrentVer = null;
let footerLatestKnown = null;
function cmpSemver(a, b) {
const pa = String(a || "0").split(".").map((n) => Number(n) || 0);
const pb = String(b || "0").split(".").map((n) => Number(n) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d < 0 ? -1 : 1;
}
return 0;
}
// Track whether the last check was manual. Auto-checks stay silent when
// nothing new is available; manual clicks always get a visible reply so
// the ↻ button never feels dead when the user is already current.
let footerLastCheckManual = false;
function paintFooterUpdate() {
const el = $("brandUpdate");
const verEl = $("brandVer");
if (!el) return;
// 0.8.1: the update chip and version marker share the same slot at the
// bottom-right. When a newer build is available the chip takes over the
// slot and the raw "v0.8.0" marker hides; when the check flashes "up to
// date" the chip briefly steals it back; otherwise the version marker
// is the resting state and the chip stays hidden. showVer/hideVer are
// no-ops when brandVer isn't in the DOM so the render is idempotent.
const showVer = () => { if (verEl) verEl.hidden = false; };
const hideVer = () => { if (verEl) verEl.hidden = true; };
if (!footerCurrentVer || !footerLatestKnown) { el.hidden = true; showVer(); return; }
if (cmpSemver(footerLatestKnown, footerCurrentVer) > 0) {
el.hidden = false;
el.className = "brandupd";
el.textContent = "↑ v" + footerLatestKnown;
el.title = "Aegis v" + footerLatestKnown + " is available — click to install";
el.style.cursor = "pointer";
el.onclick = () => triggerFooterUpdate();
hideVer();
return;
}
// At-or-past latest: silent on auto-check (unobtrusive), transient
// "up to date" flash on manual so the ↻ click has visible feedback.
if (footerLastCheckManual) {
el.hidden = false;
el.className = "brandupd brandok";
el.textContent = "✓ Up to date";
el.title = "Aegis v" + footerCurrentVer + " is the latest";
el.style.cursor = "default";
el.onclick = null;
hideVer();
setTimeout(() => {
if (el.classList.contains("brandok")) { el.hidden = true; showVer(); }
}, 2200);
} else {
el.hidden = true;
showVer();
}
}
async function checkFooterUpdate(opts = {}) {
const btn = $("brandCheck");
if (btn) btn.classList.add("spin");
footerLastCheckManual = !!opts.manual;
try {
// The addon frame runs under a file:// origin — fetch to https is fine,
// no CORS block since no server headers are involved for a same-origin
// request... actually addon frames CAN cross-fetch. Cache-bust with a
// per-minute query so a fresh check reflects a just-published manifest.
const bust = Math.floor(Date.now() / 60_000);
const r = await fetch(OTA_URL + "?t=" + bust, { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
const j = await r.json();
const entries = Array.isArray(j?.addons) ? j.addons : [];
let best = null;
for (const e of entries) if (!best || cmpSemver(e.version, best.version) > 0) best = e;
footerLatestKnown = best?.version || null;
paintFooterUpdate();
} catch (e) {
console.warn("footer update check failed:", e?.message || e);
if (footerLastCheckManual) {
const el = $("brandUpdate");
if (el) {
el.hidden = false;
el.className = "brandupd branderr";
el.textContent = "⚠ Check failed";
el.title = String(e?.message || e);
el.style.cursor = "default";
el.onclick = null;
setTimeout(() => { if (el.classList.contains("branderr")) el.hidden = true; }, 2500);
}
}
} finally {
if (btn) btn.classList.remove("spin");
}
}
// Two-step chip flow. First click → stage the newer signed build; the
// chip's message and click handler swap to "Restart Theseus to apply".
// Second click → app.relaunch(). Both steps go through the same
// requestUpdate handler so a single Theseus IPC round-trip covers each
// leg. Falls back to opening Settings › Extensions when running under
// an older Theseus that lacks the panel-driven update hooks.
async function triggerFooterUpdate() {
const el = $("brandUpdate"); if (!el) return;
const verEl = $("brandVer");
// While the chip is doing something, the raw version marker stays
// hidden — the chip owns the slot end-to-end for the whole transaction
// so the user never sees "v0.8.0 ↑ v0.8.1" simultaneously in the
// corner. Returned to the version marker only after the flash timers
// clear (below).
if (verEl) verEl.hidden = true;
const setChip = (text, klass, title, handler) => {
el.hidden = false;
el.className = "brandupd" + (klass ? " " + klass : "");
el.textContent = text;
el.title = title || "";
el.style.cursor = handler ? "pointer" : "default";
el.onclick = handler || null;
};
const restoreVer = () => { el.hidden = true; if (verEl) verEl.hidden = false; };
try {
setChip("Staging update…", "brandwait", "Downloading + verifying the signed payload", null);
const r = await S.invoke("requestUpdate", { step: "stage" });
if (r?.fallback === "settings") {
setChip("Open Settings to update", null, "This Theseus lacks the in-panel updater — opening Settings › Extensions", () => S.invoke("openSettings", { section: "addons" }).catch(() => {}));
return;
}
if (r?.staged) {
const nextVer = r.next ? " v" + r.next : "";
setChip("↻ Restart to apply" + nextVer, null, "Aegis" + nextVer + " is staged — click to relaunch Theseus", async () => {
setChip("Restarting…", "brandwait", "", null);
try { await S.invoke("requestUpdate", { step: "apply" }); }
catch (e) { setChip("⚠ Restart failed", "branderr", String(e?.message || e), null); }
});
return;
}
const msg = r?.status === "up-to-date" ? "✓ Already up to date"
: r?.status ? "⚠ " + r.status : "⚠ Update failed";
setChip(msg, r?.status === "up-to-date" ? "brandok" : "branderr", r?.detail || "", null);
setTimeout(() => { if (el.classList.contains("brandok") || el.classList.contains("branderr")) restoreVer(); }, 2500);
} catch (e) {
console.warn("update trigger failed:", e?.message || e);
setChip("⚠ Update failed", "branderr", String(e?.message || e), null);
setTimeout(() => { if (el.classList.contains("branderr")) restoreVer(); }, 2500);
}
}
(function wireFooter() {
const link = $("brandLink"); if (!link) return;
link.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); });
const a1 = $("aboutOpenAegisSite");
if (a1) a1.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); });
const a2 = $("aboutOpenSilentmodeSite");
if (a2) a2.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://silentmode.st/"); });
// Version comes from the addon manifest; if the state message carries it
// we surface it, otherwise the slot stays empty.
S.invoke("aegisVersion").then((v) => {
const el = $("brandVer");
if (el && v) el.textContent = "v" + String(v);
footerCurrentVer = v || null;
paintFooterUpdate();
}).catch(() => {});
const check = $("brandCheck");
if (check) check.addEventListener("click", () => checkFooterUpdate({ manual: true }));
// First check on panel open — non-blocking; failures stay quiet. A user
// who never opens Settings still gets a clear update signal here.
setTimeout(() => checkFooterUpdate({ manual: false }), 500);
})();