// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 ``; } if (logo === "trx") { return ``; } if (logo === "sc") { return ``; } if (logo === "dgb") { return ``; } if (logo === "btc") { return ``; } if (logo === "eth") { return ``; } if (logo === "sol") { return ``; } 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 ` `; } // Fallback = Aegis shield (rather than a "?"), so an unrecognised // registry entry still looks intentional. return logoSvg("aegis", s); } function testnetTag() { return `TEST`; } // 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 ? `
${esc(fiat)}
` : ""; const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`; const importedTag = w.kind === "imported" ? ` IMPORTED` : ""; // 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 ? `
${esc(w.accountPath)}
` : ""; const menu = w.isLegacy || w.isDefault ? `🔒` : ``; return `
${logoSvg(w.logo, 22)}
${esc(w.label)}${importedTag}
${sub}
${pathLine}
${esc(bal)}
${fiatLine}
${menu}
`; }).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 `
${logoSvg(c.logo, 22)}
${esc(c.label)}
${esc(sub)}
▸
`; }).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:" → 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 ? `
${esc(fmtFiat(usd))}
` : ""; const activeMark = ws.some((w) => w.id === state?.selectedWalletId) ? "on" : ""; ownedRows.push(`
${logoSvg(c.logo, 22)}
${esc(c.label)} · ${ws.length} wallet${ws.length === 1 ? "" : "s"}${nets.length > 1 ? ` · ${esc(netLbl)}` : ""}
${esc(c.ticker || c.chain)}
${bal}
${fiat}
`); } else { otherRows.push(`
${logoSvg(c.logo, 22)}
${esc(c.label)}
${esc(c.ticker || c.chain)}
+ Add
`); } } // 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 = `
${ownedRows.length ? `
Your wallets
${ownedRows.join("")}` : ""} ${otherRows.length ? `
Add a new wallet
${otherRows.join("")}` : ""} ${(!ownedRows.length && !otherRows.length) ? `
No coins match "${esc(q)}".
` : ""}
`; 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; // Jump straight into the create-wallet flow for that coin. The // Add pane keys off `pickerTab = "add"` and reads the target // chain from a scratch field the coin-picker fills. pickerTab = "add"; 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 ? `
${ordered.map((n) => { const on = n === active ? "on" : ""; const cnt = ws.filter((w) => w.network === n).length; const lbl = networkLabelFor(focused, n, n); return ``; }).join("")}
` : ""; 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 ? `
${esc(fmtFiat(usd))}
` : ""; const importedTag = w.kind === "imported" ? ` IMPORTED` : ""; const addrShort = w.address ? `${String(w.address).slice(0, 10)}…${String(w.address).slice(-6)}` : ""; return `
${logoSvg(w.logo, 22)}
${esc(w.label)}${importedTag}
${esc(addrShort)}
${bal}
${fiat}
`; }).join(""); d.innerHTML = `
${netChips}
${filtered.length ? rows : `
No ${esc(ticker)} wallet on this network yet.
`}
`; 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 = `
How would you like to add a wallet?
+
Create a new wallet
Derived from your Theseus vault
›
↓
Import an existing wallet
BIP39 mnemonic, WIF, or encrypted keystore
›
⚡
Connect via WizardConnect${wcCount ? ` ${wcCount} paired` : ""}
Pair a hardware / desktop signer over WC
›

🛡
About Aegis · aegis.x
Open the wallet's front-door site
›
`; 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 = `
Pick a coin, then a network. The wallet is derived from your Theseus vault — nothing to write down.
${coinRows}
Load an existing wallet by pasting its BIP39 mnemonic + derivation path, or a WIF private key. Key material is stored encrypted in Theseus's wallet-imports.enc.
${logoSvg("aegis", 22)}
Bulk-import from encrypted keystore
Deviant chipnet-keystore.json (or any chipnet-keystore/2-encrypted file) — master password unlocks all wallets in one go
›
${logoSvg("aegis", 22)}
Import a single wallet (any coin)
BIP39 mnemonic + path, or a chain-native private key (WIF / hex / base58)
›
${renderConnectPane(bchWallets)}
`; // 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 = `
${o.icon || (o.danger ? "⚠" : "🛡")}
${esc(o.title || "Are you sure?")}
${o.body ? `
${o.body}
` : ""}
${o.alertOnly ? "" : ``}
`; 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(); }); } // 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 = `
${logoSvg(w.logo, 22)}
Manage: ${esc(w.label)}
${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " · testnet" : ""}
Label
${canSetPath ? `
Derivation path (account)
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
` : ""}
${canRemove ? `` : `Default wallet — cannot be removed.`}
`; 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.

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 = `
${logoSvg("aegis", 22)}
Bulk-import from encrypted keystore
Chipnet only. Master password never leaves this panel — it decrypts the file locally via WebCrypto. Every imported wallet lands in Theseus's wallet-imports.enc, no plaintext on disk.
Keystore file
Typically Deviant/Keys/chipnet-keystore.json. Any chipnet-keystore/2-encrypted file works.
Master password
`; 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) => ``).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 = `
🔒 Unlock the vault to import
Aegis stores imported key material in Theseus's encrypted vault (wallet-imports.enc). Enter your master password once to unlock it, then Aegis will remember the import form you were filling in.
Master password
${esc(errText || "")}
`; 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 = `
Import a wallet
Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.
Coin
Network
Source
Mnemonic (12/24 words)
Derivation path
Label
Category
`; 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) => ``).join(""); fmtGroup.innerHTML = cfg.formats.map((f, i) => ``).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 = `
⇢ Consolidate balances
Every wallet you tick is swept via send-max into the currently-selected wallet. Same chain + same network only — nothing crosses networks or coins.
Destination
Loading…
`; 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 = `
No other ${esc(preview.ticker || preview.chain.toUpperCase())} wallets on ${esc(preview.network)}. Add or import one first, then come back.
`; 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 ? `
${esc(s.error)}
` : ""; const check = s.eligible ? `` : ``; const opacity = s.eligible ? "" : "opacity:.55"; return `
${esc(s.label)}
${esc(s.address || "—")}
Balance ${bal} · Fee ${fee}
${err}
${net}
will land
`; }).join(""); // Summary line at the bottom. body.innerHTML = `
${rows}
`; 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 = ` ${checked.size} wallet${checked.size === 1 ? "" : "s"} selected · Moving ${esc(fmtBig(totalBal.toString(), dec))} ${esc(preview.ticker)} (net ${esc(fmtBig(totalNet.toString(), dec))} ${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 = `
Preview failed: ${esc(cleanErr(e))}
`; } }; 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 ? `${esc(r.txid.slice(0, 16))}…` : (r.txid || ""); return `
${esc(r.label)}
Sent — ${link}
✓
`; }).join(""); const badRows = bad.map((r) => `
${esc(r.label)}
${esc(r.error || "unknown error")}
⚠
`).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 = `
${okRows}${badRows}
`; 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 = `
Loading…
`; let preview; try { preview = await S.invoke("consolidatePreview"); } catch (e) { hostEl.innerHTML = `
Preview failed: ${esc(cleanErr(e))}
`; 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 ? `
${esc(s.error)}
` : ""; const check = s.eligible ? `` : ``; const opacity = s.eligible ? "" : "opacity:.55"; return `
${esc(s.label)}
${esc(s.address || "—")}
Balance ${bal} · Fee ${fee}
${err}
${net}
will land
`; }).join(""); hostEl.innerHTML = `
Sweep same-network siblings into ${esc(preview.destinationLabel)} (${esc(shortenAddress(preview.destinationAddress))}).
${preview.sources.length ? rows : `
No other wallets to sweep.
`}
`; 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 = `${checked.size} selected · Moving ${esc(fmtBig(totalBal.toString(), dec))} ${esc(preview.ticker)} (net ${esc(fmtBig(totalNet.toString(), dec))} 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 ? `${esc(r.txid.slice(0, 16))}…` : (r.txid || ""); return `
${esc(r.label)}
Sent — ${link}
✓
`; } return `
${esc(r.label)}
${esc(r.error || "unknown error")}
⚠
`; }).join(""); hostEl.innerHTML = `
${okCount} broadcast · ${badCount} skipped/failed.
${rowsResult}
`; 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 `${esc(label)}`; } // 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 `
WizardConnect pairs Aegis with a BCH dapp (Cauldron, Moria, or any site built on the SDK).
${locked ? `
WizardConnect signs with your BCH keys, so the password vault has to be unlocked first.
` : `
Add a BCH wallet first via the Add tab, then come back.
`}
`; } // 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) => ``).join(""); const blockedNote = (!pairable.length && readyBch.length) ? `
${esc(readyBch[0].wcBlocked)}
` : ""; // 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) => `
${c.dappIcon ? `` : ""}
${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}
on ${esc(c.walletLabel)} · ${esc((c.uri || "").slice(0, 40))}…
`).join("") : `
No dapps paired yet.
`; return `
Dapps that support Aegis hand the pairing over with one click. Otherwise open the dapp's Connect dialog and press Scan page, or paste its wiz:// code below. Aegis signs every request after your approval.
${blockedNote}
Sign with
Paired dapps
${rowsHtml}
`; } 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 ":". 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 = `${esc(meta.networkLabel)}`; } // ▾ 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 ? `` : ""; 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 ? "" : `${gw.length} ▾`; // 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 ? `` : `🔒`; rows.push(`
${esc(meta.ticker)} ${chevron} ${pill} ${walletsChip} ${esc(priceTxt)} ${esc(totalNative)} ${totalFiat ? `${esc(totalFiat)}` : ""} ${rowMenu}
`); } // + 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 `
${esc(label)} ${gw.length} ${esc(native)} ${esc(ticker)}
`; }).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)); } }); }); } // 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 ? `🔒` : ``; return `
${logoSvg(meta.logo, 14)} ${esc(w.label || shortName || "—")} ${fullAddr ? `` : ``} ${esc(bal)}${esc(meta.ticker)} ${rowMenu}
`; }).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 ? `
⚠ ${esc(errs.join(" · "))}
` : ""; el.innerHTML = `
${logoSvg(meta.logo, 16)} ${esc(meta.coinName)}· ${gw.length} address${gw.length === 1 ? "" : "es"}
${esc(priceTxt)}/ ${esc(meta.ticker)} ${multiAddr ? `· ${esc(totalNative)} ${esc(meta.ticker)} ${totalFiat ? `(${esc(totalFiat)})` : ""}` : ""}
${errLine} ${rows}
`; 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(); } } const prev = b.textContent; b.classList.add("copied"); b.textContent = "✓"; setTimeout(() => { b.classList.remove("copied"); b.textContent = prev; }, 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 () => { // Add another wallet of the same coin+network directly, without // opening the picker sheet — the user is already inside this coin's // address list so their intent is unambiguous. const first = gw[0]; 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 = `

`; 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 = `
⚠
${esc(text)}
`; 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 = `
Optional. Paste a mnemonic to derive your vault from an existing seed (Ariadne mobile, another Theseus profile). Leave empty for a fresh independent seed.
`; 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 = `
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
`; 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 = `
${hasPin ? `Use PIN instead · ` : ""}Settings
`; 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 // $("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 derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."], }[phase] || ["…", "Starting…", ""]; let form = ""; if (phase === "empty") { form = `
`; } gate.innerHTML = `
${copy[0]}
${esc(copy[1])}
${esc(copy[2])}
${form}`; if (phase === "empty") { const btn = $("gateAddWallet"); if (btn) btn.addEventListener("click", () => { const d = $("drop"); pickerTab = "add"; 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 = `
🗝
Point Aegis at a walletd node
Settings › Sia › walletd URL. Any public or self-hosted go.sia.tech/walletd in "full" index mode works.
`; 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: ${esc(fiatSkeleton() || "—")}`; return; } const noun = wallets.length === 1 ? "wallet" : "wallets"; el.hidden = false; el.innerHTML = `Portfolio: ${esc(fmtFiat(total))} 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 `
${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + '' : ""}
${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}
${esc(bal)}
`; }).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 ? `` : ""; 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}${esc(name)}${symbol && name !== symbol ? ` ${esc(symbol)}` : ""}` : `${iconHtml}${esc(catShort)}`; return `
${nameLine}
${esc(catShort)}
${esc(amountLine)}
`; }).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 = `
${esc(s.meta?.ticker || chain().toUpperCase())} ${esc(s.meta?.coinLabel || "")}
native
${esc(fmtBig(nativeUnits, s.decimals))}
`; 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 ? ' ' + esc(t.name) + "" : ""}${clash ? ' LOOK-ALIKE' : ""}` : `Unknown token`; const amount = t.known ? `${esc(fmtTokenAmount(t.balance, dec))}` : `${esc(t.balance)} raw`; return `
${title}
${addrLine}
${amount}
`; }).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 = `` + tokens.map((t) => `` ).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 = `` + ``; $("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 = `
${s?.scanning ? "Syncing…" : (all.length ? "Nothing matches this filter." : "No transactions yet.")}
`; 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 `
${icon}
${esc(what)}
${amountHtml}
${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}
${esc(conf)}
`; }).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; cv.width = cv.height = px; cv.style.width = cv.style.height = px + "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); } // ---- receive actions ------------------------------------------------------- // 0.9.2: the QR is collapsed by default — it was 200px of permanent // vertical space above the address, which pushed the asset list off // screen. The choice sticks per-user because someone who receives by // QR wants it every time, and someone who copies never wants it. (function wireQrToggle() { const btn = $("qrToggle"); if (!btn) return; let open = false; try { open = localStorage.getItem("aegis/qrOpen") === "1"; } catch {} const paint = () => { const wrap = $("qrWrap"); if (!wrap) return; wrap.hidden = !open; btn.setAttribute("aria-expanded", open ? "true" : "false"); btn.classList.toggle("primary", open); }; btn.addEventListener("click", () => { open = !open; try { localStorage.setItem("aegis/qrOpen", open ? "1" : "0"); } catch {} paint(); }); paint(); })(); $("copyAddr").addEventListener("click", async () => { try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {} }); $("nextAddr").addEventListener("click", async () => { try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); } catch (e) { flash($("nextAddr"), "Failed"); } }); $("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. ${esc(r.txid.slice(0, 16))}…`; 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 = `

${esc(title)}

${esc(subtitle || "")}
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
`; 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 = `

${esc(title || "Confirm master password")}

${esc(subtitle || "")}
`; 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 = `

Confirm with PIN

${esc(subtitle || "")}
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
`; 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 = `idle`; if (st) { if (st.ok) { const covers = Object.keys(st.prices || {}).length; const age = Math.round((Date.now() - (st.at || Date.now())) / 1000); tag = `✓ ${covers} coin${covers === 1 ? "" : "s"}${age > 5 ? ` · ${age < 60 ? age + "s" : Math.round(age / 60) + "m"}` : ""}`; } else { tag = `⚠ down`; } } return `
${esc(s.label)} · ${esc(s.origin)}${tag}
`; }); 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 = `
None yet.
`; 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 `
${esc(o)}
${esc(what.join(" · "))}
`; }).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) => ``).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.

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 = `
Account path
${esc(r.accountPath)}
Account xpub
${esc(r.xpub)}
`; if (r.xprv) h += `
Account private key (xprv)
${esc(r.xprv)}
`; 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 = `
First address (index 0)
${esc(r.xpub || "")}
` + (r.xprv ? `
Wallet seed (hex)
${esc(r.xprv)}
` : ""); } catch (e) { $("scRecovery").textContent = cleanErr(e); } }); // Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": // the DOM IDs are #setFamily + #setPath. 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) => `` ).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 = `
Address
${esc(sel().address || "")}
` + `
Public key (uncompressed hex)
${esc(r.xpub || "")}
` + (r.xprv ? `
Private key (hex)
${esc(r.xprv)}
` : ""); } catch (e) { $("ethRecovery").textContent = cleanErr(e); } }); $("showSolKey").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("solRecovery").innerHTML = `
Address (public key, base58)
${esc(r.xpub || "")}
` + (r.xprv ? `
Wallet seed (hex, 32 bytes)
${esc(r.xprv)}
` : ""); } 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 = `
No dapps paired yet.
`; return; } el.innerHTML = conns.map((c) => { const label = c.dappName || c.label || "(pairing…)"; const iconHtml = c.dappIcon ? `` : ""; return `
${iconHtml}
${esc(label)} ${wcStatusTag(c.status)}
${esc((c.uri || "").slice(0, 46))}…
`; }).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 = `
⚠
${esc(cleanErr(e))}
`; } 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); })();