diff --git a/addons-host.js b/addons-host.js index 3e6031f..465d28f 100644 --- a/addons-host.js +++ b/addons-host.js @@ -148,13 +148,19 @@ function validateManifest(raw, folderName) { } 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 // of the app queries via `getActive()` / `getInstalled()`. 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.dataDir = dataDir; this.isDisabled = isDisabled || (() => false); @@ -190,6 +196,13 @@ class AddonHost { // named section (e.g. "passwords"). Uses the same IPC route the picker // uses for "Search settings…". Signature: (section?: string) => void. this._openSettings = typeof openSettings === "function" ? openSettings : null; + // 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's proxy rules (e.g. a "route everything through my VPS" add-on). // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise @@ -445,6 +458,33 @@ class AddonHost { if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); 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 // already has). Put expensive dependency loading behind this so it // never competes with the first frame at launch. @@ -576,6 +616,10 @@ class AddonHost { author: manifest?.author ?? "", icon: manifest?.icon ?? "🧩", 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, enabled: manifest?.id ? this._active.has(manifest.id) : false, error: error || null, diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 05d0171..40f751a 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,8 @@ { "id": "aegis", "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.", "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", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 00f3003..e3bed81 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -92,6 +92,21 @@ async function loadDeps(api) { const importedBchAdapter = require("./lib/chain-bch-imported.js")({ 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 // §4d combined-work requirement (dynamic linkage + license notice + source // 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, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, - importedBchAdapter, bip39, + importedBchAdapter, utxoImportedAdapter, genericImportedAdapter, + derive, bip39, dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, wcCore, wcWallet, libauth }; } @@ -477,14 +493,34 @@ async function mountWallet(entry) { // it doesn't need the material until spend support ships (M.1b). if (entry.kind === "imported") { try { - const adapter = new c.d.importedBchAdapter.ImportedBchWallet({ + let adapter; + const commonOpts = { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), network: entry.network, - cashaddr: entry.importedCashaddr, - servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, - }); + }; + if (entry.chain === "bch") { + adapter = new c.d.importedBchAdapter.ImportedBchWallet({ + ...commonOpts, + cashaddr: entry.importedCashaddr || entry.importedAddress, + 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"; adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e)); emitStateForWallet(entry.id); @@ -631,7 +667,15 @@ function deriveCashaddrFromSeed(seedHex, path, prefix) { function deriveCashaddrFromWif(wif, prefix) { const d = ctx.d; // 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}`); // First byte is version (network); we allow any — BCH mainnet uses 0x80, // 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; return { 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", 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, 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 }, phase: rt?.phase || "locked", error: rt?.error || null, @@ -705,6 +754,7 @@ function snapshotForSelected() { chain: entry?.chain, network: entry?.network, isLegacy: !!entry?.isLegacy, + kind: entry?.kind || null, meta: meta ? { logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals, coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet, @@ -851,57 +901,143 @@ function registerPanelMessages(api) { api.onMessage("importWallet", async (p, m) => { fromPanel(m); const chain = String(p && p.chain || "bch"); - if (chain !== "bch") throw new Error("only BCH imports are supported in this build"); - const network = String(p && p.network || "chipnet"); - if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`); + const network = String(p && p.network || "").trim(); const label = String(p && p.label || "").trim(); if (!label) throw new Error("label required"); const category = String(p && p.category || "operational").trim(); - const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; const source = String(p && p.source || "manual-paste"); - let kind, seedHex, path, wif, cashaddrStr; - if (p && p.wif) { - kind = "wif"; - wif = String(p.wif).trim(); - cashaddrStr = deriveCashaddrFromWif(wif, prefix); - } else if (p && p.mnemonic) { - kind = "seed"; - const words = String(p.mnemonic).trim().split(/\s+/).length; - if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) { - throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`); - } - if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) { - throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)"); - } - const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim()); - seedHex = Buffer.from(seed).toString("hex"); - path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); - cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); - } else if (p && p.seedHex) { - kind = "seed"; - seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, ""); - if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex"); - path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); - cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); + // 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) { + spec.kind = "wif"; spec.wif = String(p.wif).trim(); + address = deriveCashaddrFromWif(spec.wif, prefix); + } else if (p && p.mnemonic) { + spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim()); + spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + address = deriveCashaddrFromSeed(spec.seed, spec.path, prefix); + } else if (p && p.seedHex) { + spec.kind = "seed"; spec.seed = 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"); + spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + address = deriveCashaddrFromSeed(spec.seed, spec.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:`) — + // 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: " (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 { - 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); - - // 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 netForId = network || "mainnet"; 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"); const entry = { - id: walletId, label, chain: "bch", network, - kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category, + id: walletId, label, chain, network: netForId, + kind: "imported", importId, + importedAddress: address, importedCategory: category, + accountPath: spec.path || null, createdAt: Date.now(), }; list.push(entry); @@ -954,9 +1090,42 @@ function registerPanelMessages(api) { return snapshotForSelected(); }); 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 // 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; }); + // 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) => { fromPanel(m); @@ -1139,6 +1308,14 @@ function registerPanelMessages(api) { if (ctx.priceFeed) await ctx.priceFeed.refresh(); 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. api.onMessage("wcConnect", async (p, m) => { @@ -1156,6 +1333,26 @@ function registerPanelMessages(api) { 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("revoke", (p, m) => { fromPanel(m); @@ -1164,6 +1361,195 @@ function registerPanelMessages(api) { api.storage.set("permissions", 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 @@ -1871,7 +2257,10 @@ module.exports = { registerPanelMessages(api); registerPageMessages(api); // Restore the user's opt-in choice from storage. Off by default so a - // fresh install never hits CoinGecko without asking. + // 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(() => {}); // The deps are heavy to evaluate (noble curve precompute, bitcoinjs, // libauth, WizardConnect) and that all happens on the main thread. Wait @@ -1903,7 +2292,7 @@ module.exports = { }, }); c.wc.onStateChange(() => emitState()); - return mountAllWallets(); + return tryAutoUnlock(api).then(() => mountAllWallets()); }).catch((e) => { if (ctx !== c) return; api.log("startup failed:", e?.message); diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index 13dac81..70e3fa3 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -83,6 +83,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); 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 {} } @@ -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)." }; } - dispose() { try { this._client.disconnect(); } catch {} } + dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} } } return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js new file mode 100644 index 0000000..c60a368 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -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 }; +}; diff --git a/bundled-addons/aegis/lib/chain-utxo-imported.js b/bundled-addons/aegis/lib/chain-utxo-imported.js new file mode 100644 index 0000000..dd97aab --- /dev/null +++ b/bundled-addons/aegis/lib/chain-utxo-imported.js @@ -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 }; +}; diff --git a/bundled-addons/aegis/lib/import-derive.js b/bundled-addons/aegis/lib/import-derive.js new file mode 100644 index 0000000..3c7ae1c --- /dev/null +++ b/bundled-addons/aegis/lib/import-derive.js @@ -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 }, + }; +}; diff --git a/bundled-addons/aegis/lib/prices.js b/bundled-addons/aegis/lib/prices.js index cf015b3..ab356a7 100644 --- a/bundled-addons/aegis/lib/prices.js +++ b/bundled-addons/aegis/lib/prices.js @@ -1,31 +1,108 @@ -// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price -// endpoint, one request covers the lot. Opt-in via Settings so a -// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open. +// Fiat prices for every Aegis-supported coin. Opt-in via Settings so a +// privacy-conscious user isn't quietly telling ANY oracle when Aegis is +// open. Source is user-selectable — different oracles trade off privacy, +// coverage, and freshness: // -// Cache is in-memory (returned by fullState() → panel). The addon polls -// every 5 min while enabled; each fetch is cheap (~200 B response) and -// the free tier tolerates one call/5 min per client easily. +// - coingecko : one HTTP request covers all 7 coins, best coverage, +// default. Sees the browser IP + User-Agent every poll. +// - 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 -// + a User-Agent every poll. Not seed-linked, not address-linked, but a -// data point. Off by default. +// New sources plug in by adding an entry to SOURCES. Each provider takes a +// list of chain keys and returns { : usd } for the ones it knows +// 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 = { - bch: "bitcoin-cash", - btc: "bitcoin", - trx: "tron", - eth: "ethereum", - sol: "solana", - sc: "siacoin", - dgb: "digibyte", +const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"]; + +const SOURCES = { + coingecko: { + id: "coingecko", + label: "CoinGecko", + origin: "api.coingecko.com", + 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//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 POLL_MS = 5 * 60 * 1000; +const DEFAULT_SOURCE = "coingecko"; module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { const state = { enabled: false, + source: DEFAULT_SOURCE, prices: {}, // { : usd (number) } fetchedAt: null, error: null, @@ -33,26 +110,20 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } }; let timer = null; + function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; } + async function fetchOnce() { if (!state.enabled) return; state.loading = true; state.error = null; onChange(); try { - const ids = Object.values(COIN_GECKO_IDS).join(","); - const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`; - const r = await fetch(url); - if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`); - const body = await r.json(); - const next = {}; - for (const [chain, cgId] of Object.entries(COIN_GECKO_IDS)) { - const usd = body?.[cgId]?.usd; - if (typeof usd === "number") next[chain] = usd; - } - state.prices = next; + const src = currentProvider(); + const next = await src.fetch(); + state.prices = next || {}; state.fetchedAt = Date.now(); state.error = null; } catch (e) { state.error = e?.message || String(e); - log("price fetch failed:", state.error); + log(`price fetch (${state.source}) failed:`, state.error); } finally { state.loading = false; onChange(); @@ -62,22 +133,25 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } function schedule() { clearTimeout(timer); if (!state.enabled) return; - timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); + timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs); } return { - // Snapshot for the panel: only what the UI needs. snapshot() { return { enabled: state.enabled, + source: state.source, prices: state.prices, fetchedAt: state.fetchedAt, error: state.error, 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 - // 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) { const changed = !!on !== state.enabled; state.enabled = !!on; @@ -91,7 +165,15 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } await fetchOnce(); 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(); }, dispose() { clearTimeout(timer); state.enabled = false; }, }; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index ec790a2..5c27a21 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -21,7 +21,7 @@ html, body { margin: 0; height: 100%; } body { background: var(--bg); color: var(--ink); 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; } .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; } @@ -37,10 +37,21 @@ .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 .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); - 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; } + /* Full-panel sheet — fills the sidebar so long wallet lists and the + 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 .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 .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; } @@ -73,6 +84,45 @@ nav button.on { color: var(--acid); border-bottom-color: var(--acid); } nav button:hover { color: var(--ink); } 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; } .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; } @@ -126,6 +176,218 @@ .kv { margin-top: 10px; } .kv .lbl { margin-top: 8px; } 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; } @@ -137,8 +399,9 @@
- - + + +
@@ -160,6 +423,11 @@ + +
@@ -193,7 +461,7 @@
-
Amount
+
Amount
@@ -218,6 +486,104 @@
+ + + diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index d014c78..3d1ce1d 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -13,8 +13,33 @@ let settingsFilled = false; // Selected asset for the Send tab. `null` = native coin. Otherwise a // { mint, symbol, decimals } picked from the SOL wallet's SPL token list. let sendAsset = null; +// Wallet strip's view mode. "coins" is the six-column ticker/chain summary; +// "addresses" replaces it inline with the per-address list under one coin +// group. Toggled via the group row click / the back arrow in the inline +// header. Cleared whenever a fresh render is triggered by a wallet change +// so the strip snaps back to the summary. +let stripView = { mode: "coins", groupKey: null }; +// Cached security state ({ hasPin, requirePinForSending }). Populated on +// startup and refreshed after any pin/security invoke — used both by the +// lock screen (PIN vs. password) and the Settings General card. +let securityState = { hasPin: false, requirePinForSending: false }; +let securityLoaded = false; +// Cached session config: whether the vault stays unlocked across Theseus +// restarts (safeStorage-backed) and how many idle minutes trigger an +// auto-lock. Populated on boot; refreshed after each Settings edit. +let sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true }; +let sessionLoaded = false; +let idleTimer = null; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +// Truncate a label to at most `n` visible chars, appending an ellipsis +// when clipped. Used by the inline coin list so long user labels don't +// blow out the row width; the full name stays available via title="". +const shortLabel = (s, n) => { + const t = String(s ?? "").trim(); + const cap = Math.max(1, n || 7); + return t.length > cap ? t.slice(0, cap) + "…" : t; +}; const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } }; const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {}); const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, ""); @@ -146,8 +171,20 @@ function usdOf(chain, units, decimals) { function fmtFiat(usd) { if (usd == null) return null; if (usd === 0) return "$0.00"; - if (Math.abs(usd) < 0.01) return "< $0.01"; - if (Math.abs(usd) < 10) return "$" + usd.toFixed(2); + const abs = Math.abs(usd); + // Sub-cent coins (SC ~ $0.0007, DGB ~ $0.005) get 3 significant digits so + // users see meaningful movement without the row screaming "< $0.01" at + // every wallet. Keeps trailing zeros trimmed: $0.000756, not $0.0007560. + if (abs < 0.01) { + const sig = usd.toPrecision(3); + const num = Number(sig); + if (num === 0) return "$0"; + // Node.js's toPrecision returns e.g. "0.000756" for tiny numbers, "5.60e-4" + // for extreme. Normalise to a plain fixed string. + const s = /e/i.test(sig) ? num.toFixed(Math.max(0, -Math.floor(Math.log10(abs)) + 2)) : sig; + return "$" + s; + } + if (abs < 10) return "$" + usd.toFixed(2); const int = Math.floor(usd); const frac = Math.abs(usd - int).toFixed(2).slice(1); return "$" + int.toLocaleString("en-US") + frac; @@ -156,6 +193,103 @@ function fiatSkeleton() { return state?.prices?.enabled ? "≈ $—" : null; } +// ---- security: PIN encryption + verification (WebCrypto) ------------------- +// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters) +// derives an AES-GCM key; the master password is encrypted with a fresh +// per-blob IV. The addon (main process) only handles the opaque blob; the +// panel never sends the raw PIN or the master password to it. The rate +// limiter is stored addon-side so reloading the panel cannot reset it. +const PIN_ITERS = 200000; +const PIN_MAX_FAILS = 5; +const PIN_LOCKOUT_MS = 15 * 60 * 1000; +const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; }; +async function pinDeriveKey(pin, saltBytes, iters) { + const enc = new TextEncoder(); + const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} +async function pinEncryptMaster(pin, masterPassword) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await pinDeriveKey(pin, salt, PIN_ITERS); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword))); + return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS }; +} +async function pinDecryptMaster(pin, blob) { + const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct)); + return new TextDecoder().decode(pt); +} +async function pinLockoutRemainingMs() { + try { + const s = await S.invoke("pinFailStatus"); + if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0; + const since = Date.now() - (s.last || 0); + return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since); + } catch { return 0; } +} +async function refreshSecurityState() { + try { + securityState = await S.invoke("securityGet"); + securityLoaded = true; + } catch { securityState = { hasPin: false, requirePinForSending: false }; securityLoaded = true; } + return securityState; +} +async function refreshSessionState() { + try { + sessionState = await S.invoke("sessionStatus"); + sessionLoaded = true; + } catch { + sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true }; + sessionLoaded = true; + } + return sessionState; +} + +// Idle auto-lock. Any user gesture in the panel resets the timer; if the +// user stays quiet for `sessionState.idleMinutes`, Aegis invokes vaultLock +// so a walked-away laptop doesn't leave the wallet unlocked. Wired at +// boot; each config change bounces it via bindIdleAutoLock(). +function bindIdleAutoLock() { + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } + const mins = Number(sessionState.idleMinutes) || 0; + if (mins <= 0) return; + const reset = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(async () => { + // Only lock if the vault is actually open — no point calling lock + // while we're already on the unlock screen. + const s = sel(); + if (!s || s.phase !== "ready") return; + try { + state = await S.invoke("vaultLock"); + stripView = { mode: "coins", groupKey: null }; + render(); + } catch (e) { /* silent — user activity will retry */ } + }, mins * 60 * 1000); + }; + reset(); + // Reset on any deliberate gesture. Passive listeners so scrolling long + // wallet lists doesn't fight the idle timer. + const opts = { passive: true, capture: true }; + const listener = () => reset(); + ["mousedown", "keydown", "touchstart", "focus", "click"].forEach((ev) => document.addEventListener(ev, listener, opts)); + // Store the listener so a later bindIdleAutoLock doesn't stack duplicates. + if (bindIdleAutoLock._prev) { + for (const ev of ["mousedown", "keydown", "touchstart", "focus", "click"]) { + document.removeEventListener(ev, bindIdleAutoLock._prev, opts); + } + } + bindIdleAutoLock._prev = listener; +} + // ---- tabs ------------------------------------------------------------------ document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); @@ -165,41 +299,60 @@ function showTab(name) { document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; }); if (name === "settings") { settingsFilled = false; fillSettings(); } if (name === "send") applyUnitPicker(); - // Settings tab is always usable (fiat prices are a global setting); every - // other tab is gated by the wallet-ready state. Re-run gate visibility so - // switching TO or AWAY FROM Settings while locked does the right thing. - const s = sel(); - const ready = s && s.phase === "ready"; - const onSettings = name === "settings"; - $("tabs").hidden = !(ready || onSettings); - $("gate").hidden = ready || onSettings; + // Settings is the only tab that can be reached while the vault is + // locked. Re-run the full render() so the lock-screen overlay + chrome + // visibility stay in sync with whichever tab the user just picked. + render(); } // ---- wallet picker (two-step add) ------------------------------------------ $("pickerBtn").addEventListener("click", (e) => { - // The "+" chip inside the picker header opens the dropdown with the - // Add-wallet section pre-expanded — same UX as the empty-state gate - // button but always available. - const isAddChip = e.target && (e.target.id === "hAddWallet" || e.target.closest("#hAddWallet")); - const d = $("drop"); - if (isAddChip) { - d.hidden = false; - fillPicker(); - setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0); - e.stopPropagation(); - return; - } - d.hidden = !d.hidden; - if (!d.hidden) fillPicker(); + // The header still doubles as a quick "edit this wallet" click target — + // clicking anywhere on the wallet name/badge opens the manage modal for + // the selected wallet. The dedicated ✎ chip on the right does the same + // thing more explicitly. Clicking either the + Add or ⋯ More chip skips + // this handler because those chips have their own click handlers that + // stopPropagation, so they never accidentally re-open manage. + if (e.target && e.target.closest("#hAdd, #hMore")) return; + const sel_ = sel(); + if (!sel_) return; + const w = (state?.wallets || []).find((x) => x.id === state.selectedWalletId); + if (!w) return; + openWalletManageModal(w); + e.stopPropagation(); +}); +// + Add and ⋯ More chips moved from the wallet strip into the header +// (0.6.31). Same handlers as before — fillPicker for the Add-only picker, +// openMoreMenu for Import/Connect/About. Each stopsPropagation so the +// outer pickerBtn click doesn't also fire "manage this wallet". +$("hAdd").addEventListener("click", (e) => { + e.stopPropagation(); + pickerTab = "add"; + const d = $("drop"); d.hidden = false; + fillPicker(); +}); +$("hMore").addEventListener("click", (e) => { + e.stopPropagation(); + openMoreMenu(); }); document.addEventListener("click", (e) => { const d = $("drop"); if (d.hidden) return; - if (e.target.closest("#drop") || e.target.closest("#pickerBtn")) return; + // Also whitelist the always-visible wallet strip so its + / ⋯ buttons — + // which run fillPicker() and detach themselves during the render — don't + // trigger the outer "click outside → close" logic. Before this whitelist + // the Add button appeared broken because the picker opened and immediately + // closed in the same event tick. + if (e.target.closest("#drop") || e.target.closest("#pickerBtn") || e.target.closest("#walletStrip")) return; d.hidden = true; }); +// Which picker tab is showing. Persisted in the picker instance state so a +// user who opens the picker → picks Import → cancels → reopens returns to +// Wallets (the sane default). +let pickerTab = "wallets"; + function fillPicker() { const d = $("drop"); const wallets = state?.wallets || []; @@ -210,16 +363,25 @@ function fillPicker() { ? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString() : (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0; const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—"; - // Fiat sits on a second line under the native balance, right-aligned. - // Testnet coins mirror mainnet prices, so we don't dim them. const usd = usdOf(w.chain, totalUnits, w.decimals); const fiat = fmtFiat(usd); const fiatLine = fiat ? `
${esc(fiat)}
` : ""; const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`; - return `
- ${logoSvg(w.logo, 22)} -
${esc(w.label)}
${sub}
-
${esc(bal)}
${fiatLine}
+ const importedTag = w.kind === "imported" ? ` IMPORTED` : ""; + // Derivation path under the balance — one of the most requested pieces of + // info for anyone verifying an address against another wallet. Legacy / + // isDefault wallets can't be removed (they gate legacy funds). + const pathLine = w.accountPath ? `
${esc(w.accountPath)}
` : ""; + const menu = w.isLegacy || w.isDefault + ? `🔒` + : ``; + return `
+
+ ${logoSvg(w.logo, 22)} +
${esc(w.label)}${importedTag}
${sub}
${pathLine}
+
${esc(bal)}
${fiatLine}
+
+ ${menu}
`; }).join(""); // "Add wallet" is a two-step flyout: first show coins, then that coin's @@ -240,21 +402,74 @@ function fillPicker() {
`).join("")} `; }).join(""); - d.innerHTML = - rowsHtml + - `
+ Add wallet
${coinRows}` + - `
↓ Import existing (BCH)
-
- ${logoSvg("bch", 22)} -
Import a BCH wallet
Paste a BIP39 mnemonic + derivation path, or a WIF
-
-
`; + // Three-tab layout: Add (create new) / Import (external) / Connect + // (WizardConnect pairing). The old Wallets tab is gone — the always-visible + // strip above the header owns switching, so the picker no longer needs to + // duplicate that list. Add-only when the picker opens from [+]. + const bchWallets = wallets.filter((w) => w.chain === "bch"); + const wcCount = Object.values(state?.wc || {}).reduce((n, arr) => n + (arr?.length || 0), 0); + if (pickerTab === "wallets") pickerTab = "add"; // migrate any stale default + d.innerHTML = ` +
+ + + + +
+
+
Creates a new wallet derived from your Theseus vault. Pick a coin, then a network.
+ ${coinRows} +
+
+
Load an existing wallet by pasting its BIP39 mnemonic + derivation path, or a WIF private key. Key material is stored encrypted in Theseus's wallet-imports.enc.
+
+ ${logoSvg("aegis", 22)} +
+
Bulk-import from encrypted keystore
+
Deviant chipnet-keystore.json (or any chipnet-keystore/2-encrypted file) — master password unlocks all wallets in one go
+
+
+
+
+ ${logoSvg("aegis", 22)} +
Import a single wallet (any coin)
BIP39 mnemonic + path, or a chain-native private key (WIF / hex / base58)
+
+
+
+
+ ${renderConnectPane(bchWallets)} +
`; + + // Tab switching stays inside the picker — never triggers a state emit. + // stopPropagation because the click re-renders innerHTML: the tab element + // becomes detached, and the outer document handler (which hides the picker + // when a click lands outside #drop) then sees a disconnected target and + // dismisses the whole panel. Same reason the import row needs it below. + d.querySelectorAll("[data-ptab]").forEach((b) => b.addEventListener("click", (e) => { + e.stopPropagation(); + pickerTab = b.dataset.ptab; + fillPicker(); + })); + const closeBtn = d.querySelector("#pickerClose"); + if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; }); + if (pickerTab === "connect") wireConnectPane(); d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => { d.hidden = true; try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); } catch (e) { showErr(cleanErr(e)); } })); + // Per-row "⋯" menu — rename + remove. Removes call the same handler the + // Settings tab uses; a hard confirm gates any accidental click since the + // action is unrecoverable for the wallet's local metadata (funds stay + // on-chain; the pointer is what disappears). + d.querySelectorAll("[data-walletmenu]").forEach((b) => b.addEventListener("click", async (e) => { + e.stopPropagation(); + const id = b.dataset.walletmenu; + const w = (state?.wallets || []).find((x) => x.id === id); + if (!w) return; + openWalletManageModal(w); + })); d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => { // Collapse other coins' network groups; toggle this one. d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; }); @@ -269,53 +484,428 @@ function fillPicker() { try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); } catch (e) { showErr(cleanErr(e)); } })); - const impBtn = $("picker-import-bch"); - if (impBtn) impBtn.addEventListener("click", () => { d.hidden = true; openImportModal("bch"); }); + const impBtn = $("picker-import-single"); + if (impBtn) impBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openImportModal(null); }); + const impKs = $("picker-import-keystore"); + if (impKs) impKs.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openKeystoreImportModal(); }); } // Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label // + category. Backend derives cashaddr and stores signer material in // wallet-imports.enc (design §3.2). Modal is a plain overlay div injected // into the panel body so it works over any tab. -function openImportModal(chain) { +// Manage-wallet modal: rename + derivation path + hard remove. Backend +// handlers already exist (renameWallet, setAccountPath, removeWallet); this +// just gives them a UI in the picker so users don't dive into per-wallet +// Settings for something they view as a top-level action. +function openWalletManageModal(w) { const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:24px"; + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:24px"; + const canRemove = !(w.isDefault || w.isLegacy); + const canSetPath = ["bch", "btc", "dgb"].includes(w.chain); overlay.innerHTML = `
- ${logoSvg("bch", 22)} -
Import a BCH wallet
+ ${logoSvg(w.logo, 22)} +
Manage: ${esc(w.label)}
+ +
+
${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " · testnet" : ""}
+
+
Label
+ +
+ ${canSetPath ? `
+
Derivation path (account)
+ +
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
+
` : ""} + +
+ ${canRemove ? `` : `Default wallet — cannot be removed.`} +
+ + +
+
+
`; + document.body.appendChild(overlay); + const close = () => { try { overlay.remove(); } catch {} }; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + overlay.querySelector("#mwClose").addEventListener("click", close); + overlay.querySelector("#mwCancel").addEventListener("click", close); + overlay.querySelector("#mwSave").addEventListener("click", async () => { + const msg = overlay.querySelector("#mwMsg"); msg.hidden = true; + const nextLabel = overlay.querySelector("#mwLabel").value.trim(); + const nextPath = overlay.querySelector("#mwPath")?.value?.trim(); + try { + if (nextLabel && nextLabel !== w.label) { + state = await S.invoke("renameWallet", { id: w.id, label: nextLabel }); + } + if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) { + state = await S.invoke("setAccountPath", { id: w.id, accountPath: nextPath }); + } + close(); + fillPicker(); + render(); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }); + if (canRemove) overlay.querySelector("#mwRemove").addEventListener("click", async () => { + const msg = overlay.querySelector("#mwMsg"); msg.hidden = true; + if (!confirm(`Remove "${w.label}" from Aegis?\n\nOn-chain funds stay where they are — this only unlinks the wallet from Aegis. Add it back later on the same coin + network to derive the same addresses (${w.kind === "imported" ? "or re-import if this was imported" : "from your vault seed"}).`)) return; + try { + state = await S.invoke("removeWallet", { id: w.id }); + close(); + fillPicker(); + render(); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }); +} + +// Master-key bulk import (MASTER-KEY-INTEGRATION.md §7.1). +// The user picks a chipnet-keystore/2-encrypted JSON file + types the master +// password. Decrypt runs entirely in the panel iframe via SubtleCrypto; the +// password never crosses IPC or the network. Preview shows cashaddr + label +// + category for each entry; user picks with checkboxes and hits Import. +// Rate limit: 5 fails / 60 s → 30 s lockout (§8.6). Chipnet-only (§8.7 — +// rejects `bitcoincash:` prefixes silently). + +let keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 }; + +function hexToBytesU8(h) { + const s = String(h || ""); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; +} + +async function unlockKeystoreV2(encryptedJson, passphrase) { + if (encryptedJson.spec !== "chipnet-keystore/2-encrypted") { + throw new Error("wrong password"); // opaque — actual reason is bad file + } + const enc = new TextEncoder(); + const salt = hexToBytesU8(encryptedJson.kdf.salt); + const iv = hexToBytesU8(encryptedJson.encryption.iv); + const cipherAll = hexToBytesU8(encryptedJson.ciphertext); + const passKey = await crypto.subtle.importKey("raw", enc.encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"]); + const aesKey = await crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: encryptedJson.kdf.iterations, hash: "SHA-256" }, + passKey, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); + const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipherAll); + return JSON.parse(new TextDecoder().decode(ptBuf)); +} + +function openKeystoreImportModal() { + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px"; + overlay.innerHTML = ` +
+
+ ${logoSvg("aegis", 22)} +
Bulk-import from encrypted keystore
+ +
+
+ Chipnet only. Master password never leaves this panel — it decrypts the file locally via WebCrypto. Every imported wallet lands in Theseus's wallet-imports.enc, no plaintext on disk. +
+ +
+
Keystore file
+ +
Typically Deviant/Keys/chipnet-keystore.json. Any chipnet-keystore/2-encrypted file works.
+
+ +
+
Master password
+ +
+ + + + +
+ +
+ + +
+
+
`; + document.body.appendChild(overlay); + const close = () => { try { overlay.remove(); } catch {} }; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + overlay.querySelector("#ksClose").addEventListener("click", close); + overlay.querySelector("#ksCancel").addEventListener("click", close); + + // Loaded keystore file (parsed JSON) and the decrypted plaintext once + // the user unlocks it. Kept in this closure so nothing hits IPC. + let loadedFile = null; + let decrypted = null; + const setMsg = (t, cls = "err") => { + const el = overlay.querySelector("#ksMsg"); + if (!t) { el.hidden = true; return; } + el.className = "msg " + cls; el.textContent = t; el.hidden = false; + }; + + overlay.querySelector("#ksFile").addEventListener("change", async (e) => { + setMsg(""); + const file = e.target.files?.[0]; if (!file) { loadedFile = null; return; } + if (file.size > 512 * 1024) { setMsg("File is too large for a keystore (>512 KB)."); loadedFile = null; return; } + try { + const text = await file.text(); + loadedFile = JSON.parse(text); + if (loadedFile?.spec !== "chipnet-keystore/2-encrypted") { + setMsg("File is not a chipnet-keystore/2-encrypted."); loadedFile = null; return; + } + } catch (er) { setMsg("File is not valid JSON."); loadedFile = null; } + }); + + overlay.querySelector("#ksUnlock").addEventListener("click", async () => { + setMsg(""); + // Rate-limit check first (§8.6). + const now = Date.now(); + if (keystoreUnlockFails.lockedUntil && now < keystoreUnlockFails.lockedUntil) { + const secs = Math.ceil((keystoreUnlockFails.lockedUntil - now) / 1000); + setMsg(`Too many failed attempts — try again in ${secs}s.`); return; + } + if (!loadedFile) { setMsg("Pick a keystore file first."); return; } + const pass = overlay.querySelector("#ksPass").value; + if (!pass) { setMsg("Enter the master password."); return; } + const btn = overlay.querySelector("#ksUnlock"); + btn.disabled = true; const orig = btn.textContent; btn.textContent = "Decrypting…"; + try { + decrypted = await unlockKeystoreV2(loadedFile, pass); + // Reset failure counter on success (§8.6). + keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 }; + renderKeystorePreview(overlay, decrypted); + } catch (err) { + // Opaque error (§8.5). Track failure for rate-limit. + if (!keystoreUnlockFails.firstAt || now - keystoreUnlockFails.firstAt > 60_000) { + keystoreUnlockFails = { count: 1, firstAt: now, lockedUntil: 0 }; + } else { + keystoreUnlockFails.count++; + if (keystoreUnlockFails.count >= 5) { + keystoreUnlockFails.lockedUntil = now + 30_000; + setMsg("5 failed attempts. Locked for 30 seconds."); + } + } + if (!keystoreUnlockFails.lockedUntil) setMsg("Wrong password."); + } finally { btn.disabled = false; btn.textContent = orig; } + }); + + overlay.querySelector("#ksImport").addEventListener("click", async () => { + setMsg(""); + const rows = [...overlay.querySelectorAll("[data-ksrow]")].filter((r) => r.querySelector("input[type=checkbox]").checked); + if (!rows.length) { setMsg("Select at least one wallet to import."); return; } + const btn = overlay.querySelector("#ksImport"); + btn.disabled = true; const orig = btn.textContent; btn.textContent = "Importing…"; + let ok = 0, skipped = 0, errors = []; + for (const row of rows) { + const slug = row.dataset.ksrow; + const entry = decrypted?.wallets?.[slug]; + if (!entry) { errors.push(`${slug}: missing in decrypted payload`); continue; } + // Chipnet-only guard (§8.7). Refuse mainnet. + if (String(entry.cashaddr || "").startsWith("bitcoincash:")) { skipped++; continue; } + if (!String(entry.cashaddr || "").startsWith("bchtest:")) { skipped++; continue; } + const spec = { chain: "bch", network: "chipnet", label: entry.label || slug, + category: entry.category || "operational", + source: entry.source || `keystore-bulk-import#${slug}` }; + if (entry.wif) { + spec.wif = entry.wif; + } else if (entry.seed) { + // Deviant's keystore stores `seed` as either raw hex (fromMasterSeed + // path) or a BIP39 mnemonic (word list). Route based on shape. + const s = String(entry.seed).trim(); + if (/^[0-9a-f]{64,128}$/i.test(s)) { spec.seedHex = s; spec.path = entry.path; } + else { spec.mnemonic = s; spec.path = entry.path; } + } else { errors.push(`${slug}: no wif or seed`); continue; } + try { + await S.invoke("importWallet", spec); + ok++; + } catch (er) { + const msg = cleanErr(er); + // Duplicate imports are non-errors: user re-ran on the same file. + if (/duplicate/i.test(msg)) { skipped++; continue; } + errors.push(`${slug}: ${msg}`); + } + } + btn.textContent = orig; btn.disabled = false; + if (errors.length) { setMsg(`Imported ${ok}, ${skipped} skipped (mainnet). ${errors.length} error(s): ${errors.slice(0, 3).join("; ")}${errors.length > 3 ? "…" : ""}`); } + else if (ok) { + // Session pw is dropped when the overlay closes; we don't hold it. + close(); + // Refresh panel state so the wallet strip shows the new imports. + try { state = await S.invoke("state"); render(); } catch {} + } else { setMsg(`Nothing imported${skipped ? ` — ${skipped} mainnet entries skipped (chipnet-only)` : ""}.`); } + }); +} + +function renderKeystorePreview(overlay, plain) { + const wallets = plain?.wallets || {}; + const entries = Object.entries(wallets).map(([slug, w]) => ({ + slug, cashaddr: String(w.cashaddr || ""), label: w.label || slug, + category: w.category || "operational", kind: w.wif ? "wif" : (w.seed ? "seed" : "?"), + })); + const chipnet = entries.filter((e) => e.cashaddr.startsWith("bchtest:")); + const mainnet = entries.filter((e) => e.cashaddr.startsWith("bitcoincash:")); + const el = overlay.querySelector("#ksList"); + el.innerHTML = chipnet.map((e) => ``).join(""); + const meta = `${chipnet.length} chipnet wallets available.` + (mainnet.length ? ` ${mainnet.length} mainnet entries hidden (chipnet-only import).` : ""); + overlay.querySelector("#ksPreviewMeta").textContent = meta; + overlay.querySelector("#ksPreview").hidden = false; + overlay.querySelector("#ksUnlock").hidden = true; + overlay.querySelector("#ksImport").hidden = false; + overlay.querySelector("#ksFileField").style.display = "none"; + overlay.querySelector("#ksPassField").style.display = "none"; + overlay.querySelector("#ksSelAll").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = true)); + overlay.querySelector("#ksSelNone").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = false)); + overlay.querySelector("#ksSelBns").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => { + const cat = r.querySelector(".ttag")?.textContent || ""; + r.querySelector("input[type=checkbox]").checked = cat === "bns" || cat === "bns-infra"; + })); + overlay.querySelector("#ksSelOps").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => { + const cat = r.querySelector(".ttag")?.textContent || ""; + r.querySelector("input[type=checkbox]").checked = cat === "operational"; + })); +} + +// Multi-chain import config — drives the form shape per coin. Every entry +// declares: label / logo / networks (with default derivation path) / +// key-material formats accepted / placeholder for the raw-key input. +const IMPORT_COIN_CONFIG = { + bch: { + label: "Bitcoin Cash", logo: "bch", + networks: [ + { id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/1'/0'/0/0", testnet: true }, + { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" }, + ], + }, + btc: { + label: "Bitcoin", logo: "btc", + // Testnet3 is de facto abandoned (blocks stall for weeks, faucets + // dried up); Signet is Bitcoin's living testnet now. Only Signet is + // exposed to new imports. The testnet3 adapter is kept in + // lib/chain-btc.js so any wallet created on an earlier version still + // loads — it just no longer appears in the picker. + networks: [ + { id: "mainnet", label: "Mainnet", defaultPath: "m/84'/0'/0'/0/0" }, + { id: "signet", label: "Signet", defaultPath: "m/84'/1'/0'/0/0", testnet: true }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" }, + ], + }, + dgb: { + label: "DigiByte", logo: "dgb", + networks: [ + { id: "mainnet", label: "Mainnet", defaultPath: "m/84'/20'/0'/0/0" }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "wif", label: "WIF private key", placeholder: "L… / K… (base58check)" }, + ], + }, + eth: { + label: "Ethereum", logo: "eth", + networks: [ + { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/60'/0'/0/0" }, + { id: "sepolia", label: "Sepolia", defaultPath: "m/44'/60'/0'/0/0", testnet: true }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" }, + ], + }, + trx: { + label: "Tron", logo: "trx", + networks: [ + { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/195'/0'/0/0" }, + { id: "nile", label: "Nile testnet", defaultPath: "m/44'/195'/0'/0/0", testnet: true }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" }, + ], + }, + sol: { + label: "Solana", logo: "sol", + networks: [ + { id: "mainnet", label: "Mainnet-beta", defaultPath: "m/44'/501'/0'/0'" }, + { id: "devnet", label: "Devnet", defaultPath: "m/44'/501'/0'/0'", testnet: true }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic + path" }, + { id: "privHex", label: "Private key (hex)", placeholder: "32 or 64 bytes hex" }, + { id: "privB58", label: "Private key (base58)", placeholder: "Phantom / Solflare export" }, + ], + }, +}; + +function openImportModal(initialChain) { + const chains = Object.keys(IMPORT_COIN_CONFIG); + let curChain = chains.includes(initialChain) ? initialChain : "bch"; + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px"; + overlay.innerHTML = ` +
+
+ +
Import a wallet
Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.
+
+
Coin
+ +
Network
-
- - -
+
Source
-
- - -
+
Mnemonic (12/24 words)
Derivation path
- -
Default: m/44'/1'/0'/0/0 for chipnet, m/44'/145'/0'/0/0 for mainnet.
+ +
-