theseus/bundled-addons/aegis/panel.js
Local Dev 2e54bf5e5a Ship Theseus 0.3.28 5d15508b (Aegis update card + DevTools in tab sidebar + real favicons)
Setup    5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643
Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4

Bundled since 0.3.27:

32d4b75 - Aegis (bchwallet) gains its own update card in Settings >
General beside Ariadne. Check for updates hits the same signed OTA
endpoint the boot timer uses; Restart to apply appears when a signed
newer version is staged. Uses the existing addons-check-updates + a
new app-restart IPC. New Aegis versions ship without a Theseus release.

32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to
the right of the tab (mode: 'right') instead of a detached window.
Matches stock Chrome. Users who prefer detached can drag out via the
DevTools own toolbar.

b71c925 - Search-engine favicons in Settings > Search now use Google's
/s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough
hosts (Brave, Bing, Yandex, etc.) that half the list was falling
through to the emoji placeholder.

Deployed. Verified LIVE 0.3.28.
2026-09-08 18:17:25 +02:00

836 lines
42 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") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin Cash" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#0ac18e" stroke="#0a9a72" stroke-width=".8"/>
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="20" font-weight="800" fill="#fff">₿</text>
</svg>`;
}
if (logo === "trx") {
// Simplified from the official geometric Tron mark: triangle + tail line.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Tron" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#ff060a" stroke="#c1050a" stroke-width=".8"/>
<path d="M7.5 10.2 L24 12.5 L14.5 23.8 Z"
fill="none" stroke="#fff" stroke-width="1.7" stroke-linejoin="round"/>
<line x1="7.5" y1="10.2" x2="14.5" y2="23.8" stroke="#fff" stroke-width="1.7" stroke-linejoin="round"/>
</svg>`;
}
if (logo === "sc") {
// Sia's mark is a stylized S built from two mirrored crescents. Approximated
// here with a plain S glyph on the brand green so it reads at 22px.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Siacoin" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#20be82" stroke="#158e5f" stroke-width=".8"/>
<text x="16" y="22.3" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="800" fill="#fff">S</text>
</svg>`;
}
if (logo === "dgb") {
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="DigiByte" style="vertical-align:middle;flex:none">
<polygon points="10,2 22,2 30,10 30,22 22,30 10,30 2,22 2,10" fill="#0066cc" stroke="#004a99" stroke-width=".8"/>
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="800" fill="#fff">D</text>
</svg>`;
}
if (logo === "btc") {
// Orange disc with the Bitcoin sign — the widely-recognised BTC mark.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#f7931a" stroke="#c76e0a" stroke-width=".8"/>
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="20" font-weight="800" fill="#fff">₿</text>
</svg>`;
}
if (logo === "eth") {
// Ethereum's mark is the two-triangle rhombus. Simplified to the
// silhouette on a light-purple disc so it reads at 22px.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Ethereum" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#627eea" stroke="#4058c0" stroke-width=".8"/>
<polygon points="16,5 22,17 16,20 10,17" fill="#fff" opacity=".95"/>
<polygon points="16,21 22,18 16,27 10,18" fill="#fff" opacity=".7"/>
</svg>`;
}
if (logo === "sol") {
// Solana's three-slant mark. Purple → green gradient in the brand
// spec; approximated with two solid parallelograms on a dark disc.
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Solana" style="vertical-align:middle;flex:none">
<circle cx="16" cy="16" r="15.5" fill="#0f0f14" stroke="#2a2a3a" stroke-width=".8"/>
<polygon points="9,11 22,11 20,14 7,14" fill="#9945ff"/>
<polygon points="9,15 22,15 20,18 7,18" fill="#14f195"/>
<polygon points="9,19 22,19 20,22 7,22" fill="#00d1ff"/>
</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(); }
// ---- 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();
}
// ---- 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 bal = w.balance ? fmtBig(w.balance.confirmed || 0, w.decimals) + " " + w.ticker : "—";
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">${esc(bal)}</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}`;
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)); }
}));
}
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";
$("tabs").hidden = !ready;
const gate = $("gate");
gate.hidden = ready;
// 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.", "Settings › Passwords. Aegis derives its keys from the vault seed, so there is nothing separate to unlock."],
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings › Passwords › Set up. Use a recovery phrase there and every wallet in Aegis can be recreated from it on any machine."],
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 showAddButton = s?.phase === "empty";
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>`
+ (showAddButton ? `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>` : "");
// Wire the empty-state button — opens the header picker with the Add
// section pre-expanded so the user can pick a coin in one click.
if (showAddButton) {
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);
});
}
}
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`;
} else {
$("balMain").textContent = "—"; $("balTicker").textContent = "";
}
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();
}
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() {
const s = sel(); if (!s) 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 || "");
} 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 = "";
}
renderSites();
}
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); }
});
// ---- 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>`; }
})();