chore(theseus): 0.3.47 — plug-in category + panel-driven addon self-update, aegis 0.6.31

Theseus core:
- addons-host: manifest.category ("plugin") propagates through snapshot(); new
  addon API surface checkAndStageSelfUpdate() + restartApp() so a plug-in
  can offer in-panel "update now → restart to apply" without pushing the
  user to Settings.
- main.js: wires the two new hooks into the AddonHost constructor.
- settings.html: Extensions listing filters out category==="plugin"; those
  add-ons live in Plug-ins instead, single source of truth.

Aegis 0.6.31:
- BTC picker trimmed to Signet only; testnet3 hidden (adapter kept so any
  existing wallet still loads).
- Wallet strip groups by chain, not chain:network; ticker gets a ▾ chevron
  and a dropdown listing every subnetwork with its own totals. Mainnet
  reads as the plain ticker; testnets carry a small Chipnet/Signet/Sepolia
  pill inline.
- Per-unit price sits directly under the ticker; amount + fiat mirror on
  the right — one glance covers name/price/holding/value.
- + Add and ⋯ More promoted from the strip into the header's action row,
  next to the new ✎ chip (was the redundant top ⋯). Duplicate "Manage
  current wallet" entry removed from the More menu.
- Footer update chip is a two-step flow via the new API: stage → restart.
  Falls back to opening Settings on any Theseus that lacks the hooks.
- Manifest declares "category": "plugin".
This commit is contained in:
Local Dev 2026-09-14 02:30:51 +02:00
parent d7d127d7b4
commit f46e9112b7
13 changed files with 3667 additions and 251 deletions

View file

@ -148,13 +148,19 @@ function validateManifest(raw, folderName) {
} }
if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`);
} }
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs }; // Category: "plugin" for first-class Silent Mode components (Aegis and
// future Ariadne-as-addon) that are surfaced in Settings Plug-ins with
// their own copy instead of the raw Extensions list. Anything else falls
// back to plain-extension rendering.
const category = m.category && ["plugin"].includes(String(m.category))
? String(m.category) : null;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs, category };
} }
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`. // of the app queries via `getActive()` / `getInstalled()`.
class AddonHost { class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) { constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, checkAndStageUpdates, restartApp }) {
this.addonsDir = addonsDir; this.addonsDir = addonsDir;
this.dataDir = dataDir; this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false); this.isDisabled = isDisabled || (() => false);
@ -190,6 +196,13 @@ class AddonHost {
// named section (e.g. "passwords"). Uses the same IPC route the picker // named section (e.g. "passwords"). Uses the same IPC route the picker
// uses for "Search settings…". Signature: (section?: string) => void. // uses for "Search settings…". Signature: (section?: string) => void.
this._openSettings = typeof openSettings === "function" ? openSettings : null; this._openSettings = typeof openSettings === "function" ? openSettings : null;
// Panel-driven self-update: an add-on may ask the host to run the
// OTA check + verify + stage flow for itself and, if a newer signed
// build lands, restart Theseus so promoteStagedUpdates picks it up.
// Owns the entire trust chain (sig, hash, manifest match) so no
// add-on ever gets to hand-write into its own installed folder.
this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null;
this._restartApp = typeof restartApp === "function" ? restartApp : null;
// Session-proxy hook — injected by main so add-ons can swap the default // 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). // session's proxy rules (e.g. a "route everything through my VPS" add-on).
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void> // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
@ -445,6 +458,33 @@ class AddonHost {
if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)");
this._openSettings(typeof section === "string" ? section : ""); this._openSettings(typeof section === "string" ? section : "");
}, },
// Check the OTA channel for a newer signed build of THIS add-on and
// stage it if one is found. Returns { status, staged, current, next }
// — status matches the shared addon-updater report vocabulary
// ("up-to-date" | "staged" | "already-staged" | "fetch-failed" | …).
// The staged copy activates on the next Theseus launch, so pair with
// restartApp() when the caller wants an immediate apply. Scoped to
// the calling add-on so a plug-in can't stage updates for its
// neighbours.
checkAndStageSelfUpdate: async () => {
if (!this._checkAndStageUpdates) throw new Error("checkAndStageSelfUpdate unavailable (host not wired)");
const full = await this._checkAndStageUpdates();
const own = (full?.report || []).find((r) => r.id === manifest.id) || { status: "no-update-url" };
return {
status: own.status || "unknown",
detail: own.detail || null,
current: own.currentVer || manifest.version,
next: own.newVer || null,
staged: (full?.staged || []).find((s) => s.id === manifest.id) || null,
};
},
// Cleanly relaunch Theseus. Used by the plug-in card's "apply
// update" chip to activate a staged build without asking the user
// to hunt for the app menu.
restartApp: () => {
if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)");
this._restartApp();
},
// Resolves once the browser chrome has painted (immediately if it // Resolves once the browser chrome has painted (immediately if it
// already has). Put expensive dependency loading behind this so it // already has). Put expensive dependency loading behind this so it
// never competes with the first frame at launch. // never competes with the first frame at launch.
@ -576,6 +616,10 @@ class AddonHost {
author: manifest?.author ?? "", author: manifest?.author ?? "",
icon: manifest?.icon ?? "🧩", icon: manifest?.icon ?? "🧩",
capabilities: manifest?.capabilities ?? [], capabilities: manifest?.capabilities ?? [],
// "plugin" — first-class Silent Mode component (Aegis, future
// Ariadne-as-addon) surfaced in Settings Plug-ins instead of
// the raw Extensions list. Absent → plain extension.
category: manifest?.category || null,
folder, folder,
enabled: manifest?.id ? this._active.has(manifest.id) : false, enabled: manifest?.id ? this._active.has(manifest.id) : false,
error: error || null, error: error || null,

View file

@ -1,7 +1,8 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.6.2", "version": "0.6.31",
"category": "plugin",
"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.", "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", "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", "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

@ -92,6 +92,21 @@ async function loadDeps(api) {
const importedBchAdapter = require("./lib/chain-bch-imported.js")({ const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx, sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
}); });
// Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC
// reader. Every runtime is read-only in M.1b, matching chain-bch-imported.
const utxoImportedAdapter = require("./lib/chain-utxo-imported.js")({
sha256, bitcoinjs, dgbCore, electrum, WebSocket,
});
const genericImportedAdapter = require("./lib/chain-generic-imported.js")();
// Per-chain address derivation from raw material (mnemonic + path or
// chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
});
// WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the // WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the
// §4d combined-work requirement (dynamic linkage + license notice + source // §4d combined-work requirement (dynamic linkage + license notice + source
// availability) is met — package sources ship with npm. // availability) is met — package sources ship with npm.
@ -101,7 +116,8 @@ async function loadDeps(api) {
return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b, return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b,
cashaddr, keysLib, tx, electrum, base58check, cashaddr, keysLib, tx, electrum, base58check,
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
importedBchAdapter, bip39, importedBchAdapter, utxoImportedAdapter, genericImportedAdapter,
derive, bip39,
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, dgbCore, dgbPsbt, bitcoinjs, ecc, eip712,
wcCore, wcWallet, libauth }; wcCore, wcWallet, libauth };
} }
@ -477,14 +493,34 @@ async function mountWallet(entry) {
// it doesn't need the material until spend support ships (M.1b). // it doesn't need the material until spend support ships (M.1b).
if (entry.kind === "imported") { if (entry.kind === "imported") {
try { try {
const adapter = new c.d.importedBchAdapter.ImportedBchWallet({ let adapter;
const commonOpts = {
walletId: entry.id, storage: c.api.storage, walletId: entry.id, storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a), log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id), onChange: () => emitStateForWallet(entry.id),
network: entry.network, network: entry.network,
cashaddr: entry.importedCashaddr, };
if (entry.chain === "bch") {
adapter = new c.d.importedBchAdapter.ImportedBchWallet({
...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
}); });
adapter.schedulePoll(20_000);
} else if (entry.chain === "btc" || entry.chain === "dgb") {
adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress,
});
adapter.schedulePoll(20_000);
} else if (entry.chain === "eth" || entry.chain === "trx" || entry.chain === "sol") {
adapter = new c.d.genericImportedAdapter.GenericImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress,
rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined),
});
adapter.schedulePoll(20_000);
} else {
throw new Error(`no imported adapter for chain "${entry.chain}"`);
}
rt.adapter = adapter; rt.phase = "ready"; rt.adapter = adapter; rt.phase = "ready";
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e)); adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
emitStateForWallet(entry.id); emitStateForWallet(entry.id);
@ -631,7 +667,15 @@ function deriveCashaddrFromSeed(seedHex, path, prefix) {
function deriveCashaddrFromWif(wif, prefix) { function deriveCashaddrFromWif(wif, prefix) {
const d = ctx.d; const d = ctx.d;
// WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01]) // WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01])
const raw = d.base58check.decode(wif); // Wrap decodeCheck so a malformed WIF (bad chars, bad checksum, or an
// internal library shape change) surfaces as a user-facing "invalid WIF"
// instead of leaking "TypeError: base58check.decode is not a function".
let raw;
try {
raw = d.base58check.decodeCheck(wif);
} catch (e) {
throw new Error("invalid WIF format (base58check decode failed)");
}
if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`); 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, // First byte is version (network); we allow any — BCH mainnet uses 0x80,
// testnet 0xEF. Both round-trip through the same address derivation below. // testnet 0xEF. Both round-trip through the same address derivation below.
@ -683,10 +727,15 @@ function walletSummary(w) {
const snap = rt && rt.adapter ? rt.adapter.snapshot() : null; const snap = rt && rt.adapter ? rt.adapter.snapshot() : null;
return { return {
id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy, id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy,
kind: w.kind || null,
logo: meta?.logo || null, color: meta?.color || "#888", logo: meta?.logo || null, color: meta?.color || "#888",
coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet, coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet,
ticker: meta?.ticker || "?", short: meta?.short || w.chain, decimals: meta?.decimals || 8, ticker: meta?.ticker || "?", short: meta?.short || w.chain, decimals: meta?.decimals || 8,
address: snap?.address || null, address: snap?.address || null,
// Prefer the wallet-registry's stored accountPath; fall back to whatever
// the runtime derived (default when the user hasn't overridden). Nulls
// stay null so the picker knows whether to render the mono path line.
accountPath: w.accountPath || snap?.accountPath || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 }, balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
phase: rt?.phase || "locked", phase: rt?.phase || "locked",
error: rt?.error || null, error: rt?.error || null,
@ -705,6 +754,7 @@ function snapshotForSelected() {
chain: entry?.chain, chain: entry?.chain,
network: entry?.network, network: entry?.network,
isLegacy: !!entry?.isLegacy, isLegacy: !!entry?.isLegacy,
kind: entry?.kind || null,
meta: meta ? { meta: meta ? {
logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals, logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals,
coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet, coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet,
@ -851,57 +901,143 @@ function registerPanelMessages(api) {
api.onMessage("importWallet", async (p, m) => { api.onMessage("importWallet", async (p, m) => {
fromPanel(m); fromPanel(m);
const chain = String(p && p.chain || "bch"); 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 || "").trim();
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(); const label = String(p && p.label || "").trim();
if (!label) throw new Error("label required"); if (!label) throw new Error("label required");
const category = String(p && p.category || "operational").trim(); const category = String(p && p.category || "operational").trim();
const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
const source = String(p && p.source || "manual-paste"); const source = String(p && p.source || "manual-paste");
let kind, seedHex, path, wif, cashaddrStr; // Chain-specific address derivation. Every branch has to produce an
// `address` string + fill spec.{seed,path} or spec.wif/privkey. The
// spec is what lands in wallet-imports.enc; the address gets stored on
// the Aegis wallet entry so the picker/strip can show it without
// touching the imports file.
const spec = { kind: null, label, category, source };
let address = null;
const der = ctx.d.derive;
if (chain === "bch") {
const net = network || "chipnet";
if (net !== "mainnet" && net !== "chipnet") throw new Error(`BCH network must be mainnet or chipnet (got ${net})`);
const prefix = net === "mainnet" ? "bitcoincash" : "bchtest";
if (p && p.wif) { if (p && p.wif) {
kind = "wif"; spec.kind = "wif"; spec.wif = String(p.wif).trim();
wif = String(p.wif).trim(); address = deriveCashaddrFromWif(spec.wif, prefix);
cashaddrStr = deriveCashaddrFromWif(wif, prefix);
} else if (p && p.mnemonic) { } else if (p && p.mnemonic) {
kind = "seed"; spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim());
const words = String(p.mnemonic).trim().split(/\s+/).length; spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) { address = deriveCashaddrFromSeed(spec.seed, spec.path, prefix);
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) { } else if (p && p.seedHex) {
kind = "seed"; spec.kind = "seed"; spec.seed = String(p.seedHex).trim().toLowerCase().replace(/^0x/, "");
seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, ""); if (!/^[0-9a-f]{64,128}$/.test(spec.seed)) throw new Error("seedHex must be 32-64 bytes of hex");
if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex"); spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); address = deriveCashaddrFromSeed(spec.seed, spec.path, prefix);
cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); } else { throw new Error("supply mnemonic, seedHex, or wif"); }
spec.cashaddr = address;
} else if (chain === "btc" || chain === "dgb") {
const defaults = { btc: { network: "mainnet", path: "m/84'/0'/0'/0/0" }, dgb: { network: "mainnet", path: "m/84'/20'/0'/0/0" } };
const net = network || defaults[chain].network;
const purposeHint = Number(p && p.purpose || 84);
if (p && p.wif) {
spec.kind = "wif"; spec.wif = String(p.wif).trim();
address = chain === "btc" ? der.btc.fromWif(spec.wif, net, purposeHint) : der.dgb.fromWif(spec.wif, purposeHint);
} else if (p && p.mnemonic) {
spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim());
spec.path = String(p.path || defaults[chain].path);
address = chain === "btc" ? der.btc.fromSeed(spec.seed, spec.path, net) : der.dgb.fromSeed(spec.seed, spec.path);
} else { throw new Error("supply mnemonic or wif"); }
// Theseus's api.vault.imports.add validates a `cashaddr` field (from
// when only BCH imports existed). Reuse the same field name for
// every chain — Aegis reads it back by importId and knows the shape
// via entry.chain. Doesn't have to be a real cashaddr.
spec.cashaddr = address;
} else if (chain === "eth" || chain === "trx" || chain === "sol") {
const defaults = {
eth: { network: "mainnet", path: "m/44'/60'/0'/0/0" },
trx: { network: "mainnet", path: "m/44'/195'/0'/0/0" },
sol: { network: "mainnet", path: "m/44'/501'/0'/0'" },
};
const net = network || defaults[chain].network;
if (p && p.mnemonic) {
spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim());
spec.path = String(p.path || defaults[chain].path);
if (chain === "eth") address = der.eth.fromSeed(spec.seed, spec.path);
else if (chain === "trx") address = der.trx.fromSeed(spec.seed, spec.path);
else address = der.sol.fromSeed(spec.seed, spec.path);
} else if (p && p.privHex) {
// Theseus's vault.imports.add only recognises kind "seed" (BIP39
// + path) and "wif" (a base58check Bitcoin key). Raw hex keys
// for ETH/TRX/SOL don't fit either shape, so we pack them into
// the wif slot with a scheme prefix (`aegis-privhex:<hex>`) —
// the vault doesn't inspect the value, just stores it. Aegis
// reads its own prefix back when spending ships. Panel state
// + address are computed here, so read-only balance / receive
// work today without touching the vault field.
spec.kind = "wif";
// Normalise raw hex the user pasted. Tolerate every common mangle
// path so the panel error surface is a clear "expected 32-byte
// hex" instead of the raw noble/hashes error string:
// - leading / trailing whitespace, mixed case
// - "0x" or "0X" prefix
// - internal whitespace, tabs, newlines, commas, colons, dashes
// - accidental quotes wrapping the paste
// - a preamble like "private key: <hex>" (e.g. from an AI-agent
// transcript) — pick the longest hex-shaped substring.
let raw = String(p.privHex).trim();
raw = raw.replace(/^['"`]+|['"`]+$/g, "");
// If the user pasted a multi-line block, extract the longest
// run of hex characters and treat that as the key.
const hexRuns = raw.match(/[0-9a-fA-F]{16,}/g);
if (hexRuns && hexRuns.length) {
hexRuns.sort((a, b) => b.length - a.length);
raw = hexRuns[0];
}
raw = raw.toLowerCase().replace(/^0x/, "").replace(/[\s,:_\-]/g, "");
if (!/^[0-9a-f]+$/.test(raw)) {
// Give the user something concrete to act on. Tron-specific
// hints: base58-shaped strings that start with T (34 chars) are
// addresses, not private keys; whitespace-separated words look
// like a mnemonic.
const original = String(p.privHex).trim();
if (/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(original)) {
throw new Error("That looks like a Tron address (T…), not a private key. Paste the 64-hex-character private key instead.");
}
if (/^([a-z]+\s+){11,}[a-z]+$/i.test(original)) {
throw new Error("That looks like a BIP39 mnemonic. Switch the import format to 'Mnemonic + path'.");
}
throw new Error("Private key must be hex (with or without 0x). Whitespace, dashes and colons are ignored, but non-hex characters aren't accepted.");
}
if (raw.length !== 64) {
throw new Error(`Private key must be 32 bytes (64 hex characters). Got ${raw.length} hex character${raw.length === 1 ? "" : "s"} after normalising the paste.`);
}
// Derive first so any bad key surfaces before we write to disk.
if (chain === "eth") address = der.eth.fromPrivHex(raw);
else if (chain === "trx") address = der.trx.fromPrivHex(raw);
else address = der.sol.fromPrivHex(raw);
spec.wif = `aegis-privhex:${raw}`;
} else if (p && p.privB58 && chain === "sol") {
// Same repacking trick as privhex above — Solana's Phantom-style
// base58 key gets packed into wif with an `aegis-privb58:` tag.
const raw = String(p.privB58).trim();
address = der.sol.fromBase58(raw);
spec.kind = "wif";
spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above
} else { } else {
throw new Error("supply mnemonic, seedHex, or wif"); throw new Error(`import not supported for chain "${chain}"`);
} }
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); const { id: importId } = await api.vault.imports.add(spec);
const netForId = network || "mainnet";
// 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 list = walletEntries().slice();
const walletId = `bch-imported-${importId}`; const walletId = `${chain}-imported-${importId}`;
if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id"); if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id");
const entry = { const entry = {
id: walletId, label, chain: "bch", network, id: walletId, label, chain, network: netForId,
kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category, kind: "imported", importId,
importedAddress: address, importedCategory: category,
accountPath: spec.path || null,
createdAt: Date.now(), createdAt: Date.now(),
}; };
list.push(entry); list.push(entry);
@ -954,9 +1090,42 @@ function registerPanelMessages(api) {
return snapshotForSelected(); return snapshotForSelected();
}); });
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
api.onMessage("aegisVersion", (_p, m) => {
fromPanel(m);
try { return require("./addon.json").version; } catch { return ""; }
});
// Panel gate uses this to jump to Settings Passwords when the vault is // 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. // 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("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; });
// Panel-initiated update flow. Preferred path: Theseus exposes
// checkAndStageSelfUpdate + restartApp (added 0.3.48). The panel calls
// "requestUpdate" for the two-step chip flow:
// step "stage" — verify + stage the newest signed build; the reply
// carries { status, current, next } so the panel can
// show "Update to vX.Y.Z ready — restart to apply".
// step "apply" — cleanly relaunches Theseus, which runs
// promoteStagedUpdates() before activating add-ons.
// Falls back to opening Settings Extensions when running under an
// older Theseus that lacks either hook.
api.onMessage("requestUpdate", async (p, m) => {
fromPanel(m);
const step = String(p?.step || "stage");
if (step === "apply") {
if (typeof api.restartApp !== "function") return { restarted: false, fallback: "settings" };
try { api.restartApp(); return { restarted: true }; }
catch (e) { return { restarted: false, err: e?.message || String(e) }; }
}
if (typeof api.checkAndStageSelfUpdate !== "function") return { staged: false, fallback: "settings" };
try {
const r = await api.checkAndStageSelfUpdate();
// "staged" and "already-staged" both mean a newer signed build is
// waiting for the next launch — surface it to the panel identically.
const ok = r?.status === "staged" || r?.status === "already-staged";
return { staged: ok, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null };
} catch (e) {
return { staged: false, err: e?.message || String(e) };
}
});
api.onMessage("setBchServers", (p, m) => { api.onMessage("setBchServers", (p, m) => {
fromPanel(m); fromPanel(m);
@ -1139,6 +1308,14 @@ function registerPanelMessages(api) {
if (ctx.priceFeed) await ctx.priceFeed.refresh(); if (ctx.priceFeed) await ctx.priceFeed.refresh();
return fullState(); return fullState();
}); });
api.onMessage("setPricesSource", async (p, m) => {
fromPanel(m);
const id = String(p && p.source || "").trim();
if (!id) throw new Error("source required");
api.storage.set("pricesSource", id);
if (ctx.priceFeed) await ctx.priceFeed.setSource(id);
return fullState();
});
// WizardConnect: pair a wiz:// URI with a specific BCH wallet. // WizardConnect: pair a wiz:// URI with a specific BCH wallet.
api.onMessage("wcConnect", async (p, m) => { api.onMessage("wcConnect", async (p, m) => {
@ -1156,6 +1333,26 @@ function registerPanelMessages(api) {
return fullState(); return fullState();
}); });
// Reorder wallets by an explicit ID list. Silently drops IDs that are
// not in the current wallet set (removed since the panel last read);
// appends any wallets missing from `order` to the end of the list so a
// stale panel reorder cannot make a wallet vanish from the strip.
api.onMessage("reorderWallets", (p, m) => {
fromPanel(m);
const order = Array.isArray(p && p.order) ? p.order.map(String) : [];
const current = walletEntries();
const byId = new Map(current.map((w) => [w.id, w]));
const next = [];
const seen = new Set();
for (const id of order) {
if (byId.has(id) && !seen.has(id)) { next.push(byId.get(id)); seen.add(id); }
}
for (const w of current) if (!seen.has(w.id)) next.push(w);
writeWallets(api, next);
emitState();
return fullState();
});
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("revoke", (p, m) => { api.onMessage("revoke", (p, m) => {
fromPanel(m); fromPanel(m);
@ -1164,6 +1361,195 @@ function registerPanelMessages(api) {
api.storage.set("permissions", perms); api.storage.set("permissions", perms);
return perms; return perms;
}); });
// ---- security: quick-access PIN + policy flags ------------------------
// The PIN blob is a WebCrypto AES-GCM ciphertext of the master password,
// derived from PBKDF2(pin, salt). Panel handles the actual encryption /
// decryption inside its iframe — the master password never crosses the
// process boundary except via vaultUnlock. These handlers only shuttle
// the opaque blob + a small policy object in and out of api.storage.
api.onMessage("pinBlobGet", (_p, m) => {
fromPanel(m);
const b = api.storage.get("aegis/pin/v1", null);
return (b && typeof b === "object") ? b : null;
});
api.onMessage("pinBlobSet", (p, m) => {
fromPanel(m);
const blob = p && p.blob;
if (!blob || typeof blob !== "object") throw new Error("blob required");
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
throw new Error("blob shape invalid");
}
api.storage.set("aegis/pin/v1", { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters });
return true;
});
api.onMessage("pinBlobClear", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/pin/v1", null);
api.storage.set("aegis/pin/failCount", 0);
return true;
});
// Track failed PIN attempts in the addon so a panel reload cannot bypass
// rate-limiting by dropping panel-side counters.
api.onMessage("pinFailInc", (_p, m) => {
fromPanel(m);
const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
const next = cur + 1;
api.storage.set("aegis/pin/failCount", next);
api.storage.set("aegis/pin/failLast", Date.now());
return { count: next, at: Date.now() };
});
api.onMessage("pinFailReset", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/failLast", 0);
return true;
});
api.onMessage("pinFailStatus", (_p, m) => {
fromPanel(m);
return {
count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0,
last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0,
};
});
api.onMessage("securityGet", (_p, m) => {
fromPanel(m);
const cfg = api.storage.get("aegis/security/v1", {}) || {};
return {
hasPin: !!api.storage.get("aegis/pin/v1", null),
requirePinForSending: !!cfg.requirePinForSending,
};
});
api.onMessage("securitySet", (p, m) => {
fromPanel(m);
const cur = api.storage.get("aegis/security/v1", {}) || {};
const next = { ...cur };
if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending;
api.storage.set("aegis/security/v1", next);
return {
hasPin: !!api.storage.get("aegis/pin/v1", null),
requirePinForSending: !!next.requirePinForSending,
};
});
// ---- session: stay-signed-in + idle-lock + manual sign out ------------
// "Stay signed in" persists the master password across Theseus restarts
// using electron.safeStorage — an OS-level protected keystore (Windows
// DPAPI, macOS Keychain, libsecret on Linux). The encrypted blob only
// decrypts under the same OS user account, so filesystem-only access
// (SSH from another user, a lost backup) cannot use it.
//
// Storage:
// aegis/session/enc — { encPwB64, savedAt } — safeStorage blob
// aegis/session/cfg — { lockOnClose: bool, idleMinutes: number }
//
// Defaults: lockOnClose=true, idleMinutes=15. The user opts in to
// remember-me by turning "Lock on Navigator close" off in Settings.
api.onMessage("sessionStatus", (_p, m) => {
fromPanel(m);
return sessionStatusFor(api);
});
api.onMessage("sessionConfigSet", (p, m) => {
fromPanel(m);
const cur = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 };
const next = { ...cur };
if (p && typeof p.lockOnClose === "boolean") next.lockOnClose = p.lockOnClose;
if (p && typeof p.idleMinutes === "number") {
const im = Math.max(0, Math.min(180, Math.floor(p.idleMinutes)));
next.idleMinutes = im;
}
api.storage.set("aegis/session/cfg", next);
// Turning "Lock on close" on invalidates any stored remember-me blob.
if (next.lockOnClose) api.storage.set("aegis/session/enc", null);
return sessionStatusFor(api);
});
api.onMessage("sessionEnable", (p, m) => {
fromPanel(m);
const pw = String(p && p.masterPassword || "");
if (!pw) throw new Error("master password required");
const ss = safeStorageOr(api);
if (!ss || !ss.isEncryptionAvailable()) throw new Error("OS keystore unavailable — remember-me needs Windows DPAPI / macOS Keychain / libsecret");
const enc = ss.encryptString(pw).toString("base64");
api.storage.set("aegis/session/enc", { encPwB64: enc, savedAt: Date.now() });
// Force lockOnClose = false alongside — semantically they're the same
// switch as far as the user's UI expects.
const cur = api.storage.get("aegis/session/cfg", {}) || {};
api.storage.set("aegis/session/cfg", { ...cur, lockOnClose: false });
return sessionStatusFor(api);
});
api.onMessage("sessionDisable", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/session/enc", null);
const cur = api.storage.get("aegis/session/cfg", {}) || {};
api.storage.set("aegis/session/cfg", { ...cur, lockOnClose: true });
return sessionStatusFor(api);
});
// Manual sign-out: locks the vault (main-process re-locks it in memory)
// and drops the runtime cache. Also wipes any remember-me blob so the
// NEXT Theseus launch will require the master password again — the user
// just said "sign me out", not "sign me out just for this restart".
api.onMessage("vaultLock", async (_p, m) => {
fromPanel(m);
api.storage.set("aegis/session/enc", null);
for (const walletId of Array.from(ctx.runtimes.keys())) unmountWallet(walletId);
try { await api.vault.lifecycle.lock(); } catch (e) { api.log("vault lock:", e?.message || e); }
emitState();
return fullState();
});
}
// Read session status. Kept as a plain helper so both the message handler
// and the startup auto-unlock path can call it without duplicating shape.
function sessionStatusFor(api) {
const cfg = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 };
const blob = api.storage.get("aegis/session/enc", null);
const ss = safeStorageOr(api);
return {
lockOnClose: !!cfg.lockOnClose,
idleMinutes: Number(cfg.idleMinutes) || 0,
hasSession: !!(blob && blob.encPwB64),
safeStorageAvailable: !!(ss && ss.isEncryptionAvailable && ss.isEncryptionAvailable()),
};
}
// Best-effort access to electron.safeStorage from inside the addon. The
// addon runs in the main process, so require("electron") gives us the
// full main-process API; on hosts that shadow this (tests, older builds)
// we degrade to "unavailable" instead of throwing.
function safeStorageOr(api) {
try {
const e = api.require ? api.require("electron") : require("electron");
return e && e.safeStorage ? e.safeStorage : null;
} catch { return null; }
}
// Called from activate() after deps + WC init, BEFORE mountAllWallets.
// If the user opted into stay-signed-in AND we have a stored blob AND
// safeStorage can decrypt it under this OS user → auto-unlock the vault.
// Any failure is silent (log-only) — mountAllWallets will fall back to
// the panel's lock screen exactly as before.
async function tryAutoUnlock(api) {
try {
const status = await api.vault.lifecycle.status();
if (status && status.unlocked) return;
} catch {}
const cfg = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 };
if (cfg.lockOnClose) return;
const blob = api.storage.get("aegis/session/enc", null);
if (!blob || !blob.encPwB64) return;
const ss = safeStorageOr(api);
if (!ss || !ss.isEncryptionAvailable()) return;
try {
const pw = ss.decryptString(Buffer.from(blob.encPwB64, "base64"));
await api.vault.lifecycle.unlock(pw);
api.log("auto-unlocked via safeStorage session");
} catch (e) {
api.log("auto-unlock failed:", e?.message || e);
// Drop the stale blob so we don't retry every launch.
api.storage.set("aegis/session/enc", null);
}
} }
// One "describePlan" is enough for both chains because plan() returns a // One "describePlan" is enough for both chains because plan() returns a
@ -1871,7 +2257,10 @@ module.exports = {
registerPanelMessages(api); registerPanelMessages(api);
registerPageMessages(api); registerPageMessages(api);
// Restore the user's opt-in choice from storage. Off by default so a // Restore the user's opt-in choice from storage. Off by default so a
// fresh install never hits CoinGecko without asking. // fresh install never hits any oracle without asking. Source can also
// be pre-restored so a user who picked Kraken stays on Kraken.
const savedSource = String(api.storage.get("pricesSource", "") || "").trim();
if (savedSource) c.priceFeed.setSource(savedSource).catch(() => {});
if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {}); if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {});
// The deps are heavy to evaluate (noble curve precompute, bitcoinjs, // The deps are heavy to evaluate (noble curve precompute, bitcoinjs,
// libauth, WizardConnect) and that all happens on the main thread. Wait // libauth, WizardConnect) and that all happens on the main thread. Wait
@ -1903,7 +2292,7 @@ module.exports = {
}, },
}); });
c.wc.onStateChange(() => emitState()); c.wc.onStateChange(() => emitState());
return mountAllWallets(); return tryAutoUnlock(api).then(() => mountAllWallets());
}).catch((e) => { }).catch((e) => {
if (ctx !== c) return; if (ctx !== c) return;
api.log("startup failed:", e?.message); api.log("startup failed:", e?.message);

View file

@ -83,6 +83,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
this._client.setServers(this._servers); this._client.setServers(this._servers);
} }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} } _emit() { try { this.onChange(); } catch {} }
@ -139,7 +143,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
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)." }; } 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 {} } dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} }
} }
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };

View file

@ -0,0 +1,137 @@
// Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number.
//
// The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() {
const CHAIN_CFGS = {
eth: {
ticker: "ETH", decimals: 18,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
},
// JSON-RPC eth_getBalance → hex-string wei.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
const j = await r.json();
const hex = String(j?.result || "0x0").replace(/^0x/, "");
return BigInt("0x" + hex).toString();
},
},
trx: {
ticker: "TRX", decimals: 6,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
nile: { id: "nile", label: "Nile testnet", rpc: "https://api.nileex.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
},
// Tron HTTP API returns account.balance in SUN (10^-6 TRX).
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address, visible: true }) });
const j = await r.json();
return String(j?.balance || 0);
},
},
sol: {
ticker: "SOL", decimals: 9,
networks: {
mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" },
devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" },
},
// Solana JSON-RPC getBalance returns lamports as a number.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) });
const j = await r.json();
return String(j?.result?.value || 0);
},
},
};
class GenericImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) {
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = { ...net, rpc: rpcUrl || net.rpc };
this.log = log;
this.onChange = onChange;
this._address = address;
this._state = {
balance: { confirmed: "0", unconfirmed: "0" },
history: [],
scanning: false,
error: null,
};
this._pollTimer = null;
}
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
scanning: this._state.scanning,
error: this._state.error,
server: this._net.rpc,
rpcUrl: this._net.rpc,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
explorerSuffix: this._net.explorerSuffix || "",
faucet: this._net.faucet || null,
};
}
async refresh() {
this._state.scanning = true; this._emit();
try {
const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address });
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "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 }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); }
}
return { GenericImportedWallet, CHAIN_CFGS };
};

View file

@ -0,0 +1,140 @@
// Generic single-address read-only imported adapter for UTXO chains
// (BTC + DGB). Uses electrum for balance + history, bitcoinjs to convert
// the address back into a locking script for the scripthash.
//
// M.1b will add spending; for now these wallets show as read-only,
// matching chain-bch-imported.js's stance.
module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore, electrum, WebSocket }) {
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
const NETWORKS = {
btc: {
ticker: "BTC", decimals: 8,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet",
bitcoinjsNet: bitcoinjs.networks.bitcoin,
servers: ["wss://electrum.blockstream.info:50004", "wss://fulcrum.sethforprivacy.com:50002"],
explorerAddr: "https://mempool.space/address/", explorerTx: "https://mempool.space/tx/",
},
testnet3: {
id: "testnet3", label: "Testnet3",
bitcoinjsNet: bitcoinjs.networks.testnet,
servers: ["wss://electrumx.tomasi.name:50004"],
explorerAddr: "https://mempool.space/testnet/address/", explorerTx: "https://mempool.space/testnet/tx/",
testnet: true, faucet: "https://coinfaucet.eu/en/btc-testnet/",
},
signet: {
id: "signet", label: "Signet",
bitcoinjsNet: bitcoinjs.networks.testnet,
servers: ["wss://signet-electrumx.wakiyamap.dev:50004"],
explorerAddr: "https://mempool.space/signet/address/", explorerTx: "https://mempool.space/signet/tx/",
testnet: true, faucet: "https://signet.bc-2.jp/",
},
},
},
dgb: {
ticker: "DGB", decimals: 8,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet",
bitcoinjsNet: dgbCore ? dgbCore.digibyte : null,
servers: ["wss://electrum1.cipig.net:20063", "wss://electrum2.cipig.net:20063"],
explorerAddr: "https://chainz.cryptoid.info/dgb/address.dws?", explorerTx: "https://chainz.cryptoid.info/dgb/tx.dws?",
},
},
},
};
class UtxoImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {} } = {}) {
const cfg = NETWORKS[chain]; if (!cfg) throw new Error(`chain-utxo-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-utxo-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
if (!net.bitcoinjsNet) throw new Error(`chain-utxo-imported: ${chain}/${network} missing bitcoinjs network params`);
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = net;
this.log = log;
this.onChange = onChange;
this._address = address;
try {
this._script = bitcoinjs.address.toOutputScript(address, net.bitcoinjsNet);
} catch (e) {
throw new Error(`invalid ${chain} address for ${network}: ${e?.message || e}`);
}
this._scripthash = scripthashOf(this._script);
this._client = new electrum.Client(net.servers.slice());
this._client.onServer = () => this._emit();
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
height: 0,
scanning: false,
error: null,
};
}
setServers(list) { this._client.setServers(list && list.length ? list : this._net.servers.slice()); }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
height: this._state.height,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
faucet: this._net.faucet || null,
};
}
async refresh(full) {
this._state.scanning = true; this._emit();
try {
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 }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} }
}
return { UtxoImportedWallet, NETWORKS };
};

View file

@ -0,0 +1,218 @@
// Per-chain address derivation for imported wallets. Every helper turns
// either a BIP39 mnemonic (+ path) OR a raw private key (chain-native
// format — WIF for UTXO chains, hex for account chains, base58 for Solana)
// into the canonical address that chain uses.
//
// Deps arrive from index.js loadDeps() so nothing here has to know about
// npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore,
}) {
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const 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;
};
const hash160 = (b) => ripemd160(sha256(b));
// BIP39 mnemonic → 64-byte seed hex. Same wire format the vault-derive
// path stores, so keystore-mirrored seeds land in wallet-imports.enc
// identically whether they came from a mnemonic or hex directly.
function mnemonicToSeedHex(m) {
if (!bip39.validateMnemonic(m)) throw new Error("invalid BIP39 mnemonic");
return toHex(bip39.mnemonicToSeedSync(m));
}
// ---- BTC ------------------------------------------------------------------
const BTC_NET = {
mainnet: bitcoinjs.networks.bitcoin,
testnet3: bitcoinjs.networks.testnet,
signet: bitcoinjs.networks.testnet, // signet uses testnet params here
};
function btcAddressFromNode(node, path, network) {
const net = BTC_NET[network];
if (!net) throw new Error(`unknown BTC network ${network}`);
// Purpose byte in the path decides the address type. m/84' -> bech32,
// m/49' -> P2SH-P2WPKH, m/86' -> P2TR, m/44' -> P2PKH.
const m = /^m\/(\d+)'/.exec(String(path || ""));
const purpose = m ? Number(m[1]) : 84;
const pk = Buffer.from(node.publicKey);
if (purpose === 86) {
// Taproot — bitcoinjs.p2tr wants the 32-byte x-only pubkey.
const xonly = pk.slice(1, 33);
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
function deriveBtcFromSeed(seedHex, path, network) {
const bip32 = bip32Factory(ecc);
const node = bip32.fromSeed(Buffer.from(fromHex(seedHex)), BTC_NET[network]).derivePath(path);
return btcAddressFromNode(node, path, network);
}
function deriveBtcFromWif(wif, network, hint) {
const ECPair = ecpairFactory(ecc);
const kp = ECPair.fromWIF(wif, BTC_NET[network]);
// WIF alone doesn't tell us the address family; caller passes hint = 44/49/84/86.
const purpose = hint || 84;
const pk = kp.publicKey;
const net = BTC_NET[network];
if (purpose === 86) {
const xonly = pk.slice(1, 33);
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
// ---- DGB (mirrors BTC pattern with digibyte params) -----------------------
function digibyteNetwork() {
if (!dgbCore) throw new Error("DGB adapter not available");
return dgbCore.digibyte;
}
function deriveDgbFromSeed(seedHex, path) {
const bip32 = bip32Factory(ecc);
const net = digibyteNetwork();
const node = bip32.fromSeed(Buffer.from(fromHex(seedHex)), net).derivePath(path);
const pk = Buffer.from(node.publicKey);
const m = /^m\/(\d+)'/.exec(String(path || ""));
const purpose = m ? Number(m[1]) : 84;
if (purpose === 86) {
const xonly = pk.slice(1, 33);
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
function deriveDgbFromWif(wif, hint) {
const ECPair = ecpairFactory(ecc);
const net = digibyteNetwork();
const kp = ECPair.fromWIF(wif, net);
const purpose = hint || 84;
const pk = kp.publicKey;
if (purpose === 86) return bitcoinjs.payments.p2tr({ internalPubkey: pk.slice(1, 33), network: net }).address;
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
// ---- ETH (EIP-55 checksummed 0x address) ----------------------------------
function ethAddressFromPubkey(pubUncompressed64) {
// Strip the 0x04 prefix if present so we hash just the 64 raw bytes.
const raw = pubUncompressed64.length === 65 ? pubUncompressed64.slice(1) : pubUncompressed64;
const h = keccak_256(raw);
const addr20 = h.slice(-20);
const hex = toHex(addr20);
// EIP-55 checksum
const hashOfLower = toHex(keccak_256(new TextEncoder().encode(hex)));
let out = "0x";
for (let i = 0; i < hex.length; i++) {
out += parseInt(hashOfLower[i], 16) >= 8 ? hex[i].toUpperCase() : hex[i];
}
return out;
}
function deriveEthFromSeed(seedHex, path) {
const node = HDKey.fromMasterSeed(fromHex(seedHex)).derive(path);
// secp256k1.getPublicKey with compressed=false gives 65 bytes (04||X||Y).
const pub = secp256k1.getPublicKey(node.privateKey, false);
return ethAddressFromPubkey(pub);
}
function deriveEthFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32) throw new Error("ETH private key must be 32 bytes hex");
const pub = secp256k1.getPublicKey(priv, false);
return ethAddressFromPubkey(pub);
}
// ---- TRX (T... base58check, network 0x41) --------------------------------
function tronAddressFromPubkey(pubUncompressed65) {
const raw = pubUncompressed65.length === 65 ? pubUncompressed65.slice(1) : pubUncompressed65;
const h = keccak_256(raw);
const last20 = h.slice(-20);
const versioned = new Uint8Array(21);
versioned[0] = 0x41; // Tron mainnet address prefix — same for Nile testnet
versioned.set(last20, 1);
return base58check.encodeCheck(versioned);
}
function deriveTrxFromSeed(seedHex, path) {
const node = HDKey.fromMasterSeed(fromHex(seedHex)).derive(path);
const pub = secp256k1.getPublicKey(node.privateKey, false);
return tronAddressFromPubkey(pub);
}
function deriveTrxFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32) throw new Error("TRX private key must be 32 bytes hex");
const pub = secp256k1.getPublicKey(priv, false);
return tronAddressFromPubkey(pub);
}
// ---- SOL (base58 pubkey, ed25519) ----------------------------------------
// SLIP-0010 ed25519 hardened derivation. Slightly different HD scheme
// from BIP32 secp256k1 — every step is hardened, index >= 0x80000000.
function slip0010DeriveEd25519(seed, path) {
const HMAC_KEY = new TextEncoder().encode("ed25519 seed");
const parts = String(path).split("/").slice(1);
// Compute master
const enc = new (require("crypto")).createHmac ? require("crypto") : null;
// Not using node crypto — the deps hand in @noble/hashes hmac via sha512.
// We rely on secp256k1's helpers? No — use ed25519 utils.
// Simplified: compute HMAC-SHA512(HMAC_KEY, seed) → I=I_L||I_R, sk=I_L, cc=I_R.
// Then each step: HMAC-SHA512(cc, 0x00 || sk || idx).
// Implementation via @noble/hashes/hmac imported as `hmacSha512`. We
// require it lazily so unavailable deps error out here rather than at
// load time.
const { hmac } = require("@noble/hashes/hmac");
const { sha512 } = require("@noble/hashes/sha2");
let I = hmac(sha512, HMAC_KEY, seed);
let sk = I.slice(0, 32); let cc = I.slice(32);
for (const seg of parts) {
const m = /^(\d+)'?$/.exec(seg);
if (!m) throw new Error(`bad path segment: ${seg}`);
const idx = (Number(m[1]) | 0x80000000) >>> 0;
const data = new Uint8Array(1 + 32 + 4);
data[0] = 0;
data.set(sk, 1);
data[33] = (idx >>> 24) & 0xff; data[34] = (idx >>> 16) & 0xff;
data[35] = (idx >>> 8) & 0xff; data[36] = idx & 0xff;
I = hmac(sha512, cc, data);
sk = I.slice(0, 32); cc = I.slice(32);
}
return sk;
}
function deriveSolFromSeed(seedHex, path) {
const sk = slip0010DeriveEd25519(fromHex(seedHex), path);
const pub = ed25519.getPublicKey(sk);
return base58check.encodeBase58(pub);
}
function deriveSolFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32 && priv.length !== 64) throw new Error("SOL private key must be 32 or 64 bytes hex");
const seed = priv.length === 64 ? priv.slice(0, 32) : priv;
const pub = ed25519.getPublicKey(seed);
return base58check.encodeBase58(pub);
}
function deriveSolFromBase58(b58) {
const bytes = base58check.decodeBase58(b58);
if (bytes.length !== 32 && bytes.length !== 64) throw new Error("SOL private key base58 must decode to 32 or 64 bytes");
const seed = bytes.length === 64 ? bytes.slice(0, 32) : bytes;
const pub = ed25519.getPublicKey(seed);
return base58check.encodeBase58(pub);
}
return {
mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
dgb: { fromSeed: deriveDgbFromSeed, fromWif: deriveDgbFromWif },
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 },
};
};

View file

@ -1,31 +1,108 @@
// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price // Fiat prices for every Aegis-supported coin. Opt-in via Settings so a
// endpoint, one request covers the lot. Opt-in via Settings so a // privacy-conscious user isn't quietly telling ANY oracle when Aegis is
// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open. // open. Source is user-selectable — different oracles trade off privacy,
// coverage, and freshness:
// //
// Cache is in-memory (returned by fullState() → panel). The addon polls // - coingecko : one HTTP request covers all 7 coins, best coverage,
// every 5 min while enabled; each fetch is cheap (~200 B response) and // default. Sees the browser IP + User-Agent every poll.
// the free tier tolerates one call/5 min per client easily. // - kraken : per-pair spot from Kraken's public /Ticker; fewer
// pairs (BCH/BTC/ETH/SOL/TRX; no SC/DGB). Sees IP but
// no user id.
// - coinbase : Coinbase's public spot endpoint; similar coverage to
// Kraken, similar IP-only exposure.
// //
// Trade-off named in the settings copy: CoinGecko sees the browser's IP // New sources plug in by adding an entry to SOURCES. Each provider takes a
// + a User-Agent every poll. Not seed-linked, not address-linked, but a // list of chain keys and returns { <chain>: usd } for the ones it knows
// data point. Off by default. // about; unknown chains just stay absent from the snapshot. The poller is
// generic.
//
// Cache is in-memory (returned by fullState() → panel). Poll interval is
// per-source since some rate-limit tighter than others. Off by default.
const COIN_GECKO_IDS = { const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"];
bch: "bitcoin-cash",
btc: "bitcoin", const SOURCES = {
trx: "tron", coingecko: {
eth: "ethereum", id: "coingecko",
sol: "solana", label: "CoinGecko",
sc: "siacoin", origin: "api.coingecko.com",
dgb: "digibyte", pollMs: 5 * 60 * 1000,
coversAll: true,
fetch: async () => {
const ids = {
bch: "bitcoin-cash", btc: "bitcoin", trx: "tron",
eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte",
};
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(Object.values(ids).join(","))}&vs_currencies=usd`;
const r = await fetch(url);
if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`);
const body = await r.json();
const out = {};
for (const [chain, cgId] of Object.entries(ids)) {
const usd = body?.[cgId]?.usd;
if (typeof usd === "number") out[chain] = usd;
}
return out;
},
},
kraken: {
id: "kraken",
label: "Kraken",
origin: "api.kraken.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Kraken uses non-standard pair names (XBT, ZUSD…). Only cover the
// coins Kraken lists with USD spot. SC + DGB are not on Kraken.
const pairs = { bch: "BCHUSD", btc: "XBTUSD", eth: "ETHUSD", sol: "SOLUSD", trx: "TRXUSD" };
const url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
const body = await r.json();
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
// Kraken returns keys like "XBCHZUSD" — match by suffix.
const out = {};
const result = body?.result || {};
const entries = Object.entries(result);
for (const [chain, pair] of Object.entries(pairs)) {
const hit = entries.find(([k]) => k === pair || k.endsWith(pair) || k.endsWith(pair.replace("XBT", "BT")));
const last = hit && parseFloat(hit[1]?.c?.[0]);
if (Number.isFinite(last)) out[chain] = last;
}
return out;
},
},
coinbase: {
id: "coinbase",
label: "Coinbase",
origin: "api.coinbase.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Coinbase publishes one spot per pair via /v2/prices/<pair>/spot.
// Runs the requests in parallel — 5 calls, each ~150 B response.
const map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
const out = {};
await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
try {
const r = await fetch(`https://api.coinbase.com/v2/prices/${pair}/spot`);
if (!r.ok) return;
const body = await r.json();
const usd = parseFloat(body?.data?.amount);
if (Number.isFinite(usd)) out[chain] = usd;
} catch { /* one pair failing shouldn't kill the others */ }
}));
return out;
},
},
}; };
const ENDPOINT = "https://api.coingecko.com/api/v3/simple/price"; const DEFAULT_SOURCE = "coingecko";
const POLL_MS = 5 * 60 * 1000;
module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = { const state = {
enabled: false, enabled: false,
source: DEFAULT_SOURCE,
prices: {}, // { <chain>: usd (number) } prices: {}, // { <chain>: usd (number) }
fetchedAt: null, fetchedAt: null,
error: null, error: null,
@ -33,26 +110,20 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
}; };
let timer = null; let timer = null;
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
async function fetchOnce() { async function fetchOnce() {
if (!state.enabled) return; if (!state.enabled) return;
state.loading = true; state.error = null; onChange(); state.loading = true; state.error = null; onChange();
try { try {
const ids = Object.values(COIN_GECKO_IDS).join(","); const src = currentProvider();
const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`; const next = await src.fetch();
const r = await fetch(url); state.prices = next || {};
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.fetchedAt = Date.now();
state.error = null; state.error = null;
} catch (e) { } catch (e) {
state.error = e?.message || String(e); state.error = e?.message || String(e);
log("price fetch failed:", state.error); log(`price fetch (${state.source}) failed:`, state.error);
} finally { } finally {
state.loading = false; state.loading = false;
onChange(); onChange();
@ -62,22 +133,25 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
function schedule() { function schedule() {
clearTimeout(timer); clearTimeout(timer);
if (!state.enabled) return; if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs);
} }
return { return {
// Snapshot for the panel: only what the UI needs.
snapshot() { snapshot() {
return { return {
enabled: state.enabled, enabled: state.enabled,
source: state.source,
prices: state.prices, prices: state.prices,
fetchedAt: state.fetchedAt, fetchedAt: state.fetchedAt,
error: state.error, error: state.error,
loading: state.loading, loading: state.loading,
sources: Object.values(SOURCES).map((s) => ({
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll,
})),
}; };
}, },
// Turn the feed on/off. Enabling triggers an immediate fetch so the // Turn the feed on/off. Enabling triggers an immediate fetch so the
// panel doesn't wait 5 minutes for the first price. // panel doesn't wait a full poll interval for the first price.
async setEnabled(on) { async setEnabled(on) {
const changed = !!on !== state.enabled; const changed = !!on !== state.enabled;
state.enabled = !!on; state.enabled = !!on;
@ -91,7 +165,15 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
await fetchOnce(); await fetchOnce();
schedule(); schedule();
}, },
// Force-refresh — bound to a manual "refresh" button in the panel. // Switch source. Clears the current cache, kicks a fresh fetch if the
// feed is enabled. No-op when the source is already current.
async setSource(id) {
if (!SOURCES[id] || id === state.source) return;
state.source = id;
state.prices = {}; state.fetchedAt = null;
onChange();
if (state.enabled) { await fetchOnce(); schedule(); }
},
refresh() { return fetchOnce(); }, refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; }, dispose() { clearTimeout(timer); state.enabled = false; },
}; };

View file

@ -21,7 +21,7 @@
html, body { margin: 0; height: 100%; } html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink); body { background: var(--bg); color: var(--ink);
font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; } display: flex; flex-direction: column; position: relative; }
header { padding: 10px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); position: relative; } header { padding: 10px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); position: relative; }
.picker { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; user-select: none; } .picker { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; user-select: none; }
.picker .t { display: flex; align-items: center; gap: 7px; font-weight: 600; min-width: 0; } .picker .t { display: flex; align-items: center; gap: 7px; font-weight: 600; min-width: 0; }
@ -37,10 +37,21 @@
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; } .bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
.bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; } .bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drop { position: absolute; left: 10px; right: 10px; top: 100%; background: var(--panel); border: 1px solid var(--line); /* Full-panel sheet — fills the sidebar so long wallet lists and the
border-radius: 10px; box-shadow: 0 8px 26px rgba(0,0,0,.3); z-index: 20; padding: 4px; margin-top: 4px; max-height: 60vh; overflow-y: auto; } import form aren't squeezed into a small popover. */
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 0;
background: var(--panel); border-top: 1px solid var(--line);
box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20;
display: flex; flex-direction: column; }
#drop[hidden] { display: none; } #drop[hidden] { display: none; }
#drop .row, #drop .coinrow { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: 7px; cursor: pointer; } #drop .droptabs { display: flex; border-bottom: 1px solid var(--line); flex: none; }
#drop .droptabs button { flex: 1; background: transparent; border: 0; color: var(--dim);
padding: 10px 8px; font: inherit; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; }
#drop .droptabs button.on { color: var(--ink); border-bottom-color: var(--acid, #d6ff3d); font-weight: 600; }
#drop .droptabs .closex { flex: none; width: 40px; font-size: 16px; color: var(--dim); border-left: 1px solid var(--line); }
#drop .droppane { flex: 1; overflow-y: auto; padding: 6px; }
#drop .droppane[hidden] { display: none; }
#drop .row, #drop .coinrow { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 7px; cursor: pointer; }
#drop .row:hover, #drop .coinrow:hover { background: rgba(255,255,255,.05); } #drop .row:hover, #drop .coinrow:hover { background: rgba(255,255,255,.05); }
#drop .row .m, #drop .coinrow .m { flex: 1; min-width: 0; } #drop .row .m, #drop .coinrow .m { flex: 1; min-width: 0; }
#drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@ -73,6 +84,45 @@
nav button.on { color: var(--acid); border-bottom-color: var(--acid); } nav button.on { color: var(--acid); border-bottom-color: var(--acid); }
nav button:hover { color: var(--ink); } nav button:hover { color: var(--ink); }
main { flex: 1; overflow: auto; padding: 14px; } main { flex: 1; overflow: auto; padding: 14px; }
/* Persistent aegis.x brand strip. Sticks to the panel's bottom edge so the
wallet always advertises its own front-door site — helps users bookmark
it, and doubles as a version marker for support triage. */
.brandfoot { flex: none; display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; border-top: 1px solid var(--line); background: var(--panel);
font-size: 11px; color: var(--dim); }
.brandlink { display: inline-flex; align-items: center; gap: 5px; color: var(--dim); text-decoration: none;
padding: 2px 4px; border-radius: 4px; }
.brandlink:hover { color: var(--acid, #d6ff3d); }
.brandlink svg { color: currentColor; }
.brandver { font-variant-numeric: tabular-nums; letter-spacing: .3px; }
.brandfoot-right { display: inline-flex; align-items: center; gap: 8px; }
.brandcheck { background: transparent; border: 0; color: var(--dim); cursor: pointer; padding: 0 2px;
font: inherit; font-size: 12px; line-height: 1; opacity: .7; transition: opacity .12s, color .12s; }
.brandcheck:hover { color: var(--acid, #d6ff3d); opacity: 1; }
.brandcheck.spin { animation: brandspin 1s linear infinite; }
@keyframes brandspin { to { transform: rotate(360deg); } }
.brandupd { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; padding: 2px 8px;
border-radius: 999px; background: rgb(from var(--acid, #d6ff3d) r g b / .14); color: var(--acid, #d6ff3d);
cursor: pointer; text-decoration: none; font-weight: 600;
transition: opacity .2s; }
.brandupd[hidden] { display: none; }
.brandupd:hover { background: rgb(from var(--acid, #d6ff3d) r g b / .22); }
/* Transient states painted by paintFooterUpdate: "✓ Up to date" after a
manual check when no newer version is available, and "⚠ Check failed"
when the OTA fetch errors. Both auto-hide after ~2s; the styling reads
as ephemeral confirmation rather than a persistent chip. */
.brandupd.brandok { background: rgba(255,255,255,.06); color: var(--dim); font-weight: 500;
cursor: default; }
.brandupd.brandok:hover { background: rgba(255,255,255,.06); }
.brandupd.branderr { background: rgba(224,90,90,.18); color: #e05a5a; font-weight: 500;
cursor: default; }
.brandupd.branderr:hover { background: rgba(224,90,90,.18); }
/* In-progress state: painted while the panel is fetching / staging /
restarting. Not clickable, dimmer than the acid CTA chip so users
don't try to smash it. */
.brandupd.brandwait { background: rgba(255,255,255,.06); color: var(--mut);
font-weight: 500; cursor: default; }
.brandupd.brandwait:hover { background: rgba(255,255,255,.06); }
section[hidden] { display: none; } section[hidden] { display: none; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; } .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
.mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; } .mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
@ -126,6 +176,218 @@
.kv { margin-top: 10px; } .kv { margin-top: 10px; }
.kv .lbl { margin-top: 8px; } .kv .lbl { margin-top: 8px; }
a.link { color: var(--acid); text-decoration: none; cursor: pointer; } a.link { color: var(--acid); text-decoration: none; cursor: pointer; }
/* Preset-server checkbox list — one row per known Electrum endpoint, plus
any URL the user added via the "Add a custom server" reveal. Rows show
the host + a small hover-to-remove for user-added entries. */
.serverlist { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.serverlist label { display: flex; align-items: center; gap: 8px; padding: 5px 6px;
border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); }
.serverlist label:hover { background: rgba(255,255,255,.04); color: var(--ink); }
.serverlist input[type=checkbox] { accent-color: var(--acid, #d6ff3d); width: 14px; height: 14px; margin: 0; }
.serverlist .surl { flex: 1; font: 11.5px/1.4 ui-monospace, Consolas, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.serverlist .sremove { flex: none; background: transparent; border: 0; color: var(--dim); cursor: pointer; padding: 0 4px; opacity: 0; }
.serverlist label:hover .sremove { opacity: 1; }
.serverlist .sremove:hover { color: var(--danger, #f6768a); }
/* Always-visible wallet list — vertical column of rows so users can scan
names + balances without dragging chips sideways. Capped at ~40vh so a
giant list still leaves the main content readable; scrolls inside. */
.wstrip { flex: none; display: flex; flex-direction: column; gap: 1px; padding: 4px 6px 5px 6px;
max-height: 44vh; overflow-y: auto; scrollbar-width: thin;
background: var(--panel); border-bottom: 1px solid var(--line); }
.wstrip::-webkit-scrollbar { width: 4px; }
.wstrip::-webkit-scrollbar-thumb { background: var(--line); border-radius: 2px; }
.wstrip .wchip { display: flex; align-items: center; gap: 4px; padding: 4px 6px 4px 4px;
background: transparent; border: 1px solid transparent; border-radius: 7px;
font: inherit; font-size: 12.5px; color: var(--mut); min-width: 0; }
.wstrip .wchip:hover { background: rgba(255,255,255,.04); color: var(--ink); }
.wstrip .wchip.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35);
color: var(--ink); }
.wstrip .wchip .wclick { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
cursor: pointer; padding: 2px 4px; border-radius: 5px; }
.wstrip .wchip .wtext { flex: 1; min-width: 0; overflow: hidden; display: inline-flex;
align-items: center; gap: 6px; flex-wrap: nowrap; }
.wstrip .wchip .wname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .wchip .wsub { color: var(--dim); font-size: 10.5px; font-weight: 400; margin-left: 6px;
font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Price chip sitting right next to the wallet label. Acid tint with a
subtle glow lifts it off the grey row so users spot movement without
hunting; the small background gives it structural presence too. */
.wstrip .wchip .wprice { color: var(--acid, #d6ff3d); font-size: 11px; font-weight: 600;
font-variant-numeric: tabular-nums; white-space: nowrap;
padding: 1px 6px; border-radius: 999px;
background: rgb(from var(--acid, #d6ff3d) r g b / .10);
text-shadow: 0 0 6px rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .wchip .wact { flex: none; background: transparent; border: 0; color: var(--dim);
cursor: pointer; padding: 3px 6px; border-radius: 5px; font-size: 12px; line-height: 1; }
.wstrip .wchip .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Four-column layout: [logo][ticker+price stack][amount+fiat stack][actions].
The old separate Price and Chain columns collapsed into the ticker cell —
per-unit price sits directly under the ticker so the eye can read
"BCH · $430" as one unit before jumping to the balance on the right.
A non-mainnet row also carries a small network pill inline with the
ticker (Chipnet / Sepolia / …); mainnet rows omit it entirely. */
.wstrip { padding: 4px 6px 6px 6px; }
.wstrip .wrow { display: grid; grid-template-columns: 18px minmax(72px,auto) minmax(0,1fr) auto auto;
gap: 8px; align-items: center; padding: 5px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer;
min-height: 30px; }
.wstrip .wrow:hover { background: rgba(255,255,255,.04); }
.wstrip .wrow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .wrow.dragging { opacity: .45; cursor: grabbing; }
.wstrip .wrow.drop-before { box-shadow: 0 -2px 0 0 var(--acid, #d6ff3d); }
.wstrip .wrow.drop-after { box-shadow: 0 2px 0 0 var(--acid, #d6ff3d); }
.wstrip .wgrip { display: inline-block; color: var(--dim); font-size: 10px; padding: 0 2px;
cursor: grab; user-select: none; opacity: 0; transition: opacity .12s; }
.wstrip .wrow:hover .wgrip { opacity: 1; }
.wstrip .wrow[draggable="true"] { cursor: grab; }
.wstrip .wcell { min-width: 0; display: inline-flex; align-items: center; gap: 3px; }
.wstrip .wclogo { justify-self: start; align-self: start; padding-top: 2px; }
/* Ticker cell: two-line stack. Line 1 has the ticker, an optional ▾
chevron (only when the chain has more than one network to switch
between), an optional Chipnet/Testnet pill, and the wallet count.
Line 2 is the per-unit price in acid-green. The cell as a whole is
the click target for the network dropdown. */
.wstrip .wcname { flex-direction: column; align-items: flex-start; line-height: 1.15; gap: 1px; }
.wstrip .wcname .wtline { display: inline-flex; align-items: center; gap: 5px; }
.wstrip .wcname .wtck { font-weight: 700; font-size: 13px; color: var(--ink); }
.wstrip .wcname .wchev { color: var(--dim); font-size: 9px; line-height: 1; padding: 0 1px;
transition: color .12s, transform .12s; }
.wstrip .wcname.wswitchable { cursor: pointer; }
.wstrip .wcname.wswitchable:hover .wchev { color: var(--acid, #d6ff3d); }
.wstrip .wcname .wnetpill { font-size: 10px; padding: 1px 6px; border-radius: 999px;
background: rgba(255,255,255,.06); color: var(--mut);
white-space: nowrap; line-height: 1.35; }
.wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18);
color: var(--acid, #d6ff3d); font-weight: 600; }
.wstrip .wcname .wnetpill.wtestnet { background: rgba(224,179,65,.18); color: #e0b341; font-weight: 600; }
.wstrip .wcname .wgcount { color: var(--dim); font-size: 10.5px; padding: 0 6px; border-radius: 999px;
background: rgba(255,255,255,.06); font-variant-numeric: tabular-nums; line-height: 1.4; }
.wstrip .wcname .wcprice { color: var(--acid, #d6ff3d); font-size: 11px; font-weight: 600;
font-variant-numeric: tabular-nums; white-space: nowrap;
text-shadow: 0 0 5px rgb(from var(--acid, #d6ff3d) r g b / .30); }
.wstrip .wcamt { flex-direction: column; align-items: flex-end; text-align: right;
font-variant-numeric: tabular-nums; line-height: 1.2; }
.wstrip .wcamt .wnative { font-size: 12.5px; color: var(--ink); font-weight: 500; }
.wstrip .wcamt .wfiat { font-size: 11px; color: var(--dim); }
.wstrip .wcact { display: inline-flex; gap: 2px; }
.wstrip .wcact .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
.wstrip .wcact .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Network dropdown menu — anchored below the ticker cell on click. Shows
every network under the chain (mainnet + testnets) with its own totals,
so switching networks feels like flipping a segment on the same coin
row rather than jumping to a separate wallet. */
.netmenu { position: absolute; z-index: 60; min-width: 180px; background: var(--panel, #1a1a1a);
border: 1px solid var(--line); border-radius: 8px; padding: 4px;
box-shadow: 0 6px 20px rgba(0,0,0,.35); }
.netmenu .nmitem { display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 6px 8px; border-radius: 5px; cursor: pointer;
color: var(--ink); font-size: 12.5px; }
.netmenu .nmitem:hover { background: rgba(255,255,255,.06); }
.netmenu .nmitem.on { background: rgb(from var(--acid, #d6ff3d) r g b / .12);
color: var(--acid, #d6ff3d); }
.netmenu .nmitem .nmname { display: inline-flex; align-items: center; gap: 6px; }
.netmenu .nmitem .nmnet { font-weight: 600; }
.netmenu .nmitem .nmcount { color: var(--dim); font-size: 10.5px; padding: 0 5px; border-radius: 999px;
background: rgba(255,255,255,.06); }
.netmenu .nmitem .nmamt { font-variant-numeric: tabular-nums; font-size: 11.5px; color: var(--dim); }
.netmenu .nmitem.on .nmamt { color: var(--acid, #d6ff3d); }
/* Acid-green testnet tag for the Chipnet group. Overrides the warm-amber
default so chipnet reads as "the friendly BCH testnet" instead of
borrowing the caution palette. */
.ttag.acid { background: rgb(from var(--acid, #d6ff3d) r g b / .18); color: var(--acid, #d6ff3d); }
.wstrip .waddwrap { display: flex; gap: 5px; padding: 0 0 4px 0; margin-bottom: 4px; border-bottom: 1px solid var(--line); }
.wstrip .waddwrap button { flex: 1; background: transparent; border: 1px dashed var(--line); border-radius: 6px;
padding: 5px 8px; color: var(--dim); cursor: pointer; font: inherit; font-size: 12px; line-height: 1; }
.wstrip .waddwrap button:hover { color: var(--acid, #d6ff3d); border-color: var(--acid, #d6ff3d); }
/* Inline coin-address view — takes the place of the six-column grid when
a user opens a multi-wallet group. Back-arrow row + one row per wallet
under the coin. Same container so the visual context stays put. */
.wstrip .wcoinhead { display: flex; align-items: center; gap: 6px; padding: 3px 4px 4px 4px;
border-bottom: 1px solid var(--line); }
.wstrip .wcoinhead .wback { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font: inherit; font-size: 12px; line-height: 1; }
.wstrip .wcoinhead .wback:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
.wstrip .wcoinhead .wctitle { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .wcoinhead .wcount { color: var(--dim); font-size: 11.5px; font-weight: 500; }
.wstrip .warow { display: grid; grid-template-columns: 16px minmax(60px,1fr) auto auto auto;
gap: 6px; align-items: center; padding: 5px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; }
.wstrip .warow:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .warow .waname { font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-weight: 500; }
.wstrip .warow .waaddr { font: 11px/1.15 ui-monospace, Consolas, monospace; color: var(--dim); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--ink); }
.wstrip .warow .wafiat { font-size: 11px; color: var(--dim); }
.wstrip .warow .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
.wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Full-panel lock screen — takes over the entire panel below the aegis
footer when the vault is locked or awaiting first-time setup. Rest of
the app (header picker, tabs, wallet strip) is hidden until unlocked
so nothing sensitive shows and there is no accidental interaction
surface. */
#lockScreen[hidden] { display: none; }
#lockScreen { position: absolute; inset: 0 0 30px 0; z-index: 50; background: var(--bg);
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 24px 18px; color: var(--ink); text-align: center; }
#lockScreen .aegisMark { width: 64px; height: 64px; margin-bottom: 14px; }
#lockScreen .aegisMark svg { width: 100%; height: 100%; color: var(--acid, #d6ff3d);
filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); }
#lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; }
#lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; }
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: left; }
#lockScreen .lockform input[type=password],
#lockScreen .lockform input[type=text],
#lockScreen .lockform textarea { text-align: center; }
#lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; }
#lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; }
#lockScreen .altline a:hover { text-decoration: underline; }
/* PIN pad — six dots + a 3x4 keypad. Used both in lock screen and in the
PIN approval modal. */
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.pinpad .pindots { display: flex; gap: 12px; }
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
background: transparent; transition: background .12s, border-color .12s; }
.pinpad .pindot.on { background: var(--acid, #d6ff3d); border-color: var(--acid, #d6ff3d);
box-shadow: 0 0 6px rgb(from var(--acid, #d6ff3d) r g b / .5); }
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 62px); gap: 8px; }
.pinpad .pinkeys button { height: 46px; border-radius: 10px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); font: 500 18px inherit;
cursor: pointer; }
.pinpad .pinkeys button:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
.pinpad .pinerr { color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
/* PIN modal overlay — used for set-PIN / change-PIN / verify-PIN flows. */
.pinmodal { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex;
align-items: center; justify-content: center; z-index: 9999; padding: 20px; }
.pinmodal .pincard { width: min(94vw, 340px); background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 18px 16px 14px; box-shadow: 0 10px 40px rgba(0,0,0,.4); }
.pinmodal h2 { margin: 0 0 6px 0; font: 600 15px inherit; }
.pinmodal .pinsub { color: var(--mut); font-size: 12px; margin-bottom: 12px; text-align: center; }
.pinmodal .pinactions { display: flex; gap: 6px; justify-content: center; margin-top: 12px; }
/* General settings cards — pinned at top of Settings tab so cross-cutting
security/policy controls stay in one place, ahead of the per-wallet
stuff below. */
.gsec { margin-bottom: 14px; }
.gsec .gtitle { font: 600 13px inherit; color: var(--ink); margin-bottom: 6px; }
.gsec .gline { display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 6px 0; border-top: 1px solid var(--line); }
.gsec .gline:first-of-type { border-top: 0; }
.gsec .gline .glabel { min-width: 0; font-size: 12.5px; color: var(--ink); }
.gsec .gline .ghint { display: block; color: var(--dim); font-size: 11px; margin-top: 2px; }
.gsec .gline .gactions { flex: none; display: inline-flex; gap: 6px; }
.gsec .gsoon { font-size: 10px; padding: 1px 6px; border-radius: 999px; letter-spacing: .04em;
background: rgba(224,179,65,.14); color: #e0b341; text-transform: uppercase; }
</style> </style>
</head> </head>
<body> <body>
@ -137,8 +399,9 @@
<span id="hNet"></span> <span id="hNet"></span>
</div> </div>
<div class="picker-actions"> <div class="picker-actions">
<button type="button" id="hAddWallet" class="chip" title="Add a new wallet"></button> <button type="button" id="hAdd" class="chip" title="Add a new wallet"></button>
<span class="caret"></span> <button type="button" id="hMore" class="chip" title="Import / Connect / About"></button>
<button type="button" id="hManage" class="chip" title="Edit this wallet — rename, derivation path, remove"></button>
</div> </div>
</div> </div>
<div id="drop" hidden></div> <div id="drop" hidden></div>
@ -160,6 +423,11 @@
<button data-tab="history">History</button> <button data-tab="history">History</button>
<button data-tab="settings">Settings</button> <button data-tab="settings">Settings</button>
</nav> </nav>
<!-- Always-visible wallet list. Sits under the Receive/Send/History/Settings
tab bar so the top of the panel stays about the SELECTED wallet, and
the list of siblings/switch-targets reads as a secondary layer below.
[ Add] opens the Add-only picker; [⋯ More] opens Import/Connect/Manage. -->
<div id="walletStrip" class="wstrip" aria-label="Your wallets"></div>
<main> <main>
<div id="gate" class="gate" hidden></div> <div id="gate" class="gate" hidden></div>
<div id="tabs"> <div id="tabs">
@ -193,7 +461,7 @@
<div class="hint" id="sendToHint"></div> <div class="hint" id="sendToHint"></div>
</div> </div>
<div class="field"> <div class="field">
<div class="lbl">Amount</div> <div class="lbl">Amount <span id="sendAmtFiat" class="fiat" style="margin-left:8px" hidden></span></div>
<div class="amt"> <div class="amt">
<input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00"> <input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00">
<div class="unit" id="unitPicker"></div> <div class="unit" id="unitPicker"></div>
@ -218,6 +486,104 @@
<div id="txlist"></div> <div id="txlist"></div>
</section> </section>
<section id="tab-settings" hidden> <section id="tab-settings" hidden>
<!-- Cross-cutting security / policy controls, ahead of anything
per-wallet or chain-specific. Present even when the vault is
still locked so users can set up a PIN or read the multi-sig
roadmap without unlocking first (they only do the ACTUAL PIN
enrollment after a fresh unlock, though). -->
<div class="card gsec" id="genSecurity" style="margin-bottom:14px">
<div class="gtitle">Security</div>
<div class="gline">
<div class="glabel">
Quick-access PIN
<span class="ghint" id="pinStatusHint">Off — Aegis asks for the master password every time.</span>
</div>
<div class="gactions">
<button class="btn sm" id="gsPinSet" hidden>Set PIN…</button>
<button class="btn sm" id="gsPinChange" hidden>Change…</button>
<button class="btn sm danger" id="gsPinRemove" hidden>Remove</button>
</div>
</div>
<div class="gline" id="gsRequirePinLine" hidden>
<div class="glabel">
Require PIN for sending
<span class="ghint">Prompts for your PIN on every panel-initiated Send. Dapp-driven approvals still use the standard approval overlay.</span>
</div>
<div class="gactions">
<label class="switch"><input type="checkbox" id="gsRequirePin"><span></span></label>
</div>
</div>
</div>
<!-- Session: stay-signed-in + idle auto-lock + manual sign-out.
Uses electron.safeStorage under the hood so the master password
is only decryptable under this OS user account. When "Lock on
Navigator close" is on (the default), Aegis stores nothing on
disk and forces a fresh unlock on every launch. -->
<div class="card gsec" id="genSession" style="margin-bottom:14px">
<div class="gtitle">Session</div>
<div class="gline">
<div class="glabel">
Lock on Navigator close
<span class="ghint" id="gsSessionHint">On — Aegis asks for the master password (or PIN) every time Theseus starts. Turn off to stay signed in across restarts via the OS keystore.</span>
</div>
<div class="gactions">
<label class="switch"><input type="checkbox" id="gsLockOnClose" checked><span></span></label>
</div>
</div>
<div class="gline">
<div class="glabel">
Auto-lock when idle
<span class="ghint">Locks the vault after this long with no panel activity. Applies inside the wallet panel; a locked panel elsewhere in the browser doesn't count as idle.</span>
</div>
<div class="gactions">
<select id="gsIdleMinutes" style="padding:5px 8px;border-radius:6px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:12px">
<option value="0">Never</option>
<option value="5">5 min</option>
<option value="15" selected>15 min</option>
<option value="30">30 min</option>
<option value="60">1 hour</option>
<option value="180">3 hours</option>
</select>
</div>
</div>
<div class="gline">
<div class="glabel">
Sign out now
<span class="ghint">Locks the vault immediately. Any dapp connections stay paired, but Aegis will refuse to sign until you unlock again.</span>
</div>
<div class="gactions">
<button class="btn sm danger" id="gsSignOut">Sign out</button>
</div>
</div>
</div>
<div class="card gsec" id="genMasterPw" style="margin-bottom:14px">
<div class="gtitle">Master password</div>
<div class="gline">
<div class="glabel">
Change master password
<span class="ghint">Opens Theseus <span class="mono">Settings Passwords</span>. The vault re-encrypts under the new password; every derived wallet keeps working.</span>
</div>
<div class="gactions">
<button class="btn sm" id="gsOpenPasswords">Open Passwords…</button>
</div>
</div>
</div>
<div class="card gsec" id="genMultiSig" style="margin-bottom:14px">
<div class="gtitle">Second-device approval <span class="gsoon">soon</span></div>
<div class="gline">
<div class="glabel">
Co-sign sends from a second device
<span class="ghint">Aegis will pair with Ariadne on your phone so large transfers need a nod from both places before they broadcast. Not shipped yet.</span>
</div>
<div class="gactions">
<button class="btn sm" disabled title="Not shipped yet">Set up…</button>
</div>
</div>
</div>
<div class="card" id="walletManage" style="margin-bottom:14px"> <div class="card" id="walletManage" style="margin-bottom:14px">
<div class="lbl">This wallet</div> <div class="lbl">This wallet</div>
<div class="field" style="margin-top:6px;margin-bottom:8px"> <div class="field" style="margin-top:6px;margin-bottom:8px">
@ -237,8 +603,15 @@
<div class="hint">Changing this switches to a different set of addresses under the same wallet seed.</div> <div class="hint">Changing this switches to a different set of addresses under the same wallet seed.</div>
</div> </div>
<div class="field" id="bchServersRow"> <div class="field" id="bchServersRow">
<div class="lbl">Electrum servers (shared across BCH mainnet wallets, one per line)</div> <div class="lbl">Electrum servers <span class="hint" style="font-weight:normal">(BCH mainnet — shared across BCH wallets)</span></div>
<textarea id="setServers" spellcheck="false"></textarea> <div id="setServersList" class="serverlist"></div>
<details style="margin-top:6px">
<summary style="cursor:pointer;color:var(--dim);font-size:11.5px">Add a custom server…</summary>
<div style="display:flex;gap:6px;margin-top:6px">
<input type="text" id="setServersCustom" spellcheck="false" placeholder="wss://host:port" style="flex:1">
<button class="btn sm" id="addCustomServer" type="button">Add</button>
</div>
</details>
<div class="hint" id="serverHint"></div> <div class="hint" id="serverHint"></div>
</div> </div>
<div class="actions"> <div class="actions">
@ -255,19 +628,30 @@
</div> </div>
</div> </div>
<div class="card" style="margin-top:16px"> <div class="card" style="margin-top:16px" id="wcCard">
<div class="lbl">WizardConnect</div> <div class="lbl">WizardConnect</div>
<div class="hint" style="margin-bottom:8px"> <div class="hint" style="margin-bottom:8px">
Pair this BCH wallet with a dapp that speaks the WizardConnect protocol 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> (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. URI the dapp shows in its Connect dialog. Aegis signs every transaction only after you approve it.
</div> </div>
<!-- Warning that appears when the selected wallet is imported —
WC signing needs the derive-based signer path, which imported
wallets don't have yet. Shown in place of the input so users
don't try to paste a URI and hit "wallet not ready". -->
<div class="msg err" id="wcImportedNotice" hidden style="margin-bottom:8px">
WizardConnect isn't available for imported wallets yet — pairing
uses Aegis' vault-derived signer path, and imported wallets are
read-only for now. Coming in a later release.
</div>
<div id="wcInputs">
<div class="field" style="margin-top:8px"> <div class="field" style="margin-top:8px">
<input type="text" id="wcUri" spellcheck="false" placeholder="wiz://?p=…&amp;s=…"> <input type="text" id="wcUri" spellcheck="false" placeholder="wiz://?p=…&amp;s=…">
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn primary" id="wcConnectBtn">Connect</button> <button class="btn primary" id="wcConnectBtn">Connect</button>
</div> </div>
</div>
<div class="msg" id="wcMsg" hidden></div> <div class="msg" id="wcMsg" hidden></div>
<div class="kv" id="wcSites" style="margin-top:8px"></div> <div class="kv" id="wcSites" style="margin-top:8px"></div>
</div> </div>
@ -390,10 +774,15 @@
<div class="card" style="margin-top:16px"> <div class="card" style="margin-top:16px">
<div class="lbl">Fiat prices</div> <div class="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px"> <div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis fetches USD prices for the seven supported coins from Off by default. When enabled, Aegis polls the chosen oracle for USD prices
<span class="mono">api.coingecko.com</span> every 5 minutes while the panel is open. on each supported coin. No keys and no address data are ever sent — but the
One HTTP request per interval, no keys and no address data — but CoinGecko can see your IP, oracle sees your IP + a User-Agent every poll, which is a signal that a
which is a signal that a wallet is open on this machine. wallet is open on this machine.
</div>
<div class="field">
<div class="lbl">Oracle</div>
<select id="pricesSource" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px"></select>
<div class="hint" id="pricesSourceHint"></div>
</div> </div>
<div class="actions" style="align-items:center;gap:12px"> <div class="actions" style="align-items:center;gap:12px">
<label class="switch"> <label class="switch">
@ -414,6 +803,40 @@
</section> </section>
</div> </div>
</main> </main>
<!-- Full-panel lock screen. Absolutely positioned so it takes over the
entire content area (everything above the aegis footer) whenever the
vault is locked or awaiting first-time setup — the header picker, nav
tabs, and wallet strip stay in the DOM but sit visually under it. The
Settings tab still hides itself; nothing here interacts with it. -->
<div id="lockScreen" hidden>
<div class="aegisMark" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none">
<polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<circle cx="16" cy="16" r="1.6" fill="currentColor"/>
</svg>
</div>
<h1 id="lockTitle">Unlock Aegis</h1>
<div class="subhint" id="lockSub">Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet.</div>
<div id="lockBody"></div>
</div>
<footer class="brandfoot" id="brandFoot">
<a href="#" id="brandLink" class="brandlink" title="Open aegis.x">
<svg viewBox="0 0 32 32" width="14" height="14" aria-hidden="true"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.3" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="16" cy="16" r="1.3" fill="currentColor"/></svg>
<span>aegis.x</span>
</a>
<!-- Update controls: a subtle "check" link that polls the OTA manifest,
then swaps to an "Update to vX.Y.Z" chip when a newer version is
ready. Panel-only view since the addon can't promote itself; the
chip links to Settings > Extensions > Aegis where the Apply flow
lives. Version marker on the far right doubles as the up-to-date
affordance so a happy panel says nothing extra. -->
<span id="brandUpdate" class="brandupd" hidden></span>
<span class="brandfoot-right">
<button id="brandCheck" class="brandcheck" type="button" title="Check for updates"></button>
<span class="brandver" id="brandVer"></span>
</span>
</footer>
<script src="qr.js"></script> <script src="qr.js"></script>
<script src="panel.js"></script> <script src="panel.js"></script>
</body> </body>

File diff suppressed because it is too large Load diff

24
main.js
View file

@ -1804,6 +1804,30 @@ function initAddons() {
} }
createTab(null, { settings: true, settingsSection: slug }); createTab(null, { settings: true, settingsSection: slug });
}, },
// Panel-driven update flow. Runs the same signed-payload verify + stage
// path used by Settings Extensions Check-for-updates and the boot
// timer, but on demand from an add-on's own UI so a plug-in card can
// offer "Update now" in one click. checkAndStageUpdates itself iterates
// every installed add-on; the API wrapper filters the report down to
// the caller. restartApp mirrors the "app-restart" IPC so the plug-in
// can apply a freshly-staged build without asking the user to hunt
// for the OS menu.
checkAndStageUpdates: async () => {
const stagedDir = addonsStagedDir();
try {
const result = await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(),
stagedDir,
pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a),
});
return { report: result?.report || [], skipped: result?.skipped || null, staged: listStagedAddons(stagedDir) };
} catch (e) {
console.warn("[addons] panel-driven check-updates failed:", e?.message || e);
return { report: [], skipped: "unexpected-error", staged: [] };
}
},
restartApp: () => { try { app.relaunch(); } catch {} app.quit(); },
// open-tab (addon-file variant): open one of the add-on's OWN files in a // open-tab (addon-file variant): open one of the add-on's OWN files in a
// full tab. The path is joined against the resolved add-on folder and // full tab. The path is joined against the resolved add-on folder and
// rejected if the result escapes it — belt-and-braces with the sanity // rejected if the result escapes it — belt-and-braces with the sanity

View file

@ -1,6 +1,6 @@
{ {
"name": "theseus-navigator", "name": "theseus-navigator",
"version": "0.3.46", "version": "0.3.47",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode", "author": "Silent Mode",
"main": "main.js", "main": "main.js",

View file

@ -1272,7 +1272,13 @@
return '<div class="d" style="' + cls + ';margin-top:4px">' + msg + '</div>'; return '<div class="d" style="' + cls + ';margin-top:4px">' + msg + '</div>';
} }
function renderAddons(snap) { function renderAddons(snap) {
const items = (snap && snap.installed) || []; // Plug-in category add-ons (Aegis and future first-class Silent Mode
// components) live in Settings Plug-ins with their own product-facing
// copy — showing them again here as raw extension rows was confusing
// ("why can I toggle my wallet off from two places?"). Filter them
// out of the Extensions listing entirely; the Plug-ins tab is the
// single source of truth for those.
const items = ((snap && snap.installed) || []).filter((a) => a.category !== "plugin");
if (!items.length) { if (!items.length) {
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Drop a folder into the extensions directory to install one.</div>'; addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Drop a folder into the extensions directory to install one.</div>';
return; return;