// Aegis wallet panel. All state comes from activate() via window.silentmode.
// This file renders the multi-wallet picker, per-chain views, and collects
// input; it never touches keys or the vault.
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
// Which surface is this? The sidebar panel and the full-screen tab are the
// SAME file — an add-on's own tab is handed a window.silentmode with the
// same invoke/on surface, and main dispatches it as from:"panel", so
// everything below works identically in both. The flag only drives layout.
const SURFACE = (function () {
try { return new URL(location.href).searchParams.get("surface") || "panel"; }
catch (e) { return "panel"; }
})();
if (SURFACE === "web") document.documentElement.dataset.surface = "web";
let state = null; // full state (all wallets + selected)
let tab = "receive";
let unit = null; // "big" | "small" — chain-dependent
let sendMax = false;
let planTimer = null;
// 0.8.0: mode toggles for the Send + Receive tabs. "send"/"receive" is
// the normal flow; "consolidate" swaps the tab body for the inline
// batch-consolidate picker. Session-scoped — resets to normal on reload.
let sendMode = "send";
let rcvMode = "receive";
// Cached inline consolidate render tokens — rebuilt on demand, reused
// across paints while the mode is active.
let consolidateInlineHost = null;
let lastPlan = null;
let settingsFilled = false;
// Selected asset for the Send tab. `null` = native coin. Otherwise a
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
let sendAsset = null;
// Wallet strip's view mode. "coins" is the six-column ticker/chain summary;
// "addresses" replaces it inline with the per-address list under one coin
// group. Toggled via the group row click / the back arrow in the inline
// header. Cleared whenever a fresh render is triggered by a wallet change
// so the strip snaps back to the summary.
let stripView = { mode: "coins", groupKey: null };
// Cached security state ({ hasPin, requirePinForSending }). 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");
}
// Header balance. Groups the integer part and dims the fraction — the
// "98,230.02" treatment the reference wallets lead with — and by default
// shortens a long tail, because eight decimals of noise sit exactly where
// the eye lands.
//
// The shortening NEVER rounds and never collapses a non-zero balance to
// zeros, which is the way this idea normally goes wrong: a flat "2 decimal
// places" rule renders 0.00042 BCH as "0.00" and the wallet reads as empty.
// Instead it keeps two decimals minimum and then extends past any leading
// zeros so roughly four significant digits always survive, capped at what
// the amount actually has:
//
// 1204.23234145 -> 1,204.23 0.23234145 -> 0.2323
// 0.00012345 -> 0.0001234 0.00000001 -> 0.00000001 (1 sat, intact)
//
// It truncates rather than rounds, so the figure shown is never more than
// the wallet holds and sending the displayed amount always clears.
// An ellipsis marks the cut, hover shows the exact figure, and clicking
// pins full precision (remembered). Only this hero is abbreviated: Send,
// MAX, history and the wallet strip keep calling fmtBig directly, so every
// number you act on is exact.
const BAL_MIN_DP = 2, BAL_SIG_DP = 4;
let balFullText = "—";
let balFullPrecision = false;
try { balFullPrecision = localStorage.getItem("aegis/fullPrecision") === "1"; } catch (_e) {}
function setBalMain(text) {
balFullText = String(text == null ? "—" : text);
paintBalMain();
}
function paintBalMain() {
const el = $("balMain");
if (!el) return;
const m = /^(-?)(\d+)(\.\d+)?$/.exec(balFullText);
if (!m) {
el.textContent = balFullText;
el.removeAttribute("title");
el.classList.remove("balx");
return;
}
const group = (d) => d.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const head = m[1] + group(m[2]);
const fullFrac = m[3] ? m[3].slice(1) : "";
let frac = fullFrac, cut = false;
if (!balFullPrecision && fullFrac) {
// Whole units already carry the significant digits, so anything with a
// non-zero integer part only needs the two decimals. It is the amounts
// under 1 that have to dig past their leading zeros to keep any meaning.
const whole = m[2] !== "0";
const zeros = (/^0*/.exec(fullFrac) || [""])[0].length;
const want = whole ? BAL_MIN_DP : zeros + BAL_SIG_DP;
const keep = Math.max(BAL_MIN_DP, Math.min(fullFrac.length, want));
if (keep < fullFrac.length) { frac = fullFrac.slice(0, keep); cut = true; }
}
// Every interpolated piece is digits, a sign, a dot or a comma the regex
// vouched for, so innerHTML here cannot inject markup.
el.innerHTML = head + (frac
? '.' + frac + (cut ? '' + String.fromCharCode(0x2026) + "" : "") + ""
: "");
const exact = head + (fullFrac ? "." + fullFrac : "");
const toggleable = cut || balFullPrecision;
el.title = cut ? exact + " — click for every decimal"
: (balFullPrecision ? exact + " — click to shorten" : exact);
el.classList.toggle("balx", toggleable);
}
$("balMain") && $("balMain").addEventListener("click", () => {
balFullPrecision = !balFullPrecision;
try { localStorage.setItem("aegis/fullPrecision", balFullPrecision ? "1" : "0"); } catch (_e) {}
paintBalMain();
});
function fmtSmall(units) {
if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return Number(units || 0).toLocaleString("en-US");
}
function smallUnitLabel() {
const c = chain();
if (c === "bch" || c === "dgb" || c === "btc") return "sat";
if (c === "trx") return "sun";
if (c === "sc") return "H";
if (c === "eth") return "wei";
if (c === "sol") return "lamports";
return "u";
}
// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet.
function explorerHref(base, id) {
const s = sel();
return base + id + (s?.explorerSuffix || "");
}
function bigUnitLabel() { return ticker(); }
// ---- fiat helpers ----------------------------------------------------------
// Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled
// or missing, fiat helpers return null and the caller renders nothing.
function priceFor(chain) {
if (!state?.prices?.enabled) return null;
return state.prices.prices?.[chain] ?? null;
}
// Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe
// for wide-decimals coins (SC=24, ETH=18) that overflow Number.
function usdOf(chain, units, decimals) {
const price = priceFor(chain);
if (price == null || !units) return null;
const d = Number(decimals) || 0;
if (typeof units === "string" && /^-?\d+$/.test(units)) {
// BigInt-safe: divide the units by 10^d first via BigInt, then use
// the fractional remainder as a Number multiplier for the last dp.
const neg = units.startsWith("-");
const abs = neg ? units.slice(1) : units;
const base = 10n ** BigInt(d);
const bi = BigInt(abs);
const whole = Number(bi / base);
const frac = Number(bi % base) / Number(base);
return (neg ? -1 : 1) * (whole + frac) * price;
}
const n = Number(units) / Math.pow(10, d);
return n * price;
}
// Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else
// grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the
// feed is enabled but hasn't returned yet.
function fmtFiat(usd) {
if (usd == null) return null;
if (usd === 0) return "$0.00";
const abs = Math.abs(usd);
// Sub-cent coins (SC ~ $0.0007, DGB ~ $0.005) get 3 significant digits so
// users see meaningful movement without the row screaming "< $0.01" at
// every wallet. Keeps trailing zeros trimmed: $0.000756, not $0.0007560.
if (abs < 0.01) {
const sig = usd.toPrecision(3);
const num = Number(sig);
if (num === 0) return "$0";
// Node.js's toPrecision returns e.g. "0.000756" for tiny numbers, "5.60e-4"
// for extreme. Normalise to a plain fixed string.
const s = /e/i.test(sig) ? num.toFixed(Math.max(0, -Math.floor(Math.log10(abs)) + 2)) : sig;
return "$" + s;
}
if (abs < 10) return "$" + usd.toFixed(2);
const int = Math.floor(usd);
const frac = Math.abs(usd - int).toFixed(2).slice(1);
return "$" + int.toLocaleString("en-US") + frac;
}
function fiatSkeleton() {
return state?.prices?.enabled ? "≈ $—" : null;
}
// ---- security: PIN 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.
// Open the same wallet as a full Theseus tab. CSS hides this chip on the
// full-screen surface, so it never offers to open a second copy of itself.
$("hFull") && $("hFull").addEventListener("click", async (e) => {
e.stopPropagation();
try { await S.invoke("openFullScreen"); }
catch (err) { showErr(cleanErr(err)); }
});
$("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 ? `
`;
}).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 `
`;
}).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 ? `
`);
}
}
// 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 = `
`;
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();
});
}
// Pick-one-of-several sibling of aegisConfirm, for branches where the
// honest answer is a question rather than a yes/no. Resolves the chosen
// option's `id`, or null if dismissed.
// opts: { title, body, icon, options: [{id, label, hint, primary}] }
function aegisChoose(opts) {
const o = opts || {};
const options = Array.isArray(o.options) ? o.options : [];
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
overlay.innerHTML = `
${o.icon || "+"}
${esc(o.title || "Choose")}
${o.body ? `
${o.body}
` : ""}
${options.map((op) => `
`).join("")}
`;
document.body.appendChild(overlay);
let done = false;
const finish = (val) => {
if (done) return;
done = true;
document.removeEventListener("keydown", onKey, true);
try { overlay.remove(); } catch {}
resolve(val);
};
const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); finish(null); } };
document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(null); });
overlay.querySelectorAll("[data-ac]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
finish(b.dataset.ac || null);
}));
const first = overlay.querySelector('[data-ac]:not([data-ac=""])');
if (first) first.focus();
});
}
// Single-button sibling of aegisConfirm, for the error notices that used
// window.alert(). Fire-and-forget: callers don't need the result, so it
// works from sync handlers too.
function aegisAlert(message, opts) {
const o = opts || {};
return aegisConfirm({
title: o.title || "Something went wrong",
icon: o.icon || "⚠",
body: esc(String(message == null ? "" : message)),
confirmLabel: o.confirmLabel || "OK",
cancelLabel: null,
alertOnly: true,
});
}
// Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label
// + category. Backend derives cashaddr and stores signer material in
// wallet-imports.enc (design §3.2). Modal is a plain overlay div injected
// into the panel body so it works over any tab.
// Manage-wallet modal: rename + derivation path + hard remove. Backend
// handlers already exist (renameWallet, setAccountPath, removeWallet); this
// just gives them a UI in the picker so users don't dive into per-wallet
// Settings for something they view as a top-level action.
function openWalletManageModal(w) {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:24px";
const canRemove = !(w.isDefault || w.isLegacy);
const canSetPath = ["bch", "btc", "dgb"].includes(w.chain);
// Only BCH imports can be promoted: the other imported adapters still
// throw "read-only" from plan(), so there is no sweep to run.
const canPromote = w.kind === "imported" && w.chain === "bch";
overlay.innerHTML = `
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
` : ""}
${canPromote ? `
WizardConnect
This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.
` : ""}
${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; }
});
// Promote: preview the sweep (costed without creating anything), confirm
// with the real numbers, then create + sweep in one host call. The confirm
// carries the amounts because this moves the wallet's entire balance.
if (canPromote) overlay.querySelector("#mwPromote").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
let pv;
try { pv = await S.invoke("promotePreview", { walletId: w.id }); }
catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; return; }
if (pv.error) { msg.textContent = pv.error; msg.hidden = false; return; }
const amt = (u) => fmtBig(u, pv.decimals);
const ok = await aegisConfirm({
title: "Promote to HD wallet?",
confirmLabel: "Create and sweep",
body: `Aegis will derive ${esc(pv.suggestedLabel)} from your vault on ${esc(pv.networkLabel)}, then send this wallet's whole balance to it.`
+ `
The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`,
});
if (!ok) return;
try {
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
close();
fillPicker();
render();
await aegisAlert(
`Sent ${amt(r.sent)} ${r.ticker} to "${r.newWalletLabel}". It can pair with WizardConnect once the sweep confirms.`
+ (r.txid ? ` Txid ${r.txid}` : ""),
{ title: "Promoted to HD wallet", icon: "✅", confirmLabel: "Done" });
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
if (canRemove) overlay.querySelector("#mwRemove").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
const ok = await aegisConfirm({
title: `Remove "${w.label}"?`,
danger: true,
confirmLabel: "Remove wallet",
body: `On-chain funds stay exactly where they are — this only unlinks the wallet from Aegis.
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
Select wallets to import
`;
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" },
],
},
};
// ---- QR import (image file, never the camera) -------------------------------
//
// Reads a seed phrase or a private key out of a picture of a QR code. The
// file never leaves the panel: it is drawn to a canvas here and decoded by
// the vendored jsQR, with no upload and no camera permission.
//
// What comes back is CLASSIFIED, not trusted. It picks the field to fill and
// nothing else — no auto-submit, no auto-import. A QR is an opaque blob to
// the person holding it, and "scan this to restore your wallet" is a working
// phish; the user still reads what landed in the box and presses Import, and
// the real validation happens in the host handler either way.
const BIP39_LENGTHS = new Set([12, 15, 18, 21, 24]);
// A BIP39 English phrase is only lowercase a-z words, 3-8 letters each, in
// one of the defined lengths. Word membership is not checked here — the host
// handler does that when it derives; this only decides which box to fill.
function mnemonicIn(str) {
const words = String(str || "").toLowerCase().split(/\s+/).filter(Boolean);
if (!BIP39_LENGTHS.has(words.length)) return null;
if (!words.every((w) => /^[a-z]{3,8}$/.test(w))) return null;
return { value: words.join(" "), words: words.length };
}
function classifyScannedSecret(text) {
const s = String(text || "").trim();
if (!s) return { kind: "empty" };
const bare = mnemonicIn(s);
if (bare) return { kind: "mnemonic", value: bare.value, words: bare.words };
// WIF: base58, 51-52 chars. 5/K/L = BTC-family mainnet, 9/c = testnet.
if (/^[5KL9c][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return { kind: "wif", value: s };
// Raw 32-byte hex, with or without 0x.
if (/^(0x)?[0-9a-fA-F]{64}$/.test(s)) return { kind: "hex", value: s };
// Wrapped phrases. Several wallets export the seed inside an envelope —
// a version marker, the words, sometimes a derivation path, pipe- or
// comma-separated ("1||m/44'/145'/0'"). Split on every run of
// non-letters (keeping spaces, which separate the words) and look for a
// BIP39-shaped run in any piece. This is tolerant of the wrapper without
// being loose about what counts as a phrase: a URL or an address still
// breaks into single-word pieces and matches nothing.
for (const piece of s.split(/[^A-Za-z ]+/)) {
const hit = mnemonicIn(piece);
if (!hit) continue;
// A path anywhere in the payload is worth carrying over — pulled from
// the ORIGINAL string, since splitting on non-letters shreds it.
const path = (s.match(/m(?:\/\d+'?)+/) || [])[0] || null;
return { kind: "mnemonic", value: hit.value, words: hit.words, path, wrapped: true };
}
return { kind: "unknown", value: s };
}
async function decodeQrFile(file) {
if (typeof jsQR !== "function") throw new Error("the QR decoder failed to load");
const bitmap = await createImageBitmap(file);
try {
// Very large photos cost time and memory for no accuracy gain; jsQR wants
// pixels, not megapixels. Cap the long edge and let the browser scale.
const MAX = 1600;
const scale = Math.min(1, MAX / Math.max(bitmap.width, bitmap.height));
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const cv = document.createElement("canvas");
cv.width = w; cv.height = h;
const ctx = cv.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, w, h);
const img = ctx.getImageData(0, 0, w, h);
// Photographs often invert or sit on a dark background; try both.
const hit = jsQR(img.data, w, h, { inversionAttempts: "attemptBoth" });
return hit && hit.data ? hit.data : null;
} finally {
try { bitmap.close(); } catch (_e) {}
}
}
function wireQrImport(overlay) {
const btn = overlay.querySelector("#imQrBtn");
const input = overlay.querySelector("#imQrFile");
if (!btn || !input) return;
const msg = () => overlay.querySelector("#imMsg");
const say = (text, isErr) => {
const m = msg(); if (!m) return;
m.textContent = text;
m.className = isErr ? "msg err" : "msg";
m.hidden = false;
};
btn.addEventListener("click", (e) => { e.preventDefault(); input.value = ""; input.click(); });
input.addEventListener("change", async () => {
const file = input.files && input.files[0];
if (!file) return;
const prev = btn.textContent;
btn.textContent = "Reading…"; btn.disabled = true;
try {
const text = await decodeQrFile(file);
if (!text) { say("No QR code found in that image. A flat, well-lit crop of just the code works best.", true); return; }
const found = classifyScannedSecret(text);
if (found.kind === "mnemonic") {
const ta = overlay.querySelector("#imMnemonic");
if (ta) { ta.value = found.value; ta.dispatchEvent(new Event("input", { bubbles: true })); }
// A derivation path in the QR is worth surfacing, but it does not get
// to silently replace a path already in the box — that box is
// prefilled with this coin's default and may have been edited, and
// quietly changing which addresses get derived is the kind of thing
// that looks like lost funds. Fill it only when empty; otherwise say
// what the QR carried and let the user decide.
let pathNote = "";
if (found.path) {
const pf = overlay.querySelector("#imPath");
if (pf && !pf.value.trim()) {
pf.value = found.path;
pf.dispatchEvent(new Event("input", { bubbles: true }));
pathNote = ` Its derivation path ${found.path} went into the path box.`;
} else if (pf && pf.value.trim() !== found.path) {
pathNote = ` It also carried the path ${found.path} — the box says ${pf.value.trim()}, change it if that is wrong.`;
}
}
const unwrapped = found.wrapped ? " (unwrapped from the QR's own format)" : "";
say(`Read a ${found.words}-word phrase${unwrapped}.${pathNote} Check it, pick the coin and network, then press Import.`, false);
} else if (found.kind === "wif" || found.kind === "hex") {
const raw = overlay.querySelector("#imRaw");
if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); }
say(`Read a ${found.kind === "wif" ? "WIF private key" : "32-byte key"}. Switch Source to the matching option if it is not already, then press Import.`, false);
} else {
// Don't paste unrecognised payloads into a key field — show a short
// preview so the user can see it was, say, a URL, and stop there.
const peek = found.value.length > 90 ? found.value.slice(0, 90) + "…" : found.value;
say(`That QR decoded to something that is not a seed phrase or key: "${peek}"`, true);
}
} catch (err) {
say(cleanErr(err), true);
} finally {
btn.textContent = prev; btn.disabled = false;
input.value = "";
}
});
}
function openImportModal(initialChain) {
const chains = Object.keys(IMPORT_COIN_CONFIG);
let curChain = chains.includes(initialChain) ? initialChain : "bch";
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px";
// Check vault lock state up front. Imported wallets that mounted at
// startup can leave overallPhase === "ready" even when the vault is
// still locked (imports skip vault.derive) — that's what makes the
// main panel look unlocked while a fresh "wallet-imports-add" IPC
// fails with "password vault is locked". So we test the vault
// directly here and, if it's locked, surface an unlock form inside
// the modal rather than blindly submitting and showing red text.
const paintUnlockGate = (errText) => {
overlay.innerHTML = `
🔒 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.
`;
}
};
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 = `
`;
hostEl.querySelectorAll("[data-inconurl]").forEach((a) => a.addEventListener("click", (e) => { e.preventDefault(); openUrl(a.dataset.inconurl); }));
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
go.disabled = false; go.textContent = "Consolidate";
}
});
}
function paintSendMode() {
const normal = $("sendNormal"); const cons = $("sendConsolidate");
if (!normal || !cons) return;
const buttons = document.querySelectorAll("[data-send-mode]");
buttons.forEach((b) => b.classList.toggle("on", b.dataset.sendMode === sendMode));
normal.hidden = sendMode !== "send";
cons.hidden = sendMode !== "consolidate";
if (sendMode === "consolidate") {
const host = $("sendConsolidateInline");
if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); }
}
}
// Which half of the Receive pane is showing. Persistent: the choice sticks
// across re-renders and across wallet switches, because someone comparing
// token balances between wallets should not be knocked back to the QR on
// every click.
let rcvView = "address";
try { rcvView = localStorage.getItem("aegis/rcvView") === "assets" ? "assets" : "address"; } catch (_e) {}
function paintRcvView() {
const addr = $("rcvAddressPane"), assets = $("rcvAssetsPane");
if (!addr || !assets) return;
document.querySelectorAll("[data-rcv-view]").forEach((b) =>
b.classList.toggle("on", b.dataset.rcvView === rcvView));
addr.hidden = rcvView !== "address";
assets.hidden = rcvView !== "assets";
// The QR's backing store is sized from its rendered box, so one drawn
// while its pane was hidden comes out blank. Redraw on reveal — and note
// renderReceive only redraws when the address CHANGES, so switching back
// to an unchanged address would otherwise never repaint it.
if (rcvView === "address") {
const a = sel()?.address || "";
if (a) { try { drawQr(qrPayload(chain(), a, sel()?.network)); } catch (_e) {} }
}
}
function paintRcvMode() {
const normal = $("rcvNormal"); const cons = $("rcvConsolidate");
if (!normal || !cons) return;
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("");
// Greying an option out with "can't pair" and giving no reason anywhere on
// the page reads as a broken wallet rather than a property of how it was
// added. Show the reasons whenever ANY wallet is blocked — the old note
// only appeared when nothing at all could pair, so a user with one good
// mainnet wallet and ten WIF-imported chipnet ones saw no explanation.
const blocked = readyBch.filter((w) => w.wcBlocked);
const reasons = [...new Set(blocked.map((w) => w.wcBlocked))].map(esc).join(" ");
const blockedNote = !blocked.length
? ""
: !pairable.length
? `
${reasons}
`
: `
${blocked.length} of ${readyBch.length} BCH wallets can't pair. ${reasons}
`;
// 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) => `
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) {
// A dapp drops its pairing code from the DOM once it is connected,
// so the commonest reason a scan comes up empty is that the page is
// ALREADY paired. Telling that user to "open the Connect dialog"
// sends them looking for a dialog the dapp will not show again.
const alreadyPaired = Object.entries(state?.wc || {})
.flatMap(([wid, conns]) => (conns || []).map((c) => ({ ...c, walletId: wid })));
const nameOf = (c) => c.dappName || c.label || "a dapp";
const walletLabel = (wid) => (state?.wallets || []).find((w) => w.id === wid)?.label || wid;
const where = res?.origin ? ` on ${res.origin}` : " on the open tab";
msg.textContent = alreadyPaired.length
? `No wiz:// pairing code found${where}. Aegis already has ${nameOf(alreadyPaired[0])} paired on ${walletLabel(alreadyPaired[0].walletId)} — a dapp removes its code once it is connected, so if this page shows itself as connected there is nothing left to scan. To pair a different wallet, disconnect on both sides first.`
: `No wiz:// pairing code found${where}. Open the dapp's Connect dialog first, then scan again.`;
msg.hidden = false;
return;
}
// Fill the field rather than pairing outright: the user still picks
// which wallet signs, and still presses Connect. A scan that silently
// paired would be a click with a much larger consequence than the
// button implies.
field.value = uris[0];
msg.textContent = uris.length > 1
? `Found ${uris.length} codes on ${res.origin || "the page"} — filled the first. Press Connect to pair.`
: `Found a pairing code on ${res.origin || "the page"}. Press Connect to pair.`;
msg.classList.remove("err");
msg.hidden = false;
} catch (err) {
msg.textContent = cleanErr(err);
msg.classList.add("err");
msg.hidden = false;
} finally {
scanBtn.textContent = prev; scanBtn.disabled = false;
}
});
document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => {
const [walletId, connId] = b.dataset.wcpick.split("|");
try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); }
catch (e) { const m = document.getElementById("pkConnectMsg"); m.textContent = cleanErr(e); m.hidden = false; }
}));
}
// Always-visible wallet strip at the top of the panel. Each existing wallet
// is a chip (click to switch). Trailing [+] opens the Add-only picker;
// trailing [⋯] opens Import / Connect / Manage. Existing wallets are NEVER
// duplicated inside the picker — the picker is for creation flows only now.
// Which coin groups are collapsed in the wallet strip. Persisted per-user in
// panel-scoped session state; not durable across restarts because the picker
// already opens on the currently-selected wallet's group (auto-expand below).
const collapsedGroups = new Set();
// Per-chain "which network is showing" pointer. Rows grouped by chain
// alone (BCH, BTC, ETH, …); this map picks which subnetwork's wallets
// the row surfaces. Missing entry → pickDefaultNetwork() below prefers
// mainnet when present, falls back to the first wallet's network.
// 0.8.0: persisted to localStorage under aegis/activeNetworks so a user
// who prefers Sepolia on ETH stays on Sepolia across reloads. Fresh
// installs (no persisted entry) still fall back to mainnet — a
// last-selected-when-known, mainnet-otherwise policy.
const activeNetworkByChain = new Map(
(function () {
try {
const raw = localStorage.getItem("aegis/activeNetworks");
const j = raw ? JSON.parse(raw) : null;
return j && typeof j === "object" ? Object.entries(j) : [];
} catch { return []; }
})(),
);
function persistActiveNetworks() {
try {
const obj = {}; for (const [k, v] of activeNetworkByChain) obj[k] = v;
localStorage.setItem("aegis/activeNetworks", JSON.stringify(obj));
} catch {}
}
// 0.7.5: analogous pointer for "which specific wallet is active" within
// a chain+network bucket. Row click uses this to select the right wallet
// instead of always drilling into the address list, and the count pill
// (▾) is what opens the picker to change it. Keyed as ":".
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)}`;
}
// 0.16.0: no ▾ network dropdown on the coin row. The coin view already
// carries real network chips (data-browse-net — they filter the list and
// show a per-network count), so a popover menu doing the same job was a
// second way to do one thing, and the only one that hid its options
// behind a click. Chips are the single network control now; the ticker
// is plain text again.
const multiNet = false;
const chevron = "";
const nameCls = "wcname";
const nameTitle = meta.coinName;
// Single wallet under the active network → click selects it. Multi-
// wallet: click selects the "active" wallet for this bucket (defaults
// to the currently-selected one if it's in the group, otherwise the
// first). The wgcount chip becomes a ▾ dropdown trigger that opens
// the inline addresses list — that's how you swap the active wallet.
const single = gw.length === 1;
// Pick the wallet the row will select on click. Precedence: previously
// selected in this bucket → globally selected wallet (if it's in gw)
// → first wallet in the group. Result is what the "active" pointer
// stores AND what the row's data-wstripid points at, so click always
// lands on a valid entry.
let activeWalletId = activeWalletBySubgroup.get(subKey) || null;
if (activeWalletId && !gw.some((w) => w.id === activeWalletId)) activeWalletId = null;
if (!activeWalletId && gw.some((w) => w.id === selId)) activeWalletId = selId;
if (!activeWalletId) activeWalletId = gw[0].id;
activeWalletBySubgroup.set(subKey, activeWalletId);
const walletId = activeWalletId;
const clickAction = `data-wstripid="${esc(walletId)}"`;
// Count chip carries the ▾ to signal the picker; single-wallet rows
// still get no chip.
const walletsChip = single ? "" : `${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(`
`);
}
// + 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 `
`;
}).join("");
netMenuEl = document.createElement("div");
netMenuEl.className = "netmenu";
netMenuEl.innerHTML = items;
document.body.appendChild(netMenuEl);
const r = anchorEl.getBoundingClientRect();
const mr = netMenuEl.getBoundingClientRect();
const maxLeft = window.innerWidth - mr.width - 8;
const left = Math.max(8, Math.min(maxLeft, r.left));
const top = r.bottom + 4;
netMenuEl.style.left = left + "px";
netMenuEl.style.top = top + "px";
netMenuEl.querySelectorAll(".nmitem").forEach((it) => it.addEventListener("click", (e) => {
e.stopPropagation();
const n = it.dataset.net;
closeNetworkPicker();
if (!n || n === active) return;
activeNetworkByChain.set(chain, n);
persistActiveNetworks();
renderWalletStrip();
}));
netMenuDismiss = (e) => {
if (e.type === "keydown" && e.key !== "Escape") return;
if (e.type === "mousedown" && netMenuEl && netMenuEl.contains(e.target)) return;
closeNetworkPicker();
};
// Defer wiring so the click that opened the menu doesn't immediately close it.
setTimeout(() => {
document.addEventListener("mousedown", netMenuDismiss, true);
document.addEventListener("keydown", netMenuDismiss, true);
}, 0);
}
function wireStripDragDrop(el, chainGroups) {
const chainKeys = Array.from(chainGroups.keys());
let dragChain = null;
el.querySelectorAll(".wrow[draggable=true]").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragChain = row.dataset.chain || null;
if (!dragChain) return;
row.classList.add("dragging");
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragChain); } catch {}
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after"));
dragChain = null;
});
row.addEventListener("dragover", (e) => {
if (!dragChain || row.dataset.chain === dragChain) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = "move"; } catch {}
const rect = row.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after"));
row.classList.add(before ? "drop-before" : "drop-after");
});
row.addEventListener("dragleave", () => {
row.classList.remove("drop-before", "drop-after");
});
row.addEventListener("drop", async (e) => {
e.preventDefault();
const targetChain = row.dataset.chain;
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (!dragChain || !targetChain || dragChain === targetChain) return;
const next = chainKeys.filter((k) => k !== dragChain);
const at = next.indexOf(targetChain);
next.splice(before ? at : at + 1, 0, dragChain);
// Flatten chain order to a wallet ID list. Inside each chain,
// mainnet wallets come first followed by testnets, matching the
// dropdown's own order. Individual wallets keep their existing
// relative order inside each subnetwork.
const walletOrder = [];
for (const k of next) {
const cg = chainGroups.get(k); if (!cg) continue;
const nets = orderNetworks(Array.from(cg.byNet.keys()));
for (const n of nets) for (const w of cg.byNet.get(n)) walletOrder.push(w.id);
}
try { state = await S.invoke("reorderWallets", { order: walletOrder }); render(); }
catch (er) { showErr(cleanErr(er)); }
});
});
}
// Copy glyph as inline SVG. The 📋 emoji has no glyph in this platform's
// font stack, so it rendered as a tofu box that read like a stray
// character stuck to the balance beside it.
const COPY_ICON = ``;
const CHECK_ICON = ``;
// 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 `
`;
}).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 ? `
`;
const back = () => { stripView = { mode: "coins", groupKey: null }; renderWalletStrip(); };
el.querySelector("#stripBack").addEventListener("click", back);
el.querySelector("#stripBackX").addEventListener("click", back);
// Manual refresh: force every wallet under this coin+network to
// re-poll now. Handy when a testnet faucet just delivered or a
// mainnet transfer is expected to have landed.
const refreshBtn = el.querySelector("#stripRefresh");
if (refreshBtn) refreshBtn.addEventListener("click", async () => {
if (refreshBtn.dataset.spinning === "1") return;
refreshBtn.dataset.spinning = "1";
const prev = refreshBtn.textContent;
refreshBtn.textContent = "…";
try {
const r = await S.invoke("refreshChain", { chain: gw[0]?.chain, network: gw[0]?.network });
const failed = (r?.results || []).filter((x) => !x.ok);
if (failed.length) refreshBtn.title = "Refresh failed: " + failed.map((f) => f.error).join("; ");
else refreshBtn.title = "Refresh balances now";
} catch (e) {
refreshBtn.title = "Refresh failed: " + (e?.message || e);
} finally {
refreshBtn.textContent = prev;
delete refreshBtn.dataset.spinning;
}
});
el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => {
if (e.target.closest(".wact")) return;
if (e.target.closest(".wacopy")) return;
const id = row.dataset.listpick;
try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); }
catch (er) { showErr(cleanErr(er)); }
}));
// Address copy chip. Uses navigator.clipboard when available (the addon
// panel runs under file:// but Electron gives it clipboard access), and
// falls back to a textarea+execCommand for older stacks. Visual "copied"
// flash lasts ~1s so the user sees the click landed.
el.querySelectorAll(".wacopy[data-lpcopy]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const addr = b.dataset.lpcopy || "";
if (!addr) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(addr);
else {
const ta = document.createElement("textarea");
ta.value = addr; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
// innerHTML, not textContent — the button holds an inline SVG now,
// and assigning textContent would destroy it and leave a blank
// square once the confirmation timed out.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
}));
el.querySelectorAll("[data-lpedit]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit);
if (w) openWalletManageModal(w);
}));
// Removing a wallet now happens inside the manage modal (the row's ⋯),
// so the row no longer emits its own remove button. The modal calls
// render() itself, which redraws the strip — including falling back to
// the coin list when the last address under a coin goes away.
el.querySelector("#stripAddMore").addEventListener("click", async () => {
// This used to create a fresh wallet immediately, on the reasoning
// that being inside a coin's address list made the intent
// unambiguous. It doesn't: "add another BCH wallet" is just as often
// "bring in the one I already have somewhere else". Creating instead
// of importing is not a harmless guess either — the user ends up with
// an empty new address and has to work out why their funds aren't
// there. So ask.
const first = gw[0];
const pick = await aegisChoose({
title: `Add another ${meta.ticker} wallet`,
// meta.coinName already carries the network ("TRX Nile"), so naming
// networkLabel again read "TRX Nile · Nile testnet".
body: `On ${esc(meta.coinName)}.`,
options: [
{ id: "create", label: "Create a new wallet", hint: "Derived from your Theseus vault — nothing to write down", primary: true },
{ id: "import", label: "Import an existing wallet", hint: "BIP39 mnemonic, or a chain-native private key" },
],
});
if (!pick) return;
if (pick === "import") { openImportModal(first.chain); return; }
try {
state = await S.invoke("addWallet", { chain: first.chain, network: first.network });
settingsFilled = false; render();
} catch (er) { showErr(cleanErr(er)); }
});
}
// Small popover for the "⋯" chip on the strip. Lists Import / Connect /
// Manage / About without cluttering the strip itself.
function openMoreMenu() {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:flex-start;justify-content:center;z-index:99998;padding-top:60px";
// "Manage current wallet" removed in 0.6.31 — the header's dedicated ✎
// chip already opens the same modal, and the header row itself remains
// a click target for the same thing. Two identical entry points were
// fine when they were the only path; three would just be clutter.
overlay.innerHTML = `
`;
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 = `
`;
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 can derive a fresh wallet from your Theseus password vault — nothing extra to write down — or import one you already have from its seed phrase or private key."],
}[phase] || ["…", "Starting…", ""];
let form = "";
if (phase === "empty") {
form = ``;
}
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");
// First-run is the MOST important place to offer import: someone
// arriving with an existing seed who is handed a create-only flow
// ends up staring at an empty wallet wondering where their coins
// went. Method chooser, not straight to create.
pickerTab = "method";
d.hidden = false;
fillPicker();
});
}
}
const dot = $("dot");
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
$("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || ""));
if (ready) {
// Sia-specific gate: adapter is up, keys are derived, but no walletd URL
// means no balance / history until the user configures one in Settings.
if (chain() === "sc" && s.needsWalletdUrl) {
setBalMain("—"); $("balTicker").textContent = s.meta.ticker;
$("netlbl").textContent = "point Aegis at a walletd node in Settings";
$("tabs").hidden = true;
gate.hidden = false;
gate.innerHTML = `
🗝
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);
setBalMain(fmtBig(total));
$("balTicker").textContent = s.meta.ticker;
const uc = s.balance?.unconfirmed;
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
// Fiat under the native amount (opt-in, might be null while loading).
const usd = usdOf(chain(), total, decimals());
const fiat = fmtFiat(usd) || fiatSkeleton();
$("balFiat").textContent = fiat || "";
$("balFiat").hidden = !fiat;
} else {
setBalMain("—"); $("balTicker").textContent = "";
$("balFiat").hidden = true;
}
renderPortfolio();
if (!ready) return;
const addr = s.address || "";
if ($("addr").textContent !== addr) {
$("addr").textContent = addr;
drawQr(qrPayload(chain(), addr, sel()?.network));
}
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
$("nextAddr").hidden = chain() !== "bch";
$("openFaucet").hidden = !s.faucet;
// Render SPL tokens list (SOL wallets only). Sending a token clicks
// through to the Send tab with that asset pre-picked.
renderTokens();
applyUnitPicker();
$("feeField").hidden = chain() !== "bch";
// OP_RETURN memo is BCH-only (added 0.6.36). Every other chain hides
// the field completely so the Send tab stays consistent.
const memoEl = $("memoField"); if (memoEl) memoEl.hidden = chain() !== "bch";
renderHistory();
// 0.8.0: consolidate is a MODE TOGGLE on Send + Receive, not a chip.
// Show the toggle when there's at least one same-network sibling; the
// count sits inside the button label. paintSendMode() / paintRcvMode()
// swap the body between normal and consolidate views.
const cur = sel();
const others = (state?.wallets || []).filter((w) =>
cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId,
);
const showToggle = others.length > 0;
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();
paintRcvView();
}
// Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N
// wallets" under the header. Only rendered when prices are on AND there are
// two or more wallets (a single wallet's fiat already sits in #balFiat).
function renderPortfolio() {
const el = $("portfolio");
const wallets = state?.wallets || [];
// Show whenever prices are on and at least one wallet exists — the single-
// wallet case still benefits from a portfolio row when the balance-line
// fiat is elided (e.g. header hidden during pane switches).
if (!state?.prices?.enabled || !wallets.length) { el.hidden = true; return; }
let total = 0, priced = 0;
for (const w of wallets) {
const b = w.balance;
if (!b) continue;
const units = (typeof b.confirmed === "string")
? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString()
: (b.confirmed || 0) + (b.unconfirmed || 0);
const usd = usdOf(w.chain, units, w.decimals);
if (usd != null) { total += usd; priced++; }
}
if (!priced) {
el.hidden = false;
el.innerHTML = `Portfolio: ${esc(fiatSkeleton() || "—")}`;
return;
}
const noun = wallets.length === 1 ? "wallet" : "wallets";
el.hidden = false;
el.innerHTML = `Portfolio: ${esc(fmtFiat(total))} across ${wallets.length} ${noun}`;
}
// Assets is a chip now, not a card that disappears when the count is zero.
// The count rides on the chip and an empty wallet says so in the pane, so
// "this wallet holds nothing" is a visible answer rather than a missing
// section the user has to infer — which is exactly how 46 CashTokens managed
// to look like a working, empty wallet before 0.15.0.
function setAssetsCount(n) {
const chip = $("rcvAssetsCount");
if (chip) chip.textContent = n ? String(n) : "";
const card = $("tokensCard");
if (card) card.hidden = false;
if (!n) {
const el = $("tokensList");
if (el) el.innerHTML = `
No assets held by this wallet.
`;
const cnt = $("tokensCount");
if (cnt) cnt.textContent = "";
}
}
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 || [];
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.";
if (!tokens.length) return;
el.innerHTML = tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const bal = fmtTokenAmount(t.balance, dec);
return `
`;
}).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);
setAssetsCount(cats.length);
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) { setAssetsCount(0); return; }
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
const kindLabel = chain() === "trx" ? "TRC20" : chain() === "eth" ? "ERC20" : "Tokens";
if (hintEl) hintEl.textContent = `${kindLabel} tokens held by this wallet. Read-only in this build — open the explorer from the header to move them.`;
// A symbol claimed by more than one contract is the lookalike pattern:
// spam mints borrow a trusted ticker so a careless send lands on the
// wrong contract. We can't tell which is genuine, so we don't guess —
// we mark every member of the clash and let the user check the address.
const symCount = new Map();
for (const t of tokens) {
const k = (t.symbol || "").toLowerCase();
if (t.known && k) symCount.set(k, (symCount.get(k) || 0) + 1);
}
const nativeUnits = walletBalanceUnits(sel()) || 0;
const nativeRow = `
`;
}).join("");
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid))));
wireHistFilter();
}
function shortAddr(a) {
if (!a) return "";
const s = String(a).replace(/^bitcoincash:|^bchtest:/, "");
return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4));
}
// ---- QR --------------------------------------------------------------------
// Coin-scheme URI so wallet apps that scan know which chain the payment is
// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681
// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed
// URI scheme exists).
function qrPayload(chain, address, network) {
if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, "");
if (chain === "btc") return "bitcoin:" + address; // BIP21
if (chain === "dgb") return "digibyte:" + address;
if (chain === "eth") return "ethereum:" + address;
if (chain === "sol") return "solana:" + address;
if (chain === "trx") return "tron:" + address;
return String(address);
}
function drawQr(text) {
const cv = $("qr");
const g = cv.getContext("2d");
let q;
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
const px = (q.size + 2) * scale;
// Backing store only. The DISPLAY size belongs to the user's dragged
// preference — setting cv.style here would reset the panel to its
// intrinsic size on every redraw (i.e. whenever the address changed).
cv.width = cv.height = px;
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
g.fillStyle = "#000";
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
applyQrSize();
}
// ---- receive actions -------------------------------------------------------
// 0.9.8: the QR is always visible and the panel is drag-resizable, which
// replaces the 0.9.2 show/hide toggle — a size the user sets once is a
// better answer than a binary, and it means the QR button no longer
// competes with Copy for the one row that gets used.
let applyQrSize = () => {};
(function wireQrResize() {
const grip = $("qrGrip"), wrap = $("qrWrap");
if (!grip || !wrap) return;
const MIN = 90, MAX = 420;
const clamp = (v) => Math.max(MIN, Math.min(MAX, Math.round(v)));
let size = 200;
try {
const saved = Number(localStorage.getItem("aegis/qrSize"));
if (Number.isFinite(saved) && saved > 0) size = clamp(saved);
} catch {}
const apply = () => {
// Cap against the actual panel width too — a size dragged wide on a
// roomy sidebar must not overflow when the sidebar is narrowed later.
const room = Math.max(MIN, (wrap.clientWidth || MAX) - 24);
const px = Math.min(size, room);
const cv = $("qr");
if (cv) { cv.style.width = px + "px"; cv.style.height = px + "px"; }
};
applyQrSize = apply;
apply();
window.addEventListener("resize", apply);
let startY = 0, startSize = 0, active = false;
const onMove = (e) => {
if (!active) return;
e.preventDefault();
size = clamp(startSize + (e.clientY - startY));
apply();
};
const onUp = () => {
if (!active) return;
active = false;
grip.classList.remove("dragging");
try { grip.releasePointerCapture?.(grip._pid); } catch {}
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
grip.addEventListener("pointerdown", (e) => {
active = true; startY = e.clientY;
const cv = $("qr");
startSize = cv ? (parseInt(cv.style.width, 10) || 200) : 200;
grip._pid = e.pointerId;
grip.classList.add("dragging");
try { grip.setPointerCapture(e.pointerId); } catch {}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
e.preventDefault();
});
// Keyboard affordance — a drag-only control is unusable without a mouse.
grip.tabIndex = 0;
grip.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 24 : 8;
if (e.key === "ArrowUp") { size = clamp(size - step); }
else if (e.key === "ArrowDown") { size = clamp(size + step); }
else return;
e.preventDefault(); apply();
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
});
})();
// The inline copy button holds an SVG; set it once at load.
(function paintCopyIcon() {
const b = $("copyAddr");
if (b && !b.firstElementChild) b.innerHTML = COPY_ICON;
})();
$("copyAddr").addEventListener("click", async () => {
const b = $("copyAddr");
try {
await navigator.clipboard.writeText(sel().address);
// innerHTML, not flash() — flash() swaps textContent, which would
// delete the inline SVG and leave an empty square behind.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
});
$("nextAddr").addEventListener("click", async () => {
try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); }
// The old handler discarded the error and flashed a bare "Failed", so
// neither the user nor a maintainer could tell WHY — which is why
// "next unused address doesn't work" had no diagnosable cause. Show it.
catch (e) { showErr("Next address: " + cleanErr(e)); }
});
$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address)));
$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet));
function flash(btn, text) {
const old = btn.textContent; btn.textContent = text;
setTimeout(() => { btn.textContent = old; }, 1200);
}
// ---- send ------------------------------------------------------------------
$("sendMax").addEventListener("click", () => {
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
if ($("sendMemo")) $("sendMemo").addEventListener("input", () => schedulePlan());
// 0.7.7 legacy chips — hidden in 0.8.0 but kept for graceful transition;
// clicking still opens the standalone modal for anyone with muscle memory.
if ($("consolidateChip")) $("consolidateChip").addEventListener("click", () => openConsolidateModal());
if ($("consolidateChipRcv")) $("consolidateChipRcv").addEventListener("click", () => openConsolidateModal());
// 0.8.0 mode-toggle wiring. One class="modetoggle" element per tab —
// clicking a segment flips the mode variable and repaints the body via
// paintSendMode / paintRcvMode. Freshly-rendered inline hosts get their
// consolidate view populated on first switch.
document.querySelectorAll("[data-send-mode]").forEach((b) => b.addEventListener("click", () => {
sendMode = b.dataset.sendMode;
consolidateInlineHost = null; // force re-render on next switch
paintSendMode();
}));
// Address & QR / Assets — the persistent pair inside the Receive body.
document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => {
rcvView = b.dataset.rcvView === "assets" ? "assets" : "address";
try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {}
paintRcvView();
}));
document.querySelectorAll("[data-rcv-mode]").forEach((b) => b.addEventListener("click", () => {
rcvMode = b.dataset.rcvMode;
consolidateInlineHost = null;
paintRcvMode();
paintRcvView();
}));
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => {
if (id === "sendAmt" && sendMax) return;
if (id === "sendAmt") updateSendFiatPreview();
schedulePlan();
}));
// Live ≈$ preview beside the Amount label, updated on every keystroke. Off
// when prices are disabled or the input is empty, so a quiet form stays quiet.
function updateSendFiatPreview() {
const el = $("sendAmtFiat"); if (!el) return;
const s = sel(); if (!s || sendAsset) { el.hidden = true; return; }
const units = amountUnits();
if (!units || !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 = `
`;
}).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);
})();