theseus/bundled-addons/aegis/panel.js
Local Dev 992c02ea89 feat(theseus/aegis): 0.6.1 — in-panel vault setup/unlock, BCH wallet imports, opt-in fiat prices, WizardConnect
Aegis Wallet 0.4.4 → 0.6.1:

- Vault lifecycle from the wallet gate. The locked / not-yet-created states
  now show a master-password form (with optional BIP39 mnemonic on setup)
  instead of redirecting users to Settings › Passwords. New
  api.vault.lifecycle {status, setup, unlock, lock} in addons-host, gated by
  the existing "vault-derive" capability. api.openSettings(section) also
  added; settings.html honours a #section hash on open.
- Imported BCH wallets (design M.1a, read-only). Paste a mnemonic + BIP44
  path or a WIF; the cashaddr is derived in the add-on, the signer material
  goes to a separate wallet-imports.enc via api.vault.imports {list, add,
  remove, signer}. Argus password-vault gains createImports / unlockImports /
  saveImports with its own KDF salt so the imports key is disjoint from the
  passwords key. lib/chain-bch-imported.js is a single-address Electrum
  adapter; spend support is deferred to M.1b.
- Opt-in USD prices via CoinGecko (lib/prices.js), off by default, persisted
  in add-on storage. Fiat lines under balances, in the wallet picker, and a
  portfolio total when 2+ wallets are open. Settings tab is now reachable
  while the vault is locked so the toggle is always available.
- WizardConnect wallet-side pairing for BCH wallets (lib/wc.js, lib/wc-sign.js).
  @wizardconnect/{core,wallet} are loaded dynamically via api.import to stay
  on the right side of LGPL §4d. Sign requests go through approvalModal and
  are restricted to P2PKH inputs with SIGHASH_ALL|FORKID|UTXOS.
- DGB adapter load is now soft-fail: when Aegis runs from userData/addons the
  bundled ESM can't resolve peer deps, so DGB becomes unavailable instead of
  taking the whole add-on down.
2026-09-09 10:33:21 +02:00

1158 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Aegis wallet panel. All state comes from activate() via window.silentmode.
// This file renders the multi-wallet picker, per-chain views, and collects
// input; it never touches keys or the vault.
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
let state = null; // full state (all wallets + selected)
let tab = "receive";
let unit = null; // "big" | "small" — chain-dependent
let sendMax = false;
let planTimer = null;
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;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } };
const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {});
const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, "");
// ---- coin logos ------------------------------------------------------------
// Inline SVGs so the header, wallet picker and settings surface all render
// the same mark. Sized by the container via width/height attributes.
function logoSvg(logo, size) {
const s = size || 20;
if (logo === "bch") {
// All coin marks below are the canonical SVGs from
// github.com/spothq/cryptocurrency-icons — the permissive-licensed
// set most wallets, exchanges, and explorers standardised on, so
// Aegis's logos match what users see everywhere else. Inline so
// panel load doesn't fetch anything.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin Cash" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" fill="#8dc351" r="16"/><path d="M21.207 10.534c-.776-1.972-2.722-2.15-4.988-1.71l-.807-2.813-1.712.491.786 2.74c-.45.128-.908.27-1.363.41l-.79-2.758-1.711.49.805 2.813c-.368.114-.73.226-1.085.328l-.003-.01-2.362.677.525 1.83s1.258-.388 1.243-.358c.694-.199 1.035.139 1.2.468l.92 3.204c.047-.013.11-.029.184-.04l-.181.052 1.287 4.49c.032.227.004.612-.48.752.027.013-1.246.356-1.246.356l.247 2.143 2.228-.64c.415-.117.825-.227 1.226-.34l.817 2.845 1.71-.49-.807-2.815a65.74 65.74 0 001.372-.38l.802 2.803 1.713-.491-.814-2.84c2.831-.991 4.638-2.294 4.113-5.07-.422-2.234-1.724-2.912-3.471-2.836.848-.79 1.213-1.858.642-3.3zm-.65 6.77c.61 2.127-3.1 2.929-4.26 3.263l-1.081-3.77c1.16-.333 4.704-1.71 5.34.508zm-2.322-5.09c.554 1.935-2.547 2.58-3.514 2.857l-.98-3.419c.966-.277 3.915-1.455 4.494.563z" fill="#fff" fill-rule="nonzero"/></g></svg>`;
}
if (logo === "trx") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Tron" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#EF0027" cx="16" cy="16" r="16"/><path d="M21.932 9.913L7.5 7.257l7.595 19.112 10.583-12.894-3.746-3.562zm-.232 1.17l2.208 2.099-6.038 1.093 3.83-3.192zm-5.142 2.973l-6.364-5.278 10.402 1.914-4.038 3.364zm-.453.934l-1.038 8.58L9.472 9.487l6.633 5.502zm.96.455l6.687-1.21-7.67 9.343.983-8.133z" fill="#FFF"/></g></svg>`;
}
if (logo === "sc") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Siacoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#20EE82"/><path fill="#FFF" d="M16 7.5a8.5 8.5 0 018.5 8.5v8.5H16a8.5 8.5 0 110-17zm5.1 13.6v-5.023c0-2.82-2.255-5.163-5.074-5.177a5.106 5.106 0 00-5.126 5.126c.014 2.819 2.358 5.074 5.177 5.074H21.1z"/></g></svg>`;
}
if (logo === "dgb") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="DigiByte" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#006AD2"/><path fill="#FFF" d="M12.368 25l.479-1.282-.85.084-.306.81c-.024.061-.044.125-.075.183-.067.125-.17.203-.313.204-.63.001-1.258 0-1.888-.001-.015 0-.03-.009-.063-.019l.402-1.085c-.733-.02-1.446-.032-2.156-.113.012-.133 4.062-10.345 4.223-10.652.04-.003.087-.01.135-.01h3.27c.033 0 .066 0 .098.002.331.025.515.305.4.623-.153.42-.315.838-.472 1.256l-2.058 5.474c-.021.056-.039.114-.065.19.058.003.103.009.148.007 3.096-.135 5.368-1.613 6.836-4.39a6.711 6.711 0 00.67-1.935c.073-.395.096-.791-.003-1.186a1.763 1.763 0 00-.698-1.03c-.468-.337-.994-.481-1.562-.484H7.5c.024-.06.035-.1.054-.136l1.388-2.501a.754.754 0 01.706-.418h5.866l.601-1.59h1.782c.044 0 .088-.003.13.003.127.02.2.12.181.25-.008.054-.028.106-.048.158-.123.331-.249.661-.372.992-.021.056-.038.113-.06.18h.805c.02-.043.04-.087.058-.132l.496-1.317c.05-.133.052-.134.185-.134.564 0 1.129-.002 1.693 0 .238.001.323.127.238.357-.135.369-.274.735-.412 1.102-.019.051-.036.103-.06.173.055.01.1.02.145.026.785.096 1.549.274 2.274.601.551.249 1.052.574 1.464 1.03.558.615.835 1.35.879 2.18.042.805-.105 1.581-.372 2.33-.632 1.775-1.53 3.388-2.83 4.747-.896.936-1.93 1.68-3.064 2.282-1.224.65-2.518 1.105-3.858 1.427-.12.03-.183.082-.224.2-.147.41-.303.818-.457 1.226-.095.25-.19.318-.452.318h-1.868z"/></g></svg>`;
}
if (logo === "btc") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#F7931A"/><path fill="#FFF" fill-rule="nonzero" d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z"/></g></svg>`;
}
if (logo === "eth") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Ethereum" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#627EEA"/><g fill="#FFF" fill-rule="nonzero"><path fill-opacity=".602" d="M16.498 4v8.87l7.497 3.35z"/><path d="M16.498 4L9 16.22l7.498-3.35z"/><path fill-opacity=".602" d="M16.498 21.968v6.027L24 17.616z"/><path d="M16.498 27.995v-6.028L9 17.616z"/><path fill-opacity=".2" d="M16.498 20.573l7.497-4.353-7.497-3.348z"/><path fill-opacity=".602" d="M9 16.22l7.498 4.353v-7.701z"/></g></g></svg>`;
}
if (logo === "sol") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Solana" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#66F9A1" cx="16" cy="16" r="16"/><path d="M9.925 19.687a.59.59 0 01.415-.17h14.366a.29.29 0 01.207.497l-2.838 2.815a.59.59 0 01-.415.171H7.294a.291.291 0 01-.207-.498l2.838-2.815zm0-10.517A.59.59 0 0110.34 9h14.366c.261 0 .392.314.207.498l-2.838 2.815a.59.59 0 01-.415.17H7.294a.291.291 0 01-.207-.497L9.925 9.17zm12.15 5.225a.59.59 0 00-.415-.17H7.294a.291.291 0 00-.207.498l2.838 2.815c.11.109.26.17.415.17h14.366a.291.291 0 00.207-.498l-2.838-2.815z" fill="#FFF"/></g></svg>`;
}
if (logo === "aegis") {
// Athena's aspis — hexagonal shield with a boss at center + four
// spoke marks. Same silhouette as the aegis.x hero SVG so the wallet
// and the marketing page read as one identity.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Aegis" style="vertical-align:middle;flex:none">
<polygon points="16,2 29,9 29,23 16,30 3,23 3,9" fill="none" stroke="#d6ff3d" stroke-width="2" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="4.3" fill="none" stroke="#d6ff3d" stroke-width="1.4"/>
<circle cx="16" cy="16" r="1.3" fill="#d6ff3d"/>
<path d="M16 10.5 v-2.4 M16 21.5 v2.4 M10.5 16 h-2.4 M21.5 16 h2.4" stroke="#d6ff3d" stroke-width="1.4" stroke-linecap="round"/>
</svg>`;
}
// Fallback = Aegis shield (rather than a "?"), so an unrecognised
// registry entry still looks intentional.
return logoSvg("aegis", s);
}
function testnetTag() { return `<span class="ttag">TEST</span>`; }
// Selected wallet convenience.
const sel = () => state && state.selected;
const chain = () => sel()?.chain || "";
const decimals = () => sel()?.meta?.decimals || 8;
const ticker = () => sel()?.meta?.ticker || "";
// Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at
// 10^24-scale routinely. Use BigInt for anything that arrives as a string.
function fmtBig(units, dec) {
const d = dec != null ? dec : decimals();
if (typeof units === "string" && /^-?\d+$/.test(units)) {
const neg = units.startsWith("-");
const raw = neg ? units.slice(1) : units;
const bi = BigInt(raw || "0");
const base = 10n ** BigInt(d);
const whole = (bi / base).toString();
let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, "");
// Show 8-digit precision at most for very small units; keep 2 dp minimum.
const cap = Math.min(d, 8);
if (frac.length > cap) frac = frac.slice(0, cap);
if (!frac) frac = "";
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
}
const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d);
return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
}
function fmtSmall(units) {
if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return Number(units || 0).toLocaleString("en-US");
}
function smallUnitLabel() {
const c = chain();
if (c === "bch" || c === "dgb" || c === "btc") return "sat";
if (c === "trx") return "sun";
if (c === "sc") return "H";
if (c === "eth") return "wei";
if (c === "sol") return "lamports";
return "u";
}
// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet.
function explorerHref(base, id) {
const s = sel();
return base + id + (s?.explorerSuffix || "");
}
function bigUnitLabel() { return ticker(); }
// ---- fiat helpers ----------------------------------------------------------
// Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled
// or missing, fiat helpers return null and the caller renders nothing.
function priceFor(chain) {
if (!state?.prices?.enabled) return null;
return state.prices.prices?.[chain] ?? null;
}
// Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe
// for wide-decimals coins (SC=24, ETH=18) that overflow Number.
function usdOf(chain, units, decimals) {
const price = priceFor(chain);
if (price == null || !units) return null;
const d = Number(decimals) || 0;
if (typeof units === "string" && /^-?\d+$/.test(units)) {
// BigInt-safe: divide the units by 10^d first via BigInt, then use
// the fractional remainder as a Number multiplier for the last dp.
const neg = units.startsWith("-");
const abs = neg ? units.slice(1) : units;
const base = 10n ** BigInt(d);
const bi = BigInt(abs);
const whole = Number(bi / base);
const frac = Number(bi % base) / Number(base);
return (neg ? -1 : 1) * (whole + frac) * price;
}
const n = Number(units) / Math.pow(10, d);
return n * price;
}
// Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else
// grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the
// feed is enabled but hasn't returned yet.
function fmtFiat(usd) {
if (usd == null) return null;
if (usd === 0) return "$0.00";
if (Math.abs(usd) < 0.01) return "< $0.01";
if (Math.abs(usd) < 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;
}
// ---- 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(); }
if (name === "send") applyUnitPicker();
// Settings tab is always usable (fiat prices are a global setting); every
// other tab is gated by the wallet-ready state. Re-run gate visibility so
// switching TO or AWAY FROM Settings while locked does the right thing.
const s = sel();
const ready = s && s.phase === "ready";
const onSettings = name === "settings";
$("tabs").hidden = !(ready || onSettings);
$("gate").hidden = ready || onSettings;
}
// ---- wallet picker (two-step add) ------------------------------------------
$("pickerBtn").addEventListener("click", (e) => {
// The "" chip inside the picker header opens the dropdown with the
// Add-wallet section pre-expanded — same UX as the empty-state gate
// button but always available.
const isAddChip = e.target && (e.target.id === "hAddWallet" || e.target.closest("#hAddWallet"));
const d = $("drop");
if (isAddChip) {
d.hidden = false;
fillPicker();
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
e.stopPropagation();
return;
}
d.hidden = !d.hidden;
if (!d.hidden) fillPicker();
});
document.addEventListener("click", (e) => {
const d = $("drop");
if (d.hidden) return;
if (e.target.closest("#drop") || e.target.closest("#pickerBtn")) return;
d.hidden = true;
});
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 : "—";
// Fiat sits on a second line under the native balance, right-aligned.
// Testnet coins mirror mainnet prices, so we don't dim them.
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() : ""}`;
return `<div class="row ${on}" data-select="${esc(w.id)}">
${logoSvg(w.logo, 22)}
<div class="m"><div class="l">${esc(w.label)}</div><div class="s">${sub}</div></div>
<div class="v"><div>${esc(bal)}</div>${fiatLine}</div>
</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("");
d.innerHTML =
rowsHtml +
`<hr><div class="addhdr"> Add wallet</div>${coinRows}` +
`<hr><div class="addhdr">↓ Import existing (BCH)</div>
<div class="coinrow" id="picker-import-bch">
${logoSvg("bch", 22)}
<div class="m"><div class="l">Import a BCH wallet</div><div class="s">Paste a BIP39 mnemonic + derivation path, or a WIF</div></div>
<div class="caret"></div>
</div>`;
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)); }
}));
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 ? "▸" : "▾";
}));
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-bch");
if (impBtn) impBtn.addEventListener("click", () => { d.hidden = true; openImportModal("bch"); });
}
// 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.
function openImportModal(chain) {
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:24px";
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("bch", 22)}
<div style="font-weight:600;flex:1">Import a BCH 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">Network</div>
<div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px">
<label><input type="radio" name="imNet" value="chipnet" checked> Chipnet testnet</label>
<label><input type="radio" name="imNet" value="mainnet"> Mainnet</label>
</div>
</div>
<div class="field">
<div class="lbl">Source</div>
<div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px">
<label><input type="radio" name="imKind" value="mnemonic" checked> BIP39 mnemonic</label>
<label><input type="radio" name="imKind" value="wif"> WIF</label>
</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" style="margin-top:6px">Derivation path</div>
<input type="text" id="imPath" spellcheck="false" placeholder="m/44'/1'/0'/0/0">
<div class="hint">Default: <span class="mono">m/44'/1'/0'/0/0</span> for chipnet, <span class="mono">m/44'/145'/0'/0/0</span> for mainnet.</div>
</div>
<div class="field" id="imWifField" hidden>
<div class="lbl">WIF private key</div>
<input type="text" id="imWif" spellcheck="false" placeholder="Kx… or Lz… (base58check)">
</div>
<div class="field">
<div class="lbl">Label</div>
<input type="text" id="imLabel" placeholder="e.g. Sirius.x · BNS name buyer">
</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>`;
document.body.appendChild(overlay);
const close = () => { try { overlay.remove(); } catch {} };
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#imClose").addEventListener("click", close);
overlay.querySelector("#imCancel").addEventListener("click", close);
// Toggle mnemonic vs WIF fields.
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", () => {
const kind = overlay.querySelector('input[name="imKind"]:checked').value;
overlay.querySelector("#imMnemonicField").hidden = kind !== "mnemonic";
overlay.querySelector("#imWifField").hidden = kind !== "wif";
}));
// Update default path on network switch.
const setDefaultPath = () => {
const net = overlay.querySelector('input[name="imNet"]:checked').value;
const path = overlay.querySelector("#imPath");
if (!path.value.trim()) path.value = net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0";
};
overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", setDefaultPath));
setDefaultPath();
overlay.querySelector("#imGo").addEventListener("click", async () => {
const msg = overlay.querySelector("#imMsg"); msg.hidden = true;
const network = overlay.querySelector('input[name="imNet"]:checked').value;
const kind = overlay.querySelector('input[name="imKind"]:checked').value;
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: "bch", 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; }
} else {
payload.wif = overlay.querySelector("#imWif").value.trim();
if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; }
}
try {
state = await S.invoke("importWallet", payload);
close();
render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
}
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 ----------------------------------------------------------------
function render() {
if (!state) return;
const s = sel();
const ready = s && s.phase === "ready";
// 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;
if (onSettings) fillSettings();
// 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";
$("hNet").innerHTML = s?.meta
? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}`
: "";
if (!ready) {
const copy = {
locked: ["🔒", "Unlock your password vault to open the wallet.", "Aegis derives its keys from the vault seed, so there is nothing separate to unlock — the vault is your wallet."],
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Pick a master password on the next screen. Every Aegis wallet is derived from it — the same master password on another machine recreates the same addresses."],
error: ["⚠", "This wallet could not start.", s?.error || ""],
empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."],
}[s?.phase || "locked"] || ["…", "Starting…", ""];
const phase = s?.phase;
let form = "";
if (phase === "nosetup") {
form = `
<div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left">
<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, etc.). 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>`;
} else if (phase === "locked") {
form = `
<div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left">
<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>`;
} else 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");
d.hidden = false;
fillPicker();
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
});
}
if (phase === "locked") {
const doUnlock = async () => {
const pw = $("gateUnlockPw").value;
const msg = $("gateUnlockMsg"); msg.hidden = true;
if (!pw) return;
try { state = await S.invoke("vaultUnlock", { masterPassword: pw }); 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 (phase === "nosetup") {
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);
}
}
const dot = $("dot");
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
$("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || ""));
if (ready) {
// Sia-specific gate: adapter is up, keys are derived, but no walletd URL
// means no balance / history until the user configures one in Settings.
if (chain() === "sc" && s.needsWalletdUrl) {
$("balMain").textContent = "—"; $("balTicker").textContent = s.meta.ticker;
$("netlbl").textContent = "point Aegis at a walletd node in Settings";
$("tabs").hidden = true;
gate.hidden = false;
gate.innerHTML = `<div class="big">🗝</div><div><b>Point Aegis at a walletd node</b></div><div class="hint" style="margin-top:8px">Settings Sia walletd URL. Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode works.</div>`;
return;
}
const total = balanceSum(s.balance);
$("balMain").textContent = fmtBig(total);
$("balTicker").textContent = s.meta.ticker;
const uc = s.balance?.unconfirmed;
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
// Fiat under the native amount (opt-in, might be null while loading).
const usd = usdOf(chain(), total, decimals());
const fiat = fmtFiat(usd) || fiatSkeleton();
$("balFiat").textContent = fiat || "";
$("balFiat").hidden = !fiat;
} else {
$("balMain").textContent = "—"; $("balTicker").textContent = "";
$("balFiat").hidden = true;
}
renderPortfolio();
if (!ready) return;
const addr = s.address || "";
if ($("addr").textContent !== addr) {
$("addr").textContent = addr;
drawQr(qrPayload(chain(), addr, sel()?.network));
}
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
$("nextAddr").hidden = chain() !== "bch";
$("openFaucet").hidden = !s.faucet;
// Render SPL tokens list (SOL wallets only). Sending a token clicks
// through to the Send tab with that asset pre-picked.
renderTokens();
applyUnitPicker();
$("feeField").hidden = chain() !== "bch";
renderHistory();
}
// 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 || [];
if (!state?.prices?.enabled || wallets.length < 2) { 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 tokens = (chain() === "sol" && s?.tokens) || [];
const card = $("tokensCard");
card.hidden = tokens.length === 0;
if (!tokens.length) return;
const el = $("tokensList");
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");
}));
}
// 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;
if (d === 0) return (neg ? "-" : "") + abs;
const pad = abs.padStart(d + 1, "0");
const whole = pad.slice(0, pad.length - d);
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return (neg ? "-" : "") + whole + (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);
}
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 ---------------------------------------------------------------
function renderHistory() {
const s = sel();
const list = s?.history || [];
const el = $("txlist");
if (!list.length) { el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; }
el.innerHTML = list.map((t) => {
const inc = t.delta >= 0;
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : "");
const what = (inc ? "Received" : "Sent") + (who ? " " + who : "");
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed");
const delta = Math.abs(t.delta || 0);
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div>
<div class="what">${esc(what)}</div>
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : ""}${delta ? fmtBig(delta) : "—"}</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))));
}
function shortAddr(a) {
if (!a) return "";
const s = String(a).replace(/^bitcoincash:|^bchtest:/, "");
return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4));
}
// ---- QR --------------------------------------------------------------------
// Coin-scheme URI so wallet apps that scan know which chain the payment is
// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681
// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed
// URI scheme exists).
function qrPayload(chain, address, network) {
if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, "");
if (chain === "btc") return "bitcoin:" + address; // BIP21
if (chain === "dgb") return "digibyte:" + address;
if (chain === "eth") return "ethereum:" + address;
if (chain === "sol") return "solana:" + address;
if (chain === "trx") return "tron:" + address;
return String(address);
}
function drawQr(text) {
const cv = $("qr");
const g = cv.getContext("2d");
let q;
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
const px = (q.size + 2) * scale;
cv.width = cv.height = px;
cv.style.width = cv.style.height = px + "px";
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
g.fillStyle = "#000";
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
}
// ---- receive actions -------------------------------------------------------
$("copyAddr").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {}
});
$("nextAddr").addEventListener("click", async () => {
try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); }
catch (e) { flash($("nextAddr"), "Failed"); }
});
$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address)));
$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet));
function flash(btn, text) {
const old = btn.textContent; btn.textContent = text;
setTimeout(() => { btn.textContent = old; }, 1200);
}
// ---- send ------------------------------------------------------------------
$("sendMax").addEventListener("click", () => {
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); }));
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 p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax });
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;
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
const isToken = sendAsset && lastPlan._token;
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
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 });
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;
$("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();
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 || "";
$("setServers").value = (state.bchServers?.list || []).join("\n");
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 || "");
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.
function renderPricesSetting() {
const p = state?.prices;
const toggle = $("pricesToggle");
if (!toggle) return;
toggle.checked = !!p?.enabled;
$("refreshPrices").hidden = !p?.enabled;
const st = $("pricesStatus");
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.";
}
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();
const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean);
if (path && path !== (sel().accountPath || "")) {
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path });
}
if (sel().network === "mainnet") {
const currentJoined = (state.bchServers?.list || []).join();
if (state.bchServers?.custom || servers.join() !== currentJoined) {
state = await S.invoke("setBchServers", { servers });
}
}
settingsFilled = false; fillSettings(); render();
flash($("applySettings"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.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;
if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) 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 || "(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)}</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;
}
});
$("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 () => {
try { state = await S.invoke("state"); render(); }
catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
})();