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