// Aegis wallet panel. All state comes from activate() via window.silentmode.
// This file renders the multi-wallet picker, per-chain views, and collects
// input; it never touches keys or the vault.
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
// Which surface is this? The sidebar panel and the full-screen tab are the
// SAME file — an add-on's own tab is handed a window.silentmode with the
// same invoke/on surface, and main dispatches it as from:"panel", so
// everything below works identically in both. The flag only drives layout.
const SURFACE = (function () {
try { return new URL(location.href).searchParams.get("surface") || "panel"; }
catch (e) { return "panel"; }
})();
if (SURFACE === "web") document.documentElement.dataset.surface = "web";
let state = null; // full state (all wallets + selected)
let tab = "receive";
let unit = null; // "big" | "small" — chain-dependent
let sendMax = false;
let planTimer = null;
// 0.8.0: mode toggles for the Send + Receive tabs. "send"/"receive" is
// the normal flow; "consolidate" swaps the tab body for the inline
// batch-consolidate picker. Session-scoped — resets to normal on reload.
let sendMode = "send";
let rcvMode = "receive";
// Cached inline consolidate render tokens — rebuilt on demand, reused
// across paints while the mode is active.
let consolidateInlineHost = null;
let lastPlan = null;
let settingsFilled = false;
// Selected asset for the Send tab. `null` = native coin. Otherwise a
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
let sendAsset = null;
// Wallet strip's view mode. "coins" is the six-column ticker/chain summary;
// "addresses" replaces it inline with the per-address list under one coin
// group. Toggled via the group row click / the back arrow in the inline
// header. Cleared whenever a fresh render is triggered by a wallet change
// so the strip snaps back to the summary.
let stripView = { mode: "coins", groupKey: null };
// Cached security state ({ hasPin, requirePinForSending, requirePinForReveal }).
// Populated on startup and refreshed after any pin/security invoke — used by
// the lock screen (PIN vs. password), the Settings General card, and the
// reveal gate. requirePinForReveal defaults TRUE, here and in the host, so a
// failed read never lands on the permissive setting.
let securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true };
let securityLoaded = false;
// Cached session config: whether the vault stays unlocked across Theseus
// restarts (safeStorage-backed) and how many idle minutes trigger an
// auto-lock. Populated on boot; refreshed after each Settings edit.
let sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true };
let sessionLoaded = false;
let idleTimer = null;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 ----------------------------------------------------------
// The pads below only collect six digits. The host (index.js pinUnwrap)
// holds the PIN blob, counts every guess before trying it, and enforces the
// 15-minute lockout after PIN_MAX_FAILS wrong ones. The panel never sees the
// blob, so it cannot be searched from here, and it cannot reset the counter.
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
const PIN_MAX_FAILS = 5;
// A refusal that is not a verdict on the PIN (the TPM is in its lockout
// window, or its key is gone) carries `error`; it is thrown, so each pad's
// catch shows it as it is instead of as a wrong PIN.
const pinTry = async (pin) => {
const r = await S.invoke("pinUnwrap", { pin: String(pin) });
if (r && !r.ok && r.error) throw new Error(r.error);
return r;
};
async function pinLockoutRemainingMs() {
try { return Number((await S.invoke("pinStatus")).lockedMs) || 0; } catch { return 0; }
}
async function refreshSecurityState() {
try {
securityState = await S.invoke("securityGet");
securityLoaded = true;
} catch { securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true }; securityLoaded = true; }
return securityState;
}
async function refreshSessionState() {
try {
sessionState = await S.invoke("sessionStatus");
sessionLoaded = true;
} catch {
sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true };
sessionLoaded = true;
}
return sessionState;
}
// Idle auto-lock. Any user gesture in the panel resets the timer; if the
// user stays quiet for `sessionState.idleMinutes`, Aegis invokes vaultLock
// so a walked-away laptop doesn't leave the wallet unlocked. Wired at
// boot; each config change bounces it via bindIdleAutoLock().
function bindIdleAutoLock() {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
const mins = Number(sessionState.idleMinutes) || 0;
if (mins <= 0) return;
const reset = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
// Only lock if the vault is actually open — no point calling lock
// while we're already on the unlock screen.
const s = sel();
if (!s || s.phase !== "ready") return;
try {
state = await S.invoke("vaultLock");
// Start the panel over rather than re-render it. Whatever was open
// goes with the page: a revealed key, an import form holding a seed
// phrase, a half-filled send, the password remembered for PIN
// enrolment. Painting a lock screen underneath left all of that on
// top of a "locked" wallet.
try { delete window.__aegisLastPw; } catch {}
location.reload();
} catch (e) { /* silent — user activity will retry */ }
}, mins * 60 * 1000);
};
reset();
// Reset on any deliberate gesture. Passive listeners so scrolling long
// wallet lists doesn't fight the idle timer.
const opts = { passive: true, capture: true };
const listener = () => reset();
["mousedown", "keydown", "touchstart", "focus", "click"].forEach((ev) => document.addEventListener(ev, listener, opts));
// Store the listener so a later bindIdleAutoLock doesn't stack duplicates.
if (bindIdleAutoLock._prev) {
for (const ev of ["mousedown", "keydown", "touchstart", "focus", "click"]) {
document.removeEventListener(ev, bindIdleAutoLock._prev, opts);
}
}
bindIdleAutoLock._prev = listener;
}
// ---- tabs ------------------------------------------------------------------
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
function showTab(name) {
tab = name;
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
if (name === "settings") { settingsFilled = false; fillSettings(); applySetSec(activeSetSec); }
if (name === "send") applyUnitPicker();
// Settings is the only tab that can be reached while the vault is
// locked. Re-run the full render() so the lock-screen overlay + chrome
// visibility stay in sync with whichever tab the user just picked.
render();
}
// ---- settings section nav (0.8.2) -----------------------------------------
// Settings grew tall enough (Security, Session, Master password, MultiSig,
// Wallet manage + 6 per-chain cards, Prices, Sites) that scrolling to any
// one was awkward. Segment the tab with a chip row: only one section is
// visible at a time, choice persists in localStorage so users land back
// where they left off.
let activeSetSec = "security";
try {
const saved = localStorage.getItem("aegis/setSec");
if (saved) activeSetSec = saved;
} catch (_e) {}
function applySetSec(name) {
activeSetSec = name || "security";
try { localStorage.setItem("aegis/setSec", activeSetSec); } catch (_e) {}
document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => {
b.classList.toggle("on", b.dataset.setsec === activeSetSec);
});
// A section can span more than one card (Security holds both the top
// Security card and the MultiSig card lower down), so toggle every
// matching body — hide non-matches.
document.querySelectorAll("[data-setsec-body]").forEach((el) => {
el.hidden = el.dataset.setsecBody !== activeSetSec;
});
}
document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => {
b.addEventListener("click", () => applySetSec(b.dataset.setsec));
});
applySetSec(activeSetSec);
// ---- wallet picker (two-step add) ------------------------------------------
$("pickerBtn").addEventListener("click", (e) => {
// 0.8.0: header is a CURRENCY PICKER. Clicking the wallet name/badge
// opens the browse pane in #drop (coin list → wallet list per coin
// → click a wallet to select it). The ✎ chip on the right opens the
// per-wallet manage modal (rename, path, remove), which is the
// dedicated "edit THIS wallet" affordance — different intent.
if (e.target && e.target.closest("#hAdd")) return;
if (e.target && e.target.closest("#hManage")) {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === state?.selectedWalletId);
if (w) openWalletManageModal(w);
return;
}
e.stopPropagation();
pickerTab = "browse";
const d = $("drop");
positionDropBelowTabs(d);
d.hidden = false;
fillPicker();
});
// 0.8.4: separate netchip row. Click jumps straight to the browse:chain
// view for the current wallet's chain — the network-chip strip at the
// top of that view is what actually switches networks.
// Open the same wallet as a full Theseus tab. CSS hides this chip on the
// full-screen surface, so it never offers to open a second copy of itself.
$("hFull") && $("hFull").addEventListener("click", async (e) => {
e.stopPropagation();
try { await S.invoke("openFullScreen"); }
catch (err) { showErr(cleanErr(err)); }
});
// Network indicator and network switch, merged into one always-visible
// control under the balance. Before this, the indicator was a chip in the
// status row and the real selector was a chip row inside the picker's coin
// drilldown: the half that told you where you were and the half that could
// move you were in different places, and the useful half was two clicks deep.
//
// A chain with a single network still renders its one chip. That chip is the
// indicator, and dropping it would make the header height jump as the user
// moves between chains.
function paintNetSelector(s) {
const host = $("hNetSel");
if (!host) return;
const chain = s && s.chain;
if (!chain) { host.hidden = true; host.innerHTML = ""; return; }
const mine = (state?.wallets || []).filter((w) => w.chain === chain);
const nets = orderNetworks(Array.from(new Set(mine.map((w) => w.network))));
if (!nets.length) { host.hidden = true; host.innerHTML = ""; return; }
host.hidden = false;
host.innerHTML = nets.map((n) => {
const peers = mine.filter((w) => w.network === n);
const lbl = networkLabelFor(chain, n, n);
// "Chipnet testnet TEST" says it twice. Only tag a testnet whose own
// label doesn't already announce it (Nile, Sepolia, devnet, signet).
const isTestnet = !!peers.find((w) => w.testnet) && !/test/i.test(lbl);
return ``;
}).join("");
host.querySelectorAll("[data-netsel]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const net = b.dataset.netsel;
if (net === s.network) return;
const peers = mine.filter((w) => w.network === net);
if (!peers.length) return;
// Keep the picker's own per-chain network memory in step, so the two
// views never disagree about which network this chain is showing.
activeNetworkByChain.set(chain, net);
persistActiveNetworks();
// The wallet list has to come along. In the addresses drilldown its
// groupKey pins ":", so without this the header says
// chipnet while the list below keeps showing mainnet addresses.
const subKey = `${chain}:${net}`;
if (stripView.mode === "addresses" && String(stripView.groupKey || "").split(":")[0] === chain) {
stripView = { mode: "addresses", groupKey: subKey };
}
// Land on whichever wallet this network was last left on, so the header
// switch and the row the strip highlights are the same wallet.
const remembered = activeWalletBySubgroup.get(subKey);
const target = peers.find((w) => w.id === remembered) || peers[0];
activeWalletBySubgroup.set(subKey, target.id);
try {
state = await S.invoke("selectWallet", { id: target.id });
settingsFilled = false;
render();
} catch (err) { aegisAlert("Could not switch network: " + cleanErr(err)); }
}));
}
// + Add and ⋯ More chips moved from the wallet strip into the header
// (0.6.31). Same handlers as before — fillPicker for the Add-only picker,
// openMoreMenu for Import/Connect/About. Each stopsPropagation so the
// outer pickerBtn click doesn't also fire "manage this wallet".
$("hAdd").addEventListener("click", (e) => {
e.stopPropagation();
// 0.7.1: header + opens the method chooser first (New / Import /
// Connect), then routes into the coin picker for the chosen method.
// Users who want to skip straight to "Add new" from another entry
// point (e.g. openMoreMenu) still set pickerTab = "add" directly.
pickerTab = "method";
const d = $("drop");
// 0.7.3: anchor the drop to the BOTTOM of the tabs bar (nav) so it
// opens over the wallet list + main content but leaves the wallet
// header (balance + selected wallet) AND the Receive/Send/History/
// Settings tabs visible above it. Measured at open time because
// header height varies with content (portfolio line, error banner).
positionDropBelowTabs(d);
d.hidden = false;
fillPicker();
});
function positionDropBelowTabs(dropEl) {
try {
const nav = document.querySelector("nav");
if (!nav) return;
const y = Math.round(nav.getBoundingClientRect().bottom);
if (y > 0) dropEl.style.top = y + "px";
} catch {}
}
// hMore chip removed in 0.7.2 — the compact method chooser under hAdd
// carries Create / Import / Connect / About, so a second right-corner
// button was redundant.
document.addEventListener("click", (e) => {
const d = $("drop");
if (d.hidden) return;
// Also whitelist the always-visible wallet strip so its + / ⋯ buttons —
// which run fillPicker() and detach themselves during the render — don't
// trigger the outer "click outside → close" logic. Before this whitelist
// the Add button appeared broken because the picker opened and immediately
// closed in the same event tick.
if (e.target.closest("#drop") || e.target.closest("#pickerBtn") || e.target.closest("#walletStrip")) return;
d.hidden = true;
});
// Which picker tab is showing. Persisted in the picker instance state so a
// user who opens the picker → picks Import → cancels → reopens returns to
// Wallets (the sane default).
let pickerTab = "wallets";
// 0.8.4: coin catalogue search query + one-shot preselected chain for the
// Add pane (set when the browse view routes into "add" for a specific
// unowned coin).
let browseQuery = "";
let pickerAddChain = null;
function fillPicker() {
const d = $("drop");
const wallets = state?.wallets || [];
const coins = state?.coins || [];
const rowsHtml = wallets.map((w) => {
const on = w.id === state.selectedWalletId ? "on" : "";
const totalUnits = w.balance ? (typeof w.balance.confirmed === "string"
? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString()
: (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0;
const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—";
const usd = usdOf(w.chain, totalUnits, w.decimals);
const fiat = fmtFiat(usd);
const fiatLine = fiat ? `
${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") {
// Enter means "the focused button". It used to mean yes even with
// focus on Cancel, so Tab → Enter removed the wallet.
e.preventDefault(); e.stopPropagation();
finish(document.activeElement?.dataset?.ac !== "no");
}
};
document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); });
overlay.querySelectorAll("[data-ac]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
finish(b.dataset.ac === "yes");
}));
// A destructive question opens on Cancel, like the host's own overlay.
const yes = overlay.querySelector('[data-ac="yes"]');
const no = overlay.querySelector('[data-ac="no"]');
if (o.danger && no) no.focus(); else if (yes) yes.focus();
});
}
// Pick-one-of-several sibling of aegisConfirm, for branches where the
// honest answer is a question rather than a yes/no. Resolves the chosen
// option's `id`, or null if dismissed.
// opts: { title, body, icon, options: [{id, label, hint, primary}] }
function aegisChoose(opts) {
const o = opts || {};
const options = Array.isArray(o.options) ? o.options : [];
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
overlay.innerHTML = `
${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";
// Per-purpose defaults. WizardConnect is BCH-only and a WIF import has no
// xpub to pair with, so the row explains itself rather than offering a
// choice that must fail.
const roles = (state && state.roles) || {};
const isPay = roles.payments === w.id;
const isWc = roles.wizardconnect === w.id;
const wcOk = w.chain === "bch" && !w.wcBlocked;
const wcWhy = w.chain !== "bch"
? "WizardConnect pairs Bitcoin Cash wallets only."
: (w.wcBlocked || "");
overlay.innerHTML = `
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
` : ""}
Use this wallet for
${wcOk ? "" : `
${esc(wcWhy)}
`}
Secret key
Everything needed to spend this wallet elsewhere. Aegis asks for your PIN (or master password) first.
${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 });
}
// Roles. Only send a change: unticking clears the role, but only when
// this wallet is the one currently holding it, so closing the modal on
// some other wallet can't unset someone else's default.
const wantPay = !!overlay.querySelector("#mwRolePay")?.checked;
const wantWc = !!overlay.querySelector("#mwRoleWc")?.checked;
if (wantPay !== isPay) {
state = await S.invoke("setWalletRole", { role: "payments", walletId: wantPay ? w.id : null });
}
if (wcOk && wantWc !== isWc) {
state = await S.invoke("setWalletRole", { role: "wizardconnect", walletId: wantWc ? w.id : null });
}
close();
fillPicker();
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
overlay.querySelector("#mwReveal").addEventListener("click", () => openRevealSecretModal(w));
// Promote: preview the sweep (costed without creating anything), confirm
// with the real numbers, then create + sweep in one host call. The confirm
// carries the amounts because this moves the wallet's entire balance.
if (canPromote) overlay.querySelector("#mwPromote").addEventListener("click", async () => {
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
let pv;
try { pv = await S.invoke("promotePreview", { walletId: w.id }); }
catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; return; }
if (pv.error) { msg.textContent = pv.error; msg.hidden = false; return; }
const amt = (u) => fmtBig(u, pv.decimals);
const ok = await aegisConfirm({
title: "Promote to HD wallet?",
confirmLabel: "Create and sweep",
body: `Aegis will derive ${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;
// The sweep is a spend: with "PIN on every transaction" the host refuses
// it without a proof given for a transaction.
if (!await pinGate("transaction", "Confirm the sweep with your PIN.")) 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: [
// Chipnet prefills the BCH coin type, not BIP44's testnet 1'. Chipnet
// is a BCH testnet and the tooling around it (keystore exports,
// faucets, test wallets) overwhelmingly derives from 145'; prefilling
// 1' sent every chipnet seed import to an empty address. 1' is still
// one of the presets, and the scan finds whichever is real.
// NOTE: this is the IMPORT prefill only. Vault-derived chipnet wallets
// keep m/44'/1'/0' (chain-bch.js) — changing that would move the
// addresses of wallets that already exist.
{ id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/145'/0'/0/0", testnet: true },
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" },
],
},
btc: {
label: "Bitcoin", logo: "btc",
// Testnet3 is de facto abandoned (blocks stall for weeks, faucets
// dried up); Signet is Bitcoin's living testnet now. Only Signet is
// exposed to new imports. The testnet3 adapter is kept in
// lib/chain-btc.js so any wallet created on an earlier version still
// loads — it just no longer appears in the picker.
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/84'/0'/0'/0/0" },
{ id: "signet", label: "Signet", defaultPath: "m/84'/1'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" },
],
},
dgb: {
label: "DigiByte", logo: "dgb",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/84'/20'/0'/0/0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "wif", label: "WIF private key", placeholder: "L… / K… (base58check)" },
],
},
eth: {
label: "Ethereum", logo: "eth",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/60'/0'/0/0" },
{ id: "sepolia", label: "Sepolia", defaultPath: "m/44'/60'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" },
],
},
trx: {
label: "Tron", logo: "trx",
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/195'/0'/0/0" },
{ id: "nile", label: "Nile testnet", defaultPath: "m/44'/195'/0'/0/0", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" },
],
},
sol: {
label: "Solana", logo: "sol",
networks: [
{ id: "mainnet", label: "Mainnet-beta", defaultPath: "m/44'/501'/0'/0'" },
{ id: "devnet", label: "Devnet", defaultPath: "m/44'/501'/0'/0'", testnet: true },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic + path" },
{ id: "privHex", label: "Private key (hex)", placeholder: "32 or 64 bytes hex" },
{ id: "privB58", label: "Private key (base58)", placeholder: "Phantom / Solflare export" },
],
},
sc: {
label: "Siacoin", logo: "sc",
// Sia's walletd (v2) uses a 32-byte root seed and an integer index
// (KeyFromSeed layout) — no BIP44 path. Sia Central Lite / walletd
// both accept a 12-word BIP39 mnemonic that PBKDF2's down to the
// root; the raw 32-byte hex is the alternative that walletd's API
// itself takes. defaultPath doubles as the address index the import
// starts on (0 is standard for a fresh import).
networks: [
{ id: "mainnet", label: "Mainnet", defaultPath: "0" },
],
formats: [
{ id: "mnemonic", label: "BIP39 mnemonic (12 words)" },
{ id: "seedHex", label: "Seed hex (64 chars)", placeholder: "32-byte root, walletd-compatible" },
],
},
};
// ---- QR import (image file, never the camera) -------------------------------
//
// Reads a seed phrase or a private key out of a picture of a QR code. The
// file never leaves the panel: it is drawn to a canvas here and decoded by
// the vendored jsQR, with no upload and no camera permission.
//
// What comes back is CLASSIFIED, not trusted. It picks the field to fill and
// nothing else — no auto-submit, no auto-import. A QR is an opaque blob to
// the person holding it, and "scan this to restore your wallet" is a working
// phish; the user still reads what landed in the box and presses Import, and
// the real validation happens in the host handler either way.
const BIP39_LENGTHS = new Set([12, 15, 18, 21, 24]);
// A BIP39 English phrase is only lowercase a-z words, 3-8 letters each, in
// one of the defined lengths. Word membership is not checked here — the host
// handler does that when it derives; this only decides which box to fill.
function mnemonicIn(str) {
const words = String(str || "").toLowerCase().split(/\s+/).filter(Boolean);
if (!BIP39_LENGTHS.has(words.length)) return null;
if (!words.every((w) => /^[a-z]{3,8}$/.test(w))) return null;
return { value: words.join(" "), words: words.length };
}
function classifyScannedSecret(text) {
const s = String(text || "").trim();
if (!s) return { kind: "empty" };
const bare = mnemonicIn(s);
if (bare) return { kind: "mnemonic", value: bare.value, words: bare.words };
// WIF: base58, 51-52 chars. 5/K/L = BTC-family mainnet, 9/c = testnet.
if (/^[5KL9c][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return { kind: "wif", value: s };
// Raw 32-byte hex, with or without 0x.
if (/^(0x)?[0-9a-fA-F]{64}$/.test(s)) return { kind: "hex", value: s };
// Wrapped phrases. Several wallets export the seed inside an envelope —
// a version marker, the words, sometimes a derivation path, pipe- or
// comma-separated ("1||m/44'/145'/0'"). Split on every run of
// non-letters (keeping spaces, which separate the words) and look for a
// BIP39-shaped run in any piece. This is tolerant of the wrapper without
// being loose about what counts as a phrase: a URL or an address still
// breaks into single-word pieces and matches nothing.
for (const piece of s.split(/[^A-Za-z ]+/)) {
const hit = mnemonicIn(piece);
if (!hit) continue;
// A path anywhere in the payload is worth carrying over — pulled from
// the ORIGINAL string, since splitting on non-letters shreds it.
const path = (s.match(/m(?:\/\d+'?)+/) || [])[0] || null;
return { kind: "mnemonic", value: hit.value, words: hit.words, path, wrapped: true };
}
return { kind: "unknown", value: s };
}
// jsQR is 257 KB of vendored decoder and it is only needed when someone
// actually imports a QR image. Loading it from panel.html meant every panel
// open — every hide/show — parsed all of it before panel.js even started.
// Fetch it the first time it is wanted instead.
let jsqrLoad = null;
function loadJsQr() {
if (typeof jsQR === "function") return Promise.resolve();
if (jsqrLoad) return jsqrLoad;
jsqrLoad = new Promise((resolve, reject) => {
const el = document.createElement("script");
el.src = "lib/jsqr.js";
el.onload = () => (typeof jsQR === "function" ? resolve() : reject(new Error("the QR decoder loaded but exposed nothing")));
el.onerror = () => { jsqrLoad = null; reject(new Error("the QR decoder failed to load")); };
document.head.appendChild(el);
});
return jsqrLoad;
}
async function decodeQrFile(file) {
await loadJsQr();
const bitmap = await createImageBitmap(file);
try {
// Very large photos cost time and memory for no accuracy gain; jsQR wants
// pixels, not megapixels. Cap the long edge and let the browser scale.
const MAX = 1600;
const scale = Math.min(1, MAX / Math.max(bitmap.width, bitmap.height));
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const cv = document.createElement("canvas");
cv.width = w; cv.height = h;
const ctx = cv.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, w, h);
const img = ctx.getImageData(0, 0, w, h);
// Photographs often invert or sit on a dark background; try both.
const hit = jsQR(img.data, w, h, { inversionAttempts: "attemptBoth" });
return hit && hit.data ? hit.data : null;
} finally {
try { bitmap.close(); } catch (_e) {}
}
}
function wireQrImport(overlay) {
const btn = overlay.querySelector("#imQrBtn");
const input = overlay.querySelector("#imQrFile");
if (!btn || !input) return;
const msg = () => overlay.querySelector("#imMsg");
const say = (text, isErr) => {
const m = msg(); if (!m) return;
m.textContent = text;
m.className = isErr ? "msg err" : "msg";
m.hidden = false;
};
btn.addEventListener("click", (e) => { e.preventDefault(); input.value = ""; input.click(); });
input.addEventListener("change", async () => {
const file = input.files && input.files[0];
if (!file) return;
const prev = btn.textContent;
btn.textContent = "Reading…"; btn.disabled = true;
try {
const text = await decodeQrFile(file);
if (!text) { say("No QR code found in that image. A flat, well-lit crop of just the code works best.", true); return; }
const found = classifyScannedSecret(text);
if (found.kind === "mnemonic") {
const ta = overlay.querySelector("#imMnemonic");
if (ta) { ta.value = found.value; ta.dispatchEvent(new Event("input", { bubbles: true })); }
// A derivation path in the QR is worth surfacing, but it does not get
// to silently replace a path already in the box — that box is
// prefilled with this coin's default and may have been edited, and
// quietly changing which addresses get derived is the kind of thing
// that looks like lost funds. Fill it only when empty; otherwise say
// what the QR carried and let the user decide.
let pathNote = "";
if (found.path) {
const pf = overlay.querySelector("#imPath");
if (pf && !pf.value.trim()) {
pf.value = found.path;
pf.dispatchEvent(new Event("input", { bubbles: true }));
pathNote = ` Its derivation path ${found.path} went into the path box.`;
} else if (pf && pf.value.trim() !== found.path) {
pathNote = ` It also carried the path ${found.path} — the box says ${pf.value.trim()}, change it if that is wrong.`;
}
}
const unwrapped = found.wrapped ? " (unwrapped from the QR's own format)" : "";
say(`Read a ${found.words}-word phrase${unwrapped}.${pathNote} Check it, pick the coin and network, then press Import.`, false);
} else if (found.kind === "wif" || found.kind === "hex") {
const raw = overlay.querySelector("#imRaw");
if (raw) { raw.value = found.value; raw.dispatchEvent(new Event("input", { bubbles: true })); }
say(`Read a ${found.kind === "wif" ? "WIF private key" : "32-byte key"}. Switch Source to the matching option if it is not already, then press Import.`, false);
} else {
// Don't paste unrecognised payloads into a key field — show a short
// preview so the user can see it was, say, a URL, and stop there.
const peek = found.value.length > 90 ? found.value.slice(0, 90) + "…" : found.value;
say(`That QR decoded to something that is not a seed phrase or key: "${peek}"`, true);
}
} catch (err) {
say(cleanErr(err), true);
} finally {
btn.textContent = prev; btn.disabled = false;
input.value = "";
}
});
}
function openImportModal(initialChain) {
const chains = Object.keys(IMPORT_COIN_CONFIG);
let curChain = chains.includes(initialChain) ? initialChain : "bch";
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px";
// Check vault lock state up front. Imported wallets that mounted at
// startup can leave overallPhase === "ready" even when the vault is
// still locked (imports skip vault.derive) — that's what makes the
// main panel look unlocked while a fresh "wallet-imports-add" IPC
// fails with "password vault is locked". So we test the vault
// directly here and, if it's locked, surface an unlock form inside
// the modal rather than blindly submitting and showing red text.
const paintUnlockGate = (errText) => {
overlay.innerHTML = `
🔒 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.
None of these paths has any history on ${esc(r.network)}. Either this seed has never been used here, or its wallet uses a path Aegis does not list — type it in by hand and re-scan.
`;
}
};
overlay.querySelector("#conGo").addEventListener("click", async () => {
const checked = Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked')).map((c) => c.dataset.conwid);
if (!checked.length) return;
const msg = overlay.querySelector("#conMsg"); msg.hidden = true;
// PIN gate fires ONCE for the whole batch — a batch send-max operation
// is a single user intent.
if (!await pinGate("transaction", `Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`)) {
msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return;
}
setBusy(true, "Sending…");
try {
const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
renderResult(res);
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
setBusy(false);
}
});
const renderResult = (res) => {
const ok = res.results.filter((r) => r.ok);
const bad = res.results.filter((r) => !r.ok);
const explorer = sel()?.explorerTx || "";
const okRows = ok.map((r) => {
const link = explorer && r.txid ? `${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.
const RCV_VIEWS = ["address", "assets", "nfts"];
let rcvView = "address";
try {
const saved = localStorage.getItem("aegis/rcvView");
if (RCV_VIEWS.includes(saved)) rcvView = saved;
} catch (_e) {}
function paintRcvView() {
const addr = $("rcvAddressPane"), assets = $("rcvAssetsPane"), nfts = $("rcvNftsPane");
if (!addr || !assets || !nfts) return;
document.querySelectorAll("[data-rcv-view]").forEach((b) =>
b.classList.toggle("on", b.dataset.rcvView === rcvView));
addr.hidden = rcvView !== "address";
assets.hidden = rcvView !== "assets";
nfts.hidden = rcvView !== "nfts";
// The QR's backing store is sized from its rendered box, so one drawn
// while its pane was hidden comes out blank. Redraw on reveal — and note
// renderReceive only redraws when the address CHANGES, so switching back
// to an unchanged address would otherwise never repaint it.
if (rcvView === "address") {
const a = sel()?.address || "";
if (a) { try { drawQr(qrPayload(chain(), a, sel()?.network)); } catch (_e) {} }
}
}
function paintRcvMode() {
const normal = $("rcvNormal"); const cons = $("rcvConsolidate");
if (!normal || !cons) return;
normal.hidden = rcvMode !== "receive";
cons.hidden = rcvMode !== "consolidate";
if (rcvMode === "consolidate") {
const host = $("rcvConsolidateInline");
if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); }
}
}
// A paired dapp's relay status, from RelayStatus.status. Worth showing:
// "reconnecting" is the difference between "the dapp will see my next
// signature" and "this pairing is dead and I should re-pair", and that is
// invisible otherwise. "connected" is the normal case, so it stays quiet.
function wcStatusTag(status) {
const s = String(status || "");
if (!s || s === "connected") return "";
const label = s === "reconnecting" ? "RECONNECTING"
: s === "session_deleted" ? "SESSION GONE"
: s === "disconnected" ? "OFFLINE" : s.toUpperCase();
return `${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 blocked = readyBch.filter((w) => w.wcBlocked);
// Same precedence as the page-initiated pairing in index.js — the wallet
// nominated for WizardConnect, else whatever the panel is showing. Two
// different rules here and there is how a pairing surprises someone.
const wcRole = (state && state.roles && state.roles.wizardconnect) || null;
const preferId = (pairable.find((w) => w.id === wcRole) || pairable.find((w) => w.id === state.selectedWalletId) || pairable[0] || {}).id || null;
const opt = (w) => ``;
// Pairable first, under a heading, whenever anything is blocked. In
// registry order a bulk keystore import puts fifteen unpairable WIF
// wallets ahead of the two chipnet ones that pair fine, and a list whose
// visible rows are all greyed-out testnet entries reads as "chipnet isn't
// supported" — which is not true and never was. Grouping keeps the
// reasons visible without letting them bury the working options.
const options = blocked.length
? ``
+ ``
: pairable.map(opt).join("");
// Greying an option out with "can't pair" and giving no reason anywhere on
// the page reads as a broken wallet rather than a property of how it was
// added. Show the reasons whenever ANY wallet is blocked — the old note
// only appeared when nothing at all could pair, so a user with one good
// mainnet wallet and ten WIF-imported chipnet ones saw no explanation.
const reasons = [...new Set(blocked.map((w) => w.wcBlocked))].map(esc).join(" ");
const blockedNote = !blocked.length
? ""
: !pairable.length
? `
${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) => `
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
? `🔒`
: ``;
// Role badges, so which wallet pays and which one dapps get is legible
// from the list instead of only from inside the manage modal. They share
// the label's grid column via a flex wrapper: the label keeps its
// ellipsis, the badge stays whole.
const roles = (state && state.roles) || {};
const badges = [
roles.payments === w.id ? `pay` : "",
roles.wizardconnect === w.id ? `wc` : "",
].join("");
const labelCell = `${esc(w.label || shortName || "—")}`;
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("#stripBackX").addEventListener("click", back);
// Manual refresh: force every wallet under this coin+network to
// re-poll now. Handy when a testnet faucet just delivered or a
// mainnet transfer is expected to have landed.
const refreshBtn = el.querySelector("#stripRefresh");
if (refreshBtn) refreshBtn.addEventListener("click", async () => {
if (refreshBtn.dataset.spinning === "1") return;
refreshBtn.dataset.spinning = "1";
const prev = refreshBtn.textContent;
refreshBtn.textContent = "…";
// Say what happened. A tooltip-only result is indistinguishable from the
// button doing nothing, which is how this one came to be reported as
// broken even when it worked: balances that were already current changed
// nothing on screen.
let mark = null;
try {
const r = await S.invoke("refreshChain", { chain: gw[0]?.chain, network: gw[0]?.network });
const failed = (r?.results || []).filter((x) => !x.ok);
if (failed.length) {
refreshBtn.title = `Refresh failed on ${failed.length} of ${r.results.length}: ` + failed.map((f) => f.error).join("; ");
mark = "⚠";
} else {
refreshBtn.title = `Refreshed ${r.results.length} wallet${r.results.length === 1 ? "" : "s"}`;
mark = "✓";
}
} catch (e) {
refreshBtn.title = "Refresh failed: " + cleanErr(e);
mark = "⚠";
} finally {
delete refreshBtn.dataset.spinning;
if (mark) {
refreshBtn.textContent = mark;
setTimeout(() => { if (refreshBtn.isConnected) refreshBtn.textContent = prev; }, 1200);
} else refreshBtn.textContent = prev;
}
});
el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => {
if (e.target.closest(".wact")) return;
if (e.target.closest(".wacopy")) return;
const id = row.dataset.listpick;
// Record it as this network's current wallet, so switching away on the
// header's network chips and back returns here rather than to whichever
// wallet happens to be first.
const picked = (state?.wallets || []).find((w) => w.id === id);
if (picked) activeWalletBySubgroup.set(`${picked.chain}:${picked.network}`, id);
try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); }
catch (er) { showErr(cleanErr(er)); }
}));
// Address copy chip. Uses navigator.clipboard when available (the addon
// panel runs under file:// but Electron gives it clipboard access), and
// falls back to a textarea+execCommand for older stacks. Visual "copied"
// flash lasts ~1s so the user sees the click landed.
el.querySelectorAll(".wacopy[data-lpcopy]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
const addr = b.dataset.lpcopy || "";
if (!addr) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(addr);
else {
const ta = document.createElement("textarea");
ta.value = addr; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
// innerHTML, not textContent — the button holds an inline SVG now,
// and assigning textContent would destroy it and leave a blank
// square once the confirmation timed out.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
}));
el.querySelectorAll("[data-lpedit]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit);
if (w) openWalletManageModal(w);
}));
// Removing a wallet now happens inside the manage modal (the row's ⋯),
// so the row no longer emits its own remove button. The modal calls
// render() itself, which redraws the strip — including falling back to
// the coin list when the last address under a coin goes away.
el.querySelector("#stripAddMore").addEventListener("click", async () => {
// This used to create a fresh wallet immediately, on the reasoning
// that being inside a coin's address list made the intent
// unambiguous. It doesn't: "add another BCH wallet" is just as often
// "bring in the one I already have somewhere else". Creating instead
// of importing is not a harmless guess either — the user ends up with
// an empty new address and has to work out why their funds aren't
// there. So ask.
const first = gw[0];
const pick = await aegisChoose({
title: `Add another ${meta.ticker} wallet`,
// meta.coinName already carries the network ("TRX Nile"), so naming
// networkLabel again read "TRX Nile · Nile testnet".
body: `On ${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";
if (body.dataset.mode === "setup") return;
body.dataset.mode = "setup";
sub.textContent = "Pick a master password — every Aegis wallet is derived from it. The same master password on another machine recreates the same addresses.";
body.innerHTML = `
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) {
// render() runs on every state push — balance polls fire it every couple
// of seconds — and this used to rebuild body.innerHTML each time, wiping
// the pad DOM and its digit buffer out from under someone mid-entry.
// That is the "resets after two digits" report: the reset is timed to the
// poll, not to the keypress count. Rebuild only when the mode changes.
if (body.dataset.mode === "pin") return;
body.dataset.mode = "pin";
body.innerHTML = `
`;
const doUnlock = async () => {
const pw = $("gateUnlockPw").value;
const msg = $("gateUnlockMsg"); msg.hidden = true;
if (!pw) return;
try {
state = await S.invoke("vaultUnlock", { masterPassword: pw });
// Remember the master password for a moment so the user can, right
// after unlock, enroll a PIN without re-typing it. Cleared as soon
// as the panel navigates or reloads.
window.__aegisLastPw = pw;
setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000);
// Empty the field and let the form be rebuilt next time. It stayed
// filled behind the unlocked wallet, so after an idle lock or Sign out
// the password was sitting in the box for anyone to press Unlock.
try { $("gateUnlockPw").value = ""; } catch {}
body.dataset.mode = "";
body.dataset.forcePw = "";
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
$("gateUnlockBtn").addEventListener("click", doUnlock);
$("gateUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); });
try { $("gateUnlockPw").focus(); } catch {}
if (hasPin && $("lockUsePin")) $("lockUsePin").addEventListener("click", () => { body.dataset.forcePw = ""; renderLockScreen("locked"); });
if ($("lockGoSettings")) $("lockGoSettings").addEventListener("click", () => showTab("settings"));
}
// Wire up a PIN pad instance. `onComplete(pin)` runs when 6 digits are
// typed and must return "ok" (leave state) or "reset" (clear back to
// empty). Rendered by renderLockScreen for the unlock flow and by the
// PIN modal helper for set / verify flows.
// IMPORTANT: pass `dots`/`keys`/`err` as elements scoped to the pad's own
// container, never via getElementById. Each pad's markup hard-codes its ids,
// so two pads alive at once (easy: a load-time gate plus a transaction gate)
// are duplicate ids — a global lookup then returns the FIRST one, and this
// function binds a second click handler to the wrong pad's buttons. The
// symptom is a pad that appends two digits per press and resets after three.
function setupPinPad({ dots, keys, err, onComplete }) {
let buf = "";
const paint = () => {
const nodes = dots.querySelectorAll(".pindot");
nodes.forEach((n, i) => n.classList.toggle("on", i < buf.length));
};
keys.querySelectorAll("button[data-k]").forEach((b) => b.addEventListener("click", async () => {
const k = b.dataset.k;
if (err) err.textContent = "";
if (k === "clear") { buf = ""; paint(); return; }
if (k === "back") { buf = buf.slice(0, -1); paint(); return; }
if (buf.length >= 6) return;
buf += k;
paint();
if (buf.length === 6) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
// Always forget the digits. A pad that kept its six after "ok" was
// dead on the next lock and held the PIN in memory until then.
buf = ""; paint();
}
}
}));
// Keyboard fallback — some users prefer typing 6 digits fast.
const keyHandler = async (e) => {
if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; }
// Only while this pad is actually on screen, and never for keys typed
// into a field. The lock-screen pad stays in the DOM (hidden) after
// unlock, so six digits typed into the amount box counted as a PIN
// attempt — and five such amounts locked the PIN for 15 minutes.
if (dots.offsetParent === null) return;
// Two pads can be visible at once (a dapp's PIN request over a send's
// pad): only the topmost one listens, or six digits would complete both
// and a wrong PIN would cost two attempts.
const topModal = [...document.querySelectorAll(".pinmodal")].pop();
if (topModal && !topModal.contains(dots)) return;
const tgt = e.target;
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = "";
if (/^[0-9]$/.test(e.key)) {
if (buf.length >= 6) return;
buf += e.key; paint();
if (buf.length === 6) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false);
buf = ""; paint();
}
}
} else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); }
else if (e.key === "Escape") { buf = ""; paint(); }
};
document.addEventListener("keydown", keyHandler);
}
function render() {
if (!state) return;
noteSelectedWallet();
const s = sel();
const ready = s && s.phase === "ready";
const phase = s?.phase;
// Locked / no-setup take over the whole panel — the wallet strip, tab
// bar and per-wallet views would show either nothing or partial data,
// so we hide them behind an opaque overlay until the vault is open.
const fullLock = phase === "locked" || phase === "nosetup";
// Settings is the only always-usable tab (fiat prices, connected sites
// — nothing needs a live wallet). Every other tab is gated.
const onSettings = tab === "settings";
// An owed PIN hides everything, Settings included: unlike the vault lock
// (where Settings must stay reachable to configure a PIN in the first
// place), the PIN is already configured here and letting Settings through
// would be a way around the door.
if (pinGateBlocked) { paintPinDoor(); return; }
$("tabs").hidden = !(ready || onSettings);
const gate = $("gate");
gate.hidden = ready || onSettings || fullLock;
if (onSettings) fillSettings();
const lockScreen = $("lockScreen");
const showLock = fullLock && !onSettings;
lockScreen.hidden = !showLock;
// Hide the rest of the panel behind the lock overlay. Settings stays
// open even while locked (users can set up PIN policy without unlocking
// first), so a lock override does NOT hide the tab bar when the user
// has clicked into Settings.
const hideChrome = fullLock && !onSettings;
document.querySelector("header").hidden = hideChrome;
document.querySelector("nav").hidden = hideChrome;
$("walletStrip").hidden = hideChrome;
// Re-drawing the strip after unhiding keeps the coins/addresses view
// in sync with the current wallet set.
if (!hideChrome) renderWalletStrip();
// Header: replace the badge slot with the coin's SVG and show
//
$("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22);
$("hLabel").textContent = s?.label || "Aegis Wallet";
// 0.25.0: the network name moved out of this row into the selector chips
// under the balance. What is left here is connection state, which only
// earns attention when it is bad.
$("hNetRow").hidden = !s?.meta;
paintNetSelector(s);
if (showLock) {
renderLockScreen(phase);
}
if (!ready && !fullLock) {
const copy = {
error: ["⚠", "This wallet could not start.", s?.error || ""],
empty: ["🛡", "No wallets yet.", "Aegis can derive a fresh wallet from your Theseus password vault — nothing extra to write down — or import one you already have from its seed phrase or private key."],
}[phase] || ["…", "Starting…", ""];
let form = "";
if (phase === "empty") {
form = ``;
}
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;
paintSendFrom(cur, others);
// Send keeps its toggle. Receive's became a button in the address actions
// (0.18.0), so it is shown/counted the same way but is not a toggle.
for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvConsolidateBtn", "rcvConsolidateCount"]]) {
const wrap = $(wrapId); if (!wrap) continue;
wrap.hidden = !showToggle;
if (showToggle) {
const c = $(cntId); if (c) c.textContent = `${others.length}`;
}
}
// Reset to normal mode when the toggle disappears (no siblings left).
if (!showToggle) { sendMode = "send"; rcvMode = "receive"; }
paintSendMode();
paintRcvMode();
paintRcvView();
}
// Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N
// wallets" under the header. Only rendered when prices are on AND there are
// two or more wallets (a single wallet's fiat already sits in #balFiat).
function renderPortfolio() {
const el = $("portfolio");
const wallets = state?.wallets || [];
// Show whenever prices are on and at least one wallet exists — the single-
// wallet case still benefits from a portfolio row when the balance-line
// fiat is elided (e.g. header hidden during pane switches).
if (!state?.prices?.enabled || !wallets.length) { el.hidden = true; return; }
let total = 0, priced = 0;
for (const w of wallets) {
const b = w.balance;
if (!b) continue;
const units = (typeof b.confirmed === "string")
? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString()
: (b.confirmed || 0) + (b.unconfirmed || 0);
const usd = usdOf(w.chain, units, w.decimals);
if (usd != null) { total += usd; priced++; }
}
if (!priced) {
el.hidden = false;
el.innerHTML = `Portfolio: ${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 = "";
}
}
// Certificates chip. Counts individual certificates, not categories — "3"
// should mean three things you hold, which is the question the chip is
// answering. A chain with no non-fungible concept lands here with 0 and says
// so, rather than the chip vanishing on some coins and not others.
function setCertsCount(n) {
const chip = $("rcvNftsCount");
if (chip) chip.textContent = n ? String(n) : "";
const card = $("nftsCard");
if (card) card.hidden = false;
if (!n) {
const el = $("nftsList");
if (el) el.innerHTML = `
No certificates held by this wallet.
`;
const cnt = $("nftsCount");
if (cnt) cnt.textContent = "";
}
}
// One row per certificate: which category it belongs to, its commitment (the
// payload that makes it specific), and its capability — minting / mutable /
// none is the difference between a certificate that can still issue others
// and one that is fixed, which is worth seeing at a glance.
function renderCerts(balances, metaMap) {
const rows = [];
for (const cat of Object.keys(balances || {})) {
const b = balances[cat];
const list = Array.isArray(b?.nfts) ? b.nfts : [];
for (const n of list) rows.push({ cat, ...n });
}
setCertsCount(rows.length);
if (!rows.length) return;
const el = $("nftsList");
if (!el) return;
const cntEl = $("nftsCount");
if (cntEl) cntEl.textContent = `· ${rows.length}`;
el.innerHTML = rows.map((r) => {
const meta = metaMap?.[r.cat] || null;
const named = meta?.name || meta?.symbol || null;
const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6));
// "immutable" is the quiet default — it describes most certificates and
// tagging every row with it would say nothing. Only the capabilities
// that change what the holder can DO get a tag. ("none" is Electrum's
// word for the same thing; lib/wallet.js normalises it to immutable, but
// accept both so an older cached snapshot still reads correctly.)
const cap = String(r.capabilityLabel || "").toLowerCase();
const capTag = cap && cap !== "none" && cap !== "immutable"
? ` ${esc(cap.toUpperCase())}`
: "";
// A commitment is arbitrary bytes; it is the only thing distinguishing
// two certificates of the same category, so show it rather than hide it.
const commit = String(r.commitmentHex || "");
const commitTxt = commit
? (commit.length > 24 ? commit.slice(0, 24) + "…" : commit)
: "no commitment";
return `
${esc(title)}${capTag}
${esc(commitTxt)}
${named ? esc(r.cat.slice(0, 8)) + "…" : ""}
`;
}).join("");
}
// Name a token yourself. Self-minted categories publish nothing — no
// registry entry, and usually not even an OP_RETURN in the genesis — so
// without this they can only ever read as a hex string. Clearing both fields
// deletes the local name and lets any registry entry show through again.
function openTokenLabelModal(category, meta) {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:100000;padding-top:24px";
const isLocal = !!meta?.local;
overlay.innerHTML = `
Name this token
${esc(category)}
Name
Ticker
Decimals
How many decimal places the amount should be shown with. Leave blank for whole units.
Stored on this device only. It takes precedence over any BCMR registry; clear both fields to remove it.
${isLocal ? `` : ``}
`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch (_e) {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#tlClose").addEventListener("click", close);
overlay.querySelector("#tlCancel").addEventListener("click", close);
const save = async (clear) => {
const msg = overlay.querySelector("#tlMsg"); msg.hidden = true;
const dRaw = overlay.querySelector("#tlDecimals").value.trim();
if (!clear && dRaw && !/^\d{1,2}$/.test(dRaw)) {
msg.textContent = "Decimals must be a whole number between 0 and 18."; msg.hidden = false; return;
}
try {
await S.invoke("setTokenLabel", {
category,
name: clear ? "" : overlay.querySelector("#tlName").value,
symbol: clear ? "" : overlay.querySelector("#tlSymbol").value,
decimals: clear || !dRaw ? undefined : Number(dRaw),
});
close();
renderTokens();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
};
overlay.querySelector("#tlSave").addEventListener("click", () => save(false));
if (isLocal) overlay.querySelector("#tlClear").addEventListener("click", () => save(true));
overlay.querySelector("#tlName").focus();
}
function renderTokens() {
const s = sel();
const card = $("tokensCard");
const el = $("tokensList");
// Certificates are BCH-only today; every other chain lands on 0 and says
// so. Overwritten below for BCH.
if (chain() !== "bch") setCertsCount(0);
// SOL wallets: SPL tokens with a Send button (existing flow).
if (chain() === "sol") {
const tokens = s?.tokens || [];
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.";
if (!tokens.length) return;
el.innerHTML = tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const bal = fmtTokenAmount(t.balance, dec);
return `
`;
}).join("");
el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => {
sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) };
showTab("send");
}));
return;
}
// BCH wallets: CashTokens. Read-only display in 0.7.0 (spend ships in
// 0.7.1). Categories come pre-serialised from the wallet (fungible is
// a decimal string; NFTs are per-UTXO). Names/symbols/decimals/icons
// come from BCMR, fetched async; the first paint uses raw category hex.
if (chain() === "bch") {
const balances = s?.tokenBalances || {};
const allCats = Object.keys(balances);
// Assets = categories carrying a fungible balance. Certificates live in
// their own pane, so a category that is purely non-fungible does not
// take up a row here saying "1 NFT".
const cats = allCats.filter((c) => String(balances[c]?.fungible || "0") !== "0");
setAssetsCount(cats.length);
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "Fungible CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings.";
const draw = (metaMap) => {
// setAssetsCount already wrote the "no assets" line when there are no
// fungible categories; joining an empty list would wipe it. Common
// case now that purely-non-fungible categories live in the other pane.
if (!cats.length) { renderCerts(balances, metaMap); return; }
el.innerHTML = cats.map((cat) => {
const b = balances[cat];
const meta = metaMap?.[cat] || null;
const decimals = meta && Number.isFinite(meta.decimals) ? meta.decimals : 0;
const fungibleRaw = String(b.fungible || "0");
const showFungible = fungibleRaw !== "0";
const name = meta?.name || meta?.symbol || null;
const symbol = meta?.symbol || "";
const icon = meta?.iconUri || "";
const iconHtml = icon ? `` : "";
const catShort = cat.slice(0, 10) + "…" + cat.slice(-6);
const fungibleTxt = showFungible ? fmtTokenAmount(fungibleRaw, decimals) + (symbol ? " " + symbol : "") : "";
const amountLine = fungibleTxt || "—";
const localTag = meta?.local ? ` YOURS` : "";
// Named: name on top, category beneath. Unnamed: the category IS the
// only identity, so it goes on top and the sub-line says what it is
// rather than repeating the same hex twice.
const utxoTxt = b.utxoCount ? `${b.utxoCount} UTXO${b.utxoCount === 1 ? "" : "s"}` : "";
const nameLine = name
? `${iconHtml}${esc(name)}${symbol && name !== symbol ? ` ${esc(symbol)}` : ""}${localTag}`
: `${iconHtml}${esc(catShort)}`;
const subLine = name
? `${esc(catShort)}`
: ["unnamed token", utxoTxt].filter(Boolean).join(" · ");
// ✎ per row: these tokens often publish no metadata at all, so
// naming one locally is the only way it will ever read as anything
// but a hex string.
return `
${nameLine}
${subLine}
${esc(amountLine)}
`;
}).join("");
el.querySelectorAll("[data-tokname]").forEach((b) => b.addEventListener("click", () => {
openTokenLabelModal(b.dataset.tokname, metaMap?.[b.dataset.tokname] || null);
}));
renderCerts(balances, metaMap);
};
// Paint immediately with whatever we know synchronously so the row
// set doesn't wait for the network. Then run the async lookup and
// redraw with names + icons.
draw({});
if (!allCats.length) return;
S.invoke("tokenMetadata", { categories: allCats }).then((res) => {
if (chain() !== "bch") return; // user switched away mid-fetch
// 0.17.0: the reply carries the map under .meta plus whether any
// registry actually answered. The old shape was the bare map and the
// failure path was .catch(() => {}), so two dead default registries
// went unnoticed — every token looked simply unregistered.
const map = res && res.meta ? res.meta : (res || {});
draw(map);
if (hintEl && res && res.registriesAnswered === false) {
hintEl.textContent = res.registries
? `No configured BCMR registry answered, so names are unavailable — check the registry list in Settings, or name a token yourself with ✎.`
: `No BCMR registry configured, so names are unavailable — add one in Settings, or name a token yourself with ✎.`;
}
}).catch((e) => {
if (hintEl) hintEl.textContent = `Token names unavailable: ${cleanErr(e)}. You can still name a token yourself with ✎.`;
});
return;
}
// Every other account-model chain (TRX, ETH, and anything added later)
// renders from the same `tokens` shape the adapters already return, so
// this is keyed on the DATA rather than on a list of chain ids — a new
// chain gets the asset list for free instead of silently falling
// through to a hidden card the way ETH did until 0.9.3.
{
const tokens = s?.tokens || [];
if (!tokens.length) { setAssetsCount(0); return; }
setAssetsCount(tokens.length);
const hintEl = $("tokensHint");
const kindLabel = chain() === "trx" ? "TRC20" : chain() === "eth" ? "ERC20" : "Tokens";
if (hintEl) hintEl.textContent = `${kindLabel} tokens held by this wallet. Read-only in this build — open the explorer from the header to move them.`;
// A symbol claimed by more than one contract is the lookalike pattern:
// spam mints borrow a trusted ticker so a careless send lands on the
// wrong contract. We can't tell which is genuine, so we don't guess —
// we mark every member of the clash and let the user check the address.
const symCount = new Map();
for (const t of tokens) {
const k = (t.symbol || "").toLowerCase();
if (t.known && k) symCount.set(k, (symCount.get(k) || 0) + 1);
}
const nativeUnits = walletBalanceUnits(sel()) || 0;
const nativeRow = `
`;
const cnt = $("tokensCount");
if (cnt) cnt.textContent = `· ${tokens.length + 1}`;
el.innerHTML = nativeRow + tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const short = String(t.mint || "");
const addrLine = `${esc(short.slice(0, 10))}…${esc(short.slice(-6))}`;
const clash = t.known && symCount.get((t.symbol || "").toLowerCase()) > 1;
// Unknown contracts have no decimals, so a raw integer would be a
// misleading "balance". Say so instead of inventing a number.
const title = t.known
? `${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + "" : ""}${clash ? ' LOOK-ALIKE' : ""}`
: `Unknown token`;
const amount = t.known
? `${esc(fmtTokenAmount(t.balance, dec))}`
: `${esc(t.balance)} raw`;
return `
${title}
${addrLine}
${amount}
`;
}).join("");
}
}
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.
function fmtTokenAmount(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
const group = (w) => w.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
if (d === 0) return (neg ? "-" : "") + group(abs);
const pad = abs.padStart(d + 1, "0");
const whole = pad.slice(0, pad.length - d);
let frac = pad.slice(pad.length - d).replace(/0+$/, "");
// An 18-decimal token renders as 60000000.000000005435817984 without a
// cap — 26 digits of noise for a balance whose meaningful part is the
// first few. Keep enough to distinguish small amounts, drop the rest;
// the exact value stays available in the row's title attribute.
const CAP = 8;
let truncated = false;
if (frac.length > CAP) { frac = frac.slice(0, CAP).replace(/0+$/, ""); truncated = true; }
return (neg ? "-" : "") + group(whole) + (frac ? "." + frac : "") + (truncated && !frac ? "…" : "");
}
// Exact, ungrouped rendering for tooltips — what fmtTokenAmount hides.
function fmtTokenAmountExact(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
if (d === 0) return (neg ? "-" : "") + abs;
const pad = abs.padStart(d + 1, "0");
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return (neg ? "-" : "") + pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
// ---- Send: which address the money leaves from -----------------------------
// Aegis models one address per wallet entry, so "send from" is the same
// question as "which of this coin's wallets". Picking one switches the panel
// selection rather than carrying a separate source: planSend and send resolve
// the wallet host-side from the selection, and a second notion of "current"
// would be two answers to one question, with the approval dialog free to
// disagree with the form.
function paintSendFrom(cur, others) {
const field = $("sendFromField"), box = $("sendFrom"), hint = $("sendFromHint");
if (!field || !box) return;
if (!cur || !others.length) { field.hidden = true; return; }
// List order, not "current first" — the order matches the wallet list the
// user already knows, so the same address sits in the same place each time.
const mine = (state?.wallets || []).filter((w) => w.chain === cur.chain && w.network === cur.network);
const roles = (state && state.roles) || {};
// Rebuild only when the options or balances actually change, so a re-render
// mid-typing doesn't reset a select under the cursor.
const key = mine.map((w) => `${w.id}:${walletBalanceUnits(w) || 0}:${roles.payments === w.id ? "p" : ""}`).join("|");
if (box.dataset.key !== key) {
box.dataset.key = key;
box.innerHTML = mine.map((w) => {
const bal = fmtBig(walletBalanceUnits(w) || 0, w.decimals);
const a = stripAddrPrefix(String(w.address || ""));
const tail = a ? " · " + (a.length > 16 ? a.slice(0, 8) + "…" + a.slice(-5) : a) : "";
const star = roles.payments === w.id ? " ★" : "";
return ``;
}).join("");
box.onchange = async () => {
const id = box.value;
if (!id || id === state.selectedWalletId) return;
try {
state = await S.invoke("selectWallet", { id });
settingsFilled = false;
// A different source means the costed plan is about other UTXOs.
lastPlan = null;
render();
schedulePlan();
} catch (e) {
const m = $("sendMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
}
};
}
box.value = cur.id;
const payW = mine.find((w) => roles.payments === w.id);
if (hint) {
hint.textContent = payW
? (payW.id === cur.id ? "Your default wallet for payments." : `★ ${payW.label} is your default for payments.`)
: "Pick a default for payments in a wallet's ⋯ menu.";
}
field.hidden = false;
}
function applyUnitPicker() {
const s = sel(); if (!s) return;
if (!unit) unit = "big";
// ---- SPL asset picker (SOL wallets with tokens) ---------------------
const tokens = (chain() === "sol" && s.tokens) || [];
const assetField = $("sendAssetField");
if (tokens.length) {
assetField.hidden = false;
const sel_ = $("sendAsset");
// Rebuild whenever the asset set changes so a new token appears.
const key = tokens.map((t) => t.mint).join("|");
if (sel_.dataset.key !== key) {
sel_.dataset.key = key;
sel_.innerHTML = `` + tokens.map((t) =>
``
).join("");
sel_.onchange = () => {
const opt = sel_.options[sel_.selectedIndex];
sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null;
applyUnitPicker(); schedulePlan();
};
}
// Reflect the current sendAsset back into the select.
sel_.value = sendAsset ? sendAsset.mint : "";
} else {
assetField.hidden = true;
sendAsset = null;
}
const isToken = sendAsset != null;
const big = isToken ? sendAsset.symbol : bigUnitLabel();
const small = isToken ? "raw" : smallUnitLabel();
$("unitPicker").innerHTML =
`` +
``;
$("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u)));
$("sendTo").placeholder = ({
bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…",
btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)",
trx: "T… (base58check, 34 chars)",
sc: "addr1… (76-hex + checksum)",
dgb: "dgb1q… (or D… / S… depending on family)",
eth: "0x… (40 hex chars, EIP-55)",
sol: "base58 public key (32 bytes)",
})[chain()] || "recipient address";
$("sendAmt").placeholder = unit === "big" ? "0.00" : "0";
}
function setUnit(u) {
if (u === unit) return;
const s = amountUnits();
unit = u;
applyUnitPicker();
// Exact conversion in the decimals of what is being sent. fmtBig() caps at
// 8 places and always used the chain's decimals, so 1 wei became "0" and a
// 6-decimal token amount shrank 1000× on a round trip.
if (s) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(s), amountDecimals()) : String(s);
updateSendFiatPreview();
}
function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); }
// — pure functions, exercised by the sandbox harness.
// What the user typed → { whole, frac } digit strings. A wallet must never
// guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
// and Number() accepted "1e3", "0x10" and "-0.5".
function parseAmountText(text) {
let raw = String(text == null ? "" : text).trim();
if (!raw) return { empty: true };
// Space inside the number is refused, not deleted: "1 0" used to become 10.
if (/\s/.test(raw)) return { error: "Remove the space from the amount" };
if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) {
raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
} else if (/^\d{1,3},\d{3}$/.test(raw)) {
return { error: `"${raw}" could mean ${raw.replace(",", "")} or ${raw.replace(",", ".")} — write it without the comma, or with a dot` };
} else if (/^\d*,\d+$/.test(raw)) {
raw = raw.replace(",", "."); // decimal comma: 0,5
}
if (!/^(\d+\.?\d*|\.\d+)$/.test(raw)) return { error: "Enter a plain number, like 0.5" };
const [w, f = ""] = raw.split(".");
return { whole: (w || "0").replace(/^0+(?=\d)/, ""), frac: f };
}
// → smallest units as a decimal string, or { error }. `unit` is "big" (coins)
// or "small" (sats / wei / raw token units).
function amountToUnits(text, decimalsN, unit) {
const p = parseAmountText(text);
if (p.empty) return { units: "0", empty: true };
if (p.error) return { error: p.error };
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
if (unit === "small") {
if (/[1-9]/.test(p.frac)) return { error: "The smallest unit cannot have decimals" };
return { units: BigInt(p.whole).toString() };
}
if (/[1-9]/.test(p.frac.slice(d))) return { error: `At most ${d} decimal place${d === 1 ? "" : "s"} here` };
const frac = (p.frac + "0".repeat(d)).slice(0, d);
return { units: (BigInt(p.whole) * (10n ** BigInt(d)) + BigInt(frac || "0")).toString() };
}
// Exact inverse for filling the field: units → "1.5" with no grouping.
function unitsToDecimalText(units, decimalsN) {
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
const s = String(units).replace(/^0+(?=\d)/, "");
if (!/^\d+$/.test(s)) return "";
if (d === 0) return s;
const pad = s.padStart(d + 1, "0");
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
//
// Why the amount in the field is not usable, or null. Set by amountUnits().
let amountError = null;
function amountUnits() {
amountError = null;
const d = amountDecimals();
const r = amountToUnits($("sendAmt").value, d, unit);
if (r.error) { amountError = r.error; return 0; }
if (r.empty) return 0;
// SPL tokens and 18/24-decimal coins travel as decimal strings; the 8–9
// decimal chains keep their Number contract with the host, bounded so a
// value past 2^53 is refused instead of silently rounded.
const bigDecimals = sendAsset != null || d > 15;
if (bigDecimals) return r.units;
if (BigInt(r.units) > BigInt(Number.MAX_SAFE_INTEGER)) { amountError = "That amount is too large"; return 0; }
return Number(r.units);
}
// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere).
function balanceSum(b) {
if (!b) return 0;
if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") {
return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString();
}
return (b.confirmed || 0) + (b.unconfirmed || 0);
}
// ---- history ---------------------------------------------------------------
// History asset filter (0.9.2). Both on by default — these narrow a
// combined feed, they don't opt into one.
let histShowNative = true;
let histShowTokens = true;
let histFilterWired = false;
function wireHistFilter() {
if (histFilterWired) return;
const n = $("histNative"), t = $("histTokens");
if (!n || !t) return;
histFilterWired = true;
// Unchecking both would leave an empty list with no hint why, so the
// last one standing stays checked.
const onChange = () => {
if (!n.checked && !t.checked) { n.checked = true; }
histShowNative = n.checked;
histShowTokens = t.checked;
renderHistory();
};
n.addEventListener("change", onChange);
t.addEventListener("change", onChange);
}
function renderHistory() {
const s = sel();
const all = s?.history || [];
const el = $("txlist");
// Only offer the filter when there is actually something to filter.
const hasTokens = all.some((t) => t.asset);
const filterEl = $("histFilter");
if (filterEl) {
filterEl.hidden = !hasTokens;
const nativeLbl = $("histNativeLbl");
if (nativeLbl) nativeLbl.textContent = s?.meta?.ticker || "Coin";
}
const list = hasTokens
? all.filter((t) => (t.asset ? histShowTokens : histShowNative))
: all;
const cntEl = $("histCount");
if (cntEl) cntEl.textContent = hasTokens ? `${list.length} of ${all.length}` : "";
if (!list.length) {
el.innerHTML = `
${s?.scanning ? "Syncing…" : (all.length ? "Nothing matches this filter." : "No transactions yet.")}
`;
return;
}
const dec = s?.decimals ?? 8;
el.innerHTML = list.map((t) => {
// `delta` arrives in three shapes now: a number (UTXO chains), a decimal
// STRING (ETH — 18 decimals of wei overflows a JS number, so it must
// stay exact), or null (Solana, where the signature feed carries no
// amount and fetching one per tx would be 25 extra round trips a poll).
// Treating null as 0 would render "+ —", claiming a receive we cannot
// actually verify, so unknown amounts get their own neutral branch.
const known = t.delta != null;
const neg = known && String(t.delta).trim().startsWith("-");
const inc = known ? !neg : null;
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
const who = inc === null ? "" : inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : "");
const what = (inc === null ? (t.kind || "Transaction") : inc ? "Received" : "Sent") + (who ? " " + who : "");
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf")
: (t.status === "failed" ? "failed" : t.status === "pending" ? "pending" : "unconfirmed");
// Strip the sign as text rather than via Math.abs so a big-decimal
// string keeps every digit.
const magnitude = known ? String(t.delta).trim().replace(/^[-+]/, "") : "";
const isZero = known && /^0*$/.test(magnitude);
// A token row carries its OWN decimals and ticker — rendering a USDT
// amount against the chain's 6-decimal TRX scale (or an 18-decimal
// ERC-20 against 18 for ETH) would be off by orders of magnitude.
const rowDec = t.asset ? (t.assetDecimals ?? 0) : dec;
const rowTicker = t.asset ? ` ${t.asset}` : "";
const amountHtml = !known ? "—"
: isZero ? "—"
: `${inc ? "+" : "−"}${esc(fmtTokenAmount(magnitude, rowDec))}${esc(rowTicker)}`;
const icon = inc === null ? "·" : inc ? "↓" : "↑";
const iconCls = inc === null ? "" : inc ? "in" : "out";
return `
`;
}).join("");
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid))));
wireHistFilter();
}
function shortAddr(a) {
if (!a) return "";
const s = String(a).replace(/^bitcoincash:|^bchtest:/, "");
return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4));
}
// ---- QR --------------------------------------------------------------------
// Coin-scheme URI so wallet apps that scan know which chain the payment is
// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681
// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed
// URI scheme exists).
function qrPayload(chain, address, network) {
if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, "");
if (chain === "btc") return "bitcoin:" + address; // BIP21
if (chain === "dgb") return "digibyte:" + address;
if (chain === "eth") return "ethereum:" + address;
if (chain === "sol") return "solana:" + address;
if (chain === "trx") return "tron:" + address;
return String(address);
}
function drawQr(text) {
const cv = $("qr");
const g = cv.getContext("2d");
let q;
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
const px = (q.size + 2) * scale;
// Backing store only. The DISPLAY size belongs to the user's dragged
// preference — setting cv.style here would reset the panel to its
// intrinsic size on every redraw (i.e. whenever the address changed).
cv.width = cv.height = px;
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
g.fillStyle = "#000";
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
applyQrSize();
}
// ---- receive actions -------------------------------------------------------
// 0.9.8: the QR is always visible and the panel is drag-resizable, which
// replaces the 0.9.2 show/hide toggle — a size the user sets once is a
// better answer than a binary, and it means the QR button no longer
// competes with Copy for the one row that gets used.
let applyQrSize = () => {};
(function wireQrResize() {
const grip = $("qrGrip"), wrap = $("qrWrap");
if (!grip || !wrap) return;
const MIN = 90, MAX = 420;
const clamp = (v) => Math.max(MIN, Math.min(MAX, Math.round(v)));
let size = 200;
try {
const saved = Number(localStorage.getItem("aegis/qrSize"));
if (Number.isFinite(saved) && saved > 0) size = clamp(saved);
} catch {}
const apply = () => {
// Cap against the actual panel width too — a size dragged wide on a
// roomy sidebar must not overflow when the sidebar is narrowed later.
const room = Math.max(MIN, (wrap.clientWidth || MAX) - 24);
const px = Math.min(size, room);
const cv = $("qr");
if (cv) { cv.style.width = px + "px"; cv.style.height = px + "px"; }
};
applyQrSize = apply;
apply();
window.addEventListener("resize", apply);
let startY = 0, startSize = 0, active = false;
const onMove = (e) => {
if (!active) return;
e.preventDefault();
size = clamp(startSize + (e.clientY - startY));
apply();
};
const onUp = () => {
if (!active) return;
active = false;
grip.classList.remove("dragging");
try { grip.releasePointerCapture?.(grip._pid); } catch {}
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
grip.addEventListener("pointerdown", (e) => {
active = true; startY = e.clientY;
const cv = $("qr");
startSize = cv ? (parseInt(cv.style.width, 10) || 200) : 200;
grip._pid = e.pointerId;
grip.classList.add("dragging");
try { grip.setPointerCapture(e.pointerId); } catch {}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
e.preventDefault();
});
// Keyboard affordance — a drag-only control is unusable without a mouse.
grip.tabIndex = 0;
grip.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 24 : 8;
if (e.key === "ArrowUp") { size = clamp(size - step); }
else if (e.key === "ArrowDown") { size = clamp(size + step); }
else return;
e.preventDefault(); apply();
try { localStorage.setItem("aegis/qrSize", String(size)); } catch {}
});
})();
// The inline copy button holds an SVG; set it once at load.
(function paintCopyIcon() {
const b = $("copyAddr");
if (b && !b.firstElementChild) b.innerHTML = COPY_ICON;
})();
$("copyAddr").addEventListener("click", async () => {
const b = $("copyAddr");
try {
await navigator.clipboard.writeText(sel().address);
// innerHTML, not flash() — flash() swaps textContent, which would
// delete the inline SVG and leave an empty square behind.
b.classList.add("copied"); b.innerHTML = CHECK_ICON;
setTimeout(() => { b.classList.remove("copied"); b.innerHTML = COPY_ICON; }, 1000);
} catch {}
});
$("nextAddr").addEventListener("click", async () => {
try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); }
// The old handler discarded the error and flashed a bare "Failed", so
// neither the user nor a maintainer could tell WHY — which is why
// "next unused address doesn't work" had no diagnosable cause. Show it.
catch (e) { showErr("Next address: " + cleanErr(e)); }
});
$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address)));
$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet));
function flash(btn, text) {
const old = btn.textContent; btn.textContent = text;
setTimeout(() => { btn.textContent = old; }, 1200);
}
// ---- send ------------------------------------------------------------------
$("sendMax").addEventListener("click", () => {
// A token has no "max mode": its fee is paid in the coin, so Max is just
// the whole token balance, written into the field exactly. The toggle
// below did nothing for tokens — the plan never received it, and Send
// went out with whatever sat in the disabled field.
if (sendAsset) {
const t = (sel()?.tokens || []).find((x) => x.mint === sendAsset.mint);
sendMax = false;
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
if (t) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(t.balance), amountDecimals()) : String(t.balance);
updateSendFiatPreview();
schedulePlan();
return;
}
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
if ($("sendMemo")) $("sendMemo").addEventListener("input", () => schedulePlan());
// 0.7.7 legacy chips — hidden in 0.8.0 but kept for graceful transition;
// clicking still opens the standalone modal for anyone with muscle memory.
if ($("consolidateChip")) $("consolidateChip").addEventListener("click", () => openConsolidateModal());
if ($("consolidateChipRcv")) $("consolidateChipRcv").addEventListener("click", () => openConsolidateModal());
// 0.8.0 mode-toggle wiring. One class="modetoggle" element per tab —
// clicking a segment flips the mode variable and repaints the body via
// paintSendMode / paintRcvMode. Freshly-rendered inline hosts get their
// consolidate view populated on first switch.
document.querySelectorAll("[data-send-mode]").forEach((b) => b.addEventListener("click", () => {
sendMode = b.dataset.sendMode;
consolidateInlineHost = null; // force re-render on next switch
paintSendMode();
}));
// Consolidate, now an address action rather than a view toggle. Entering the
// view forces a re-render of the inline host so the preview is costed fresh
// each time, the same thing the old toggle did.
if ($("rcvConsolidateBtn")) $("rcvConsolidateBtn").addEventListener("click", () => {
rcvMode = "consolidate";
consolidateInlineHost = null;
paintRcvMode();
});
if ($("rcvConsolidateBack")) $("rcvConsolidateBack").addEventListener("click", () => {
rcvMode = "receive";
consolidateInlineHost = null;
paintRcvMode();
paintRcvView();
});
// Address & QR / Assets — the persistent pair inside the Receive body.
document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => {
rcvView = RCV_VIEWS.includes(b.dataset.rcvView) ? b.dataset.rcvView : "address";
try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {}
paintRcvView();
}));
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => {
if (id === "sendAmt" && sendMax) return;
if (id === "sendAmt") updateSendFiatPreview();
schedulePlan();
}));
// Live ≈$ preview beside the Amount label, updated on every keystroke. Off
// when prices are disabled or the input is empty, so a quiet form stays quiet.
function updateSendFiatPreview() {
const el = $("sendAmtFiat"); if (!el) return;
const s = sel(); if (!s || sendAsset) { el.hidden = true; return; }
const units = amountUnits();
if (!units || amountError || !state?.prices?.enabled) { el.hidden = true; return; }
const usd = usdOf(chain(), units, decimals());
const txt = fmtFiat(usd);
el.textContent = txt ? "≈ " + txt : "";
el.hidden = !txt;
}
function schedulePlan() {
// The summary on screen is stale from the first keystroke: Send stays off
// until a plan for exactly what is in the form has come back.
lastPlan = null; lastPlanReq = null;
const b = $("sendBtn"); if (b) b.disabled = true;
clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250);
}
// The request a plan was made for. Send submits THIS, never a fresh read of
// the form, so what is signed is what the summary showed.
let lastPlanReq = null;
let planSeq = 0;
function currentSendReq() {
const amount = amountUnits();
const base = { walletId: state?.selectedWalletId || null, to: $("sendTo").value.trim() };
if (sendAsset) return { ...base, mint: sendAsset.mint, amount };
return {
...base, amount,
feeRate: chain() === "bch" ? Number($("feeRate").value) : undefined,
sendMax,
memo: chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "",
};
}
// Selecting another wallet invalidates everything the Send form worked out.
// The fields used to survive the switch: the button stayed live on the old
// wallet's plan, Max swept the new wallet under the old summary, and a
// number typed in sats was re-read as wei.
let sendFormWalletId = null;
function noteSelectedWallet() {
const id = state?.selectedWalletId || null;
if (id === sendFormWalletId) return;
const prev = (state?.wallets || []).find((w) => w.id === sendFormWalletId);
const cur = (state?.wallets || []).find((w) => w.id === id);
const first = sendFormWalletId === null;
sendFormWalletId = id;
if (first) return;
lastPlan = null; lastPlanReq = null; planSeq++;
clearTimeout(planTimer);
sendAsset = null;
if (sendMax) {
sendMax = false;
try { $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendAmt").value = ""; } catch {}
}
// Another coin means the address and the number mean something else.
if (!prev || !cur || prev.chain !== cur.chain || prev.network !== cur.network) {
try { $("sendTo").value = ""; $("sendAmt").value = ""; if ($("sendMemo")) $("sendMemo").value = ""; } catch {}
unit = "big";
try { applyUnitPicker(); } catch {}
}
try {
$("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendMsg").hidden = true;
} catch {}
if ($("sendTo")?.value && $("sendAmt")?.value) schedulePlan();
}
async function updatePlan() {
const seq = ++planSeq;
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; lastPlanReq = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
const req = currentSendReq();
if (amountError && !sendMax) { msg.className = "msg err"; msg.textContent = amountError; msg.hidden = false; return; }
if (!to || (!sendMax && !req.amount)) return;
try {
if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals.
const p = await S.invoke("planTokenSend", { mint: req.mint, to, amount: req.amount });
if (seq !== planSeq) return; // a newer plan is on its way
lastPlan = { _token: true, ...p }; lastPlanReq = req;
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
$("sendBtn").disabled = false;
return;
}
const p = await S.invoke("planSend", { to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
if (seq !== planSeq) return; // a newer plan is on its way
lastPlan = p; lastPlanReq = req;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker();
$("sumFee").textContent = chain() === "bch"
? fmtSmall(p.fee) + " " + smallUnitLabel()
: fmtBig(p.fee) + " " + ticker();
$("sumTotal").textContent = fmtBig(p.total) + " " + ticker();
if (sendMax) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(p.recipients[0].value), amountDecimals()) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
if (seq !== planSeq) return;
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
if (!lastPlan || !lastPlanReq) return;
const msg = $("sendMsg"); msg.hidden = true;
// The form must still say what the summary was computed for. Max rewrites
// the amount field itself, so it is compared without the amount.
const now = currentSendReq();
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const strip = (r) => (r.sendMax ? { ...r, amount: null } : r);
if (!same(strip(now), strip(lastPlanReq))) {
await updatePlan();
if (lastPlan) { msg.className = "msg err"; msg.textContent = "The form changed — check the updated summary, then press Send again."; msg.hidden = false; }
return;
}
const req = lastPlanReq;
// PIN gate. Whether a transaction needs one is the policy's call, not a
// single flag's — and if the user only asked for a PIN at startup, this
// check passes without a prompt.
if (!await pinGate("transaction", "Confirm this send with your PIN.")) {
msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return;
}
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
const isToken = !!(req.mint && lastPlan._token);
const r = isToken
? await S.invoke("sendToken", { walletId: req.walletId, mint: req.mint, to: req.to, amount: req.amount })
: await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
// A broadcast that returned no txid is still a broadcast: never report
// it as a failure and leave a filled form inviting a second send.
const txid = r && typeof r.txid === "string" ? r.txid : "";
msg.className = "msg ok";
if (txid) {
msg.innerHTML = `Sent. ${esc(txid.slice(0, 16))}…`;
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, txid)));
} else {
msg.textContent = "Sent. The network did not return a transaction id — check History before sending again.";
}
msg.hidden = false;
lastPlanReq = null;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false;
if ($("sendMemo")) $("sendMemo").value = "";
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
lastPlan = null;
} catch (e) {
const t = cleanErr(e);
if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; }
$("sendBtn").disabled = !lastPlan;
} finally { $("sendBtn").textContent = "Send"; }
});
// ---- settings --------------------------------------------------------------
function fillSettings() {
// Global settings (fiat prices, connected sites) render even when there
// is no active wallet / the vault is locked.
renderPricesSetting();
renderSites();
renderGeneralSecurity();
const s = sel();
const chainSetup = !!s && s.phase === "ready";
$("walletManage").hidden = !chainSetup;
if (!chainSetup) {
$("bchSettings").hidden = true;
$("trxSettings").hidden = true;
$("scSettings").hidden = true;
$("dgbSettings").hidden = true;
$("btcSettings").hidden = true;
$("ethSettings").hidden = true;
$("solSettings").hidden = true;
return;
}
$("bchSettings").hidden = chain() !== "bch";
$("trxSettings").hidden = chain() !== "trx";
$("scSettings").hidden = chain() !== "sc";
$("dgbSettings").hidden = chain() !== "dgb";
$("btcSettings").hidden = chain() !== "btc";
$("ethSettings").hidden = chain() !== "eth";
$("solSettings").hidden = chain() !== "sol";
$("removeBtn").disabled = !!s.isLegacy && s.chain === "bch";
$("removeHint").textContent = (s.isLegacy && s.chain === "bch")
? "The default BCH wallet cannot be removed (it protects legacy funds)."
: (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : "");
$("renameLabel").value = s.label || "";
if (chain() === "bch") {
if (!settingsFilled) {
$("setPath").value = s.accountPath || "";
renderServerCheckboxes();
settingsFilled = true;
}
$("bchServersRow").hidden = s.network !== "mainnet";
$("serverHint").textContent = s.network !== "mainnet"
? "Chipnet uses bundled defaults in this build."
: (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
$("purpose").textContent = "silentmode/addons/" + (s.purpose || "");
// WC pairing needs the vault-derived signer path. Imported wallets
// don't have one yet (M.1b), so we hide the paste field + surface a
// clear explanation in its place — otherwise the user hits an opaque
// "wc: wallet not ready" error from the addon.
const wcImported = s.kind === "imported";
if ($("wcImportedNotice")) $("wcImportedNotice").hidden = !wcImported;
if ($("wcInputs")) $("wcInputs").hidden = wcImported;
renderWcSites();
} else if (chain() === "trx") {
$("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
} else if (chain() === "sc") {
if (!settingsFilled) {
$("setWalletdUrl").value = s.walletdUrl || "";
settingsFilled = true;
}
$("scPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("scRecovery").innerHTML = "";
} else if (chain() === "dgb") {
if (!settingsFilled) {
fillFamilyPicker("Dgb", s);
settingsFilled = true;
}
$("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("dgbRecovery").innerHTML = "";
} else if (chain() === "btc") {
if (!settingsFilled) {
fillFamilyPicker("Btc", s);
settingsFilled = true;
}
$("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("btcRecovery").innerHTML = "";
} else if (chain() === "eth") {
if (!settingsFilled) {
$("setEthRpcUrl").value = s.rpcUrl || "";
settingsFilled = true;
}
$("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("ethRecovery").innerHTML = "";
} else if (chain() === "sol") {
if (!settingsFilled) {
$("setSolRpcUrl").value = s.rpcUrl || "";
settingsFilled = true;
}
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("solRecovery").innerHTML = "";
}
}
// Reflect the current price feed state into the Settings toggle + status
// line. Called from fillSettings() and whenever fresh state arrives.
// General security card — PIN state + "Require PIN for sending" toggle.
// Loads (or refreshes) securityState on demand. Shown even when the vault
// is locked so users on the Settings tab can flip the require-pin policy
// before unlocking.
async function renderGeneralSecurity() {
if (!securityLoaded) await refreshSecurityState();
if (!sessionLoaded) await refreshSessionState();
const hasPin = !!securityState.hasPin;
const set = $("gsPinSet"), chg = $("gsPinChange"), rm = $("gsPinRemove");
const hint = $("pinStatusHint");
if (set) set.hidden = hasPin;
if (chg) chg.hidden = !hasPin;
if (rm) rm.hidden = !hasPin;
// Say what the PIN really protects against on this machine.
if (hint) hint.textContent = hasPin
? (securityState.pinHardware === "tpm"
? "On — a 6-digit PIN unlocks Aegis. It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
: "On — a 6-digit PIN unlocks Aegis. This computer has no usable security chip, so the PIN only stops casual use: anything that runs as your Windows account, or a copy of this disk with your Windows password, can find the PIN in minutes and with it your master password.")
: (securityState.pinStorable === false
? "Off — this system has no protected keystore, so Aegis cannot store a PIN safely and asks for the master password every time."
: "Off — Aegis asks for the master password every time.");
if (set) set.disabled = !hasPin && securityState.pinStorable === false;
// A PIN stored by Aegis before 0.31 sat on disk without OS protection.
// Copies of the profile made back then still hold the master password
// behind just the PIN, so the only real fix is a new master password.
let exp = $("pinExposureNote");
if (securityState.pinLegacyExposure) {
if (!exp && hint) {
exp = document.createElement("div");
exp.id = "pinExposureNote";
exp.className = "ghint pinwarn";
hint.after(exp);
}
if (exp) {
exp.textContent = "An earlier Aegis kept your PIN on disk without OS protection. If this Theseus profile was ever backed up, synced or copied, change your Theseus master password; changing the PIN alone does not help. ";
const dismiss = document.createElement("a");
dismiss.textContent = "Dismiss";
dismiss.addEventListener("click", async () => {
try { await S.invoke("pinExposureDismiss"); } catch {}
await refreshSecurityState();
renderGeneralSecurity();
});
exp.appendChild(dismiss);
}
} else if (exp) exp.remove();
// "Ask for PIN" triggers. Meaningless without a PIN to ask for.
const onLine = $("gsPinOnLine");
if (onLine) onLine.hidden = !hasPin;
const pol = securityState.pinOn || {};
for (const [id, key] of PIN_ON_FIELDS) {
const box = $(id);
if (box) box.checked = !!pol[key];
}
// Both PIN policies are meaningless without a PIN to use.
const revLine = $("gsRequirePinRevealLine"), rpr = $("gsRequirePinReveal");
if (revLine) revLine.hidden = !hasPin;
if (rpr) rpr.checked = !!securityState.requirePinForReveal;
renderSessionSettings();
}
// Session card: reflects lockOnClose + idleMinutes + safeStorage
// availability into the toggles. When the OS keystore isn't available
// (rare — mainly stripped Linux setups), lock-on-close is forced on and
// the toggle is disabled with a clear hint.
function renderSessionSettings() {
const lc = $("gsLockOnClose");
const im = $("gsIdleMinutes");
const hint = $("gsSessionHint");
if (!lc || !im) return;
const canRemember = !!sessionState.safeStorageAvailable;
lc.checked = !!sessionState.lockOnClose;
lc.disabled = !canRemember;
im.value = String(sessionState.idleMinutes || 0);
if (hint) {
if (!canRemember) {
hint.textContent = "OS keystore unavailable on this machine — Aegis can't remember the unlock across restarts. Master-password entry on every launch.";
} else if (sessionState.lockOnClose) {
hint.textContent = "On — Aegis asks for the master password (or PIN) every time Theseus starts.";
} else {
hint.textContent = "Off — Aegis stays signed in across Theseus restarts. The master password is stored in the OS keystore under this user only, so anything that runs as your Windows account can read it; the PIN's security-chip protection does not cover it.";
}
}
}
// 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({
// Scoped to this overlay, not looked up by global id — see the note
// on setupPinPad about duplicate ids across simultaneous pads.
dots: wrap.querySelector("#pmDots"), keys: wrap.querySelector("#pmKeys"), err: wrap.querySelector("#pmErr"),
onComplete: async (pin) => {
if (mode === "confirm" && first.pin == null) {
first.pin = pin;
$("pmSub").textContent = "Re-enter to confirm";
return "reset";
}
if (mode === "confirm" && first.pin !== pin) {
$("pmErr").textContent = "PINs don't match. Start again.";
first.pin = null;
$("pmSub").textContent = subtitle || "";
return "reset";
}
close(pin);
return "ok";
},
});
});
}
$("gsPinSet") && $("gsPinSet").addEventListener("click", async () => {
await handlePinSet();
});
$("gsPinChange") && $("gsPinChange").addEventListener("click", async () => {
await handlePinSet(true);
});
$("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => {
const ok = await aegisConfirm({
title: "Remove the quick-access PIN?",
danger: true,
confirmLabel: "Remove PIN",
body: "You'll have to type the master password on every unlock again.",
});
if (!ok) return;
try {
await S.invoke("pinBlobClear");
// Every trigger depends on there being a PIN, so clear them all rather
// than leaving a policy armed against a credential that no longer exists.
await S.invoke("securitySet", { pinOn: { restart: false, launch: false, interval: false, transaction: false } });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) { aegisAlert("Could not remove PIN: " + cleanErr(e)); }
});
// Checkbox id → policy key, used by both the paint and the wiring below.
const PIN_ON_FIELDS = [
["gsPinOnRestart", "restart"],
["gsPinOnLaunch", "launch"],
["gsPinOnInterval", "interval"],
["gsPinOnTransaction", "transaction"],
];
for (const [id, key] of PIN_ON_FIELDS) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", async () => {
const want = box.checked;
try {
securityState = await S.invoke("securitySet", { pinOn: { [key]: want } });
// Ticking a trigger should not fire it retroactively: the user is
// sitting in Settings having just proved whatever got them here.
// The host only records a gate against proof it verified, so this
// works when the password is still at hand (just unlocked); otherwise
// the new trigger simply asks once at its next occasion.
if (want && window.__aegisLastPw) { try { await S.invoke("pinGateSatisfied", { masterPassword: window.__aegisLastPw }); } catch {} }
renderGeneralSecurity();
} catch (e) {
box.checked = !want;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
}
$("gsRequirePinReveal") && $("gsRequirePinReveal").addEventListener("change", async () => {
const on = $("gsRequirePinReveal").checked;
try {
securityState = await S.invoke("securitySet", { requirePinForReveal: on });
renderGeneralSecurity();
} catch (e) {
$("gsRequirePinReveal").checked = !on;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
$("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => {
// Route through the addon so it can pass the section slug back to
// Theseus (main-process gates section-hint validation).
S.invoke("openSettings", { section: "passwords" }).catch(() => {});
});
// Session controls: Lock-on-close toggle + Idle-lock dropdown + Sign out.
// Turning "Lock on close" OFF is the "stay signed in" opt-in — we need
// the master password once to seed the OS keystore. Turning it back ON
// wipes the stored blob and reverts to the classic every-launch prompt.
$("gsLockOnClose") && $("gsLockOnClose").addEventListener("change", async () => {
const on = $("gsLockOnClose").checked;
try {
if (!on) {
const pw = window.__aegisLastPw || await promptMasterPassword({
title: "Stay signed in",
subtitle: "Aegis needs your master password once to encrypt it into the OS keystore. It never touches disk in plaintext.",
});
if (!pw) { $("gsLockOnClose").checked = true; return; }
sessionState = await S.invoke("sessionEnable", { masterPassword: pw });
// Drop the buffered password immediately — safeStorage now holds it.
try { delete window.__aegisLastPw; } catch {}
} else {
sessionState = await S.invoke("sessionDisable");
}
renderSessionSettings();
bindIdleAutoLock();
} catch (e) {
$("gsLockOnClose").checked = !on;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
$("gsIdleMinutes") && $("gsIdleMinutes").addEventListener("change", async () => {
const mins = Number($("gsIdleMinutes").value) || 0;
try {
sessionState = await S.invoke("sessionConfigSet", { idleMinutes: mins });
renderSessionSettings();
bindIdleAutoLock();
} catch (e) { aegisAlert("Could not save idle timeout: " + cleanErr(e)); }
});
$("gsSignOut") && $("gsSignOut").addEventListener("click", async () => {
const ok = await aegisConfirm({
title: "Sign out of Aegis?",
icon: "🔒",
confirmLabel: "Sign out",
body: "The vault will re-lock and you'll need the master password (or PIN) to open it again.",
});
if (!ok) return;
try {
state = await S.invoke("vaultLock");
stripView = { mode: "coins", groupKey: null };
render();
// Session blob was cleared server-side; refresh our cached view.
sessionState = await S.invoke("sessionStatus");
renderSessionSettings();
} catch (e) { aegisAlert("Could not sign out: " + cleanErr(e)); }
});
// Setting or changing a PIN needs the master password to encrypt against.
// If the panel has one buffered from a recent unlock (window.__aegisLastPw)
// we use it silently; otherwise we ask, verify via a fresh vaultUnlock, and
// then proceed with the PIN capture flow.
async function handlePinSet(replacing) {
let masterPw = window.__aegisLastPw || null;
if (!masterPw) {
masterPw = await promptMasterPassword({
title: replacing ? "Confirm master password" : "Set up quick-access PIN",
subtitle: replacing
? "We need the master password once to re-encrypt the PIN under a fresh key."
: "The PIN is an alias for your master password. Enter the master password once to bind them.",
});
if (!masterPw) return;
}
const pin = await openPinModal({
title: replacing ? "Choose a new PIN" : "Choose a PIN",
subtitle: "Six digits",
mode: "confirm",
});
if (!pin) return;
try {
await S.invoke("pinSet", { pin, masterPassword: masterPw });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) {
aegisAlert("Could not save PIN: " + cleanErr(e));
} finally {
// Drop the buffered password sooner rather than later — we only kept
// it around to enroll a PIN without a re-prompt.
try { delete window.__aegisLastPw; } catch {}
}
}
// Small modal that captures the master password + verifies it via a
// vaultUnlock roundtrip. Resolves with the password string on success or
// null on cancel / failure. Used both by PIN enrollment (from Settings)
// and by the PIN approval gate when the user chose to fall back.
function promptMasterPassword({ title, subtitle }) {
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
${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 {}
});
}
// What each secret form actually IS, and where it can actually be restored.
// This copy matters more than it looks: none of these are a BIP39 recovery
// phrase, because Aegis never stores one. An import converts the words to a
// seed and throws the words away; a vault wallet is HKDF(vault root, purpose)
// and never had words. Someone who writes down what we show here and believes
// it is a 12-word phrase has not backed anything up, so each form says what
// it is in its own name and the note says what to do with it.
const SECRET_FORMS = {
wif: {
title: "Private key (WIF)",
note: "This single key controls this one address and nothing else. Any Bitcoin Cash wallet that accepts a WIF key can import it.",
},
privhex: {
title: "Private key (hex)",
note: "This single key controls this one account. Most ETH / TRX / SOL wallets accept a raw hex private key.",
},
seed: {
title: "Wallet seed (hex)",
note: "There is no word list to show. You imported a recovery phrase, and Aegis converted it to this seed and discarded the words — that conversion is one-way, so the phrase cannot be recovered from here or from anywhere else in Aegis. Keep the original phrase wherever you first wrote it down. This seed plus the derivation path below is a complete backup of the wallet, and Aegis can take it back under Add → Import → Seed (hex).",
},
vault: {
title: "Wallet key (hex)",
note: "This wallet has no recovery phrase of its own — it is derived from your Theseus vault, so your real backup is the vault's master password. The key below restores this one wallet via Add → Import → Seed (hex), and the account xprv, where shown, is accepted by most other HD wallets.",
},
};
// Reveal one wallet's secret. Gated by authorizeForSecret, hidden until
// asked for a second time, and never left on screen after the modal closes.
async function openRevealSecretModal(w) {
// Two shapes reach here: a wallet summary from the manage modal, which
// keys the id as `id`, and state.selected from the Settings buttons, which
// keys it as `walletId`. Accept both rather than silently doing nothing.
const walletId = w && (w.id || w.walletId);
if (!walletId) return;
const pw = await authorizeForSecret(`Reveal the secret key for "${(w && w.label) || "this wallet"}".`);
if (!pw) return;
let r;
try { r = await S.invoke("revealSecret", { walletId, masterPassword: pw }); }
catch (e) { await aegisAlert(cleanErr(e), { title: "Could not reveal", icon: "⚠️" }); return; }
const form = SECRET_FORMS[r.form] || { title: "Secret", note: "" };
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px";
const field = (label, value, danger) => value
? `
`;
document.body.appendChild(overlay);
// Wipe the rendered secret out of the DOM on the way out rather than
// relying on the node being dropped — the modal is the only place it
// exists in the renderer, so clearing it is cheap and exact.
const close = () => {
try { overlay.querySelector("#rvShown").innerHTML = ""; } catch {}
try { overlay.remove(); } catch {}
};
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#rvClose").addEventListener("click", close);
overlay.querySelector("#rvShow").addEventListener("click", () => {
overlay.querySelector("#rvHidden").hidden = true;
overlay.querySelector("#rvShown").hidden = false;
});
overlay.querySelector("#rvHide").addEventListener("click", close);
overlay.querySelector("#rvCopy").addEventListener("click", async (e) => {
try { await navigator.clipboard.writeText(r.secret); flash(e.currentTarget, "Copied"); }
catch { await aegisAlert("Could not reach the clipboard."); }
});
}
// Ask for the PIN if the configured policy says this event needs it. The
// host decides — the panel reloads whenever it is hidden and shown, so it
// cannot be what remembers that a gate was already cleared.
//
// Returns true to proceed, false if the user cancelled or failed. A passing
// check is recorded, which is what stops Aegis asking again until one of the
// user's triggers fires. Any failure to reach the host is treated as "ask",
// since the safe direction for a lock is closed.
async function pinGate(event, subtitle) {
let st;
try { st = await S.invoke("pinGateStatus", { event }); }
catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return true;
// verifyPinInteractively resolves to what the PIN unwrapped — the vault
// master password — and the host checks that itself before it records the
// gate or lets a transaction through. A PIN the host could not verify
// does not count, so a failure here is a failed gate.
const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (!proof) return false;
// `event` says what the proof is for: "transaction" clears the panel's
// next send, anything else only records the gate.
try { await S.invoke("pinGateSatisfied", { masterPassword: proof, event }); }
catch { return false; }
return true;
}
// A dapp transaction is waiting on the PIN ("ask on every transaction").
// The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
// and the pending transaction goes through.
// Requests are answered one at a time, each with its own PIN entry, and the
// proof names the request it was entered for — so a PIN typed for one site
// never releases another site's transaction.
let pinRequestBusy = false;
async function answerPinRequest(req) {
if (!req || !req.id || pinGateBlocked || pinRequestBusy) return;
pinRequestBusy = true;
try {
const where = String(req.origin || "A site").slice(0, 80);
const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
if (proof) { try { await S.invoke("pinGateSatisfied", { masterPassword: proof, requestId: req.id }); } catch { /* host keeps waiting, then refuses */ } }
} finally { pinRequestBusy = false; }
// Another site may be waiting too.
try { const next = await S.invoke("pinRequestPending"); if (next && next.id !== req.id) answerPinRequest(next); } catch {}
}
const PIN_GATE_COPY = {
restart: "Theseus restarted — confirm your PIN to use this wallet.",
launch: "Confirm your PIN to open the wallet.",
interval: "It has been a while — confirm your PIN to carry on.",
transaction: "Confirm this transaction with your PIN.",
};
// Gate the panel itself on load. Runs once per panel script load, which is
// also once per hide/show, so "on each wallet launch" is simply this check
// with that trigger enabled.
//
// This must finish BEFORE the first render(). Asking afterwards was the
// original complaint in a new costume: the wallet painted, balances and all,
// and the pad then appeared on top of a panel the user could already read.
// `pinGateBlocked` keeps the chrome hidden for as long as the PIN is owed,
// so a cancelled or failed prompt leaves a closed door rather than an open
// wallet.
let pinGateChecked = false;
let pinGateBlocked = false;
async function pinGateOnLoad() {
if (pinGateChecked) return;
pinGateChecked = true;
if (!securityLoaded) await refreshSecurityState();
if (!securityState.hasPin) return;
// Nothing to protect yet if the vault is locked — the master-password gate
// is already in the way, and stacking a PIN prompt on top of it is two
// locks on one door.
if (state && (state.overallPhase === "locked" || state.overallPhase === "nosetup")) return;
let st;
try { st = await S.invoke("pinGateStatus", { event: "panel-load" }); }
catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return;
pinGateBlocked = true;
paintPinDoor(st.reason);
// Keep asking until it is satisfied. Cancelling does not open the wallet;
// it leaves the door shut with a button to try again, which is the only
// honest outcome for "a PIN is required here".
let proof = null;
for (;;) {
proof = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (proof) break;
const again = await aegisConfirm({
title: "PIN required",
icon: "🔒",
confirmLabel: "Enter PIN",
cancelLabel: "Leave locked",
body: "Aegis stays locked until the PIN is entered. You can also unlock with your master password from Settings.",
});
if (!again) return; // stays blocked; door remains shut
}
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* re-asks next load */ }
pinGateBlocked = false;
render();
}
// The closed door shown behind the pad, so there is never a moment where the
// wallet is readable but unauthenticated.
function paintPinDoor(reason) {
const g = $("gate");
if (!g) return;
document.querySelector("header").hidden = true;
document.querySelector("nav").hidden = true;
const strip = $("walletStrip"); if (strip) strip.hidden = true;
const ls = $("lockScreen"); if (ls) ls.hidden = true;
g.hidden = false;
g.innerHTML = `
🔒
${esc(PIN_GATE_COPY[reason] || "Confirm with your PIN.")}
`;
}
// How many wrong PINs before a sensitive reveal stops asking for the PIN and
// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose:
// someone fumbling their own PIN gets a way through that does not cost them a
// 15-minute lockout, and someone guessing is pushed onto the credential that
// is actually hard to guess. The global counter is NOT reset on the way
// across, so guesses still accumulate toward the lockout.
const REVEAL_PIN_MAX_FAILS = 3;
// Prove entitlement to see a secret, and hand back the master password —
// which is what the host verifies before it parts with anything. The PIN blob
// wraps that same password, so both routes end at the same proof and the host
// never has to take the panel's word for it.
//
// Returns the master password, or null if the user backed out.
async function authorizeForSecret(subtitle) {
if (!securityLoaded) await refreshSecurityState();
const usePin = securityState.requirePinForReveal && securityState.hasPin;
if (!usePin) {
// No PIN configured, or the user turned the PIN prompt off. Either way
// the master password is the gate — never nothing.
return promptMasterPassword({ title: "Confirm master password", subtitle });
}
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
// Locked out of the PIN, but the password is a separate credential and
// the lockout exists to stop PIN guessing, not to lock the owner out.
return promptMasterPassword({
title: "Confirm master password",
subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(),
});
}
const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled
if (pin === "__fallback__") {
return promptMasterPassword({
title: "Confirm master password",
subtitle: `${REVEAL_PIN_MAX_FAILS} wrong PIN attempts. ${subtitle || ""}`.trim(),
});
}
return pin;
}
// PIN pad that resolves with the DECRYPTED MASTER PASSWORD on success, null
// on cancel, or "__fallback__" once the user has burned REVEAL_PIN_MAX_FAILS
// attempts. Separate from verifyPinInteractively because that one only
// answers yes/no and throws the password away.
function capturePinForSecret(subtitle) {
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
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("#rsCancel").addEventListener("click", () => done(null));
wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__"));
let tries = 0;
setupPinPad({
dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
onComplete: async (pin) => {
let r;
try { r = await pinTry(pin); }
catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
if (r.ok) { done(r.masterPassword); return "ok"; }
// Every guess here also counts toward the 15 min lockout.
tries++;
if (r.lockedMs > 0 || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = REVEAL_PIN_MAX_FAILS - tries;
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
},
});
});
}
// Ask the user to prove they know the PIN. Uses the same lockout counter
// as the unlock flow so an attacker can't drain guesses via a spammed
// Send button. Returns true on match, false on cancel / lockout / bad PIN.
// Only one PIN prompt at a time. Two concurrent prompts are asking the same
// question, so the second joins the first instead of stacking a second pad
// over it — which is also how duplicate pad ids came to collide.
let pinPromptInFlight = null;
function verifyPinInteractively(subtitle) {
if (pinPromptInFlight) return pinPromptInFlight;
pinPromptInFlight = verifyPinInteractivelyOnce(subtitle)
.finally(() => { pinPromptInFlight = null; });
return pinPromptInFlight;
}
async function verifyPinInteractivelyOnce(subtitle) {
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: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
onComplete: async (pin) => {
let r;
try { r = await pinTry(pin); }
catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; }
// The unwrapped master password is truthy for every existing caller;
// the gate hands it to the host as proof (see pinGate).
if (r.ok) { done(r.masterPassword || true); return "ok"; }
const left = Math.max(0, r.remaining);
$("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("");
}
}
}
// "Who can see the wallet". The list is the sites the user enabled by hand;
// sites with a saved connection stay visible regardless and are listed under
// Connected sites above.
async function renderInjectPolicy() {
const box = $("injectOnlyAllowed");
if (!box) return;
let st;
try { st = await S.invoke("injectPolicyGet"); } catch { return; }
box.checked = st.mode === "allowed";
box.disabled = !st.supported;
$("injectUnsupported").hidden = !!st.supported;
$("injectAllowBox").hidden = !(st.supported && st.mode === "allowed");
const list = $("injectSites");
list.innerHTML = (st.origins || []).length
? st.origins.map((o) => `
${esc(o)}
`).join("")
: `
No sites enabled yet. Open the site in a tab, then press the button above.
`; return; }
el.innerHTML = origins.map((o) => {
const p = perms[o]; const what = [];
if (p.readAddress) what.push("BCH address");
if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`);
if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address");
if (p.eth && p.eth.readAddress) what.push("EVM address" + (p.eth.chainId ? ` (chain ${p.eth.chainId})` : ""));
if (p.sol && p.sol.readAddress) what.push("Solana address");
// A plain "Connect" lasts until Theseus restarts; only "Always allow" is stored.
if (p.session) what.push("this session only");
return `
${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); }
});
// Every "show the secret" button goes through the one gated path. They used
// to call recovery({reveal:true}), which handed back the xprv behind nothing
// but an approval click and refused imported wallets outright.
$("showXprv").addEventListener("click", () => openRevealSecretModal(sel()));
function recoveryHtml(r) {
let h = `
`;
}).join("");
el.querySelectorAll("button[data-wcconn]").forEach((b) => b.addEventListener("click", async () => {
try { state = await S.invoke("wcDisconnect", { walletId, connId: b.dataset.wcconn }); render(); }
catch (e) { const m = $("wcMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; }
}));
}
$("wcConnectBtn").addEventListener("click", async () => {
const walletId = state?.selectedWalletId;
const uri = $("wcUri").value.trim();
const m = $("wcMsg"); m.hidden = true;
if (!uri) return;
try {
state = await S.invoke("wcConnect", { walletId, uri });
$("wcUri").value = "";
m.className = "msg ok"; m.textContent = "Pairing…"; m.hidden = false;
render();
} catch (e) {
m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
}
});
// ---- prices toggle ---------------------------------------------------------
$("pricesToggle").addEventListener("change", async () => {
const on = $("pricesToggle").checked;
try {
state = await S.invoke("setPricesEnabled", { enabled: on });
render(); if (tab === "settings") renderPricesSetting();
} catch (e) {
// Roll the checkbox back if the host rejected the change.
$("pricesToggle").checked = !on;
$("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false;
}
});
// pricesSource select is a hidden legacy element in 0.6.36+ — the picker
// was removed when pricing moved to multi-source majority-rule. No change
// handler needed; kept the DOM node so panel.js code that reads .value
// doesn't NPE mid-migration.
$("refreshPrices").addEventListener("click", async () => {
try {
await S.invoke("refreshPrices");
// The host emits a state event on completion; the render will pick it up.
renderPricesSetting();
} catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
});
// ---- boot ------------------------------------------------------------------
S.on("state", (s) => {
state = s;
render();
// render() returns early while a PIN is owed; don't fill a hidden Settings
// tab behind the door either.
if (tab === "settings" && !pinGateBlocked) fillSettings();
});
S.on("pinRequest", (req) => { answerPinRequest(req); });
(async () => {
// Load security + session state first so the very first render() knows
// whether to paint the PIN pad on the lock screen and what idle-lock
// timer to arm once the vault is open.
try { await refreshSecurityState(); } catch {}
try { await refreshSessionState(); } catch {}
try { state = await S.invoke("state"); }
catch (e) {
$("gate").hidden = false;
$("gate").innerHTML = `
⚠
${esc(cleanErr(e))}
`;
return;
}
// Settle the PIN BEFORE the first paint. Awaited on purpose: rendering the
// wallet first and prompting afterwards is exactly the behaviour being
// fixed, and a fire-and-forget call also let a second prompt open on top
// of this one.
await pinGateOnLoad();
render();
bindIdleAutoLock();
// Opened because a dapp transaction is waiting for the PIN? Answer it.
try { answerPinRequest(await S.invoke("pinRequestPending")); } catch {}
})();
// Persistent footer: aegis.x brand link + version marker + update check.
// The check button hits the OTA manifest and compares versions client-side;
// when a newer one is advertised, the pill turns into an "Update to vX.Y.Z"
// chip. Clicking that chip fires the addon-message "requestUpdate" which
// runs the same check + apply flow used by Settings > Extensions > Aegis
// (falls back to opening Settings for pre-0.3.47 Theseus that lacks the
// panel-facing apply path).
const OTA_URL = "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json";
let footerCurrentVer = null;
let footerLatestKnown = null;
function cmpSemver(a, b) {
const pa = String(a || "0").split(".").map((n) => Number(n) || 0);
const pb = String(b || "0").split(".").map((n) => Number(n) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d < 0 ? -1 : 1;
}
return 0;
}
// Track whether the last check was manual. Auto-checks stay silent when
// nothing new is available; manual clicks always get a visible reply so
// the ↻ button never feels dead when the user is already current.
let footerLastCheckManual = false;
function paintFooterUpdate() {
const el = $("brandUpdate");
const verEl = $("brandVer");
if (!el) return;
// 0.8.1: the update chip and version marker share the same slot at the
// bottom-right. When a newer build is available the chip takes over the
// slot and the raw "v0.8.0" marker hides; when the check flashes "up to
// date" the chip briefly steals it back; otherwise the version marker
// is the resting state and the chip stays hidden. showVer/hideVer are
// no-ops when brandVer isn't in the DOM so the render is idempotent.
const showVer = () => { if (verEl) verEl.hidden = false; };
const hideVer = () => { if (verEl) verEl.hidden = true; };
if (!footerCurrentVer || !footerLatestKnown) { el.hidden = true; showVer(); return; }
if (cmpSemver(footerLatestKnown, footerCurrentVer) > 0) {
el.hidden = false;
el.className = "brandupd";
el.textContent = "↑ v" + footerLatestKnown;
el.title = "Aegis v" + footerLatestKnown + " is available — click to install";
el.style.cursor = "pointer";
el.onclick = () => triggerFooterUpdate();
hideVer();
return;
}
// At-or-past latest: silent on auto-check (unobtrusive), transient
// "up to date" flash on manual so the ↻ click has visible feedback.
if (footerLastCheckManual) {
el.hidden = false;
el.className = "brandupd brandok";
el.textContent = "✓ Up to date";
el.title = "Aegis v" + footerCurrentVer + " is the latest";
el.style.cursor = "default";
el.onclick = null;
hideVer();
setTimeout(() => {
if (el.classList.contains("brandok")) { el.hidden = true; showVer(); }
}, 2200);
} else {
el.hidden = true;
showVer();
}
}
async function checkFooterUpdate(opts = {}) {
const btn = $("brandCheck");
if (btn) btn.classList.add("spin");
footerLastCheckManual = !!opts.manual;
try {
// The addon frame runs under a file:// origin — fetch to https is fine,
// no CORS block since no server headers are involved for a same-origin
// request... actually addon frames CAN cross-fetch. Cache-bust with a
// per-minute query so a fresh check reflects a just-published manifest.
const bust = Math.floor(Date.now() / 60_000);
const r = await fetch(OTA_URL + "?t=" + bust, { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
const j = await r.json();
const entries = Array.isArray(j?.addons) ? j.addons : [];
let best = null;
for (const e of entries) if (!best || cmpSemver(e.version, best.version) > 0) best = e;
footerLatestKnown = best?.version || null;
paintFooterUpdate();
} catch (e) {
console.warn("footer update check failed:", e?.message || e);
if (footerLastCheckManual) {
const el = $("brandUpdate");
if (el) {
el.hidden = false;
el.className = "brandupd branderr";
el.textContent = "⚠ Check failed";
el.title = String(e?.message || e);
el.style.cursor = "default";
el.onclick = null;
setTimeout(() => { if (el.classList.contains("branderr")) el.hidden = true; }, 2500);
}
}
} finally {
if (btn) btn.classList.remove("spin");
}
}
// Two-step chip flow. First click → stage the newer signed build; the
// chip's message and click handler swap to "Restart Theseus to apply".
// Second click → app.relaunch(). Both steps go through the same
// requestUpdate handler so a single Theseus IPC round-trip covers each
// leg. Falls back to opening Settings › Extensions when running under
// an older Theseus that lacks the panel-driven update hooks.
async function triggerFooterUpdate() {
const el = $("brandUpdate"); if (!el) return;
const verEl = $("brandVer");
// While the chip is doing something, the raw version marker stays
// hidden — the chip owns the slot end-to-end for the whole transaction
// so the user never sees "v0.8.0 ↑ v0.8.1" simultaneously in the
// corner. Returned to the version marker only after the flash timers
// clear (below).
if (verEl) verEl.hidden = true;
const setChip = (text, klass, title, handler) => {
el.hidden = false;
el.className = "brandupd" + (klass ? " " + klass : "");
el.textContent = text;
el.title = title || "";
el.style.cursor = handler ? "pointer" : "default";
el.onclick = handler || null;
};
const restoreVer = () => { el.hidden = true; if (verEl) verEl.hidden = false; };
try {
setChip("Staging update…", "brandwait", "Downloading + verifying the signed payload", null);
const r = await S.invoke("requestUpdate", { step: "stage" });
if (r?.fallback === "settings") {
setChip("Open Settings to update", null, "This Theseus lacks the in-panel updater — opening Settings › Extensions", () => S.invoke("openSettings", { section: "addons" }).catch(() => {}));
return;
}
if (r?.staged) {
const nextVer = r.next ? " v" + r.next : "";
setChip("↻ Restart to apply" + nextVer, null, "Aegis" + nextVer + " is staged — click to relaunch Theseus", async () => {
setChip("Restarting…", "brandwait", "", null);
try { await S.invoke("requestUpdate", { step: "apply" }); }
catch (e) { setChip("⚠ Restart failed", "branderr", String(e?.message || e), null); }
});
return;
}
const msg = r?.status === "up-to-date" ? "✓ Already up to date"
: r?.status ? "⚠ " + r.status : "⚠ Update failed";
setChip(msg, r?.status === "up-to-date" ? "brandok" : "branderr", r?.detail || "", null);
setTimeout(() => { if (el.classList.contains("brandok") || el.classList.contains("branderr")) restoreVer(); }, 2500);
} catch (e) {
console.warn("update trigger failed:", e?.message || e);
setChip("⚠ Update failed", "branderr", String(e?.message || e), null);
setTimeout(() => { if (el.classList.contains("branderr")) restoreVer(); }, 2500);
}
}
(function wireFooter() {
const link = $("brandLink"); if (!link) return;
link.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); });
const a1 = $("aboutOpenAegisSite");
if (a1) a1.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); });
const a2 = $("aboutOpenSilentmodeSite");
if (a2) a2.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://silentmode.st/"); });
// Version comes from the addon manifest; if the state message carries it
// we surface it, otherwise the slot stays empty.
S.invoke("aegisVersion").then((v) => {
const el = $("brandVer");
if (el && v) el.textContent = "v" + String(v);
footerCurrentVer = v || null;
paintFooterUpdate();
}).catch(() => {});
const check = $("brandCheck");
if (check) check.addEventListener("click", () => checkFooterUpdate({ manual: true }));
// First check on panel open — non-blocking; failures stay quiet. A user
// who never opens Settings still gets a clear update signal here.
setTimeout(() => checkFooterUpdate({ manual: false }), 500);
})();