theseus/bundled-addons/aegis/panel.js
Local Dev 4c7d8e3f56 fix(aegis): 0.9.8 — address row columns, resizable QR, inline copy
The row's grid template still described the pre-0.9.2 layout — six
columns including an address cell that no longer exists — while the row
renders five. Every cell therefore sat one column left of where it
belonged: copy landed in the label's space, the amount in the old label
column, and the ⋯ in the amount column instead of the edge. That is the
whole cause of "the copy button collides with the amount" and "the last
edit button is not on the edge".

Now five columns for five cells: icon · label · copy · amount · menu,
8px gaps. The label is the flexible one, left-aligned, so it takes the
slack and truncates rather than squeezing the number. Verified at 520,
400 and 300px: no cell overlap at any width, no row overflow, and the
amount never clips — only long labels give way.

The copy glyph was the 📋 emoji, which has no glyph in this platform's
font stack and rendered as a tofu box that read like a stray character
stuck to the balance. Replaced with an inline SVG in both the row and
the address card. Both confirmation flashes now swap innerHTML rather
than textContent, which would have deleted the SVG and left a blank
square.

Address card: the address and its copy button share one row, so the
button sits at the end of the value it copies. The address clamps to two
lines and truncates beyond that instead of growing the card in a narrow
sidebar.

The QR is always visible and the panel is drag-resizable from a grip
under it (pointer events, arrow keys as a non-mouse path, clamped
90–420px, capped against the panel's own width so a size set on a wide
sidebar cannot overflow a narrow one, persisted). That replaces the
0.9.2 show/hide toggle — a size set once beats a binary, and it frees
the row Copy was sharing with a QR button.

drawQr was setting cv.style.width/height, which would have reset the
panel to its intrinsic size on every redraw — i.e. every time the
address changed. It now sets only the backing store and re-asserts the
user's size after drawing.

"Next unused address" is correct at the adapter level on both mainnet
and chipnet (tested: the index advances and the address changes), so the
reported failure is elsewhere. The handler was discarding the error and
flashing a bare "Failed", which is why there was nothing to diagnose; it
now surfaces the real message.
2026-09-24 22:30:10 +02:00

4605 lines
235 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;
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 }). Populated on
// startup and refreshed after any pin/security invoke — used both by the
// lock screen (PIN vs. password) and the Settings General card.
let securityState = { hasPin: false, requirePinForSending: false };
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");
}
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 encryption + verification (WebCrypto) -------------------
// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters)
// derives an AES-GCM key; the master password is encrypted with a fresh
// per-blob IV. The addon (main process) only handles the opaque blob; the
// panel never sends the raw PIN or the master password to it. The rate
// limiter is stored addon-side so reloading the panel cannot reset it.
const PIN_ITERS = 200000;
const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; };
async function pinDeriveKey(pin, saltBytes, iters) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function pinEncryptMaster(pin, masterPassword) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await pinDeriveKey(pin, salt, PIN_ITERS);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword)));
return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS };
}
async function pinDecryptMaster(pin, blob) {
const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct));
return new TextDecoder().decode(pt);
}
async function pinLockoutRemainingMs() {
try {
const s = await S.invoke("pinFailStatus");
if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0;
const since = Date.now() - (s.last || 0);
return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since);
} catch { return 0; }
}
async function refreshSecurityState() {
try {
securityState = await S.invoke("securityGet");
securityLoaded = true;
} catch { securityState = { hasPin: false, requirePinForSending: false }; 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");
stripView = { mode: "coins", groupKey: null };
render();
} 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.
$("hNet").addEventListener("click", (e) => {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === state?.selectedWalletId);
if (!w) return;
pickerTab = "browse:" + w.chain;
const d = $("drop");
positionDropBelowTabs(d);
d.hidden = false;
fillPicker();
});
// + 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") { e.stopPropagation(); finish(true); }
};
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");
}));
const yes = overlay.querySelector('[data-ac="yes"]');
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);
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 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 });
}
close();
fillPicker();
render();
} 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: [
{ id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/1'/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" },
],
},
};
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">Mnemonic (12/24 words)</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>
<input type="text" id="imPath" spellcheck="false" placeholder="m/…">
<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);
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));
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", updateFormatFields));
updatePathDefault(true);
updateFormatFields();
}
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 = "";
}
}
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 (!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 (!securityLoaded) await refreshSecurityState();
if (securityState.requirePinForSending && securityState.hasPin) {
const ok = await verifyPinInteractively(`Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`);
if (!ok) { 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 });
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 (!securityLoaded) await refreshSecurityState();
if (securityState.requirePinForSending && securityState.hasPin) {
const ok = await verifyPinInteractively(`Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`);
if (!ok) { 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 });
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); }
}
}
function paintRcvMode() {
const normal = $("rcvNormal"); const cons = $("rcvConsolidate");
if (!normal || !cons) return;
const buttons = document.querySelectorAll("[data-rcv-mode]");
buttons.forEach((b) => b.classList.toggle("on", b.dataset.rcvMode === rcvMode));
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 options = readyBch.map((w) => `<option value="${esc(w.id)}" ${!w.wcBlocked && w.id === state.selectedWalletId ? "selected" : ""} ${w.wcBlocked ? "disabled" : ""}>${esc(w.label)} · ${esc(w.networkLabel)}${w.wcBlocked ? " — can't pair" : ""}</option>`).join("");
const blockedNote = (!pairable.length && readyBch.length)
? `<div class="msg err" style="margin-bottom:8px">${esc(readyBch[0].wcBlocked)}</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) {
msg.textContent = res?.origin
? `No wiz:// pairing code found on ${res.origin}. Open the dapp's Connect dialog first, then scan again.`
: "No pairing code found on the open tab.";
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>`;
}
// ▾ chevron shown only when the chain has more than one network —
// otherwise the click affordance would be a lie and the ticker acts
// like plain text.
const multiNet = nets.length > 1;
const chevron = multiNet ? `<span class="wchev" aria-hidden="true">▾</span>` : "";
const nameCls = multiNet ? "wcname wswitchable" : "wcname";
const nameTitle = multiNet
? `Switch network — ${nets.map((n) => networkLabelFor(chain, n, n)).join(" / ")}`
: 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>`;
return `<div class="warow ${on}" data-listpick="${esc(w.id)}" title="${esc(w.label || fullAddr)}">
<span class="wcell">${logoSvg(meta.logo, 14)}</span>
<span class="walabel walabel-wide" title="${esc(w.label || "")}">${esc(w.label || shortName || "—")}</span>
${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">
<button class="wback" id="stripBack" title="Back to coin list">← Back</button>
<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>
<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}
${rows}
<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("#stripBack").addEventListener("click", back);
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 = "…";
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: " + failed.map((f) => f.error).join("; ");
else refreshBtn.title = "Refresh balances now";
} catch (e) {
refreshBtn.title = "Refresh failed: " + (e?.message || e);
} finally {
refreshBtn.textContent = prev;
delete refreshBtn.dataset.spinning;
}
});
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;
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";
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) {
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: $("lockPinDots"),
keys: $("lockPinKeys"),
err: $("lockPinErr"),
onComplete: async (pin) => {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
$("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`;
return "reset";
}
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("PIN not set");
const pw = await pinDecryptMaster(pin, blob);
state = await S.invoke("vaultUnlock", { masterPassword: pw });
await S.invoke("pinFailReset");
render();
return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("lockPinErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min — use the master password instead.`;
return "reset";
}
},
});
$("lockUsePw").addEventListener("click", () => { body.dataset.forcePw = "1"; renderLockScreen("locked"); });
if ($("lockGoSettingsFromPin")) $("lockGoSettingsFromPin").addEventListener("click", () => showTab("settings"));
return;
}
// Master-password entry.
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);
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.
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);
if (res !== "ok") { buf = ""; paint(); }
}
}
}));
// Keyboard fallback — some users prefer typing 6 digits fast.
const keyHandler = async (e) => {
if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); 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);
if (res !== "ok") { 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;
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";
$("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.8.4: hNet is a chip on its own row. Show only the network name
// (coin name is already in hLabel above); hide the row when we don't
// have any wallet-context to describe yet.
if (s?.meta) {
$("hNet").innerHTML = `${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}`;
$("hNetRow").hidden = false;
} else {
$("hNet").innerHTML = "";
$("hNetRow").hidden = true;
}
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) {
$("balMain").textContent = "—"; $("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);
$("balMain").textContent = 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 {
$("balMain").textContent = "—"; $("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;
for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvModeToggle", "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();
}
// 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}`;
}
function renderTokens() {
const s = sel();
const card = $("tokensCard");
const el = $("tokensList");
// SOL wallets: SPL tokens with a Send button (existing flow).
if (chain() === "sol") {
const tokens = s?.tokens || [];
card.hidden = tokens.length === 0;
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 cats = Object.keys(balances);
card.hidden = cats.length === 0;
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings.";
if (!cats.length) return;
const draw = (metaMap) => {
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 nftCount = Array.isArray(b.nfts) ? b.nfts.length : 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 nftTxt = nftCount ? `${nftCount} NFT${nftCount === 1 ? "" : "s"}` : "";
const amountLine = [fungibleTxt, nftTxt].filter(Boolean).join(" · ") || "—";
const nameLine = name ? `${iconHtml}<b>${esc(name)}</b>${symbol && name !== symbol ? ` <span class="hint">${esc(symbol)}</span>` : ""}` : `${iconHtml}<span class="mono hint">${esc(catShort)}</span>`;
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div><div>${nameLine}</div><div class="hint mono">${esc(catShort)}</div></div>
<div class="amt2 in" style="align-self:center;text-align:right">${esc(amountLine)}</div>
</div>`;
}).join("");
};
// 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({});
S.invoke("tokenMetadata", { categories: cats }).then((res) => {
if (chain() !== "bch") return; // user switched away mid-fetch
draw(res || {});
}).catch(() => {});
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) { card.hidden = true; return; }
card.hidden = false;
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 : "");
}
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();
if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s);
updateSendFiatPreview();
}
function amountUnits() {
const raw = $("sendAmt").value.trim().replace(/,/g, "");
if (!raw) return 0;
// For SPL tokens the amount is a raw u64 string in the token's own
// smallest unit — same BigInt-safe path SC uses.
const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals();
const bigDecimals = sendAsset != null || d > 15;
if (unit === "small") {
if (bigDecimals) return raw.replace(/\D+/g, "") || "0";
return Math.round(Number(raw));
}
const [w, f = ""] = raw.split(".");
const frac = (f + "0".repeat(d)).slice(0, d);
if (bigDecimals) {
const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
return total.toString();
}
return Number(w || 0) * Math.pow(10, d) + Number(frac || 0);
}
// 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", () => {
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();
}));
document.querySelectorAll("[data-rcv-mode]").forEach((b) => b.addEventListener("click", () => {
rcvMode = b.dataset.rcvMode;
consolidateInlineHost = null;
paintRcvMode();
}));
["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 || !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() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); }
async function updatePlan() {
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
if (!to || (!sendMax && !amountUnits())) return;
try {
if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals.
const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() });
lastPlan = { _token: true, ...p };
$("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 feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "";
const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax, memo });
lastPlan = p;
$("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" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
if (!lastPlan) return;
const msg = $("sendMsg"); msg.hidden = true;
// PIN approval gate: when the user has opted into "Require PIN for
// sending", panel-initiated sends must clear a PIN check before the
// approval overlay even shows. Cancel if PIN check fails.
if (!securityLoaded) await refreshSecurityState();
if (securityState.requirePinForSending && securityState.hasPin) {
const ok = await verifyPinInteractively("Confirm this send with your PIN.");
if (!ok) { 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 = sendAsset && lastPlan._token;
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "";
const r = isToken
? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() })
: await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax, memo });
msg.className = "msg ok";
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));
msg.hidden = false;
$("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");
const line = $("gsRequirePinLine"), rp = $("gsRequirePin");
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.";
if (line) line.hidden = !hasPin;
if (rp) rp.checked = !!securityState.requirePinForSending;
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({
dots: $("pmDots"), keys: $("pmKeys"), err: $("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");
// Also disable the send-time PIN policy — it depends on having a PIN.
await S.invoke("securitySet", { requirePinForSending: false });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) { aegisAlert("Could not remove PIN: " + cleanErr(e)); }
});
$("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => {
const on = $("gsRequirePin").checked;
try {
securityState = await S.invoke("securitySet", { requirePinForSending: on });
renderGeneralSecurity();
} catch (e) {
$("gsRequirePin").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 {
const blob = await pinEncryptMaster(pin, masterPw);
await S.invoke("pinBlobSet", { blob });
await S.invoke("pinFailReset").catch(() => {});
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 {}
});
}
// 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.
async function verifyPinInteractively(subtitle) {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`);
return 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: $("vpDots"), keys: $("vpKeys"), err: $("vpErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured");
await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {});
done(true);
return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("vpErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min.`;
if (left === 0) { done(false); 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("");
}
}
}
async function renderSites() {
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));
});
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");
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); }
});
$("showXprv").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); }
catch (e) { $("recovery").textContent = cleanErr(e); }
});
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", async () => {
try {
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("scRecovery").innerHTML =
`<div class="lbl">First address (index 0)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Wallet seed (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("scRecovery").textContent = cleanErr(e); }
});
// 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", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
});
// 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", async () => {
try {
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("ethRecovery").innerHTML =
`<div class="lbl">Address</div><div class="mono">${esc(sel().address || "")}</div>` +
`<div class="lbl">Public key (uncompressed hex)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Private key (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("ethRecovery").textContent = cleanErr(e); }
});
$("showSolKey").addEventListener("click", async () => {
try {
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("solRecovery").innerHTML =
`<div class="lbl">Address (public key, base58)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Wallet seed (hex, 32 bytes)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("solRecovery").textContent = cleanErr(e); }
});
$("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", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
});
// ---- 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(); if (tab === "settings") fillSettings(); });
(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"); render(); }
catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
bindIdleAutoLock();
})();
// 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);
})();