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.
This commit is contained in:
Local Dev 2026-09-09 10:33:21 +02:00
parent 7405e444e7
commit 992c02ea89
10 changed files with 1286 additions and 28 deletions

View file

@ -154,7 +154,7 @@ function validateManifest(raw, folderName) {
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`.
class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, captureTab, saveCapture }) {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) {
this.addonsDir = addonsDir;
this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false);
@ -164,6 +164,15 @@ class AddonHost {
// Capability hooks injected by main. Each is (args..., addonId) so main
// can log/gate per add-on. Missing hook = capability unavailable.
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
// vaultImports: main-process shim {list, add, remove, signer} that owns
// wallet-imports.enc. Same trust tier as vaultDerive — an add-on that
// holds vault-derive can also see imports (design §3.2 co-tenancy).
this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null;
// vaultLifecycle: main-process shim {status, setup, unlock, lock} so the
// wallet add-on can drive vault setup/unlock without redirecting users
// to Settings > Passwords. Same "vault-derive" capability gate.
this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function"
? arguments[0].vaultLifecycle : null;
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
// Add-ons live outside the app's node_modules tree, so a bare require()
@ -177,6 +186,10 @@ class AddonHost {
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
// Signature: (addonId, relPath, queryString) => Promise<void>.
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
// openSettings: opens Theseus's Settings tab, optionally scrolled to a
// named section (e.g. "passwords"). Uses the same IPC route the picker
// uses for "Search settings…". Signature: (section?: string) => void.
this._openSettings = typeof openSettings === "function" ? openSettings : null;
// Session-proxy hook — injected by main so add-ons can swap the default
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
@ -195,6 +208,42 @@ class AddonHost {
}
}
// api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same
// "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from
// its own gate instead of redirecting users to Settings > Passwords.
_makeLifecycleApi(manifest) {
const requireCap = () => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)");
};
return {
status: async () => { requireCap(); return this._vaultLifecycle.status(); },
unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); },
setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); },
lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); },
};
}
// api.vault.imports namespace factory. Gated by the "vault-derive" cap
// because the two surfaces sit at the same trust tier (design §3.2). If
// main didn't wire the vaultImports shim, calls throw a clear error.
_makeImportsApi(manifest) {
const requireCap = () => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)");
};
return {
list: async () => { requireCap(); return this._vaultImports.list(); },
add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); },
remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); },
signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); },
};
}
discoverAndActivate() {
this.ensureDirs();
this._deactivateAll();
@ -374,6 +423,15 @@ class AddonHost {
}
return this._openAddonTab(manifest.id, s, qs);
},
// Open Theseus's Settings tab, optionally scrolled to a named section
// (validated against a known list in main). No capability needed —
// it's the same thing the user could do from the ⋮ menu, just a
// one-click shortcut so add-ons can point users at the right place
// (e.g. Aegis's "Set up vault" gate → Passwords).
openSettings: (section) => {
if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)");
this._openSettings(typeof section === "string" ? section : "");
},
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
// injected page bridges) call into the add-on with a message name +
// one JSON payload; the handler's return value goes back as the
@ -413,6 +471,8 @@ class AddonHost {
}
return this._vaultDerive(p, manifest.id);
},
imports: this._makeImportsApi(manifest),
lifecycle: this._makeLifecycleApi(manifest),
},
// approval-modal: ask the user. Resolves to the chosen action id, or
// "cancel" (Escape / mask click / window closed). With `checkbox` set

View file

@ -1,7 +1,7 @@
{
"id": "aegis",
"name": "Aegis Wallet",
"version": "0.4.4",
"version": "0.6.1",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E",

View file

@ -28,6 +28,7 @@ async function loadDeps(api) {
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
const { ed25519 } = await api.import("@noble/curves/ed25519.js");
const { sha256 } = await api.import("@noble/hashes/sha2.js");
const { hkdf } = await api.import("@noble/hashes/hkdf.js");
const { ripemd160 } = await api.import("@noble/hashes/legacy.js");
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
const { blake2b } = await api.import("@noble/hashes/blake2.js");
@ -57,25 +58,52 @@ async function loadDeps(api) {
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
// api.require from the Theseus dependency tree.
const { pathToFileURL } = require("node:url");
const dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href);
const dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href);
// DGB's bundled ESM packages import their peer deps by bare specifier
// ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in
// userData/addons/, Node's ESM resolver can't reach Theseus's node_modules
// from that path — so the import throws. Wrap it: DGB just becomes
// unavailable, the rest of Aegis keeps working.
let dgbCore = null, dgbPsbt = null, dgbAdapter = null;
try {
dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href);
dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href);
} catch (e) {
api.log("dgb unavailable:", e?.message || e);
}
const bitcoinjs = api.require("bitcoinjs-lib");
const { BIP32Factory } = api.require("bip32");
const { ECPairFactory } = api.require("ecpair");
const ecc = api.require("@bitcoinerlab/secp256k1");
const dgbAdapter = require("./lib/chain-dgb.js")({
dgbCore, dgbPsbt, bitcoinjs,
bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
sha256, electrum,
});
if (dgbCore && dgbPsbt) {
dgbAdapter = require("./lib/chain-dgb.js")({
dgbCore, dgbPsbt, bitcoinjs,
bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
sha256, electrum,
});
}
const btcAdapter = require("./lib/chain-btc.js")({
bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
sha256, electrum,
});
return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
const bip39 = api.require("bip39");
// Imported BCH — a lean read-only adapter for wallets whose key material
// lives in Theseus's wallet-imports.enc. Same electrum/cashaddr surface as
// the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
});
// WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the
// §4d combined-work requirement (dynamic linkage + license notice + source
// availability) is met — package sources ship with npm.
const wcCore = await api.import("@wizardconnect/core");
const wcWallet = await api.import("@wizardconnect/wallet");
const libauth = await api.import("@bitauth/libauth");
return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b,
cashaddr, keysLib, tx, electrum, base58check,
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 };
importedBchAdapter, bip39,
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712,
wcCore, wcWallet, libauth };
}
// ---- servers ---------------------------------------------------------------
@ -443,6 +471,30 @@ async function mountWallet(entry) {
rt.phase = "locked"; rt.error = null;
c.runtimes.set(entry.id, rt);
emitState();
// Imported wallets skip the vault-derive/HKDF path entirely: their signer
// material is in wallet-imports.enc, and the runtime here is read-only so
// it doesn't need the material until spend support ships (M.1b).
if (entry.kind === "imported") {
try {
const adapter = new c.d.importedBchAdapter.ImportedBchWallet({
walletId: entry.id, storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
network: entry.network,
cashaddr: entry.importedCashaddr,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
});
rt.adapter = adapter; rt.phase = "ready";
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
emitStateForWallet(entry.id);
} catch (e) {
rt.phase = "error"; rt.error = e?.message || String(e);
emitState();
}
return;
}
let root;
try {
root = await c.api.vault.derive(entry.purpose);
@ -490,6 +542,7 @@ async function mountWallet(entry) {
});
if (walletdUrl) adapter.startPolling();
} else if (entry.chain === "dgb") {
if (!c.d.dgbAdapter) throw new Error("DGB unavailable (bundled ESM couldn't resolve peer deps)");
adapter = new c.d.dgbAdapter.DgbWallet(root, {
walletId: entry.id,
storage: c.api.storage,
@ -537,6 +590,12 @@ async function mountWallet(entry) {
// Kick a first fetch. Errors here don't fail the mount — the panel shows
// them per-wallet via snapshot.error.
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
if (entry.chain === "bch" && c.wc) {
c.wc.startForWallet({
walletId: entry.id, label: entry.label,
root32: root, accountPath: entry.accountPath || "m/44'/145'/0'",
}).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e));
}
emitStateForWallet(entry.id);
} catch (e) {
rt.phase = "error";
@ -551,9 +610,50 @@ async function mountWallet(entry) {
function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
ctx.runtimes.delete(walletId);
}
// ---- import derive helpers (client-side, cashaddr only) --------------------
// The pasted material never leaves this process — main-side stores it once
// via api.vault.imports.add. These helpers only turn (seed+path) or WIF into
// a P2PKH cashaddr, which is safe to send back to the panel.
function deriveCashaddrFromSeed(seedHex, path, prefix) {
const d = ctx.d;
const seed = new Uint8Array(seedHex.length / 2);
for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16);
const root = d.HDKey.fromMasterSeed(seed);
const node = root.derive(path);
const h160 = d.ripemd160(d.sha256(node.publicKey));
return d.cashaddr.encode(prefix, 0, h160);
}
function deriveCashaddrFromWif(wif, prefix) {
const d = ctx.d;
// WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01])
const raw = d.base58check.decode(wif);
if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`);
// First byte is version (network); we allow any — BCH mainnet uses 0x80,
// testnet 0xEF. Both round-trip through the same address derivation below.
const priv = raw.slice(1, 33);
const compressed = raw.length === 34; // trailing 0x01 marker
const pub = d.secp256k1.getPublicKey(priv, compressed);
const h160 = d.ripemd160(d.sha256(pub));
return d.cashaddr.encode(prefix, 0, h160);
}
function buildWcApprovalBody(payload) {
const req = payload?.request || {};
const tx = req.transaction || {};
const dappName = tx.userPrompt || "unknown dapp";
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?";
const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp.";
const walletName = payload?.label || payload?.walletId || "";
return "<div><b>" + dappName + "</b> requests a BCH transaction signature.</div>"
+ "<div>Wallet: <b>" + walletName + "</b> · " + inputCount + " input(s).</div>"
+ "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>";
}
// ---- state / snapshot -------------------------------------------------------
function selectedWalletId() {
@ -629,6 +729,8 @@ function fullState() {
custom: Array.isArray(ctx.api.storage.get("servers", null)),
},
coins: coinsForPanel(),
prices: ctx.priceFeed ? ctx.priceFeed.snapshot() : { enabled: false, prices: {} },
wc: ctx.wc ? ctx.wc.snapshot() : {},
};
}
@ -718,6 +820,99 @@ function registerPanelMessages(api) {
await mountWallet(entry);
return fullState();
});
// Vault lifecycle from inside the wallet panel — no more redirecting the
// user to Settings > Passwords. After a successful unlock/setup we remount
// every wallet: the vault-derive route is now available.
api.onMessage("vaultSetup", async (p, m) => {
fromPanel(m);
const pw = String(p && p.masterPassword || "");
const seedSource = p && p.seedSource;
await api.vault.lifecycle.setup(pw, seedSource);
await mountAllWallets();
return fullState();
});
api.onMessage("vaultUnlock", async (p, m) => {
fromPanel(m);
const pw = String(p && p.masterPassword || "");
await api.vault.lifecycle.unlock(pw);
await mountAllWallets();
return fullState();
});
api.onMessage("vaultStatus", async (_p, m) => {
fromPanel(m);
return api.vault.lifecycle.status();
});
// ---- wallet import (M.1 of DESIGN-wallet-multi-account-amendment.md) ----
// Accepts either a BIP39 mnemonic (12/24 words) + BIP44 path, OR a raw WIF.
// Derives the P2PKH cashaddr client-side, stores the signer material via
// api.vault.imports.add (main-process holds it), then adds a slim Aegis
// wallet entry with kind=imported pointing at the returned importId.
api.onMessage("importWallet", async (p, m) => {
fromPanel(m);
const chain = String(p && p.chain || "bch");
if (chain !== "bch") throw new Error("only BCH imports are supported in this build");
const network = String(p && p.network || "chipnet");
if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`);
const label = String(p && p.label || "").trim();
if (!label) throw new Error("label required");
const category = String(p && p.category || "operational").trim();
const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
const source = String(p && p.source || "manual-paste");
let kind, seedHex, path, wif, cashaddrStr;
if (p && p.wif) {
kind = "wif";
wif = String(p.wif).trim();
cashaddrStr = deriveCashaddrFromWif(wif, prefix);
} else if (p && p.mnemonic) {
kind = "seed";
const words = String(p.mnemonic).trim().split(/\s+/).length;
if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) {
throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`);
}
if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) {
throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)");
}
const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim());
seedHex = Buffer.from(seed).toString("hex");
path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
} else if (p && p.seedHex) {
kind = "seed";
seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, "");
if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex");
path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
} else {
throw new Error("supply mnemonic, seedHex, or wif");
}
const spec = { kind, cashaddr: cashaddrStr, label, category, source };
if (kind === "seed") { spec.seed = seedHex; spec.path = path; }
else { spec.wif = wif; }
const { id: importId } = await api.vault.imports.add(spec);
// Persist as an Aegis wallet entry with kind=imported. Uses a distinct
// id prefix so it's obvious in storage that this row references the
// imports file rather than a vault-derive purpose.
const list = walletEntries().slice();
const walletId = `bch-imported-${importId}`;
if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id");
const entry = {
id: walletId, label, chain: "bch", network,
kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category,
createdAt: Date.now(),
};
list.push(entry);
writeWallets(api, list);
api.storage.set("selectedWalletId", walletId);
ctx.runtimes.set(walletId, { entry, phase: "locked", error: null, adapter: null });
emitState();
await mountWallet(entry);
return fullState();
});
api.onMessage("removeWallet", (p, m) => {
fromPanel(m);
const id = String(p && p.id || "");
@ -759,6 +954,9 @@ function registerPanelMessages(api) {
return snapshotForSelected();
});
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
// Panel gate uses this to jump to Settings Passwords when the vault is
// locked / not yet created — one-click bridge to Theseus's built-in UI.
api.onMessage("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; });
api.onMessage("setBchServers", (p, m) => {
fromPanel(m);
@ -927,6 +1125,37 @@ function registerPanelMessages(api) {
return out;
});
// Opt-in USD prices. Persist the choice so restart doesn't silently
// disable it, and kick a fetch immediately when switched on.
api.onMessage("setPricesEnabled", async (p, m) => {
fromPanel(m);
const on = !!(p && p.enabled);
api.storage.set("pricesEnabled", on);
if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on);
return fullState();
});
api.onMessage("refreshPrices", async (_p, m) => {
fromPanel(m);
if (ctx.priceFeed) await ctx.priceFeed.refresh();
return fullState();
});
// WizardConnect: pair a wiz:// URI with a specific BCH wallet.
api.onMessage("wcConnect", async (p, m) => {
fromPanel(m);
if (!ctx.wc) throw new Error("WizardConnect not ready");
const walletId = String(p && p.walletId || "");
const uri = String(p && p.uri || "");
await ctx.wc.connectUri(walletId, uri);
return fullState();
});
api.onMessage("wcDisconnect", async (p, m) => {
fromPanel(m);
if (!ctx.wc) throw new Error("WizardConnect not ready");
await ctx.wc.disconnect(String(p && p.walletId || ""), String(p && p.connId || ""));
return fullState();
});
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("revoke", (p, m) => {
fromPanel(m);
@ -1632,13 +1861,43 @@ module.exports = {
api,
d: null,
runtimes: new Map(), // walletId -> { entry, phase, error, adapter }
priceFeed: require("./lib/prices.js")({
log: (...a) => api.log("prices", ...a),
onChange: () => emitState(),
}),
wc: null, // WizardConnect manager, initialised when deps load
};
migrateLegacyStorage(api);
registerPanelMessages(api);
registerPageMessages(api);
// Restore the user's opt-in choice from storage. Off by default so a
// fresh install never hits CoinGecko without asking.
if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {});
loadDeps(api).then((d) => {
if (ctx !== c) return;
c.d = d;
// Start the WizardConnect manager once deps are ready. Per-wallet
// adapters get spun up in mountWallet() as each BCH wallet becomes
// available (only BCH today — hdwalletv1 is BCH-scoped).
c.wc = require("./lib/wc.js")({
HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf,
WalletConnectionManager: d.wcWallet.WalletConnectionManager,
wcCore: d.wcCore, libauth: d.libauth,
log: (...a) => api.log("wc", ...a),
api,
// Bridge sign approvals through the addon's approval-modal capability.
approvalRequest: async (payload) => {
const dappName = payload.request?.transaction?.userPrompt || "dapp";
const pick = await api.approvalModal({
title: `Sign transaction for ${dappName}`,
body: buildWcApprovalBody(payload),
approve: "Sign",
reject: "Reject",
});
return { approved: pick === "approve" };
},
});
c.wc.onStateChange(() => emitState());
return mountAllWallets();
}).catch((e) => {
if (ctx !== c) return;
@ -1649,6 +1908,7 @@ module.exports = {
deactivate() {
const c = ctx; ctx = null;
if (!c) return;
try { c.priceFeed && c.priceFeed.dispose(); } catch {}
for (const rt of c.runtimes.values()) {
try { rt.adapter && rt.adapter.dispose(); } catch {}
}

View file

@ -0,0 +1,146 @@
// Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but
// does NOT go through vault.derive + HKDF: derivation is direct from the
// seed+path or WIF that the user imported.
//
// M.1a scope: read-only (balance + history over Electrum). planSend/send
// throw with a clear message until M.1b lands the sign path.
module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) {
// Same electrum scripthash convention chain-bch uses: sha256(script), byte-
// reversed, hex. P2PKH-only for imports today — that's what every entry in
// Deviant's keystore is.
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
const hash160 = (b) => ripemd160(sha256(b));
const IMPORTED_BCH_NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
"wss://electroncash.dk:50004",
"wss://fulcrum.jettscythe.xyz:50004",
],
},
chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest",
explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/",
defaultServers: [
"wss://chipnet.imaginary.cash:50004",
"wss://chipnet.bch.ninja:50004",
],
faucet: "https://tbch.googol.cash/",
},
};
// Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import
// time and use it here to compute the scripthash for Electrum without ever
// asking main for the signer material — that only happens at sign time.
function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`);
return hash;
}
class ImportedBchWallet {
constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) {
const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId;
this.chain = "bch";
this.network = net.id;
this._net = net;
this.log = log;
this.onChange = onChange;
this._address = address;
this._h160 = h160OfCashaddr(address);
this._script = p2pkhScript(this._h160);
this._scripthash = scripthashOf(this._script);
this._scriptHex = toHex(this._script);
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
this._client = new electrum.Client(this._servers);
this._client.onServer = () => this._emit();
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
height: 0,
scanning: false,
error: null,
};
}
setServers(list) {
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
this._client.setServers(this._servers);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: "bch",
network: this._net.id,
ticker: "BCH",
decimals: 8,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
height: this._state.height,
history: this._state.history,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
servers: this._servers,
imported: true,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
};
}
async refresh(full) {
this._state.scanning = true; this._emit();
try {
// Balance for this single scripthash.
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
if (full) {
const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));
}
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);
} finally {
this._state.scanning = false;
this._emit();
}
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; }
plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); }
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); }
signMessage() { throw new Error("Imported wallets are read-only in this build."); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }
dispose() { try { this._client.disconnect(); } catch {} }
}
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
};

View file

@ -0,0 +1,98 @@
// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price
// endpoint, one request covers the lot. Opt-in via Settings so a
// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open.
//
// Cache is in-memory (returned by fullState() → panel). The addon polls
// every 5 min while enabled; each fetch is cheap (~200 B response) and
// the free tier tolerates one call/5 min per client easily.
//
// Trade-off named in the settings copy: CoinGecko sees the browser's IP
// + a User-Agent every poll. Not seed-linked, not address-linked, but a
// data point. Off by default.
const COIN_GECKO_IDS = {
bch: "bitcoin-cash",
btc: "bitcoin",
trx: "tron",
eth: "ethereum",
sol: "solana",
sc: "siacoin",
dgb: "digibyte",
};
const ENDPOINT = "https://api.coingecko.com/api/v3/simple/price";
const POLL_MS = 5 * 60 * 1000;
module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = {
enabled: false,
prices: {}, // { <chain>: usd (number) }
fetchedAt: null,
error: null,
loading: false,
};
let timer = null;
async function fetchOnce() {
if (!state.enabled) return;
state.loading = true; state.error = null; onChange();
try {
const ids = Object.values(COIN_GECKO_IDS).join(",");
const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`;
const r = await fetch(url);
if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`);
const body = await r.json();
const next = {};
for (const [chain, cgId] of Object.entries(COIN_GECKO_IDS)) {
const usd = body?.[cgId]?.usd;
if (typeof usd === "number") next[chain] = usd;
}
state.prices = next;
state.fetchedAt = Date.now();
state.error = null;
} catch (e) {
state.error = e?.message || String(e);
log("price fetch failed:", state.error);
} finally {
state.loading = false;
onChange();
}
}
function schedule() {
clearTimeout(timer);
if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
}
return {
// Snapshot for the panel: only what the UI needs.
snapshot() {
return {
enabled: state.enabled,
prices: state.prices,
fetchedAt: state.fetchedAt,
error: state.error,
loading: state.loading,
};
},
// Turn the feed on/off. Enabling triggers an immediate fetch so the
// panel doesn't wait 5 minutes for the first price.
async setEnabled(on) {
const changed = !!on !== state.enabled;
state.enabled = !!on;
if (!state.enabled) {
state.prices = {}; state.fetchedAt = null; state.error = null;
clearTimeout(timer);
if (changed) onChange();
return;
}
onChange();
await fetchOnce();
schedule();
},
// Force-refresh — bound to a manual "refresh" button in the panel.
refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; },
};
};

View file

@ -0,0 +1,95 @@
// WizardConnect transaction signing for Aegis.
//
// The dapp hands us a full BCH transaction plus its source outputs. Per the
// WC protocol, we must sign every input with SIGHASH_ALL | FORKID | UTXOS.
// Any other sighash flag combination MUST be rejected (protocol/security).
//
// This module supports P2PKH inputs only. Contract inputs (a source output
// carrying a `contract` field) are rejected with a clear error — they need
// script-aware signing that Aegis's BCH runtime doesn't do today.
// SIGHASH byte required for this protocol: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS
// = 0x01 | 0x40 | 0x20 = 0x61.
const REQUIRED_SIGHASH = 0x61;
function toHex(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0"); return s; }
function fromHex(h) {
const s = String(h || "").replace(/^0x/i, "");
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
function ensureTransaction(txOrHex, libauth) {
if (typeof txOrHex === "string") {
const dec = libauth.decodeTransactionCommon
? libauth.decodeTransactionCommon(fromHex(txOrHex))
: libauth.decodeTransaction(fromHex(txOrHex));
if (typeof dec === "string") throw new Error(`wc-sign: bad tx hex — ${dec}`);
return dec;
}
return txOrHex;
}
async function signTx({ request, account, branches, libauth, secp256k1 }) {
const {
generateSigningSerializationBCH,
hash256, encodeTransaction,
} = libauth;
const tx = ensureTransaction(request.transaction, libauth);
const sourceOutputs = (request.sourceOutputs || []).map((o, i) => {
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);
return {
lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode),
valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis),
};
});
if (sourceOutputs.length !== tx.inputs.length) {
throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`);
}
const inputPathMap = new Map(); // inputIndex -> { branch, addressIndex }
for (const [inputIndex, pathName, addressIndex] of (request.inputPaths || [])) {
inputPathMap.set(Number(inputIndex), { pathName: String(pathName), addressIndex: Number(addressIndex) });
}
const signedInputs = tx.inputs.map((inp, i) => ({ ...inp }));
for (let i = 0; i < tx.inputs.length; i++) {
const hint = inputPathMap.get(i);
if (!hint) throw new Error(`wc-sign: no path for input ${i}`);
const branch = branches[hint.pathName];
if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`);
const node = branch.deriveChild(hint.addressIndex);
const preimage = generateSigningSerializationBCH({
inputIndex: i,
signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]),
sourceOutputs,
transaction: { ...tx, inputs: signedInputs },
});
const digest = hash256(preimage);
const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" });
// signature || sighashType byte
const sigWithHash = new Uint8Array(sig.length + 1);
sigWithHash.set(sig, 0); sigWithHash[sig.length] = REQUIRED_SIGHASH;
// P2PKH unlocking: <sig+hashtype> <pubkey>
const pushSig = new Uint8Array(1 + sigWithHash.length);
pushSig[0] = sigWithHash.length;
pushSig.set(sigWithHash, 1);
const pushPk = new Uint8Array(1 + node.publicKey.length);
pushPk[0] = node.publicKey.length;
pushPk.set(node.publicKey, 1);
const unlocking = new Uint8Array(pushSig.length + pushPk.length);
unlocking.set(pushSig, 0); unlocking.set(pushPk, pushSig.length);
signedInputs[i].unlockingBytecode = unlocking;
}
const encoded = encodeTransaction({ ...tx, inputs: signedInputs });
return { signedTransaction: toHex(encoded) };
}
module.exports = { signTx, REQUIRED_SIGHASH };

View file

@ -0,0 +1,196 @@
// WizardConnect wallet-side bridge for Aegis.
//
// Aegis's BCH runtime acts as a WizardConnect wallet: sites we build (dapps)
// pair via a wiz:// URI, get xpubs for BCH derivation paths, and send us
// sign requests that we route through the existing approval-modal capability.
//
// LGPL boundary: @wizardconnect/{core,wallet} are dynamic-linked via
// api.import(); we do not statically embed them. Their sources live at
// https://github.com/whiterun-labs/wizardconnect (also on npm) and their
// LICENSE / copyright headers are shipped by npm inside the package.
//
// Docs: https://docs.riftenlabs.com/wizardconnect/
const WC_PATH_RECEIVE = "receive"; // m/44'/145'/0'/0
const WC_PATH_CHANGE = "change"; // m/44'/145'/0'/1
const WC_PATH_CAULDRON = "defi"; // m/44'/145'/0'/7 (BCH DEX ecosystem)
const WALLET_ICON = "data:image/svg+xml;utf8," + encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'>
<polygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='#0a0a0d' stroke='#D6FF3D' stroke-width='1.6' stroke-linejoin='round'/>
<circle cx='16' cy='16' r='4.5' fill='none' stroke='#D6FF3D' stroke-width='1.4'/>
<circle cx='16' cy='16' r='1.6' fill='#D6FF3D'/>
</svg>`
);
module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) {
// ---- WalletAdapter --------------------------------------------------------
//
// Bound to one BCH runtime. Uses its 32-byte root to reproduce the account
// HDKey and to derive per-URI relay identities via HKDF, so reconnecting
// yields the same Nostr identity (dapp recognises us on reload).
function makeAdapter({ root32, accountPath, walletId, label }) {
const account = HDKey.fromMasterSeed(root32).derive(accountPath);
const branches = new Map();
const branchFor = (childIndex) => {
let b = branches.get(childIndex);
if (!b) { b = account.deriveChild(childIndex); branches.set(childIndex, b); }
return b;
};
// WC path enum → BCH child index (identity mapping today; enum members
// hold the numeric child index directly — see docs/protocol.
const childOf = (path) => Number(path);
return {
walletName: label ? `Aegis · ${label}` : "Aegis",
walletIcon: WALLET_ICON,
// Stable identity per pairing URI. HKDF salt binds it to this wallet's
// root, info binds it to the URI, so:
// - reconnecting to the same URI = same Nostr identity
// - two different URIs = uncorrelatable identities (privacy)
getRelayPrivateKey(uri) {
const salt = new TextEncoder().encode("aegis/wc/relay/v1");
const info = new TextEncoder().encode(uri);
// 32 bytes for a Nostr secp256k1 private key.
return hkdf(sha256, root32, salt, info, 32);
},
getPublicKey(path, index) {
const branch = branchFor(childOf(path));
const node = branch.deriveChild(Number(index));
return node.publicKey; // 33 bytes compressed
},
getXpub(path) {
return branchFor(childOf(path)).publicExtendedKey;
},
// Sign a transaction the dapp has already assembled. See signTx.js for
// the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth
// preimage + secp256k1 der/lowS signatures).
async signTransaction(request) {
// Route through approval-modal first — the user always sees what
// they're signing before any private key touches the request.
if (!approvalRequest) throw new Error("no approval channel");
const decision = await approvalRequest({
kind: "wc-sign",
walletId, label,
request,
});
if (!decision?.approved) throw new Error("cancelled");
const { signTx } = require("./wc-sign.js");
return signTx({
request,
account,
branches: { receive: branchFor(0), change: branchFor(1), defi: branchFor(7) },
libauth, secp256k1,
});
},
};
}
// ---- connection tracker --------------------------------------------------
// One manager per BCH wallet. We keep them in a per-walletId map so the
// panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc.
const managers = new Map(); // walletId -> WalletConnectionManager
const uris = new Map(); // walletId -> Set<uri> (persisted)
const listeners = new Set(); // () => void — panel resubscribes on state change
function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} }
function persist(walletId) {
const list = [...(uris.get(walletId) || new Set())];
api.storage.set(`wc/${walletId}/uris`, list);
}
async function startForWallet({ walletId, label, root32, accountPath }) {
if (managers.has(walletId)) return managers.get(walletId);
const adapter = makeAdapter({ root32, accountPath, walletId, label });
const mgr = new WalletConnectionManager(adapter);
managers.set(walletId, mgr);
mgr.on("connectionsChanged", fireStateChange);
mgr.on("connectionStatusChanged", fireStateChange);
mgr.on("remoteDisconnect", (connId, reason) => {
log(`wc[${walletId}] remote disconnect ${connId}: ${reason}`);
fireStateChange();
});
mgr.on("pendingSignRequest", async ({ connectionId, request }) => {
try {
const { signedTransaction } = await adapter.signTransaction(request);
await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction);
} catch (e) {
log(`wc[${walletId}] sign failed:`, e?.message || e);
try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {}
}
});
// Restore persisted pairings.
uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || []));
for (const uri of uris.get(walletId)) {
try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); }
}
return mgr;
}
function stopForWallet(walletId) {
const mgr = managers.get(walletId); if (!mgr) return;
try { mgr.disconnectAll?.(); } catch {}
managers.delete(walletId);
uris.delete(walletId);
}
async function connectUri(walletId, uri) {
const mgr = managers.get(walletId);
if (!mgr) throw new Error("wc: wallet not ready");
const trimmed = String(uri || "").trim();
if (!/^wiz:\/\//i.test(trimmed)) throw new Error("wc: URI must start with wiz://");
const id = mgr.connect(trimmed);
const set = uris.get(walletId) || new Set();
set.add(trimmed);
uris.set(walletId, set);
persist(walletId);
fireStateChange();
return id;
}
async function disconnect(walletId, connId) {
const mgr = managers.get(walletId); if (!mgr) return;
try { await mgr.disconnect(connId); } catch {}
// Trim the persisted URI so the next start doesn't re-add it.
const conn = [...(mgr.connections?.values?.() || [])].find((c) => c.id === connId);
if (conn?.uri) {
const set = uris.get(walletId); if (set) { set.delete(conn.uri); persist(walletId); }
}
fireStateChange();
}
function snapshot() {
const out = {};
for (const [walletId, mgr] of managers) {
const list = [...(mgr.connections?.values?.() || [])].map((c) => ({
id: c.id,
uri: c.uri,
dappName: c.dappName || null,
dappIcon: c.dappIcon || null,
status: c.status?.kind || String(c.status || "unknown"),
connectedAt: c.connectedAt || null,
}));
out[walletId] = list;
}
return out;
}
function onStateChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
function cleanErrForDapp(e) {
const m = String(e?.message || e);
if (m === "cancelled") return "user rejected";
return m.replace(/\n[\s\S]*$/, "").slice(0, 200);
}
return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange };
};

View file

@ -46,6 +46,7 @@
#drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drop .row .m .s, #drop .coinrow .m .s { color: var(--dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drop .row .v { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
#drop .row .v .fs { color: var(--dim); font-size: 11px; margin-top: 2px; }
#drop .row.on { background: rgb(from var(--acid) r g b / .10); }
#drop .coinrow .caret { color: var(--dim); font-size: 11px; }
#drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; }
@ -57,6 +58,10 @@
.ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px;
background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; }
#hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
.fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; }
.portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
.portfolio b { color: var(--ink); font-weight: 600; }
.portfolio[hidden] { display: none; }
.picker-actions { display: flex; align-items: center; gap: 6px; }
.chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px;
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1;
@ -82,12 +87,14 @@
.actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; }
canvas { image-rendering: pixelated; }
input[type=text], input[type=number], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
input[type=text], input[type=number], input[type=password], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; }
input:focus, textarea:focus { border-color: rgb(from var(--acid) r g b / .5); }
textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; }
.field { margin-bottom: 12px; }
.hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12.5px; color: var(--ink); }
.switch input { accent-color: var(--brand, #d6ff3d); width: 15px; height: 15px; margin: 0; }
.amt { display: flex; gap: 6px; }
.amt input { flex: 1; }
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
@ -136,11 +143,15 @@
</div>
<div id="drop" hidden></div>
<div class="bal">
<div class="big"><span id="balMain"></span><small id="balTicker"></small></div>
<div class="big">
<span id="balMain"></span><small id="balTicker"></small>
<span id="balFiat" class="fiat" hidden></span>
</div>
<div class="sub">
<span class="netlbl" id="netlbl">connecting…</span>
<span class="net"><span class="dot" id="dot"></span></span>
</div>
<div id="portfolio" class="portfolio" hidden></div>
</div>
</header>
<nav>
@ -243,6 +254,23 @@
<button class="btn danger" id="showXprv">Show account private key</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">WizardConnect</div>
<div class="hint" style="margin-bottom:8px">
Pair this BCH wallet with a dapp that speaks the WizardConnect protocol
(Cauldron, Moria, or any site built on the SDK). Paste the <span class="mono">wiz://</span>
URI the dapp shows in its Connect dialog. Aegis signs every transaction only after you approve it.
</div>
<div class="field" style="margin-top:8px">
<input type="text" id="wcUri" spellcheck="false" placeholder="wiz://?p=…&amp;s=…">
</div>
<div class="actions">
<button class="btn primary" id="wcConnectBtn">Connect</button>
</div>
<div class="msg" id="wcMsg" hidden></div>
<div class="kv" id="wcSites" style="margin-top:8px"></div>
</div>
</div>
<div id="trxSettings" hidden>
@ -359,6 +387,24 @@
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis fetches USD prices for the seven supported coins from
<span class="mono">api.coingecko.com</span> every 5 minutes while the panel is open.
One HTTP request per interval, no keys and no address data — but CoinGecko can see your IP,
which is a signal that a wallet is open on this machine.
</div>
<div class="actions" style="align-items:center;gap:12px">
<label class="switch">
<input type="checkbox" id="pricesToggle">
<span>Enable USD prices</span>
</label>
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
<span class="hint" id="pricesStatus" style="margin-left:auto"></span>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">Connected sites</div>
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>

View file

@ -113,6 +113,49 @@ function explorerHref(base, id) {
}
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)));
@ -122,6 +165,14 @@ function showTab(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) ------------------------------------------
@ -155,12 +206,20 @@ function fillPicker() {
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 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">${esc(bal)}</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
@ -183,7 +242,13 @@ function fillPicker() {
}).join("");
d.innerHTML =
rowsHtml +
`<hr><div class="addhdr"> Add wallet</div>${coinRows}`;
`<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;
@ -204,6 +269,112 @@ function fillPicker() {
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");
@ -217,9 +388,13 @@ function render() {
if (!state) return;
const s = sel();
const ready = s && s.phase === "ready";
$("tabs").hidden = !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;
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);
@ -229,17 +404,39 @@ function render() {
: "";
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."],
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 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 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");
@ -248,6 +445,34 @@ function render() {
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") : "");
@ -268,9 +493,16 @@ function render() {
$("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) {
@ -288,6 +520,33 @@ function render() {
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) || [];
@ -550,7 +809,23 @@ $("sendBtn").addEventListener("click", async () => {
// ---- settings --------------------------------------------------------------
function fillSettings() {
const s = sel(); if (!s) return;
// 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";
@ -574,6 +849,7 @@ function fillSettings() {
? "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") {
@ -612,7 +888,27 @@ function fillSettings() {
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("solRecovery").innerHTML = "";
}
renderSites();
}
// 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 = {};
@ -799,6 +1095,61 @@ $("showDgbXprv").addEventListener("click", async () => {
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 () => {

View file

@ -626,6 +626,12 @@
// Main-process asks us to jump to a section (e.g. picker's "Search settings…"
// click routes to the Search section instead of the General default).
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
// Land on the section named by `settings.html#<sec>` when opened via main
// (e.g. Aegis's "Set up vault" button → open-settings with "passwords").
try {
const initSec = String(location.hash || "").replace(/^#/, "").toLowerCase();
if (initSec) showSection(initSec);
} catch {}
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];