diff --git a/addons-host.js b/addons-host.js index 3c384486..6ff1606d 100644 --- a/addons-host.js +++ b/addons-host.js @@ -1,752 +1,782 @@ -// Theseus add-on framework — loader + API surface. -// -// Add-ons live in /extensions// as ordinary folders on disk. Each -// carries an `addon.json` manifest and (per the manifest's `main` field) a -// CommonJS entry that exports `activate(api)` and optionally `deactivate()`. -// Nothing about an add-on ships in the Theseus repo or installer — drop a -// folder, restart Theseus, it's live. This is the same trust model as -// dev-mode browser extensions: the user is choosing to run local code with -// the app's full privileges. -// -// Loading is synchronous at app-ready time; there is no hot-reload. Failed -// activations are logged and skipped without breaking the app. -// -// Persistence: -// settings.disabledAddons — ids the user has toggled off -// /extensions-data/.json — per-add-on kv store (api.storage) - -const fs = require("node:fs"); -const path = require("node:path"); - -// Extension points the framework understands. Extending this list means also -// teaching main.js and (typically) the chrome renderer about the new point. -// Right now only sidebar panels are wired — future rev adds toolbar-chip, -// proxy, page-inject, etc. -const KNOWN_CAPABILITIES = new Set([ - "sidebar-panel", "session-proxy", - // vault-derive: api.vault.derive(purposePath) — HKDF child of the password - // vault's root, namespaced under the add-on id. - // page-inject: manifest["page-inject"] = { preload, origins } — the add-on's - // preload source runs in the isolated world of every tab whose - // URL matches one of the origin patterns. - // approval-modal: api.approvalModal({...}) — user-facing consent dialog over - // the active tab, resolved by main. - // capture-tab: api.captureTab({mode, ...}) + api.saveCapture({dataUrl, filename}) - // — snapshot the active tab (visible viewport / full page / - // user-drawn rectangle) and save the result through the app's - // downloads pipeline. The add-on sees pixels of whatever the - // current tab is showing, so this is the same trust bar as a - // page-inject add-on that matches "*://*/*". - // toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] } - // — chrome renders a dropdown under the add-on's dock icon; - // picking an item dispatches "menu-select" with {id} to the - // add-on's onMessage("menu-select", …) handler. - // open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL - // this stays available without the capability (legacy). - // Declaring "open-tab" additionally lets the add-on open - // one of its OWN HTML files as a full Theseus tab, with a - // lean preload so the page can keep talking to the add-on - // via window.silentmode.invoke(). - "vault-derive", "page-inject", "approval-modal", "capture-tab", - "toolbar-menu", "open-tab", - // context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}] - // — chrome merges these into every tab's right-click - // menu, filtered by `when` (selectionText | linkURL | - // editable | image | always). Picking one dispatches - // "context-menu" with the item id + the surrounding - // context (selection text, link URL, media info, host) - // to the add-on's onMessage handler. Add-ons that also - // declare "sidebar-panel" typically follow up with - // api.revealSidebar(panelId) to surface the result. - "context-menu-item", -]); - -// Chrome-style match pattern → predicate. ":///" where -// scheme may be "*", host may start with "*." (matches the bare host and any -// subdomain) or be "*", and path is a glob where "*" matches anything. -// bns:// (how Theseus fetches BCNR sites internally) is folded into https:// -// so a pattern written the way the address bar shows it keeps working. -function compileOriginPattern(pattern) { - const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim()); - if (!m) throw new Error(`bad origin pattern: ${pattern}`); - const [, scheme, host, pathGlob = "/*"] = m; - const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase()); - let hostRe; - if (host === "*") hostRe = "[^/]+"; - else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`; - else hostRe = esc(host.toLowerCase()); - const pathRe = pathGlob.split("*").map(esc).join(".*"); - const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i"); - return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://")); -} -function urlMatchesAny(url, matchers) { - for (const fn of matchers) { try { if (fn(url)) return true; } catch {} } - return false; -} - -// Manifest field guardrails. Reject anything shape-suspicious so a bad -// addon.json can't get past the loader gate. -function validateManifest(raw, folderName) { - const m = raw && typeof raw === "object" ? raw : {}; - const id = String(m.id || "").trim(); - if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) { - throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`); - } - const name = String(m.name || id); - const version = String(m.version || "0.0.0"); - const description = String(m.description || ""); - const author = String(m.author || ""); - const icon = String(m.icon || "🧩"); - const main = String(m.main || "index.js"); - if (main.includes("..") || path.isAbsolute(main)) { - throw new Error(`addon "${id}": main must be a relative path inside the addon folder`); - } - const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : []; - for (const cap of capabilities) { - if (!KNOWN_CAPABILITIES.has(cap)) { - // Not fatal — log later. Unknown caps are silently dropped. - } - } - let pageInject = null; - if (capabilities.includes("page-inject")) { - const pi = m["page-inject"]; - if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`); - const preload = String(pi.preload || ""); - if (!preload || preload.includes("..") || path.isAbsolute(preload)) { - throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`); - } - const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : []; - if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`); - pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) }; - } - let toolbarMenu = null; - if (capabilities.includes("toolbar-menu")) { - const tm = m["toolbar-menu"]; - if (!tm || typeof tm !== "object") { - throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`); - } - const items = Array.isArray(tm.items) ? tm.items : []; - if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`); - const seen = new Set(); - const cleanItems = items.map((it, idx) => { - const iid = String(it && it.id || "").trim(); - if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { - throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`); - } - if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`); - seen.add(iid); - const label = String(it.label || iid); - const itemIcon = it.icon == null ? "" : String(it.icon); - return { id: iid, label, icon: itemIcon }; - }); - toolbarMenu = { - title: tm.title == null ? name : String(tm.title), - icon: tm.icon == null ? icon : String(tm.icon), - items: cleanItems, - }; - } - const contextMenuItems = []; - if (capabilities.includes("context-menu-item")) { - const rawItems = m["context-menu-items"]; - if (!Array.isArray(rawItems) || !rawItems.length) { - throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`); - } - const seen = new Set(); - const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]); - for (let idx = 0; idx < rawItems.length; idx++) { - const it = rawItems[idx]; - const iid = String(it && it.id || "").trim(); - if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { - throw new Error(`addon "${id}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`); - } - if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`); - seen.add(iid); - const when = String(it.when || "always"); - if (!ALLOWED_WHEN.has(when)) { - throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`); - } - contextMenuItems.push({ - id: iid, - label: String(it.label || iid), - when, - icon: it.icon == null ? "" : String(it.icon), - }); - } - } - // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on - // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so - // funds derived under the old id's paths stay reachable through the new - // one. Each entry is validated as an id itself and gates vault.derive by - // (own id OR one of these) in makeApi below. - const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; - for (const a of absorbs) { - if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { - throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); - } - if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); - } - // 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, contextMenuItems, 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, checkAndStageUpdates, restartApp, revealSidebar }) { - this.addonsDir = addonsDir; - this.dataDir = dataDir; - this.isDisabled = isDisabled || (() => false); - this.log = logger || ((...a) => console.log("[addons]", ...a)); - this._installed = []; // [{ manifest, folder, error? }] - this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject } - // Capability hooks injected by main. Each is (args..., addonId) so main - // can log/gate per add-on. Missing hook = capability unavailable. - this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; - // vaultImports: main-process shim {list, add, remove, signer} that owns - // wallet-imports.enc. Same trust tier as vaultDerive — an add-on that - // holds vault-derive can also see imports (design §3.2 co-tenancy). - this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null; - // vaultLifecycle: main-process shim {status, setup, unlock, lock} so the - // wallet add-on can drive vault setup/unlock without redirecting users - // to Settings > Passwords. Same "vault-derive" capability gate. - this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" - ? arguments[0].vaultLifecycle : null; - this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; - this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; - // Add-ons live outside the app's node_modules tree, so a bare require() - // from their folder can't see Theseus's deps (ws, @noble/*, …). Main - // hands us its own require so add-ons can share the bundled tree. - this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; - // ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's - // Node; hostImport resolves them from the app tree and import()s them. - this._hostImport = typeof hostImport === "function" ? hostImport : null; - this._openTab = typeof openTab === "function" ? openTab : null; - // open-tab: opens one of the add-on's own HTML files as a full Theseus tab. - // Signature: (addonId, relPath, queryString) => Promise. - this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null; - // openSettings: opens Theseus's Settings tab, optionally scrolled to a - // named section (e.g. "passwords"). Uses the same IPC route the picker - // uses for "Search settings…". Signature: (section?: string) => void. - this._openSettings = typeof openSettings === "function" ? openSettings : null; - // 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; - // revealSidebar: opens the sidebar and switches to the given panel id. - // Signature: (addonId, panelId) => void. Add-ons use this from their - // context-menu handler to surface a result in their sidebar UI. - this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : 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 - // A `null` rule clears the proxy. Kept as a callback rather than requiring - // the loader itself import electron. - this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null; - // capture-tab hooks — main captures/saves; the loader only enforces the - // manifest gate. - this._captureTab = typeof captureTab === "function" ? captureTab : null; - this._saveCapture = typeof saveCapture === "function" ? saveCapture : null; - // api.whenUiReady() plumbing — see signalUiReady(). - this._uiReady = false; - this._uiReadyWaiters = []; - } - - // main calls this once the browser chrome has painted. Add-ons that pull - // in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth, - // WizardConnect) gate that work on api.whenUiReady() so module evaluation - // doesn't land on the main thread while chrome.html is still trying to - // paint. Sticky: a later discoverAndActivate() resolves immediately. - signalUiReady() { - this._uiReady = true; - for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} } - } - - ensureDirs() { - for (const d of [this.addonsDir, this.dataDir]) { - try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); } - } - } - - // api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same - // "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from - // its own gate instead of redirecting users to Settings > Passwords. - _makeLifecycleApi(manifest) { - const requireCap = () => { - if (!manifest.capabilities.includes("vault-derive")) { - throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); - } - if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); - }; - return { - status: async () => { requireCap(); return this._vaultLifecycle.status(); }, - unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, - setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, - lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, - }; - } - - // api.vault.imports namespace factory. Gated by the "vault-derive" cap - // because the two surfaces sit at the same trust tier (design §3.2). If - // main didn't wire the vaultImports shim, calls throw a clear error. - _makeImportsApi(manifest) { - const requireCap = () => { - if (!manifest.capabilities.includes("vault-derive")) { - throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); - } - if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); - }; - return { - list: async () => { requireCap(); return this._vaultImports.list(); }, - add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); }, - remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); }, - signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); }, - }; - } - - discoverAndActivate() { - this.ensureDirs(); - this._deactivateAll(); - this._installed = []; - let entries = []; - try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } - for (const dirent of entries) { - if (!dirent.isDirectory()) continue; - const folder = path.join(this.addonsDir, dirent.name); - try { - const manifest = this._readManifest(folder, dirent.name); - this._installed.push({ manifest, folder }); - if (this.isDisabled(manifest.id)) { - this.log(`skipping disabled add-on ${manifest.id}`); - continue; - } - this._activateOne(manifest, folder); - } catch (e) { - this.log(`failed to load ${dirent.name}: ${e?.message || e}`); - // A manifest that parsed but whose activate() threw was already - // pushed above — replace it rather than listing the add-on twice. - const i = this._installed.findIndex((x) => x.folder === folder); - const entry = { manifest: null, folder, error: String(e?.message || e) }; - if (i >= 0) this._installed[i] = entry; else this._installed.push(entry); - } - } - return this.snapshot(); - } - - _readManifest(folder, folderName) { - const p = path.join(folder, "addon.json"); - const raw = JSON.parse(fs.readFileSync(p, "utf8")); - return validateManifest(raw, folderName); - } - - _activateOne(manifest, folder) { - const mainPath = path.join(folder, manifest.main); - // require() from a folder outside asar is fine — Electron just uses Node's - // resolver. This is where the trust decision lives: we're loading arbitrary - // JS into the main process with full API access. - let mod; - try { - // Bust the require cache so a manual reload (future feature) picks up - // edits — cheap since add-ons are small. - delete require.cache[require.resolve(mainPath)]; - mod = require(mainPath); - } catch (e) { - throw new Error(`require() failed: ${e?.message || e}`); - } - if (!mod || typeof mod.activate !== "function") { - throw new Error(`main file must export an activate(api) function`); - } - const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null }; - if (manifest.pageInject) { - // Read the inject source once at activation. It's shipped to every - // matching tab's preload verbatim, so a syntax error surfaces in the - // tab's console, not here — but a missing file is fatal for the add-on. - const abs = path.join(folder, manifest.pageInject.preload); - let source; - try { source = fs.readFileSync(abs, "utf8"); } - catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); } - active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; - } - const api = this._makeApi(active); - try { mod.activate(api); } - catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } - this._active.set(manifest.id, active); - this.log(`activated ${manifest.id} v${manifest.version}`); - } - - // Tear down every active add-on before a re-discover so long-lived state - // (sockets, timers) from a previous activation doesn't pile up. - _deactivateAll() { - for (const [id, active] of this._active) { - try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); } - catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); } - } - this._active.clear(); - } - - _makeApi(active) { - const { manifest, folder } = active; - const storageFile = path.join(this.dataDir, `${manifest.id}.json`); - return { - // Metadata the add-on may want to reflect on - id: manifest.id, - folder, - // Per-extension data folder (/extensions-data): where the kv - // store lives and where an add-on should keep scratch files rather - // than guessing the path from its own folder. - dataDir: this.dataDir, - log: (...a) => this.log(`[${manifest.id}]`, ...a), - // Persistent per-add-on storage. Small kv JSON on disk. - storage: { - get: (key, fallback = null) => { - try { - const raw = JSON.parse(fs.readFileSync(storageFile, "utf8")); - return key in raw ? raw[key] : fallback; - } catch { return fallback; } - }, - set: (key, value) => { - let store = {}; - try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {} - store[key] = value; - try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); } - }, - all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } }, - }, - // Register a sidebar panel — a right-side WebContentsView that hosts - // one of the add-on's HTML pages. `page` is a path RELATIVE to the - // add-on folder. `title` shows in the sidebar tab strip. `icon` is - // a short emoji/glyph. - registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => { - if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); - const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); - if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); - // Namespaced id so two add-ons can't collide. - const panelId = `${manifest.id}:${id}`; - active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id }); - this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); - }, - // Programmatically open the sidebar and switch to one of THIS add-on's - // panels. `panelId` is the un-namespaced id passed to registerSidebarPanel - // (e.g. "main"); host prefixes with the add-on id under the hood. Used - // by context-menu handlers to surface a result in the sidebar. No-op if - // the add-on doesn't own that panel or the host isn't wired. - revealSidebar: (panelId) => { - if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; } - const pid = String(panelId || "").trim(); - const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`; - const owned = active.sidebarPanels.some((p) => p.panelId === full); - if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; } - this._revealSidebar(manifest.id, full); - }, - // Swap the default session's proxy. Rules follow Chromium's proxy - // format ("socks5://1.2.3.4:1080" for a single SOCKS server, - // "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.). - // Pass null to clear. Add-ons that opt into this capability are - // fully replacing the browser's outgoing network path — they're - // the trust boundary while active. Same-signature as the built-in - // Tor toggle uses under the hood. - setSessionProxy: async (rules) => { - if (!this._setSessionProxy) { - this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`); - return; - } - if (!manifest.capabilities.includes("session-proxy")) { - throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`); - } - await this._setSessionProxy(rules, manifest.id); - }, - // Resolve a module from Theseus's own dependency tree. Add-ons run with - // the app's full privileges anyway; this only saves them from shipping - // a second copy of ws / @noble / etc. - require: (name) => { - if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`); - return this._hostRequire(name); - }, - // Same, for ES-module-only packages: resolves to a Promise of the - // module namespace. - import: async (name) => { - if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`); - return this._hostImport(name); - }, - // Open a new Theseus tab. Two shapes: - // - api.openTab("https://…") — no capability needed - // - api.openTab("editor.html", { query: {...} }) — opens one of the - // add-on's OWN files as a full tab; requires the "open-tab" cap. - // Path is resolved inside the add-on folder and rejected if it - // escapes it (path traversal). Query is URL-encoded. The page - // loads under addon-tab-preload.js so window.silentmode.invoke() - // reaches the same handlers as a sidebar panel — main gates by - // sender URL so a page hosted anywhere else gets nothing back. - openTab: (pathOrUrl, opts) => { - const s = String(pathOrUrl || ""); - // Bare http(s) URL with no opts — legacy behaviour, unchanged. - if (/^https?:\/\//i.test(s) && !opts) { - if (!this._openTab) throw new Error("openTab unavailable (host not wired)"); - this._openTab(s, manifest.id); - return; - } - if (!manifest.capabilities.includes("open-tab")) { - throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`); - } - if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)"); - if (!s || path.isAbsolute(s) || s.includes("..")) { - throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`); - } - let qs = ""; - if (opts && opts.query && typeof opts.query === "object") { - const usp = new URLSearchParams(); - for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v)); - qs = usp.toString(); - } - return this._openAddonTab(manifest.id, s, qs); - }, - // Open Theseus's Settings tab, optionally scrolled to a named section - // (validated against a known list in main). No capability needed — - // it's the same thing the user could do from the ⋮ menu, just a - // one-click shortcut so add-ons can point users at the right place - // (e.g. Aegis's "Set up vault" gate → Passwords). - openSettings: (section) => { - if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); - this._openSettings(typeof section === "string" ? section : ""); - }, - // 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, - }; - }, - // Ask Theseus to relaunch (the plug-in card's "apply update" chip). - // The host asks the user first and resolves { restarted, deferred } - // — an add-on cannot restart the browser on its own. - restartApp: () => { - if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); - return Promise.resolve(this._restartApp(manifest.name || manifest.id)); - }, - // 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. - whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))), - // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, - // injected page bridges) call into the add-on with a message name + - // one JSON payload; the handler's return value goes back as the - // response. `ctx.from` is "panel" or "page"; pages also carry - // `ctx.origin` ("https://host") so the add-on can scope permissions. - onMessage: (msg, handler) => { - if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`); - active.handlers.set(msg, handler); - }, - // Push an event to the add-on's own sidebar panel if it's currently - // loaded. Fire-and-forget; silently dropped when the panel is closed. - emit: (msg, payload) => { - if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); - }, - // vault-derive: a 32-byte HKDF child of the password vault's root, - // keyed by a path that MUST start with this add-on's id — or one of - // the ids it declared under `absorbs` in addon.json, so a superseding - // add-on can keep deriving the same keys as the add-on it replaced - // (funds stay reachable across the transition). Resolves only once - // the user has unlocked the vault (main polls; the await can be long). - vault: { - derive: async (purposePath) => { - if (!manifest.capabilities.includes("vault-derive")) { - throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); - } - if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); - const p = String(purposePath || ""); - if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { - throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); - } - const allowed = [manifest.id, ...(manifest.absorbs || [])]; - if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { - const list = allowed.length > 1 - ? `one of "${allowed.join('", "')}"` - : `"${manifest.id}"`; - throw new Error(`vault.derive: purposePath must start with ${list} + "/"`); - } - return this._vaultDerive(p, manifest.id); - }, - imports: this._makeImportsApi(manifest), - lifecycle: this._makeLifecycleApi(manifest), - }, - // approval-modal: ask the user. Resolves to the chosen action id, or - // "cancel" (Escape / mask click / window closed). With `checkbox` set - // and ticked, the id comes back suffixed "+"; with - // `select` {id, label, options:[{value,label}]} and a non-empty value - // chosen, "+=". - approvalModal: async (opts) => { - if (!manifest.capabilities.includes("approval-modal")) { - throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); - } - if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); - return this._approvalModal(opts || {}, manifest.id); - }, - // capture-tab: snapshot the currently-active tab. - // opts.mode "visible" | "full" | "region" (required) - // opts.format "png" | "jpeg" (default "png") - // opts.quality 1-100 (jpeg only, default 90) - // opts.overlaySource string (region only — DOM - // code the add-on wants injected while the user - // drags a selection. Must resolve to `{x,y,w,h}` - // in CSS pixels; return null/undefined to cancel.) - // Resolves to `{ dataUrl, width, height, host, format }`. - captureTab: async (opts) => { - if (!manifest.capabilities.includes("capture-tab")) { - throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); - } - if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`); - return this._captureTab(opts || {}, manifest.id); - }, - // capture-tab: route an in-memory image into the app's downloads pipeline - // so it lands in the user's Downloads folder AND shows up in the - // download-chip list the same way any HTTP download would. - // opts.dataUrl "data:image/png;base64,…" (required) - // opts.filename filename shown in the chip (required) - // Resolves to `{ savePath }`. - saveCapture: async (opts) => { - if (!manifest.capabilities.includes("capture-tab")) { - throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); - } - if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`); - return this._saveCapture(opts || {}, manifest.id); - }, - }; - } - - // Route a message to an add-on's registered handler. Callers (main) have - // already established WHO is asking; `ctx` carries that provenance. - async dispatch(id, msg, payload, ctx) { - const active = this._active.get(id); - if (!active) throw new Error(`add-on "${id}" is not active`); - const handler = active.handlers.get(String(msg)); - if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); - return handler(payload, ctx || {}); - } - hasHandler(id, msg) { - const active = this._active.get(id); - return !!(active && active.handlers.has(String(msg))); - } - // Inject scripts that apply to a tab URL — [{ id, source }]. - injectionsFor(url) { - const out = []; - for (const active of this._active.values()) { - if (active.inject && urlMatchesAny(url, active.inject.matchers)) { - out.push({ id: active.manifest.id, source: active.inject.source }); - } - } - return out; - } - // Does this add-on's page-inject declaration cover the URL? Used to gate - // page → add-on IPC so a non-matching page can't spoof a matching one. - pageAllowed(id, url) { - const active = this._active.get(id); - return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers)); - } - - // Read-only views for the rest of the app. - snapshot() { - return { - installed: this._installed.map(({ manifest, folder, error }) => ({ - id: manifest?.id ?? null, - name: manifest?.name ?? null, - version: manifest?.version ?? null, - description: manifest?.description ?? "", - 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, - })), - sidebarPanels: this.getSidebarPanels(), - toolbarMenus: this.getToolbarMenus(), - }; - } - // `plugin` marks surfaces of a first-class Silent Mode component (manifest - // category "plugin", e.g. Aegis): the chrome pins those to their own dock - // instead of the extensions row. - getSidebarPanels() { - const out = []; - for (const active of this._active.values()) { - const plugin = active.manifest.category === "plugin"; - for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name }); - } - return out; - } - // Menu declarations from every active add-on that carries a toolbar-menu - // manifest block. Chrome renders one dock button per entry, opens the - // dropdown, then dispatches "menu-select" with the picked item id. - getToolbarMenus() { - const out = []; - for (const active of this._active.values()) { - const tm = active.manifest.toolbarMenu; - if (!tm) continue; - out.push({ - addonId: active.manifest.id, - title: tm.title, - icon: tm.icon, - plugin: active.manifest.category === "plugin", - items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), - }); - } - return out; - } - // Right-click menu items declared by add-ons, filtered by the current - // context. `ctx` is what Electron's context-menu event carries: - // { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL } - // Returns [{ addonId, id, label, icon, when }]. `when` filters: - // selectionText — non-empty text is selected - // linkURL — right-clicked on a link - // editable — right-clicked inside a form control / contenteditable - // image — mediaType === "image" and srcURL is present - // always — every menu - getContextMenuItems(ctx = {}) { - const has = { - selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()), - linkURL: !!ctx.linkURL, - editable: !!ctx.isEditable, - image: ctx.mediaType === "image" && !!ctx.srcURL, - }; - const out = []; - for (const active of this._active.values()) { - const items = active.manifest.contextMenuItems || []; - for (const it of items) { - if (it.when !== "always" && !has[it.when]) continue; - out.push({ addonId: active.manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when }); - } - } - return out; - } - getInstalled() { return this._installed.slice(); } - isActive(id) { return this._active.has(id); } - // Absolute folder of an active add-on, or null. Public so main can resolve - // add-on-relative paths (openAddonTab) without reaching into internals. - folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; } -} - -module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern }; +// Theseus add-on framework — loader + API surface. +// +// Add-ons live in /extensions// as ordinary folders on disk. Each +// carries an `addon.json` manifest and (per the manifest's `main` field) a +// CommonJS entry that exports `activate(api)` and optionally `deactivate()`. +// Nothing about an add-on ships in the Theseus repo or installer — drop a +// folder, restart Theseus, it's live. This is the same trust model as +// dev-mode browser extensions: the user is choosing to run local code with +// the app's full privileges. +// +// Loading is synchronous at app-ready time; there is no hot-reload. Failed +// activations are logged and skipped without breaking the app. +// +// Persistence: +// settings.disabledAddons — ids the user has toggled off +// /extensions-data/.json — per-add-on kv store (api.storage) + +const fs = require("node:fs"); +const path = require("node:path"); + +// Extension points the framework understands. Extending this list means also +// teaching main.js and (typically) the chrome renderer about the new point. +// Right now only sidebar panels are wired — future rev adds toolbar-chip, +// proxy, page-inject, etc. +const KNOWN_CAPABILITIES = new Set([ + "sidebar-panel", "session-proxy", + // vault-derive: api.vault.derive(purposePath) — HKDF child of the password + // vault's root, namespaced under the add-on id. + // page-inject: manifest["page-inject"] = { preload, origins } — the add-on's + // preload source runs in the isolated world of every tab whose + // URL matches one of the origin patterns. + // approval-modal: api.approvalModal({...}) — user-facing consent dialog over + // the active tab, resolved by main. + // capture-tab: api.captureTab({mode, ...}) + api.saveCapture({dataUrl, filename}) + // — snapshot the active tab (visible viewport / full page / + // user-drawn rectangle) and save the result through the app's + // downloads pipeline. The add-on sees pixels of whatever the + // current tab is showing, so this is the same trust bar as a + // page-inject add-on that matches "*://*/*". + // toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] } + // — chrome renders a dropdown under the add-on's dock icon; + // picking an item dispatches "menu-select" with {id} to the + // add-on's onMessage("menu-select", …) handler. + // open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL + // this stays available without the capability (legacy). + // Declaring "open-tab" additionally lets the add-on open + // one of its OWN HTML files as a full Theseus tab, with a + // lean preload so the page can keep talking to the add-on + // via window.silentmode.invoke(). + // scan-page: api.scanActiveTabForUris({scheme, limit}) — the HOST + // searches the active tab for URIs of one scheme and + // returns only those. The add-on never receives page text, + // markup or form values, so this sits well below + // page-inject or capture-tab on the trust ladder: it can + // learn that a page is offering e.g. a wiz:// pairing + // code, and nothing else about the page. + "vault-derive", "page-inject", "approval-modal", "capture-tab", + "toolbar-menu", "open-tab", "scan-page", + // context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}] + // — chrome merges these into every tab's right-click + // menu, filtered by `when` (selectionText | linkURL | + // editable | image | always). Picking one dispatches + // "context-menu" with the item id + the surrounding + // context (selection text, link URL, media info, host) + // to the add-on's onMessage handler. Add-ons that also + // declare "sidebar-panel" typically follow up with + // api.revealSidebar(panelId) to surface the result. + "context-menu-item", +]); + +// Chrome-style match pattern → predicate. ":///" where +// scheme may be "*", host may start with "*." (matches the bare host and any +// subdomain) or be "*", and path is a glob where "*" matches anything. +// bns:// (how Theseus fetches BCNR sites internally) is folded into https:// +// so a pattern written the way the address bar shows it keeps working. +function compileOriginPattern(pattern) { + const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim()); + if (!m) throw new Error(`bad origin pattern: ${pattern}`); + const [, scheme, host, pathGlob = "/*"] = m; + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase()); + let hostRe; + if (host === "*") hostRe = "[^/]+"; + else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`; + else hostRe = esc(host.toLowerCase()); + const pathRe = pathGlob.split("*").map(esc).join(".*"); + const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i"); + return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://")); +} +function urlMatchesAny(url, matchers) { + for (const fn of matchers) { try { if (fn(url)) return true; } catch {} } + return false; +} + +// Manifest field guardrails. Reject anything shape-suspicious so a bad +// addon.json can't get past the loader gate. +function validateManifest(raw, folderName) { + const m = raw && typeof raw === "object" ? raw : {}; + const id = String(m.id || "").trim(); + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) { + throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`); + } + const name = String(m.name || id); + const version = String(m.version || "0.0.0"); + const description = String(m.description || ""); + const author = String(m.author || ""); + const icon = String(m.icon || "🧩"); + const main = String(m.main || "index.js"); + if (main.includes("..") || path.isAbsolute(main)) { + throw new Error(`addon "${id}": main must be a relative path inside the addon folder`); + } + const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : []; + for (const cap of capabilities) { + if (!KNOWN_CAPABILITIES.has(cap)) { + // Not fatal — log later. Unknown caps are silently dropped. + } + } + let pageInject = null; + if (capabilities.includes("page-inject")) { + const pi = m["page-inject"]; + if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`); + const preload = String(pi.preload || ""); + if (!preload || preload.includes("..") || path.isAbsolute(preload)) { + throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`); + } + const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : []; + if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`); + pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) }; + } + let toolbarMenu = null; + if (capabilities.includes("toolbar-menu")) { + const tm = m["toolbar-menu"]; + if (!tm || typeof tm !== "object") { + throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`); + } + const items = Array.isArray(tm.items) ? tm.items : []; + if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`); + const seen = new Set(); + const cleanItems = items.map((it, idx) => { + const iid = String(it && it.id || "").trim(); + if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { + throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`); + } + if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`); + seen.add(iid); + const label = String(it.label || iid); + const itemIcon = it.icon == null ? "" : String(it.icon); + return { id: iid, label, icon: itemIcon }; + }); + toolbarMenu = { + title: tm.title == null ? name : String(tm.title), + icon: tm.icon == null ? icon : String(tm.icon), + items: cleanItems, + }; + } + const contextMenuItems = []; + if (capabilities.includes("context-menu-item")) { + const rawItems = m["context-menu-items"]; + if (!Array.isArray(rawItems) || !rawItems.length) { + throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`); + } + const seen = new Set(); + const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]); + for (let idx = 0; idx < rawItems.length; idx++) { + const it = rawItems[idx]; + const iid = String(it && it.id || "").trim(); + if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { + throw new Error(`addon "${id}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`); + } + if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`); + seen.add(iid); + const when = String(it.when || "always"); + if (!ALLOWED_WHEN.has(when)) { + throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`); + } + contextMenuItems.push({ + id: iid, + label: String(it.label || iid), + when, + icon: it.icon == null ? "" : String(it.icon), + }); + } + } + // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on + // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so + // funds derived under the old id's paths stay reachable through the new + // one. Each entry is validated as an id itself and gates vault.derive by + // (own id OR one of these) in makeApi below. + const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; + for (const a of absorbs) { + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { + throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); + } + if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); + } + // 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, contextMenuItems, 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, scanTabForUris, checkAndStageUpdates, restartApp, revealSidebar }) { + this.addonsDir = addonsDir; + this.dataDir = dataDir; + this.isDisabled = isDisabled || (() => false); + this.log = logger || ((...a) => console.log("[addons]", ...a)); + this._installed = []; // [{ manifest, folder, error? }] + this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject } + // Capability hooks injected by main. Each is (args..., addonId) so main + // can log/gate per add-on. Missing hook = capability unavailable. + this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; + // vaultImports: main-process shim {list, add, remove, signer} that owns + // wallet-imports.enc. Same trust tier as vaultDerive — an add-on that + // holds vault-derive can also see imports (design §3.2 co-tenancy). + this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null; + // vaultLifecycle: main-process shim {status, setup, unlock, lock} so the + // wallet add-on can drive vault setup/unlock without redirecting users + // to Settings > Passwords. Same "vault-derive" capability gate. + this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" + ? arguments[0].vaultLifecycle : null; + this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; + this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; + this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null; + // Add-ons live outside the app's node_modules tree, so a bare require() + // from their folder can't see Theseus's deps (ws, @noble/*, …). Main + // hands us its own require so add-ons can share the bundled tree. + this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; + // ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's + // Node; hostImport resolves them from the app tree and import()s them. + this._hostImport = typeof hostImport === "function" ? hostImport : null; + this._openTab = typeof openTab === "function" ? openTab : null; + // open-tab: opens one of the add-on's own HTML files as a full Theseus tab. + // Signature: (addonId, relPath, queryString) => Promise. + this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null; + // openSettings: opens Theseus's Settings tab, optionally scrolled to a + // named section (e.g. "passwords"). Uses the same IPC route the picker + // uses for "Search settings…". Signature: (section?: string) => void. + this._openSettings = typeof openSettings === "function" ? openSettings : null; + // 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; + // revealSidebar: opens the sidebar and switches to the given panel id. + // Signature: (addonId, panelId) => void. Add-ons use this from their + // context-menu handler to surface a result in their sidebar UI. + this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : 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 + // A `null` rule clears the proxy. Kept as a callback rather than requiring + // the loader itself import electron. + this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null; + // capture-tab hooks — main captures/saves; the loader only enforces the + // manifest gate. + this._captureTab = typeof captureTab === "function" ? captureTab : null; + this._saveCapture = typeof saveCapture === "function" ? saveCapture : null; + // api.whenUiReady() plumbing — see signalUiReady(). + this._uiReady = false; + this._uiReadyWaiters = []; + } + + // main calls this once the browser chrome has painted. Add-ons that pull + // in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth, + // WizardConnect) gate that work on api.whenUiReady() so module evaluation + // doesn't land on the main thread while chrome.html is still trying to + // paint. Sticky: a later discoverAndActivate() resolves immediately. + signalUiReady() { + this._uiReady = true; + for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} } + } + + ensureDirs() { + for (const d of [this.addonsDir, this.dataDir]) { + try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); } + } + } + + // api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same + // "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from + // its own gate instead of redirecting users to Settings > Passwords. + _makeLifecycleApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); + }; + return { + status: async () => { requireCap(); return this._vaultLifecycle.status(); }, + unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, + setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, + lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, + }; + } + + // api.vault.imports namespace factory. Gated by the "vault-derive" cap + // because the two surfaces sit at the same trust tier (design §3.2). If + // main didn't wire the vaultImports shim, calls throw a clear error. + _makeImportsApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); + }; + return { + list: async () => { requireCap(); return this._vaultImports.list(); }, + add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); }, + remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); }, + signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); }, + }; + } + + discoverAndActivate() { + this.ensureDirs(); + this._deactivateAll(); + this._installed = []; + let entries = []; + try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } + for (const dirent of entries) { + if (!dirent.isDirectory()) continue; + const folder = path.join(this.addonsDir, dirent.name); + try { + const manifest = this._readManifest(folder, dirent.name); + this._installed.push({ manifest, folder }); + if (this.isDisabled(manifest.id)) { + this.log(`skipping disabled add-on ${manifest.id}`); + continue; + } + this._activateOne(manifest, folder); + } catch (e) { + this.log(`failed to load ${dirent.name}: ${e?.message || e}`); + // A manifest that parsed but whose activate() threw was already + // pushed above — replace it rather than listing the add-on twice. + const i = this._installed.findIndex((x) => x.folder === folder); + const entry = { manifest: null, folder, error: String(e?.message || e) }; + if (i >= 0) this._installed[i] = entry; else this._installed.push(entry); + } + } + return this.snapshot(); + } + + _readManifest(folder, folderName) { + const p = path.join(folder, "addon.json"); + const raw = JSON.parse(fs.readFileSync(p, "utf8")); + return validateManifest(raw, folderName); + } + + _activateOne(manifest, folder) { + const mainPath = path.join(folder, manifest.main); + // require() from a folder outside asar is fine — Electron just uses Node's + // resolver. This is where the trust decision lives: we're loading arbitrary + // JS into the main process with full API access. + let mod; + try { + // Bust the require cache so a manual reload (future feature) picks up + // edits — cheap since add-ons are small. + delete require.cache[require.resolve(mainPath)]; + mod = require(mainPath); + } catch (e) { + throw new Error(`require() failed: ${e?.message || e}`); + } + if (!mod || typeof mod.activate !== "function") { + throw new Error(`main file must export an activate(api) function`); + } + const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null }; + if (manifest.pageInject) { + // Read the inject source once at activation. It's shipped to every + // matching tab's preload verbatim, so a syntax error surfaces in the + // tab's console, not here — but a missing file is fatal for the add-on. + const abs = path.join(folder, manifest.pageInject.preload); + let source; + try { source = fs.readFileSync(abs, "utf8"); } + catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); } + active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; + } + const api = this._makeApi(active); + try { mod.activate(api); } + catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } + this._active.set(manifest.id, active); + this.log(`activated ${manifest.id} v${manifest.version}`); + } + + // Tear down every active add-on before a re-discover so long-lived state + // (sockets, timers) from a previous activation doesn't pile up. + _deactivateAll() { + for (const [id, active] of this._active) { + try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); } + catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); } + } + this._active.clear(); + } + + _makeApi(active) { + const { manifest, folder } = active; + const storageFile = path.join(this.dataDir, `${manifest.id}.json`); + return { + // Metadata the add-on may want to reflect on + id: manifest.id, + folder, + // Per-extension data folder (/extensions-data): where the kv + // store lives and where an add-on should keep scratch files rather + // than guessing the path from its own folder. + dataDir: this.dataDir, + log: (...a) => this.log(`[${manifest.id}]`, ...a), + // Persistent per-add-on storage. Small kv JSON on disk. + storage: { + get: (key, fallback = null) => { + try { + const raw = JSON.parse(fs.readFileSync(storageFile, "utf8")); + return key in raw ? raw[key] : fallback; + } catch { return fallback; } + }, + set: (key, value) => { + let store = {}; + try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {} + store[key] = value; + try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); } + }, + all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } }, + }, + // Register a sidebar panel — a right-side WebContentsView that hosts + // one of the add-on's HTML pages. `page` is a path RELATIVE to the + // add-on folder. `title` shows in the sidebar tab strip. `icon` is + // a short emoji/glyph. + registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => { + if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); + const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); + if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); + // Namespaced id so two add-ons can't collide. + const panelId = `${manifest.id}:${id}`; + active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id }); + this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); + }, + // Programmatically open the sidebar and switch to one of THIS add-on's + // panels. `panelId` is the un-namespaced id passed to registerSidebarPanel + // (e.g. "main"); host prefixes with the add-on id under the hood. Used + // by context-menu handlers to surface a result in the sidebar. No-op if + // the add-on doesn't own that panel or the host isn't wired. + revealSidebar: (panelId) => { + if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; } + const pid = String(panelId || "").trim(); + const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`; + const owned = active.sidebarPanels.some((p) => p.panelId === full); + if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; } + this._revealSidebar(manifest.id, full); + }, + // Swap the default session's proxy. Rules follow Chromium's proxy + // format ("socks5://1.2.3.4:1080" for a single SOCKS server, + // "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.). + // Pass null to clear. Add-ons that opt into this capability are + // fully replacing the browser's outgoing network path — they're + // the trust boundary while active. Same-signature as the built-in + // Tor toggle uses under the hood. + setSessionProxy: async (rules) => { + if (!this._setSessionProxy) { + this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`); + return; + } + if (!manifest.capabilities.includes("session-proxy")) { + throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`); + } + await this._setSessionProxy(rules, manifest.id); + }, + // Resolve a module from Theseus's own dependency tree. Add-ons run with + // the app's full privileges anyway; this only saves them from shipping + // a second copy of ws / @noble / etc. + require: (name) => { + if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`); + return this._hostRequire(name); + }, + // Same, for ES-module-only packages: resolves to a Promise of the + // module namespace. + import: async (name) => { + if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`); + return this._hostImport(name); + }, + // Open a new Theseus tab. Two shapes: + // - api.openTab("https://…") — no capability needed + // - api.openTab("editor.html", { query: {...} }) — opens one of the + // add-on's OWN files as a full tab; requires the "open-tab" cap. + // Path is resolved inside the add-on folder and rejected if it + // escapes it (path traversal). Query is URL-encoded. The page + // loads under addon-tab-preload.js so window.silentmode.invoke() + // reaches the same handlers as a sidebar panel — main gates by + // sender URL so a page hosted anywhere else gets nothing back. + openTab: (pathOrUrl, opts) => { + const s = String(pathOrUrl || ""); + // Bare http(s) URL with no opts — legacy behaviour, unchanged. + if (/^https?:\/\//i.test(s) && !opts) { + if (!this._openTab) throw new Error("openTab unavailable (host not wired)"); + this._openTab(s, manifest.id); + return; + } + if (!manifest.capabilities.includes("open-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`); + } + if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)"); + if (!s || path.isAbsolute(s) || s.includes("..")) { + throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`); + } + let qs = ""; + if (opts && opts.query && typeof opts.query === "object") { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v)); + qs = usp.toString(); + } + return this._openAddonTab(manifest.id, s, qs); + }, + // Open Theseus's Settings tab, optionally scrolled to a named section + // (validated against a known list in main). No capability needed — + // it's the same thing the user could do from the ⋮ menu, just a + // one-click shortcut so add-ons can point users at the right place + // (e.g. Aegis's "Set up vault" gate → Passwords). + openSettings: (section) => { + if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); + this._openSettings(typeof section === "string" ? section : ""); + }, + // 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, + }; + }, + // Ask Theseus to relaunch (the plug-in card's "apply update" chip). + // The host asks the user first and resolves { restarted, deferred } + // — an add-on cannot restart the browser on its own. + restartApp: () => { + if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); + return Promise.resolve(this._restartApp(manifest.name || manifest.id)); + }, + // 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. + whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))), + // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, + // injected page bridges) call into the add-on with a message name + + // one JSON payload; the handler's return value goes back as the + // response. `ctx.from` is "panel" or "page"; pages also carry + // `ctx.origin` ("https://host") so the add-on can scope permissions. + onMessage: (msg, handler) => { + if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`); + active.handlers.set(msg, handler); + }, + // Push an event to the add-on's own sidebar panel if it's currently + // loaded. Fire-and-forget; silently dropped when the panel is closed. + emit: (msg, payload) => { + if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); + }, + // vault-derive: a 32-byte HKDF child of the password vault's root, + // keyed by a path that MUST start with this add-on's id — or one of + // the ids it declared under `absorbs` in addon.json, so a superseding + // add-on can keep deriving the same keys as the add-on it replaced + // (funds stay reachable across the transition). Resolves only once + // the user has unlocked the vault (main polls; the await can be long). + vault: { + derive: async (purposePath) => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); + const p = String(purposePath || ""); + if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { + throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); + } + const allowed = [manifest.id, ...(manifest.absorbs || [])]; + if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { + const list = allowed.length > 1 + ? `one of "${allowed.join('", "')}"` + : `"${manifest.id}"`; + throw new Error(`vault.derive: purposePath must start with ${list} + "/"`); + } + return this._vaultDerive(p, manifest.id); + }, + imports: this._makeImportsApi(manifest), + lifecycle: this._makeLifecycleApi(manifest), + }, + // approval-modal: ask the user. Resolves to the chosen action id, or + // "cancel" (Escape / mask click / window closed). With `checkbox` set + // and ticked, the id comes back suffixed "+"; with + // `select` {id, label, options:[{value,label}]} and a non-empty value + // chosen, "+=". + approvalModal: async (opts) => { + if (!manifest.capabilities.includes("approval-modal")) { + throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); + } + if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); + return this._approvalModal(opts || {}, manifest.id); + }, + // capture-tab: snapshot the currently-active tab. + // opts.mode "visible" | "full" | "region" (required) + // opts.format "png" | "jpeg" (default "png") + // opts.quality 1-100 (jpeg only, default 90) + // opts.overlaySource string (region only — DOM + // code the add-on wants injected while the user + // drags a selection. Must resolve to `{x,y,w,h}` + // in CSS pixels; return null/undefined to cancel.) + // Resolves to `{ dataUrl, width, height, host, format }`. + captureTab: async (opts) => { + if (!manifest.capabilities.includes("capture-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); + } + if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`); + return this._captureTab(opts || {}, manifest.id); + }, + // capture-tab: route an in-memory image into the app's downloads pipeline + // so it lands in the user's Downloads folder AND shows up in the + // download-chip list the same way any HTTP download would. + // opts.dataUrl "data:image/png;base64,…" (required) + // opts.filename filename shown in the chip (required) + // Resolves to `{ savePath }`. + saveCapture: async (opts) => { + if (!manifest.capabilities.includes("capture-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); + } + if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`); + return this._saveCapture(opts || {}, manifest.id); + }, + // scan-page: pull URIs of ONE scheme out of the active tab. + // + // Deliberately not a "read the page" API. The host does the matching + // and hands back only the URIs that matched, so an add-on with this + // capability still cannot see page text, form values or anything else + // it did not ask for. The scheme is fixed by the caller and validated + // here, and every call is expected to be user-initiated — nothing in + // the host polls a page on an add-on's behalf. + // + // opts.scheme e.g. "wiz" (required, [a-z][a-z0-9+.-]{0,19}) + // opts.limit max URIs to return (default 20, hard cap 50) + // Resolves to `{ origin, uris: [string] }`. + scanActiveTabForUris: async (opts) => { + if (!manifest.capabilities.includes("scan-page")) { + throw new Error(`add-on "${manifest.id}" must declare the "scan-page" capability in addon.json`); + } + if (!this._scanTabForUris) throw new Error(`scanActiveTabForUris unavailable (host not wired)`); + const scheme = String(opts?.scheme || "").toLowerCase(); + if (!/^[a-z][a-z0-9+.-]{0,19}$/.test(scheme)) throw new Error("invalid scheme"); + const limit = Math.min(Math.max(Number(opts?.limit) || 20, 1), 50); + return this._scanTabForUris({ scheme, limit }, manifest.id); + }, + }; + } + + // Route a message to an add-on's registered handler. Callers (main) have + // already established WHO is asking; `ctx` carries that provenance. + async dispatch(id, msg, payload, ctx) { + const active = this._active.get(id); + if (!active) throw new Error(`add-on "${id}" is not active`); + const handler = active.handlers.get(String(msg)); + if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); + return handler(payload, ctx || {}); + } + hasHandler(id, msg) { + const active = this._active.get(id); + return !!(active && active.handlers.has(String(msg))); + } + // Inject scripts that apply to a tab URL — [{ id, source }]. + injectionsFor(url) { + const out = []; + for (const active of this._active.values()) { + if (active.inject && urlMatchesAny(url, active.inject.matchers)) { + out.push({ id: active.manifest.id, source: active.inject.source }); + } + } + return out; + } + // Does this add-on's page-inject declaration cover the URL? Used to gate + // page → add-on IPC so a non-matching page can't spoof a matching one. + pageAllowed(id, url) { + const active = this._active.get(id); + return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers)); + } + + // Read-only views for the rest of the app. + snapshot() { + return { + installed: this._installed.map(({ manifest, folder, error }) => ({ + id: manifest?.id ?? null, + name: manifest?.name ?? null, + version: manifest?.version ?? null, + description: manifest?.description ?? "", + 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, + })), + sidebarPanels: this.getSidebarPanels(), + toolbarMenus: this.getToolbarMenus(), + }; + } + // `plugin` marks surfaces of a first-class Silent Mode component (manifest + // category "plugin", e.g. Aegis): the chrome pins those to their own dock + // instead of the extensions row. + getSidebarPanels() { + const out = []; + for (const active of this._active.values()) { + const plugin = active.manifest.category === "plugin"; + for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name }); + } + return out; + } + // Menu declarations from every active add-on that carries a toolbar-menu + // manifest block. Chrome renders one dock button per entry, opens the + // dropdown, then dispatches "menu-select" with the picked item id. + getToolbarMenus() { + const out = []; + for (const active of this._active.values()) { + const tm = active.manifest.toolbarMenu; + if (!tm) continue; + out.push({ + addonId: active.manifest.id, + title: tm.title, + icon: tm.icon, + plugin: active.manifest.category === "plugin", + items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), + }); + } + return out; + } + // Right-click menu items declared by add-ons, filtered by the current + // context. `ctx` is what Electron's context-menu event carries: + // { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL } + // Returns [{ addonId, id, label, icon, when }]. `when` filters: + // selectionText — non-empty text is selected + // linkURL — right-clicked on a link + // editable — right-clicked inside a form control / contenteditable + // image — mediaType === "image" and srcURL is present + // always — every menu + getContextMenuItems(ctx = {}) { + const has = { + selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()), + linkURL: !!ctx.linkURL, + editable: !!ctx.isEditable, + image: ctx.mediaType === "image" && !!ctx.srcURL, + }; + const out = []; + for (const active of this._active.values()) { + const items = active.manifest.contextMenuItems || []; + for (const it of items) { + if (it.when !== "always" && !has[it.when]) continue; + out.push({ addonId: active.manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when }); + } + } + return out; + } + getInstalled() { return this._installed.slice(); } + isActive(id) { return this._active.has(id); } + // Absolute folder of an active add-on, or null. Public so main can resolve + // add-on-relative paths (openAddonTab) without reaching into internals. + folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; } +} + +module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern }; diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 40f751a6..59cfa0c6 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,14 +1,14 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.6.31", + "version": "0.9.0", "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", "main": "index.js", "updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json", - "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], + "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal", "scan-page"], "absorbs": ["bchwallet", "siawallet"], "page-inject": { "preload": "wallet-inject.js", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index e3bed81b..002e684c 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -91,6 +91,7 @@ async function loadDeps(api) { // the primary BCH adapter but a single fixed address per wallet. const importedBchAdapter = require("./lib/chain-bch-imported.js")({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx, + HDKey, secp256k1, base58check, vaultImports: api.vault && api.vault.imports, }); // Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum- // based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC @@ -103,7 +104,7 @@ async function loadDeps(api) { // 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, + HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, bip39, dgbCore, }); @@ -505,8 +506,43 @@ async function mountWallet(entry) { ...commonOpts, cashaddr: entry.importedCashaddr || entry.importedAddress, servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, + importId: entry.importId, }); adapter.schedulePoll(20_000); + // Imported BCH wallets were never registered with WizardConnect — + // startForWallet only ran in the vault-derived branch. They showed + // up in the "Sign with" picker (they mount as ready) and then failed + // on pair with "no manager". Register them here too. + // + // WC derives a child-key tree, so this needs a seed: mnemonic/seed + // imports qualify, WIF single-key imports never can. registerWcEligible + // records which is which so the panel can say so up front instead of + // offering a pairing that cannot work. + if (c.wc) { + c.api.vault.imports.signer(entry.importId).then((blob) => { + if (ctx !== c) return; + if (!blob || blob.kind !== "seed" || !blob.seed) { + wcIneligible.set(entry.id, "This wallet was imported from a single private key. WizardConnect needs a seed phrase to derive the per-dapp keys it signs with."); + emitStateForWallet(entry.id); + return; + } + const seedHex = String(blob.seed).trim(); + if (!/^[0-9a-f]+$/i.test(seedHex) || seedHex.length < 32) { + wcIneligible.set(entry.id, "Imported seed material is not in a form WizardConnect can derive from."); + emitStateForWallet(entry.id); + return; + } + const root = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16))); + return c.wc.startForWallet({ + walletId: entry.id, label: entry.label, + root32: root, accountPath: entry.accountPath || "m/44'/145'/0'", + }).finally(() => { try { root.fill(0); } catch {} }); + }).catch((e) => { + c.api.log(`[${entry.id}] wc start (imported):`, e?.message || e); + wcIneligible.set(entry.id, cleanWcErr(e)); + emitStateForWallet(entry.id); + }); + } } else if (entry.chain === "btc" || entry.chain === "dgb") { adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({ ...commonOpts, chain: entry.chain, address: entry.importedAddress, @@ -518,6 +554,32 @@ async function mountWallet(entry) { rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined), }); adapter.schedulePoll(20_000); + } else if (entry.chain === "sc") { + // Sia's SiaWallet needs the 32-byte root at mount time — its key + // tree derives eagerly. Fetch the signer material from the vault + // (this branch runs only while the vault is unlocked; a locked + // vault would surface at import-time and gate the flow there). + // Falls back to a read-only stub if the fetch fails so a stray + // locked mount doesn't break panel rendering. + if (!c.api.vault?.imports || typeof c.api.vault.imports.signer !== "function") { + throw new Error("vault.imports.signer unavailable — cannot mount Sia import"); + } + const signerBlob = await c.api.vault.imports.signer(entry.importId); + if (!signerBlob || signerBlob.kind !== "seed" || !signerBlob.seed) { + throw new Error("Sia signer material missing or malformed"); + } + const seedHex = String(signerBlob.seed).trim(); + if (!/^[0-9a-f]{64}$/i.test(seedHex)) throw new Error("Sia seed must be 32 bytes"); + const rootBytes = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16))); + const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || ""); + adapter = new c.d.siaAdapter.SiaWallet(rootBytes, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + walletdUrl, + }); + if (walletdUrl && typeof adapter.startPolling === "function") adapter.startPolling(); } else { throw new Error(`no imported adapter for chain "${entry.chain}"`); } @@ -647,6 +709,7 @@ function unmountWallet(walletId) { const rt = ctx.runtimes.get(walletId); if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} } + wcIneligible.delete(walletId); ctx.runtimes.delete(walletId); } @@ -721,6 +784,18 @@ function overallPhase() { return "ready"; } +// Per-wallet reason a BCH wallet can't do WizardConnect even though it's +// mounted and ready — today that's single-key (WIF) imports, which have no +// seed to derive a per-dapp key tree from. Surfaced in walletSummary so the +// picker can grey them out instead of offering a pairing that must fail. +const wcIneligible = new Map(); +function cleanWcErr(e) { + const m = e?.message || String(e); + return /locked|vault/i.test(m) + ? "Unlock the password vault to use WizardConnect with this wallet." + : m; +} + function walletSummary(w) { const meta = chainMeta(w.chain, w.network); const rt = ctx.runtimes.get(w.id); @@ -737,8 +812,15 @@ function walletSummary(w) { // 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 }, + // Per-wallet assets, so the coin drilldown can show what each ADDRESS + // holds instead of only the selected wallet's. `tokens` is the account- + // model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed + // by category. Both stay null/empty for chains that have neither. + tokens: Array.isArray(snap?.tokens) ? snap.tokens : [], + tokenBalances: snap?.tokenBalances || null, phase: rt?.phase || "locked", error: rt?.error || null, + wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id) || null) : null, }; } @@ -1024,6 +1106,31 @@ function registerPanelMessages(api) { 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 if (chain === "sc") { + // Siacoin. Uses a 32-byte root seed + u64 index (KeyFromSeed layout); + // no BIP44 path. Accepts a BIP39 12-word mnemonic (matches Sia + // Central Lite / walletd, PBKDF2 → first 32 bytes) or raw 32-byte + // seed hex. Address at index 0 is what we surface at import time; + // the mounted SiaWallet lets users advance through additional + // indices via the "Next unused address" affordance. + const net = network || "mainnet"; + if (net !== "mainnet") throw new Error(`SC only supports mainnet (got ${net})`); + const index = Number(p && p.index != null ? p.index : 0); + if (!Number.isInteger(index) || index < 0) throw new Error("SC index must be a non-negative integer"); + let seedHex; + if (p && p.mnemonic) { + const r = der.sc.fromMnemonic(String(p.mnemonic).trim(), index); + seedHex = r.seedHex; address = r.address; + } else if (p && p.seedHex) { + const r = der.sc.fromSeedHex(String(p.seedHex).trim(), index); + seedHex = r.seedHex; address = r.address; + } else { throw new Error("supply mnemonic or seedHex"); } + spec.kind = "seed"; + spec.seed = seedHex; + // path field carries the Sia address index as an integer string, + // opaque to the vault. Mount reads it back as Number(spec.path). + spec.path = String(index); + spec.cashaddr = address; } else { throw new Error(`import not supported for chain "${chain}"`); } @@ -1082,6 +1189,26 @@ function registerPanelMessages(api) { }); api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); }); + // Panel drilldown → refresh every wallet under a chain (optionally scoped + // to one subnetwork). Fires each adapter's refresh in parallel; individual + // failures set the adapter's own error field (surfaced back to the panel + // via emitStateForWallet) rather than aborting the batch. Returns the + // list of {id, ok, error} so the panel can flash a summary. + api.onMessage("refreshChain", async (p, m) => { + fromPanel(m); + const chain = String(p?.chain || ""); + const network = p?.network ? String(p.network) : null; + if (!chain) throw new Error("chain is required"); + const targets = walletEntries().filter((w) => w.chain === chain && (!network || w.network === network)); + const out = []; + await Promise.all(targets.map(async (w) => { + const rt = ctx.runtimes.get(w.id); + if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; } + try { await rt.adapter.refresh(true); out.push({ id: w.id, ok: true }); } + catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); } + })); + return { chain, network, results: out }; + }); api.onMessage("nextAddress", (_p, m) => { fromPanel(m); const rt = requireSelected(); @@ -1273,6 +1400,131 @@ function registerPanelMessages(api) { return rt.adapter.signAndBroadcast(plan); }); + // ---- Balance consolidation ------------------------------------------------ + // Batch send-max from every same-chain/same-network wallet (or a subset the + // user picked with checkboxes) into the currently-selected wallet. Runs in + // two phases: + // consolidatePreview — dry-run plan() per source; returns balance/fee/ + // net/error so the panel renders a preview list + // with checkboxes without asking the user to + // approve anything yet. + // consolidateIntoSelected — signs + broadcasts one send per chosen source. + // The panel shows a single upfront confirmation + // (with the total to move and total fees); Theseus's + // per-tx approval overlay is skipped because the + // batch itself is the user's explicit intent. + api.onMessage("consolidatePreview", async (_p, m) => { + fromPanel(m); + const destId = selectedWalletId(); + if (!destId) throw new Error("no wallet selected"); + const destEntry = walletEntries().find((w) => w.id === destId); + if (!destEntry) throw new Error("selected wallet not found"); + const destRt = ctx.runtimes.get(destId); + if (!destRt?.adapter) throw new Error("destination wallet not ready"); + const destSnap = destRt.adapter.snapshot(); + const destAddr = destSnap.address; + if (!destAddr) throw new Error("destination wallet has no receive address"); + const sources = walletEntries().filter((w) => + w.chain === destEntry.chain && + w.network === destEntry.network && + w.id !== destId, + ); + const items = []; + for (const src of sources) { + const rt = ctx.runtimes.get(src.id); + const snap = rt?.adapter?.snapshot?.() || {}; + const bal = snap.balance || {}; + const totalUnits = typeof bal.confirmed === "string" + ? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString() + : String((bal.confirmed || 0) + (bal.unconfirmed || 0)); + const base = { + walletId: src.id, label: src.label, + address: snap.address || null, + balance: totalUnits, + fee: null, net: null, error: null, eligible: false, + }; + if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; } + if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; } + // Dry-run send-max to the destination. plan() throws on empty / + // dust-only wallets — that's the "nothing to sweep" case and it + // reads as an error string per source in the preview. + try { + const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); + const fee = String(plan.fee ?? 0); + const net = String(plan.recipients?.[0]?.value ?? 0); + items.push({ ...base, fee, net, eligible: true }); + } catch (e) { + items.push({ ...base, error: e?.message || String(e) }); + } + } + const meta = chainMeta(destEntry.chain, destEntry.network) || null; + return { + destinationWalletId: destId, + destinationLabel: destEntry.label, + destinationAddress: destAddr, + chain: destEntry.chain, + network: destEntry.network, + ticker: meta?.ticker || "", + decimals: meta?.decimals || 8, + sources: items, + }; + }); + + api.onMessage("consolidateIntoSelected", async (p, m) => { + fromPanel(m); + const destId = selectedWalletId(); + if (!destId) throw new Error("no wallet selected"); + const destEntry = walletEntries().find((w) => w.id === destId); + if (!destEntry) throw new Error("selected wallet not found"); + const destRt = ctx.runtimes.get(destId); + if (!destRt?.adapter) throw new Error("destination wallet not ready"); + const destAddr = destRt.adapter.snapshot().address; + if (!destAddr) throw new Error("destination wallet has no receive address"); + // sourceIds are the wallets the user CHECKED in the preview. Defaults + // to every eligible sibling if the panel omits the field (safety net, + // shouldn't happen in normal flow). + const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length + ? new Set(p.sourceIds.map(String)) + : null; + const sources = walletEntries().filter((w) => + w.chain === destEntry.chain && + w.network === destEntry.network && + w.id !== destId && + (!requested || requested.has(w.id)), + ); + const results = []; + for (const src of sources) { + const rt = ctx.runtimes.get(src.id); + if (!rt?.adapter || typeof rt.adapter.plan !== "function") { + results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" }); + continue; + } + try { + const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); + const r = await rt.adapter.signAndBroadcast(plan); + results.push({ + walletId: src.id, label: src.label, ok: true, + txid: r?.txid || null, + sent: String(plan.recipients?.[0]?.value ?? 0), + fee: String(plan.fee ?? 0), + }); + } catch (e) { + results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) }); + } + } + // Force a refresh on the destination so its balance jumps once the txs + // reach the network's mempool. Silent-fail — panel will pick up state + // on the next state emit anyway. + try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {} + return { + destinationWalletId: destId, + destinationAddress: destAddr, + chain: destEntry.chain, + network: destEntry.network, + results, + }; + }); + api.onMessage("recovery", async (p, m) => { fromPanel(m); const id = String(p && p.id || selectedWalletId()); @@ -1333,6 +1585,80 @@ function registerPanelMessages(api) { return fullState(); }); + // Scan the open dapp tab for a wiz:// pairing code, for dapps that render + // one but haven't adopted window.wizardconnect. Strictly user-initiated — + // it runs when someone presses "Scan page", never on a timer and never in + // the background. The host does the matching and returns only the URIs, so + // Aegis never receives page content. + api.onMessage("wcScanPage", async (_p, m) => { + fromPanel(m); + if (typeof api.scanActiveTabForUris !== "function") { + throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually."); + } + const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 }); + return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] }; + }); + + // ---- WizardConnect from the page (0.8.8) -------------------------------- + // + // WC was built for cross-device pairing: the dapp renders a QR, a phone + // scans it. Same-device that means copying a wiz:// string out of one + // tab and into the wallet by hand. These two handlers back the + // window.wizardconnect bridge so a dapp can hand Aegis the URI it has + // already generated, and the user just approves. + + // Which BCH wallets can actually pair right now. Used by the page bridge + // AND by isReady() so a dapp can decide between "hand it to Aegis" and + // "render the QR" before it commits to either. + function wcPairableWallets() { + if (!ctx.wc) return []; + return walletEntries() + .filter((w) => w.chain === "bch") + .map((w) => ({ entry: w, rt: ctx.runtimes.get(w.id) })) + .filter(({ entry, rt }) => rt && rt.phase === "ready" && !wcIneligible.has(entry.id)) + .map(({ entry }) => entry); + } + + api.onMessage("wcPageReady", async (_p, m) => { + fromPage(m); + // Deliberately coarse: a page learns only whether pairing is possible, + // never how many wallets exist or what they are. + return { available: !!ctx.wc, pairable: wcPairableWallets().length > 0 }; + }); + + api.onMessage("wcConnectFromPage", async (p, m) => { + const origin = fromPage(m); + if (!ctx.wc) throw new Error("WizardConnect is still starting up — try again in a moment"); + const uri = String(p && p.uri || "").trim(); + // Validate before showing any UI so a malformed or hostile value can't + // put a confusing approval in front of the user. + if (!/^wiz:\/\//i.test(uri)) throw new Error("not a WizardConnect URI"); + if (uri.length > 4096) throw new Error("WizardConnect URI is implausibly long"); + const candidates = wcPairableWallets(); + if (!candidates.length) { + throw new Error("No Bitcoin Cash wallet is ready to pair. Unlock the Aegis vault (or add a BCH wallet) and try again."); + } + // Prefer the selected wallet when it qualifies, so the approval matches + // whatever the user currently sees in the panel. + const selId = selectedWalletId(); + const chosen = candidates.find((w) => w.id === selId) || candidates[0]; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Pair this site with your wallet?", + origin, + body: "The site will be able to ask Aegis to sign Bitcoin Cash transactions over WizardConnect. Every signature still needs your approval — pairing on its own moves no funds.", + rows: [ + { label: "Wallet", value: `${chosen.label}` }, + { label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true }, + ], + actions: [{ id: "allow", label: "Pair", primary: true }], + }); + if (!String(pick || "").startsWith("allow")) throw new Error("pairing declined"); + await ctx.wc.connectUri(chosen.id, uri); + return { paired: true, wallet: chosen.label }; + }); + }); + // 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 @@ -1700,6 +2026,52 @@ function registerPageMessages(api) { return rt.adapter.signMessage(message); }); }); + // BCH message verification (BIP-137). Panel-only path: given a message, + // a base64 signature, and an address, return { valid, address, + // recoveredHash }. No approval modal (nothing spendable happens), no + // wallet lookup — pure crypto against the given address. + // Panel → BCMR resolver. Batched: pass an array of category hex strings, + // get back { : {name, symbol, iconUri, decimals, source} } + // for every one that resolved. Missed categories map to null. This + // triggers a background fetch for anything not in the disk cache, so + // the second call for the same set returns instantly. + api.onMessage("tokenMetadata", async (p, m) => { + fromPanel(m); + const cats = Array.isArray(p?.categories) ? p.categories.map(String).filter((c) => /^[0-9a-f]{64}$/i.test(c)) : []; + if (!cats.length) return {}; + const entries = await ctx.bcmr.lookupMany(cats); + const out = {}; + for (const [cat, entry] of Object.entries(entries)) { + out[cat] = ctx.bcmr.metadataOf(entry); + } + return out; + }); + // Read the configured BCMR registry list (defaults + any user additions). + api.onMessage("bcmrRegistries", (_p, m) => { + fromPanel(m); + return { registries: ctx.bcmr.registryList() }; + }); + // Overwrite the registry list. Empty array restores defaults on next read. + api.onMessage("setBcmrRegistries", (p, m) => { + fromPanel(m); + ctx.bcmr.setRegistries(Array.isArray(p?.registries) ? p.registries : []); + return { registries: ctx.bcmr.registryList() }; + }); + + api.onMessage("verifyMessage", (p, m) => { + fromPanel(m); + const message = String(p?.message != null ? p.message : ""); + const signature = String(p?.signature || ""); + const address = String(p?.address || ""); + if (!signature || !address) throw new Error("signature and address are required"); + try { + return ctx.d.keysLib.verifyMessage(message, signature, address, { + cashaddr: ctx.d.cashaddr, secp256k1: ctx.d.secp256k1, + }); + } catch (e) { + return { valid: false, error: e?.message || String(e) }; + } + }); // ---- Tron bridge (tronWeb / tronLink) ----------------------------------- api.onMessage("trx.requestAccounts", async (_p, m) => { @@ -2251,6 +2623,13 @@ module.exports = { log: (...a) => api.log("prices", ...a), onChange: () => emitState(), }), + // BCMR (CashTokens metadata registry) — resolves category hex to + // { name, symbol, iconUri, decimals }. Storage-scoped so per-user + // caches don't stomp each other; disk-cached with 6h TTL. + bcmr: require("./lib/bcmr.js")({ + storage: api.storage, + log: (...a) => api.log("bcmr", ...a), + }), wc: null, // WizardConnect manager, initialised when deps load }; migrateLegacyStorage(api); diff --git a/bundled-addons/aegis/lib/bcmr.js b/bundled-addons/aegis/lib/bcmr.js new file mode 100644 index 00000000..74718d02 --- /dev/null +++ b/bundled-addons/aegis/lib/bcmr.js @@ -0,0 +1,146 @@ +// BCMR (Bitcoin Cash Metadata Registry) fetcher + cache. Resolves a +// CashTokens category hex to human-readable metadata: name, description, +// symbol, decimals, icon URL, and per-NFT metadata when the registry +// carries it. +// +// Registries are plain JSON documents (Bitauth "Bitcoin Cash Metadata +// Registries v2" schema). We support two ways to reach a registry today: +// +// 1. HTTPS URL configured per-user in Settings ("registry endpoints"). +// The registry publishes a compact JSON with keyed identities; +// lookup by category is O(1). +// 2. Static bundled fallback (registries/) for a handful of well-known +// tokens (Cauldron, Fex.cash, TapSwap, ParyonUSD). Ships in the +// addon so brand-new users see names on the first launch even +// before they configure a live registry. +// +// Cache is on-disk via api.storage under "bcmr/" = +// { snapshot, fetchedAt, source }. A metadata refresh runs at most once +// per REFRESH_MIN_MS per category to keep the panel snappy on repaint. +// No signature verification yet (BCMR v2 spec allows authchain-anchored +// signing; adding that is a follow-up once we support arbitrary chain +// script parsing). + +const REFRESH_MIN_MS = 6 * 60 * 60 * 1000; // 6 hours + +// Well-known registries seeded on first run so a fresh wallet doesn't need +// any configuration to see names for the top BCH tokens. Users can add / +// remove entries in Settings. +const DEFAULT_REGISTRIES = [ + { id: "cashonize", label: "Cashonize registry", url: "https://raw.githubusercontent.com/cashonize/registry/main/bcmr.json" }, + { id: "salemkode", label: "SalemKode registry", url: "https://bcmr.salemkode.com/registry.json" }, +]; + +module.exports = function makeBcmr({ storage, log = () => {} }) { + + function registryList() { + const custom = storage.get("bcmr/registries", null); + if (Array.isArray(custom) && custom.length) return custom; + return DEFAULT_REGISTRIES.slice(); + } + function setRegistries(list) { + const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https?:\/\//i.test(r.url)) : []; + storage.set("bcmr/registries", clean); + } + + // Registry lookup: index-into-registry by category. BCMR v2 stores + // identities keyed by category id (hex). Each identity has a history + // array; the newest history[0] entry is the current snapshot. + function pickIdentity(regJson, categoryHex) { + const identities = regJson?.identities || {}; + const identity = identities[categoryHex]; + if (!identity) return null; + // History is a { : snapshot } map. Newest wins by ISO + // string sort — the schema recommends ISO 8601 timestamps and both + // registries above emit them, so lexicographic sort matches temporal + // sort for anything after 1000 AD. + const entries = Object.entries(identity); + if (!entries.length) return null; + entries.sort((a, b) => (b[0] > a[0] ? 1 : -1)); + const [, snap] = entries[0]; + return snap; + } + + async function fetchRegistry(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`bcmr: HTTP ${r.status} from ${url}`); + return r.json(); + } + + // Attempt every configured registry in parallel; first identity found + // wins. When two registries carry a category, we prefer the one earlier + // in the list (user-configured order = priority). + async function lookup(categoryHex) { + const registries = registryList(); + if (!registries.length) return null; + // Try cache first. + const cached = storage.get(`bcmr/${categoryHex}`, null); + if (cached && Date.now() - (cached.fetchedAt || 0) < REFRESH_MIN_MS) return cached; + + const attempts = await Promise.all(registries.map(async (reg) => { + try { + const json = await fetchRegistry(reg.url); + const identity = pickIdentity(json, categoryHex); + return identity ? { identity, source: reg.label || reg.id, url: reg.url } : null; + } catch (e) { + log(`bcmr: registry "${reg.label || reg.url}" failed:`, e?.message || e); + return null; + } + })); + + const hit = attempts.find((a) => a); + if (!hit) { + // Negative cache with a short TTL so a missing category doesn't + // hammer every registry on every wallet refresh. + const miss = { snapshot: null, fetchedAt: Date.now(), source: null, url: null }; + storage.set(`bcmr/${categoryHex}`, miss); + return miss; + } + const entry = { + snapshot: hit.identity, + fetchedAt: Date.now(), + source: hit.source, + url: hit.url, + }; + storage.set(`bcmr/${categoryHex}`, entry); + return entry; + } + + // Batch lookup — returns { : cacheEntry }. Reuses individual + // lookup() which handles per-category caching + negative caching. + async function lookupMany(categoryHexes) { + const out = {}; + await Promise.all(categoryHexes.map(async (cat) => { + try { out[cat] = await lookup(cat); } + catch (e) { out[cat] = { snapshot: null, error: e?.message || String(e) }; } + })); + return out; + } + + // Read-only cached lookup — never hits network. Used for the panel's + // synchronous render path so tokens draw immediately with whatever's + // in the cache; the async lookup() runs in the background afterwards. + function cached(categoryHex) { + return storage.get(`bcmr/${categoryHex}`, null); + } + + // Compact metadata slice the panel wants: { name, symbol, description, + // decimals, iconUri }. Handles both the top-level identity fields and + // the token subobject (BCMR v2 puts token-specific data there). + function metadataOf(entry) { + if (!entry || !entry.snapshot) return null; + const s = entry.snapshot; + const t = s.token || {}; + return { + name: s.name || t.name || null, + symbol: s.token?.symbol || s.symbol || null, + description: s.description || null, + decimals: Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0, + // Icon URIs live under s.uris.icon per schema; older files use s.icon. + iconUri: s.uris?.icon || s.icon || null, + source: entry.source || null, + }; + } + + return { lookup, lookupMany, cached, metadataOf, registryList, setRegistries, DEFAULT_REGISTRIES }; +}; diff --git a/bundled-addons/aegis/lib/cashaddr.js b/bundled-addons/aegis/lib/cashaddr.js index 67390e18..b0a38a6e 100644 --- a/bundled-addons/aegis/lib/cashaddr.js +++ b/bundled-addons/aegis/lib/cashaddr.js @@ -31,7 +31,10 @@ function convertBits(data, from, to, pad) { return out; } -// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit). +// type: 0 = P2PKH, 1 = P2SH, 2 = P2PKH+TOKEN, 3 = P2SH+TOKEN (CashTokens +// address types, CHIP-2022-02). hash: 20 bytes (the only size we emit). +// The type is a 5-bit value stored in the upper nibble of the version byte, +// so any type up to 15 encodes cleanly; every caller here uses 0-3. function encode(prefix, type, hash) { if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported"); const versionByte = (type << 3) | 0; // size bits 000 = 160 @@ -41,6 +44,14 @@ function encode(prefix, type, hash) { for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn)); return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join(""); } +// Whether a decoded address type carries the CashTokens "token-aware" flag. +// Callers use it to warn on token sends to non-token-aware addresses (a +// consensus rule — sending tokens to type 0/1 is a burn). +function isTokenAware(type) { return type === 2 || type === 3; } +// Fold a token-aware address type down to its bare equivalent so the +// UTXO / locking-script path can stay one-shape (P2PKH vs P2SH). The +// token payload is written via the 0xef prefix, not the address type. +function bareType(type) { return type & 0x01; } // Accepts "prefix:payload" or a bare payload (assumes defaultPrefix). function decode(address, defaultPrefix = "bitcoincash") { @@ -88,15 +99,30 @@ function decodeLegacy(address, sha256) { return { prefix: "bitcoincash", type, hash: body.slice(1) }; } -// Anything a user might paste -> { type, hash, cashaddr }. Rejects other -// prefixes so a chipnet address can never be paid on mainnet by accident. +// Anything a user might paste -> { type, hash, cashaddr, tokenAware }. +// Rejects wrong prefixes so a chipnet address can never be paid on +// mainnet by accident. Type 2/3 (CashTokens-aware) is folded to type +// 0/1 for the locking-script side; the token-aware flag flows through +// so callers building token outputs can refuse to burn tokens on a +// non-aware recipient. function parseAny(input, sha256, prefix = "bitcoincash") { const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:"); if (!s) throw new Error("empty address"); const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix); if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`); - if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type"); - return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) }; + if (r.type < 0 || r.type > 3) throw new Error(`unsupported address type ${r.type}`); + const tokenAware = isTokenAware(r.type); + const bare = bareType(r.type); + return { + type: bare, hash: r.hash, tokenAware, + // Round-trip through encode() so the returned cashaddr is + // canonical-cased and normalised, even if the input was a legacy + // Base58 (1…/3…) form. Emits type 0/1 by default; callers that + // want the token-aware form for display can re-encode with type + // 2/3 explicitly. + cashaddr: encode(prefix, bare, r.hash), + cashaddrTokenAware: encode(prefix, bare | 0x02, r.hash), + }; } -module.exports = { encode, decode, decodeLegacy, parseAny }; +module.exports = { encode, decode, decodeLegacy, parseAny, isTokenAware, bareType }; diff --git a/bundled-addons/aegis/lib/cashtokens.js b/bundled-addons/aegis/lib/cashtokens.js new file mode 100644 index 00000000..a20fbc56 --- /dev/null +++ b/bundled-addons/aegis/lib/cashtokens.js @@ -0,0 +1,206 @@ +// CashTokens (CHIP-2022-02) primitives — decode + encode the prefix byte +// that wraps a token-carrying scriptPubKey. Pure functions, no wallet or +// network state. Used by: +// - wallet.js → classify UTXOs (bare BCH vs fungible vs NFT vs both) +// - tx.js → build token outputs +// - panel.js → render token balances / send flows +// +// Prefix layout (CashTokens spec): +// +// 0xef — PREFIX_TOKEN marker +// category_id (32 bytes) — genesis txid of the token, LE-serialised +// token_bitfield (1 byte) — see BITS below +// [commitment_length (varint)] — present iff HAS_COMMITMENT_LENGTH +// [commitment (bytes)] — length equal to commitment_length +// [amount (varint)] — present iff HAS_AMOUNT (fungible token) +// — the "real" P2PKH / P2SH / … script +// +// Bitfield layout (spec §"Token Prefix Encoding"): +// Upper nibble = STRUCTURE bits (which fields are present): +// 0x10 HAS_AMOUNT — fungible token amount is encoded +// 0x20 HAS_NFT — NFT is present (commitment optional) +// 0x40 HAS_COMMITMENT_LENGTH — commitment_length is present +// 0x80 reserved (must be 0) +// Lower nibble = NFT CAPABILITY (meaningful only when HAS_NFT): +// 0x00 none / immutable +// 0x01 mutable +// 0x02 minting +// 0x03-0x0F reserved (must be 0) + +const PREFIX_TOKEN = 0xef; + +// Bit masks (STRUCTURE). +const HAS_AMOUNT = 0x10; +const HAS_NFT = 0x20; +const HAS_COMMITMENT_LENGTH = 0x40; +const STRUCTURE_RESERVED = 0x80; + +// NFT capabilities. Values are read from bitfield & 0x0f. +const CAP_NONE = 0x00; // immutable NFT (or "no NFT" when HAS_NFT bit is off) +const CAP_MUTABLE = 0x01; +const CAP_MINTING = 0x02; +const CAP_LABEL = { 0: "immutable", 1: "mutable", 2: "minting" }; + +// Varint (compact size) encode/decode used for commitment length AND for +// the fungible-token amount. Amounts up to 9,223,372,036,854,775,807 sats +// (2^63-1) are legal; larger values are consensus-invalid, so we cap and +// throw on encode. +function readVarint(bytes, pos) { + if (pos >= bytes.length) throw new Error("cashtokens: truncated varint"); + const first = bytes[pos]; + if (first < 0xfd) return { value: BigInt(first), next: pos + 1 }; + if (first === 0xfd) { + if (pos + 3 > bytes.length) throw new Error("cashtokens: truncated 0xfd varint"); + return { value: BigInt(bytes[pos + 1] | (bytes[pos + 2] << 8)), next: pos + 3 }; + } + if (first === 0xfe) { + if (pos + 5 > bytes.length) throw new Error("cashtokens: truncated 0xfe varint"); + return { + value: BigInt(bytes[pos + 1]) | (BigInt(bytes[pos + 2]) << 8n) + | (BigInt(bytes[pos + 3]) << 16n) | (BigInt(bytes[pos + 4]) << 24n), + next: pos + 5, + }; + } + // 0xff = 8-byte little-endian u64 + if (pos + 9 > bytes.length) throw new Error("cashtokens: truncated 0xff varint"); + let v = 0n; + for (let i = 0; i < 8; i++) v |= BigInt(bytes[pos + 1 + i]) << BigInt(8 * i); + return { value: v, next: pos + 9 }; +} +function writeVarint(v) { + const n = typeof v === "bigint" ? v : BigInt(v); + if (n < 0n) throw new Error("cashtokens: negative varint"); + if (n < 0xfdn) return Uint8Array.from([Number(n)]); + if (n <= 0xffffn) return Uint8Array.from([0xfd, Number(n & 0xffn), Number((n >> 8n) & 0xffn)]); + if (n <= 0xffffffffn) { + return Uint8Array.from([ + 0xfe, Number(n & 0xffn), Number((n >> 8n) & 0xffn), + Number((n >> 16n) & 0xffn), Number((n >> 24n) & 0xffn), + ]); + } + if (n > (1n << 63n) - 1n) throw new Error("cashtokens: amount exceeds i64 max"); + const out = new Uint8Array(9); + out[0] = 0xff; + let x = n; + for (let i = 1; i <= 8; i++) { out[i] = Number(x & 0xffn); x >>= 8n; } + return out; +} + +// Split a scriptPubKey into { token, lockingScript, rawPrefix }. token is +// null when the script is NOT prefixed by 0xef. lockingScript is the +// tokenless portion — every downstream check (P2PKH, P2SH, OP_RETURN, +// electrum scripthash) works off THAT, so token-carrying and bare UTXOs +// stay comparable through the existing wallet code. +function decodePrefixedScript(script) { + const bytes = script instanceof Uint8Array ? script : Uint8Array.from(script); + if (!bytes.length || bytes[0] !== PREFIX_TOKEN) { + return { token: null, lockingScript: bytes, rawPrefix: null }; + } + if (bytes.length < 1 + 32 + 1) throw new Error("cashtokens: prefix truncated at category"); + let pos = 1; + const category = bytes.slice(pos, pos + 32); pos += 32; + const bitfield = bytes[pos]; pos += 1; + if (bitfield & STRUCTURE_RESERVED) throw new Error("cashtokens: reserved structure bit set"); + const hasAmount = !!(bitfield & HAS_AMOUNT); + const hasNft = !!(bitfield & HAS_NFT); + const hasCommitLen = !!(bitfield & HAS_COMMITMENT_LENGTH); + const capability = bitfield & 0x0f; + // Structure invariants (spec): + // - Commitment-length present implies HAS_NFT (a commitment without an + // NFT is meaningless) AND commitment_length ≥ 1. + // - Capability lower nibble is only meaningful when HAS_NFT is set. + // - At least one of HAS_AMOUNT / HAS_NFT must be set, otherwise the + // prefix carries no useful info and should be rejected. + if (!hasAmount && !hasNft) throw new Error("cashtokens: prefix carries neither amount nor nft"); + if (hasCommitLen && !hasNft) throw new Error("cashtokens: commitment without NFT"); + if (!hasNft && capability !== 0) throw new Error("cashtokens: capability bits set on fungible-only prefix"); + if (hasNft && capability > 2) throw new Error(`cashtokens: unknown NFT capability ${capability}`); + let commitment = null; + if (hasCommitLen) { + const clen = readVarint(bytes, pos); pos = clen.next; + if (clen.value === 0n) throw new Error("cashtokens: zero-length commitment"); + if (clen.value > 40n) throw new Error(`cashtokens: commitment exceeds 40 bytes (${clen.value})`); + const length = Number(clen.value); + if (pos + length > bytes.length) throw new Error("cashtokens: commitment truncated"); + commitment = bytes.slice(pos, pos + length); pos += length; + } + let amount = 0n; + if (hasAmount) { + const av = readVarint(bytes, pos); pos = av.next; + if (av.value === 0n) throw new Error("cashtokens: zero fungible amount"); + if (av.value > (1n << 63n) - 1n) throw new Error("cashtokens: fungible amount overflow"); + amount = av.value; + } + const lockingScript = bytes.slice(pos); + const rawPrefix = bytes.slice(0, pos); + return { + token: { + category, categoryHex: toHex(category), + amount, hasAmount, hasNft, capability, capabilityLabel: hasNft ? CAP_LABEL[capability] : null, + commitment, commitmentHex: commitment ? toHex(commitment) : null, + }, + lockingScript, rawPrefix, + }; +} + +// Encode a { category, amount, nft: { commitment, capability } } spec into +// the prefix bytes ready to be prepended to a locking script. Absent fields +// mean "not present" — e.g. { amount: 100n } → fungible only. +function encodePrefix({ category, amount = 0n, nft = null }) { + const cat = category instanceof Uint8Array + ? category + : Uint8Array.from(String(category).match(/../g).map((h) => parseInt(h, 16))); + if (cat.length !== 32) throw new Error("cashtokens: category must be 32 bytes"); + const amt = typeof amount === "bigint" ? amount : BigInt(amount || 0); + if (amt < 0n) throw new Error("cashtokens: negative amount"); + const hasAmount = amt > 0n; + const hasNft = !!nft; + const commitment = hasNft && nft.commitment + ? (nft.commitment instanceof Uint8Array + ? nft.commitment + : Uint8Array.from(String(nft.commitment).match(/../g).map((h) => parseInt(h, 16)))) + : null; + const hasCommitLen = hasNft && commitment && commitment.length > 0; + if (commitment && commitment.length > 40) throw new Error("cashtokens: commitment > 40 bytes"); + const capability = hasNft ? (Number(nft.capability) || 0) : 0; + if (capability > 2) throw new Error(`cashtokens: bad NFT capability ${capability}`); + if (!hasAmount && !hasNft) throw new Error("cashtokens: must have amount or NFT"); + let bitfield = 0; + if (hasAmount) bitfield |= HAS_AMOUNT; + if (hasNft) bitfield |= HAS_NFT; + if (hasCommitLen) bitfield |= HAS_COMMITMENT_LENGTH; + bitfield |= capability & 0x0f; + const parts = [Uint8Array.from([PREFIX_TOKEN]), cat, Uint8Array.from([bitfield])]; + if (hasCommitLen) { parts.push(writeVarint(commitment.length)); parts.push(commitment); } + if (hasAmount) parts.push(writeVarint(amt)); + return concat(...parts); +} + +// Prepend a token prefix to an existing locking script (P2PKH etc). +function wrapScript(prefix, lockingScript) { + return concat(prefix, lockingScript); +} + +// Concise helper: given a JSON-serialisable descriptor and a P2PKH pubkey +// hash, produce the full token-carrying scriptPubKey ready for an output. +function tokenP2PKHScript({ category, amount = 0n, nft = null }, h160) { + const prefix = encodePrefix({ category, amount, nft }); + const locking = Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); + return wrapScript(prefix, locking); +} + +// Utilities (kept private to this file to avoid coupling with tx.js). +function concat(...parts) { + const n = parts.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} +function toHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); } + +module.exports = { + PREFIX_TOKEN, HAS_AMOUNT, HAS_NFT, HAS_COMMITMENT_LENGTH, + CAP_NONE, CAP_MUTABLE, CAP_MINTING, CAP_LABEL, + decodePrefixedScript, encodePrefix, wrapScript, tokenP2PKHScript, + readVarint, writeVarint, +}; diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index 70e3fa33..82557b1d 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -1,17 +1,21 @@ // Imported BCH wallet — single-address, key material lives in Theseus's // wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's -// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but -// does NOT go through vault.derive + HKDF: derivation is direct from the -// seed+path or WIF that the user imported. +// public shape (snapshot, refresh, plan, signAndBroadcast, signMessage, +// dispose) but does NOT go through vault.derive + HKDF: derivation is +// direct from the seed+path or WIF that the user imported. // -// M.1a scope: read-only (balance + history over Electrum). planSend/send -// throw with a clear message until M.1b lands the sign path. +// 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's +// single scripthash UTXO set; signAndBroadcast() pulls the signer material +// from api.vault.imports.signer(importId), decodes the WIF or derives the +// mnemonic/path into a 32-byte priv key, and signs every input in RAM. +// The private key never lands in adapter state — signAndBroadcast fetches +// it fresh per broadcast and drops it before returning. -module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) { +module.exports = function makeImportedBchAdapter({ + sha256, ripemd160, cashaddr, electrum, WebSocket, tx, + HDKey, secp256k1, base58check, vaultImports, +}) { - // Same electrum scripthash convention chain-bch uses: sha256(script), byte- - // reversed, hex. P2PKH-only for imports today — that's what every entry in - // Deviant's keystore is. const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); const scripthashOf = (script) => toHex(sha256(script).slice().reverse()); @@ -19,9 +23,9 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, const IMPORTED_BCH_NETWORKS = { mainnet: { - id: "mainnet", label: "Mainnet", prefix: "bitcoincash", - explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", - explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80, + explorerTx: "https://bchexplorer.cash/tx/", + explorerAddr: "https://bchexplorer.cash/address/", defaultServers: [ "wss://bch.imaginary.cash:50004", "wss://cashnode.bch.ninja:50004", @@ -30,7 +34,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, ], }, chipnet: { - id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", + id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef, explorerTx: "https://chipnet.imaginary.cash/tx/", explorerAddr: "https://chipnet.imaginary.cash/address/", defaultServers: [ @@ -41,9 +45,6 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, }, }; - // Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import - // time and use it here to compute the scripthash for Electrum without ever - // asking main for the signer material — that only happens at sign time. function h160OfCashaddr(addr) { const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, ""); const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean); @@ -51,12 +52,56 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, return hash; } + // Decode a WIF-encoded private key. Accepts both mainnet (0x80) and + // testnet (0xef) version bytes and both compressed and uncompressed + // forms; returns { priv (32 bytes), compressed (bool) }. + function decodeWif(wif, versionByte) { + const bytes = base58check.decodeCheck(String(wif).trim()); + if (!(bytes[0] === versionByte || bytes[0] === 0x80 || bytes[0] === 0xef)) { + throw new Error(`unexpected WIF version 0x${bytes[0].toString(16)}`); + } + const compressed = bytes.length === 34 && bytes[33] === 0x01; + const priv = bytes.slice(1, 33); + if (priv.length !== 32) throw new Error("WIF payload is not 32 bytes"); + return { priv, compressed }; + } + + // Derive a P2PKH signer (32-byte priv + 33-byte compressed pubkey) from + // whatever vault.imports.signer returned. Two shapes today: + // { kind: "seed", seed: hex, path: "m/…" } — BIP32 derivation + // { kind: "wif", wif: base58check } — direct decode + // Anything else (or a missing signer) throws with a clear message so + // the panel can surface it rather than the broadcast returning garbage. + function signerToKey(signerBlob, net) { + if (!signerBlob) throw new Error("no signer material for this wallet"); + if (signerBlob.kind === "seed") { + const seed = signerBlob.seed; + if (!/^[0-9a-f]+$/i.test(seed)) throw new Error("seed material must be hex"); + const seedBytes = Uint8Array.from(seed.match(/../g).map((x) => parseInt(x, 16))); + const node = HDKey.fromMasterSeed(seedBytes).derive(signerBlob.path || "m"); + return { priv: node.privateKey, pub: node.publicKey }; + } + if (signerBlob.kind === "wif") { + const { priv } = decodeWif(signerBlob.wif, net.wifVersion); + const pub = secp256k1.getPublicKey(priv, true); + return { priv, pub }; + } + throw new Error(`unknown signer kind: ${signerBlob.kind}`); + } + class ImportedBchWallet { - constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) { + constructor({ + walletId, storage, log = () => {}, onChange = () => {}, + network = "mainnet", cashaddr: address, servers, importId, + } = {}) { const net = IMPORTED_BCH_NETWORKS[network]; if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`); if (!address) throw new Error("chain-bch-imported: cashaddr required"); this.walletId = walletId; + // importId is the vault-side id used to fetch the signer at + // sign-time. Optional here so a mount without spend capability + // still works (read-only surface unaffected). + this._importId = importId || null; this.chain = "bch"; this.network = net.id; this._net = net; @@ -73,6 +118,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, this._state = { balance: { confirmed: 0, unconfirmed: 0 }, history: [], + utxos: [], height: 0, scanning: false, error: null, @@ -107,6 +153,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, server: this._client.url || null, servers: this._servers, imported: true, + // 0.6.36+: imported wallets can spend when the vault signer is + // reachable (i.e. Theseus is unlocked). canSpend reflects that so + // the panel can enable the Send tab without probing. + canSpend: !!this._importId, explorerTx: this._net.explorerTx, explorerAddr: this._net.explorerAddr, faucet: this._net.faucet, @@ -116,11 +166,15 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, async refresh(full) { this._state.scanning = true; this._emit(); try { - // Balance for this single scripthash. - const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]); + const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]); this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; + // Always pull UTXOs so spend / send-max work off fresh state. + const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]); + this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({ + txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0), + })); if (full) { - const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]); + const hist = await this._client.call("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, })); @@ -135,11 +189,105 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, } nextAddress() { return { address: this._address, index: 0 }; } - current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; } + current() { + return { + address: this._address, index: 0, branch: 0, path: null, + h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex, + }; + } - plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); } - signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); } - signMessage() { throw new Error("Imported wallets are read-only in this build."); } + // 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's + // own UTXO set. Signing happens in signAndBroadcast, which fetches the + // key material from Theseus's vault at broadcast time — nothing key- + // bearing lives in the plan itself, so a plan can round-trip through + // the approval overlay without leaking secrets. + plan(spec) { + if (!this._state.utxos.length) throw new Error("wallet has no unspent outputs to spend from"); + const targets = Array.isArray(spec?.outputs) && spec.outputs.length + ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) + : [{ to: spec?.to, value: spec?.amount ?? spec?.value }]; + const outs = targets.map((t) => { + const a = cashaddr.parseAny(t.to, sha256, this._net.prefix); + const script = a.type === 0 + ? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac]) + : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]); + return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; + }); + if (spec?.memo) outs.push({ value: 0, script: tx.memoScript(String(spec.memo)), data: true, memo: String(spec.memo) }); + const rate = Math.min(10, Math.max(1, Number(spec?.feeRate) || 1)); + // Imported wallets have exactly one address, so change goes back to + // itself — no need to derive a fresh change entry from an HD tree. + const changeScript = this._script; + const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax }); + const nonData = sel.outputs.filter((o) => !o.data); + const total = sel.outputs.reduce((a, o) => a + o.value, 0); + return { + ...sel, + feeRate: rate, + recipients: nonData + .filter((_, i) => outs[i] && !outs[i].data) + .map((o, i) => ({ to: outs[i].to, value: o.value })), + memo: spec?.memo || null, + total, + }; + } + + async signAndBroadcast(plan) { + if (!this._importId) throw new Error("this wallet has no signer registered"); + if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable"); + const signerBlob = await vaultImports.signer(this._importId); + let key; + try { + key = signerToKey(signerBlob, this._net); + // Belt-and-braces: the signer must match the wallet's own address. + // Catches vault-side corruption and any accidental cross-mount. + const derivedH160 = hash160(key.pub); + const same = derivedH160.length === this._h160.length && derivedH160.every((b, i) => b === this._h160[i]); + if (!same) throw new Error("signer material does not match this wallet's address"); + const inputs = plan.inputs.map((u) => ({ ...u, script: this._script })); + const t = { inputs, outputs: plan.outputs }; + const signed = tx.sign(t, (inp, _i, digest) => ({ + sig: secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "der" }), + publicKey: key.pub, + })); + const txid = await this._client.call("blockchain.transaction.broadcast", [signed.hex]); + if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); + this.log("broadcast", txid); + setTimeout(() => this.refresh(false).catch(() => {}), 1500); + return { txid, hex: signed.hex, fee: plan.fee }; + } finally { + // Wipe the private material before returning. Not perfect (JS can + // still relocate the underlying buffer during GC) but it minimises + // the window in which the raw key sits in this frame. + if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} } + } + } + + // BIP-137 message signing from the imported key. Same MAGIC / double- + // sha256 payload as chain-bch.js so the resulting sig verifies through + // Electron Cash and every other BCH tool. + async signMessage(message) { + if (!this._importId) throw new Error("this wallet has no signer registered"); + if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable"); + const signerBlob = await vaultImports.signer(this._importId); + let key; + try { + key = signerToKey(signerBlob, this._net); + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const sig = secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out[0] = 27 + sig[0] + 4; + out.set(sig.subarray(1), 1); + return { address: this._address, signature: Buffer.from(out).toString("base64") }; + } finally { + if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} } + } + } 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)." }; } diff --git a/bundled-addons/aegis/lib/chain-bch.js b/bundled-addons/aegis/lib/chain-bch.js index cdddf399..2e7096c9 100644 --- a/bundled-addons/aegis/lib/chain-bch.js +++ b/bundled-addons/aegis/lib/chain-bch.js @@ -12,8 +12,8 @@ const BCH_NETWORKS = { label: "Mainnet", prefix: "bitcoincash", defaultAccountPath: "m/44'/145'/0'", - explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", - explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + explorerTx: "https://bchexplorer.cash/tx/", + explorerAddr: "https://bchexplorer.cash/address/", defaultServers: [ "wss://bch.imaginary.cash:50004", "wss://cashnode.bch.ninja:50004", @@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({ addressIndex: w.addressIndex, addressPath: w.addressPath, balance: w.balance, + // CashTokens balances (0.7.0+). Categories → { fungible: str, + // nfts: [...], utxoCount }. Empty object when no tokens held. + tokenBalances: w.tokenBalances || {}, height: w.height, history: w.history, scanning: w.scanning, @@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({ const targets = Array.isArray(spec.outputs) && spec.outputs.length ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) : [{ to: spec.to, value: spec.amount ?? spec.value }]; - return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax }); + const memo = typeof spec.memo === "string" ? spec.memo : ""; + return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo }); } async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); } // 65-byte BIP-137 recoverable signature — the format Electron Cash and diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index c60a3681..b52419c3 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -1,137 +1,389 @@ -// 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 }; -}; +// 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() { + + // base58check T… -> 41-prefixed hex, for comparing against the raw + // owner_address/to_address fields the /v1 tx feed returns. + const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + function tronAddrToHex(b58) { + try { + let n = 0n; + for (const ch of String(b58)) { + const i = B58.indexOf(ch); + if (i < 0) return ""; + n = n * 58n + BigInt(i); + } + let hex = n.toString(16); + if (hex.length % 2) hex = "0" + hex; + // 25 bytes = 21 payload + 4 checksum; drop the checksum. + return hex.padStart(50, "0").slice(0, 42); + } catch { return ""; } + } + + // Airdrop spam is the norm on public addresses — a real test address came + // back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a + // sidebar is useless, so every fetchTokens caps its list. Sorting puts + // named/known tokens first, so the cap drops spam before it drops + // anything the user recognises. + const TOKEN_CAP = 50; + + // Token names are attacker-controlled. Scam mints ship symbols that are + // blank, pure whitespace, zero-width characters, or carry bidi overrides + // to make one string render as another. Strip the invisible classes, cap + // the length, and return "" when nothing legible survives so the caller + // can mark the token unknown instead of rendering an empty-looking row + // that borrows trust from the ones above it. + // Ranges are listed numerically rather than as a regex character class on + // purpose: a literal class would need these very characters in the source, + // where they are invisible to a reviewer and easy for an editor or a patch + // tool to mangle. + const INVISIBLE_RANGES = [ + [0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls + [0x200b, 0x200f], // zero-width space..RTL mark + [0x202a, 0x202e], // bidi embedding / override + [0x2060, 0x206f], // word joiner, invisible operators + [0xfeff, 0xfeff], // BOM / zero-width no-break space + ]; + function cleanTokenText(s) { + let out = ""; + for (const ch of String(s == null ? "" : s)) { + const cp = ch.codePointAt(0); + if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue; + out += ch; + } + return out.replace(/\s+/g, " ").trim().slice(0, 32); + } + + const CHAIN_CFGS = { + eth: { + ticker: "ETH", decimals: 18, + networks: { + // `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints + // above serve balances but have no history or token concept at all — + // that's why imported ETH wallets showed a balance and nothing else. + // Etherscan V2 would need an API key; Blockscout does not. + // publicnode, not llamarpc: llamarpc was answering 525 with an HTML + // error page, which surfaced as a JSON parse error and a 0 balance. + mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://ethereum-rpc.publicnode.com", indexer: "https://eth.blockscout.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, + sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", indexer: "https://eth-sepolia.blockscout.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(); + }, + async fetchHistory({ address, net }) { + if (!net?.indexer) return null; + const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } }); + if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`); + const j = await r.json(); + const items = Array.isArray(j?.items) ? j.items : []; + const me = String(address).toLowerCase(); + return items.slice(0, 25).map((t) => { + const from = String(t.from?.hash || "").toLowerCase(); + const wei = BigInt(String(t.value || "0")); + const outgoing = from === me; + // A mempool tx comes back as {result:"pending", status:null, + // timestamp:null}. Reading that as `status !== "ok" → failed` + // showed pending sends as failures, which is the one thing a + // wallet must never get wrong. + const pending = t.result === "pending" || t.status == null; + return { + txid: t.hash, + time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0, + confirmations: Number(t.confirmations) || 0, + status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"), + // Keep wei exact — 18 decimals overflows a JS number. + delta: (outgoing ? -wei : wei).toString(), + kind: t.method || "Transfer", + }; + }).filter((t) => t.txid); + }, + async fetchTokens({ address, net }) { + if (!net?.indexer) return null; + const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } }); + if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`); + const j = await r.json(); + const list = Array.isArray(j) ? j : []; + return list.map((e) => { + const t = e?.token || {}; + const symbol = cleanTokenText(t.symbol); + return { + mint: t.address_hash || t.address || "", + symbol: symbol || "?", + name: cleanTokenText(t.name), + decimals: Number(t.decimals) || 0, + known: !!symbol, + balance: String(e.value ?? "0"), + }; + }).filter((t) => t.mint && t.balance !== "0") + .sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")) + .slice(0, TOKEN_CAP); + }, + }, + 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.trongrid.io, NOT api.nileex.io: nileex only serves the + // /wallet/* JSON-RPC family and 404s the whole /v1/ REST family, + // which is where transaction history and the trc20 token list + // live. Balance worked, everything else silently came back empty. + nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.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); + }, + async fetchHistory({ rpc, address }) { + const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`); + if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`); + const j = await r.json(); + const list = Array.isArray(j?.data) ? j.data : []; + return list.map((t) => { + const c = t?.raw_data?.contract?.[0]; + const v = c?.parameter?.value || {}; + const ownerHex = String(v.owner_address || ""); + // owner/to come back as 41-prefixed hex regardless of visible. + const mineHex = tronAddrToHex(address); + const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase(); + const amount = Number(v.amount || 0); + const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true; + return { + txid: t.txID || t.txid, + time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000), + confirmations: ok ? 1 : 0, + status: ok ? "confirmed" : "failed", + // Aegis renders `delta` in the wallet's base unit (sun here). + delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0, + kind: c?.type || "Contract", + }; + }).filter((t) => t.txid); + }, + // TRC20 balances live on the /v1 REST family. The balance map is + // contract -> raw amount with no symbol/decimals, so we join it + // against token_info from recent transfers to name what we can. + async fetchTokens({ rpc, address }) { + const base = rpc.replace(/\/+$/, ""); + const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`); + if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`); + const j = await r.json(); + const acct = Array.isArray(j?.data) ? j.data[0] : j?.data; + const raw = Array.isArray(acct?.trc20) ? acct.trc20 : []; + const balances = new Map(); + for (const entry of raw) { + for (const [contract, amt] of Object.entries(entry || {})) { + if (String(amt) !== "0") balances.set(contract, String(amt)); + } + } + if (!balances.size) return []; + const info = new Map(); + try { + const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`); + if (tr.ok) { + const tj = await tr.json(); + for (const t of (Array.isArray(tj?.data) ? tj.data : [])) { + const ti = t?.token_info; + if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti); + } + } + } catch { /* names are a nicety; balances still render */ } + // Named tokens first: an address that's been airdrop-spammed can + // hold dozens of contracts we have no token_info for, and those + // would otherwise bury the ones the user actually cares about. + return Array.from(balances, ([contract, balance]) => { + const ti = info.get(contract); + const symbol = cleanTokenText(ti?.symbol); + return { + mint: contract, + symbol: symbol || "?", + name: cleanTokenText(ti?.name), + decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, + known: !!ti && !!symbol, + balance, + }; + }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")) + .slice(0, TOKEN_CAP); + }, + }, + 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); + }, + // getSignaturesForAddress is keyless on the public RPC. It gives us + // the ledger of signatures touching this address but NOT the amounts — + // that would need a getTransaction per signature (25 extra round trips + // on every poll). We surface the entries with a null delta so the user + // at least sees activity and can open any of them in the explorer. + async fetchHistory({ rpc, address }) { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getSignaturesForAddress", params: [address, { limit: 25 }] }) }); + if (!r.ok) throw new Error(`Solana history HTTP ${r.status}`); + const j = await r.json(); + if (j?.error) throw new Error(j.error.message || "getSignaturesForAddress failed"); + const list = Array.isArray(j?.result) ? j.result : []; + return list.map((s) => ({ + txid: s.signature, + time: Number(s.blockTime) || 0, + confirmations: s.confirmationStatus === "finalized" ? 1 : 0, + status: s.err ? "failed" : "confirmed", + delta: null, + kind: "Transaction", + })).filter((t) => t.txid); + }, + // SPL balances via getTokenAccountsByOwner with jsonParsed, matching + // what the built-in Solana adapter does. Symbol/name aren't on-chain + // in the token account, so the mint stands in for the symbol. + async fetchTokens({ rpc, address }) { + const SPL = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const SPL22 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + const call = async (programId) => { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner", + params: [address, { programId }, { encoding: "jsonParsed" }] }) }); + if (!r.ok) throw new Error(`Solana tokens HTTP ${r.status}`); + const j = await r.json(); + if (j?.error) throw new Error(j.error.message || "getTokenAccountsByOwner failed"); + return Array.isArray(j?.result?.value) ? j.result.value : []; + }; + const accounts = [].concat(...await Promise.all([ + call(SPL).catch(() => []), + call(SPL22).catch(() => []), + ])); + const out = []; + for (const a of accounts) { + const info = a?.account?.data?.parsed?.info; + const amt = info?.tokenAmount; + if (!info?.mint || !amt || String(amt.amount) === "0") continue; + out.push({ + mint: String(info.mint), + symbol: String(info.mint).slice(0, 4) + "…", + name: "", + decimals: Number(amt.decimals) || 0, + known: true, // decimals ARE on-chain here, so the amount is real + balance: String(amt.amount), + }); + } + return out.sort((a, b) => (a.mint || "").localeCompare(b.mint || "")).slice(0, TOKEN_CAP); + }, + }, + }; + + 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: [], + tokens: [], + 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, + tokens: this._state.tokens, + 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(); + const opts = { rpc: this._net.rpc, address: this._address, net: this._net }; + try { + // Only the balance is load-bearing — history and tokens are + // best-effort so one 404 on a chain that has no keyless feed + // doesn't blank the wallet. + const [confirmed, history, tokens] = await Promise.all([ + this._cfg.fetchBalance(opts), + this._cfg.fetchHistory + ? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; }) + : Promise.resolve(null), + this._cfg.fetchTokens + ? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; }) + : Promise.resolve(null), + ]); + this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" }; + if (Array.isArray(history)) this._state.history = history; + if (Array.isArray(tokens)) this._state.tokens = tokens; + 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-sia.js b/bundled-addons/aegis/lib/chain-sia.js index dac88eae..c36e7826 100644 --- a/bundled-addons/aegis/lib/chain-sia.js +++ b/bundled-addons/aegis/lib/chain-sia.js @@ -20,6 +20,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { const keysLib = require("./sia/keys.js")({ sia }); const walletd = require("./sia/walletd.js")({ log: () => {} }); const walletFactory = require("./sia/wallet.js"); + const siascanLib = require("./sia/siascan.js")({ log: () => {} }); function scopedStorage(storage, keyPrefix) { const k = (key) => keyPrefix + key; @@ -32,7 +33,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { class SiaWallet { constructor(root32, { walletId, storage, log = () => {}, onChange = () => {}, - walletdUrl = "", + walletdUrl = "", siascanUrl = "", } = {}) { if (!walletId) throw new Error("chain-sia: walletId required"); this.walletId = walletId; @@ -44,9 +45,67 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { this._root = new Uint8Array(root32); this._keys = new keysLib.WalletKeys(root32); this._walletdUrl = String(walletdUrl || "").trim(); + this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE; this._client = null; this._wallet = null; + // 0.7.5: siascan is the read-only fallback when no walletd URL is + // set. Users get balance + history + broadcast (v2) with zero + // hosting on their side, and can still point at their own walletd + // if they want to run everything sovereign. + this._siascan = new siascanLib.SiascanClient(this._siascanUrl); + this._siascanState = { + balance: { confirmed: "0", unconfirmed: "0", immature: "0" }, + history: [], + height: 0, + addressIndex: 0, + scanning: false, + error: null, + }; + this._siascanTimer = null; if (this._walletdUrl) this._build(); + else this._startSiascanPoll(); + } + + _stopSiascanPoll() { clearTimeout(this._siascanTimer); this._siascanTimer = null; } + _startSiascanPoll(intervalMs = 45_000) { + this._stopSiascanPoll(); + const tick = async () => { + try { await this._refreshFromSiascan(); } + catch (e) { this._siascanState.error = e?.message || String(e); this._emitChange(); } + this._siascanTimer = setTimeout(tick, intervalMs); + }; + // Initial fire is immediate — users see a balance without a poll wait. + this._siascanTimer = setTimeout(tick, 200); + } + + _emitChange() { try { this.onChange(); } catch {} } + + async _refreshFromSiascan() { + // Poll for the current receive index (default 0). SiaWallet's own + // "nextAddress" logic is walletd-scoped; without walletd we track + // the index in storage so the snapshot address stays stable. + const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; + const entry = this._keys.entry(idx); + this._siascanState.scanning = true; this._emitChange(); + try { + const [tip, bal, events] = await Promise.all([ + this._siascan.tip().catch(() => ({ height: 0 })), + this._siascan.balance(entry.address), + this._siascan.events(entry.address, { limit: 25 }).catch(() => []), + ]); + this._siascanState.height = tip.height; + this._siascanState.balance = { + confirmed: bal.confirmed, + unconfirmed: bal.unconfirmed, + immature: bal.immature, + }; + this._siascanState.history = siascanLib.normaliseEvents(events, entry.address, tip.height); + this._siascanState.addressIndex = idx; + this._siascanState.error = null; + } finally { + this._siascanState.scanning = false; + this._emitChange(); + } } _build() { @@ -65,21 +124,44 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { const v = String(url || "").trim(); if (v === this._walletdUrl) return; this._walletdUrl = v; - if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; } + if (v) { + // Switching to walletd: stop siascan polling — walletd owns the + // read path now. + this._stopSiascanPoll(); + this._build(); + } else { + // Dropping walletd URL: shut down the walletd wallet and resume + // siascan polling so the panel keeps a live balance. + try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; + this._startSiascanPoll(); + } } // The panel treats Sia amounts as decimal strings of hastings; the // display layer picks how many SC-precision digits to show. snapshot() { const w = this._wallet && this._wallet.snapshot(); + const usingSiascan = !this._wallet; + const siascanIdx = this._siascanState.addressIndex; + const siascanEntry = usingSiascan ? this._keys.entry(siascanIdx) : null; const base = { chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, - address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`, + address: null, addressIndex: 0, + addressPath: `KeyFromSeed(seed, ${w?.addressIndex || siascanIdx || 0})`, balance: { confirmed: "0", unconfirmed: "0" }, height: 0, history: [], scanning: false, error: null, - server: this._client ? this._client.displayUrl : null, + // Server line the panel prints under the balance. When walletd is + // set that's the walletd URL; otherwise it's the siascan endpoint + // (which reads as public infrastructure, matching what's happening + // under the hood — no seed-material leaves the machine). + server: this._client ? this._client.displayUrl : (usingSiascan ? this._siascanUrl : null), walletdUrl: this._walletdUrl, - needsWalletdUrl: !this._walletdUrl, + // 0.7.5: needsWalletdUrl no longer gates the wallet. Siascan handles + // read + broadcast automatically; the field stays for callers that + // want to nudge users toward self-hosted infrastructure. + needsWalletdUrl: false, + siascanUrl: usingSiascan ? this._siascanUrl : null, + readMode: usingSiascan ? "siascan" : "walletd", explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, }; if (w) { @@ -100,21 +182,43 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { })); base.scanning = w.scanning; base.error = w.error; + } else if (siascanEntry) { + base.address = siascanEntry.address; + base.addressIndex = siascanIdx; + base.balance = { + confirmed: this._siascanState.balance.confirmed, + unconfirmed: this._siascanState.balance.unconfirmed, + }; + base.height = this._siascanState.height; + base.history = this._siascanState.history; + base.scanning = this._siascanState.scanning; + base.error = this._siascanState.error; } return base; } async refresh(full) { - if (!this._wallet) return; - return this._wallet.refresh(!!full); + if (this._wallet) return this._wallet.refresh(!!full); + // Siascan path: fetch now, don't wait for the poll tick. + return this._refreshFromSiascan(); } nextAddress() { - if (!this._wallet) throw new Error("no walletd URL configured"); - return this._wallet.nextUnusedAddress(); + if (this._wallet) return this._wallet.nextUnusedAddress(); + // Siascan path: bump the stored receive index and re-poll. The next + // snapshot round-trips through _refreshFromSiascan which reads the + // updated storage value. + const cur = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; + const nxt = cur + 1; + this.storage.set("siascan/receiveIndex", nxt); + this._refreshFromSiascan().catch(() => {}); + const entry = this._keys.entry(nxt); + return { address: entry.address, index: nxt }; } current() { - if (!this._wallet) throw new Error("no walletd URL configured"); - return this._wallet.current(); + if (this._wallet) return this._wallet.current(); + const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; + const entry = this._keys.entry(idx); + return { address: entry.address, index: idx, path: `KeyFromSeed(seed, ${idx})`, pub: entry.pub }; } plan(spec) { if (!this._wallet) throw new Error("no walletd URL configured"); @@ -162,8 +266,12 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) { xprv: this._keys.seedHex, }; } - startPolling() { if (this._wallet) this._wallet.startPolling(60_000); } + startPolling() { + if (this._wallet) this._wallet.startPolling(60_000); + else this._startSiascanPoll(); + } dispose() { + this._stopSiascanPoll(); try { this._wallet && this._wallet.dispose(); } catch {} try { this._keys && this._keys.wipe(); } catch {} if (this._root) this._root.fill(0); diff --git a/bundled-addons/aegis/lib/chain-utxo-imported.js b/bundled-addons/aegis/lib/chain-utxo-imported.js index dd97aab1..bde67708 100644 --- a/bundled-addons/aegis/lib/chain-utxo-imported.js +++ b/bundled-addons/aegis/lib/chain-utxo-imported.js @@ -109,10 +109,10 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore, async refresh(full) { this._state.scanning = true; this._emit(); try { - const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]); + const bal = await this._client.call("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]); + const hist = await this._client.call("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, })); diff --git a/bundled-addons/aegis/lib/import-derive.js b/bundled-addons/aegis/lib/import-derive.js index 3c7ae1cc..be171a78 100644 --- a/bundled-addons/aegis/lib/import-derive.js +++ b/bundled-addons/aegis/lib/import-derive.js @@ -7,10 +7,14 @@ // npm packages — same "hand it in" pattern the other adapters use. module.exports = function makeImportDerive({ - HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, + HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39, dgbCore, }) { + // Sia's key derivation lives in lib/sia/sia.js — reuse it here so + // imported SC wallets end up with byte-identical addresses to what + // Sia Central Lite or walletd would show for the same seed. + const sia = blake2b ? require("./sia/sia.js")({ ed25519, blake2b }) : null; const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const fromHex = (h) => { const s = String(h || "").replace(/^0x/i, ""); @@ -207,6 +211,42 @@ module.exports = function makeImportDerive({ return base58check.encodeBase58(pub); } + // ---- SC (Siacoin) -------------------------------------------------------- + // Sia's walletd + Sia Central Lite Wallet both use a 32-byte root seed. + // Sia Central Lite exports it as a BIP39 12-word mnemonic (PBKDF2 → + // 64-byte seed → first 32 bytes = root); walletd's API accepts the raw + // 32-byte hex. Address at index N: standardUnlockHash(ed25519.pub( + // blake2b(root32 || u64le(N)) + // )) — see lib/sia/sia.js:keyFromSeed for the byte layout. + function deriveScRootFromMnemonic(m) { + // BIP39 → 512-bit master seed; Sia Central takes the FIRST 32 bytes as + // the walletd root. Trimming the tail keeps addresses identical to + // what sialite.com and Sia Central mobile derive for the same phrase. + const fullSeedHex = mnemonicToSeedHex(m); + return fullSeedHex.slice(0, 64); + } + function deriveScRootFromHex(seedHex) { + const s = String(seedHex || "").trim().toLowerCase().replace(/^0x/, ""); + if (!/^[0-9a-f]{64}$/.test(s)) throw new Error("SC seed hex must be exactly 32 bytes (64 hex chars)"); + return s; + } + function deriveScAddressFromSeed(seedHex, index) { + if (!sia) throw new Error("SC derive unavailable (blake2b dep not passed)"); + const root = fromHex(seedHex); + if (root.length !== 32) throw new Error("SC root seed must be 32 bytes"); + const idx = Number(index || 0); + if (!Number.isInteger(idx) || idx < 0) throw new Error("SC index must be a non-negative integer"); + const k = sia.keyFromSeed(root, idx); + return k.address; // 76 hex chars, canonical Sia address form + } + function deriveScFromMnemonic(mnemonic, index) { + return { seedHex: deriveScRootFromMnemonic(mnemonic), address: deriveScAddressFromSeed(deriveScRootFromMnemonic(mnemonic), index) }; + } + function deriveScFromSeedHex(seedHex, index) { + const s = deriveScRootFromHex(seedHex); + return { seedHex: s, address: deriveScAddressFromSeed(s, index) }; + } + return { mnemonicToSeedHex, btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif }, @@ -214,5 +254,6 @@ module.exports = function makeImportDerive({ eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex }, trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex }, sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 }, + sc: { fromMnemonic: deriveScFromMnemonic, fromSeedHex: deriveScFromSeedHex, addressAt: deriveScAddressFromSeed }, }; }; diff --git a/bundled-addons/aegis/lib/keys.js b/bundled-addons/aegis/lib/keys.js index 95c087ef..3af9b990 100644 --- a/bundled-addons/aegis/lib/keys.js +++ b/bundled-addons/aegis/lib/keys.js @@ -62,5 +62,57 @@ module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashad try { this._account.wipePrivateData(); } catch {} } } - return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex }; + + // BIP-137 verification. Given a message, a 65-byte recoverable signature + // (base64, produced by signRecoverable above or Electron Cash / any other + // BCH tool), and a CashAddr, recover the signer's pubkey, hash it to the + // address's h160, and compare. Returns { valid, address, recoveredHash }. + // Deliberately pure (no wallet state) so a panel can verify a sig pasted + // from anywhere without touching the vault. + function verifyMessage(message, base64Signature, address, { cashaddr: caLib, secp256k1: sec }) { + const dec = (b64) => { + const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary"); + const u = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); + return u; + }; + const sig = dec(String(base64Signature || "").trim()); + if (sig.length !== 65) throw new Error(`signature must be 65 bytes (got ${sig.length})`); + const header = sig[0]; + // BIP-137 header layout: 27 + recid + 4 (compressed). 0..3 → uncompressed, + // 4..7 → uncompressed P2SH-P2WPKH, 8..11 → uncompressed native-segwit, + // 12..15 → compressed. Every P2PKH BCH signer we care about uses the + // 31..34 range (27 + recid + 4). Anything outside 27..34 is rejected. + if (header < 27 || header > 34) throw new Error(`bad signature header ${header}`); + const recid = (header - 27) & 3; + const compressed = header >= 31; + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + // Reconstruct the raw signature (1-byte recid || r || s) for + // secp256k1.recoverPublicKey. @noble/curves takes the recovered format + // whether we pass compressed or uncompressed, we ask for compressed + // (matches every BCH wallet's derived pubkey). + const recovered = new Uint8Array(65); + recovered[0] = recid; + recovered.set(sig.subarray(1), 1); + const pub = sec.getPublicKey + ? sec.recoverPublicKey(digest, recovered, { prehash: false, format: compressed ? "compressed" : "uncompressed" }) + : sec.Signature.fromCompact(sig.subarray(1)).addRecoveryBit(recid).recoverPublicKey(digest).toRawBytes(compressed); + const recoveredHash = hash160(pub); + // Decode the expected address to its h160 payload; accept both mainnet + // and testnet prefixes. Rejects non-P2PKH addresses (type != 0) since + // this signing scheme has no notion of a P2SH signer. + const raw = String(address || ""); + const full = raw.includes(":") ? raw : "bitcoincash:" + raw; + const { type, hash } = caLib.decode(full); + if (type !== 0) throw new Error(`address must be P2PKH (got type ${type})`); + const valid = recoveredHash.length === hash.length + && recoveredHash.every((b, i) => b === hash[i]); + return { valid, address: raw, recoveredHash: toHex(recoveredHash) }; + } + + return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex, verifyMessage }; }; diff --git a/bundled-addons/aegis/lib/prices.js b/bundled-addons/aegis/lib/prices.js index ab356a70..91c049d5 100644 --- a/bundled-addons/aegis/lib/prices.js +++ b/bundled-addons/aegis/lib/prices.js @@ -1,32 +1,39 @@ -// 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: +// Fiat prices for every Aegis-supported coin. Poll every enabled source in +// parallel and reconcile per chain: if two or more sources agree within a +// small band (±3% of the median), take their median as the truth; if none +// agree, fall back to the median of every reported value. This kills any +// single oracle's ability to make Aegis show a wrong number — a spoofed +// or wildly stale feed on one origin is outvoted by the others. // -// - 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. +// The user-facing model in 0.6.36+ is just "on / off": no source picker, +// no per-source config. Adding a new oracle here fans out to everyone +// with no UI churn. // -// 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. +// Sources currently wired: +// coingecko — 1 request covers all 7 coins, best overall coverage +// kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs +// coinbase — public /spot; BCH/BTC/ETH/SOL pairs +// coinspectrum — coin-spectrum.com free /assets/.json (~10 min TTL) // -// Cache is in-memory (returned by fullState() → panel). Poll interval is -// per-source since some rate-limit tighter than others. Off by default. +// Sources deferred (need their own protocol work first): +// oracles.cash / General Protocols — the /oracleMetadata endpoint returns +// hex-encoded signed attestations. Extracting a usable USD number +// requires decoding the message format (pair || timestamp || price_int +// || decimals) and verifying the signature against a known oracle +// pubkey per pair. Left as a TODO stub below so the plumbing is +// ready; enable once the message parser is done. const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"]; +// Sources return { : usd_number } for every chain they know about. +// Absence just means "this source doesn't cover that chain"; reconciliation +// ignores it. Errors thrown here bubble to the poller which stores them +// per-source in the snapshot so the panel can show which oracle is down. const SOURCES = { coingecko: { id: "coingecko", label: "CoinGecko", origin: "api.coingecko.com", - pollMs: 5 * 60 * 1000, coversAll: true, fetch: async () => { const ids = { @@ -49,18 +56,14 @@ const SOURCES = { 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); @@ -76,11 +79,8 @@ const SOURCES = { 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]) => { @@ -95,35 +95,154 @@ const SOURCES = { return out; }, }, + coinspectrum: { + id: "coinspectrum", + label: "Coin-Spectrum", + origin: "coin-spectrum.com", + coversAll: true, + fetch: async () => { + const slugs = { + bch: "bitcoin-cash", btc: "bitcoin", trx: "tron", + eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte", + }; + const out = {}; + await Promise.all(Object.entries(slugs).map(async ([chain, slug]) => { + try { + const r = await fetch(`https://coin-spectrum.com/api/v1/assets/${slug}.json`, { cache: "no-store" }); + if (!r.ok) return; + const body = await r.json(); + // coin-spectrum wraps everything under body.asset: + // { generated_at, asset: { slug, symbol, price_usd, … } } + // Older builds read body.price_usd, which is undefined, so every + // chain silently NaN'd and the UI showed "✓ 0 coins". + const usd = Number(body?.asset?.price_usd ?? body?.price_usd); + if (Number.isFinite(usd) && usd > 0) out[chain] = usd; + } catch { /* one slug failing shouldn't kill the others */ } + })); + return out; + }, + }, + // oracles.cash (General Protocols) is deferred until we decode their + // signed-attestation message format. Enable by moving this entry into + // SOURCES above once fetch() returns real USD numbers. + // _oraclescash: { + // id: "oraclescash", + // label: "oracles.cash", + // origin: "oracles.generalprotocols.com", + // coversAll: false, + // fetch: async () => { + // // TODO: pick per-pair oracle pubkey, fetch /api/v1/oracleMessages, + // // decode `message` = pair_ascii(2 bytes) || timestamp(u32) || + // // price_int(u32-or-u64) || decimals; verify signature. See + // // https://oracles.generalprotocols.com/api/v1/oracleMetadata for + // // the list of active oracles. + // return {}; + // }, + // }, }; -const DEFAULT_SOURCE = "coingecko"; +const POLL_MS = 5 * 60 * 1000; // 5 min: gentle on free tiers, still fresh enough +const AGREEMENT_BAND = 0.03; // ±3% around the median counts as "agreeing" + +const median = (nums) => { + const s = nums.slice().sort((a, b) => a - b); + const m = s.length; + if (!m) return null; + return m % 2 ? s[(m - 1) / 2] : (s[m / 2 - 1] + s[m / 2]) / 2; +}; + +// Reconcile a per-source map for ONE chain into a single trusted USD number. +// perSource: { : usd_number } +// Returns { usd, method, samples: [{sourceId, usd, agrees}] }. +function reconcileOne(perSource) { + const samples = Object.entries(perSource) + .filter(([, v]) => Number.isFinite(v) && v > 0) + .map(([sourceId, usd]) => ({ sourceId, usd, agrees: false })); + if (!samples.length) return { usd: null, method: "none", samples }; + if (samples.length === 1) { + samples[0].agrees = true; + return { usd: samples[0].usd, method: "single", samples }; + } + // Pin agreement around the overall median so no single outlier can shift + // the anchor. Any two samples within ±3% of that median form a "cluster"; + // if ≥2 exist we take their median as the truth. + const mid = median(samples.map((s) => s.usd)); + const lo = mid * (1 - AGREEMENT_BAND); + const hi = mid * (1 + AGREEMENT_BAND); + const agreeing = samples.filter((s) => s.usd >= lo && s.usd <= hi); + if (agreeing.length >= 2) { + for (const a of agreeing) a.agrees = true; + return { usd: median(agreeing.map((s) => s.usd)), method: `majority-${agreeing.length}of${samples.length}`, samples }; + } + // Nobody agrees within the band — every source disagrees. Fall back to + // the median of everything reported so we still show A price (biased + // toward the middle) rather than nothing. Panel can show a "spread" + // warning if callers care. + return { usd: mid, method: `median-${samples.length}`, samples }; +} + +// Fan out to every SOURCES.fetch() in parallel. Returns +// { perChain: { : {usd, method, samples} }, sourceStatus: { : {ok, error, prices, at} } }. +async function pollAll(log) { + const sourceStatus = {}; + const perSourcePrices = {}; // chain -> {sourceId: usd} + + await Promise.all(Object.values(SOURCES).map(async (s) => { + const start = Date.now(); + try { + const got = await s.fetch(); + const prices = got && typeof got === "object" ? got : {}; + sourceStatus[s.id] = { ok: true, error: null, prices, at: Date.now(), took: Date.now() - start }; + for (const [chain, usd] of Object.entries(prices)) { + if (!Number.isFinite(usd) || usd <= 0) continue; + if (!perSourcePrices[chain]) perSourcePrices[chain] = {}; + perSourcePrices[chain][s.id] = usd; + } + } catch (e) { + const msg = e?.message || String(e); + sourceStatus[s.id] = { ok: false, error: msg, prices: {}, at: Date.now(), took: Date.now() - start }; + log(`price fetch (${s.id}) failed:`, msg); + } + })); + + const perChain = {}; + for (const chain of CHAINS) { + const rec = reconcileOne(perSourcePrices[chain] || {}); + if (rec.usd != null) perChain[chain] = rec; + } + return { perChain, sourceStatus }; +} module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { const state = { enabled: false, - source: DEFAULT_SOURCE, - prices: {}, // { : usd (number) } + prices: {}, // { : usd (number) } — backward-compat + reconciled: {}, // { : {usd, method, samples: [...]} } + sourceStatus: {}, // { : {ok, error, prices, at, took} } fetchedAt: null, error: null, loading: false, }; 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 src = currentProvider(); - const next = await src.fetch(); - state.prices = next || {}; + const { perChain, sourceStatus } = await pollAll(log); + const flat = {}; + for (const [chain, rec] of Object.entries(perChain)) flat[chain] = rec.usd; + state.prices = flat; + state.reconciled = perChain; + state.sourceStatus = sourceStatus; state.fetchedAt = Date.now(); - state.error = null; + // Only escalate to a top-level error if EVERY source failed. A single + // oracle being unreachable is normal and doesn't need a red banner. + const allDown = Object.values(sourceStatus).every((s) => !s.ok); + state.error = allDown ? "All price sources unreachable" : null; } catch (e) { state.error = e?.message || String(e); - log(`price fetch (${state.source}) failed:`, state.error); + log("price poll failed:", state.error); } finally { state.loading = false; onChange(); @@ -133,30 +252,31 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } function schedule() { clearTimeout(timer); if (!state.enabled) return; - timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs); + timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); } return { snapshot() { return { enabled: state.enabled, - source: state.source, prices: state.prices, + reconciled: state.reconciled, + sourceStatus: state.sourceStatus, 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, - })), + // Retained so existing settings UI paths that expect a `sources` + // list keep rendering. `coversAll` is informational only now that + // the picker's gone. + 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 a full poll interval for the first price. async setEnabled(on) { const changed = !!on !== state.enabled; state.enabled = !!on; if (!state.enabled) { - state.prices = {}; state.fetchedAt = null; state.error = null; + state.prices = {}; state.reconciled = {}; state.sourceStatus = {}; + state.fetchedAt = null; state.error = null; clearTimeout(timer); if (changed) onChange(); return; @@ -165,15 +285,10 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } await fetchOnce(); schedule(); }, - // 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(); } - }, + // No-op kept for API compatibility — there is no source picker anymore. + // Existing callers that persisted a chosen source can still call this + // and get a benign refresh. + async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } }, refresh() { return fetchOnce(); }, dispose() { clearTimeout(timer); state.enabled = false; }, }; diff --git a/bundled-addons/aegis/lib/sia/siascan.js b/bundled-addons/aegis/lib/sia/siascan.js new file mode 100644 index 00000000..b90f6915 --- /dev/null +++ b/bundled-addons/aegis/lib/sia/siascan.js @@ -0,0 +1,156 @@ +// Siascan (SiaFoundation/explored) public read-only client. Used by +// SiaWallet when the user has NOT pointed Aegis at their own walletd +// URL — with a live siascan endpoint we can render balance, unspent +// outputs, transaction history, and the chain tip without any hosting +// on the user's side. +// +// Endpoints (from SiaFoundation/explored api/server.go): +// GET /consensus/tip +// GET /addresses/{addr}/balance +// GET /addresses/{addr}/events +// GET /addresses/{addr}/events/unconfirmed +// GET /addresses/{addr}/utxos/siacoin +// POST /txpool/broadcast — broadcast a v1 tx +// POST /v2/transactions — batch fetch (not broadcast; broadcast is v1) +// +// Broadcast (send) still requires walletd today: the tx we build is a v2 +// transaction and siascan's broadcast is currently v1-only. Once the v2 +// broadcast endpoint lands upstream this same client picks it up. + +const DEFAULT_BASE = "https://api.siascan.com"; + +module.exports = function makeSiascan({ log = () => {} } = {}) { + + class SiascanClient { + constructor(baseUrl) { + this._base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, ""); + } + get displayUrl() { return this._base; } + setBase(url) { this._base = String(url || DEFAULT_BASE).replace(/\/+$/, ""); } + + async _get(path) { + const url = this._base + path; + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) { + const body = await r.text().catch(() => ""); + throw new Error(`siascan ${r.status} ${path}: ${body.slice(0, 200)}`); + } + return r.json(); + } + + // Chain tip. Used to compute confirmations on history events. + async tip() { + const j = await this._get("/consensus/tip"); + return { height: Number(j?.height || 0), id: String(j?.id || "") }; + } + + // Wallet-agnostic balance for one address. Returns hastings as decimal + // strings so the panel keeps the BigInt-safe wire format the walletd + // path already emits. + async balance(address) { + const j = await this._get(`/addresses/${encodeURIComponent(address)}/balance`); + // explored shape: { siacoins, immatureSiacoins, siafunds } + // Each is a hastings string (v2 currency serialisation). + return { + confirmed: String(j?.siacoins || "0"), + immature: String(j?.immatureSiacoins || "0"), + // Aegis's panel treats "unconfirmed" as "not yet spendable". Explored + // lumps immature payout there; a strict unconfirmed number would + // need the /events/unconfirmed sum instead — added below. + unconfirmed: String(j?.immatureSiacoins || "0"), + siafunds: Number(j?.siafunds || 0), + }; + } + + // Confirmed history events. Each event carries a type ("v2Transaction", + // "siacoinInput", "minerPayout", …), the amount delta from THIS address's + // perspective, and a maturity/block height. + async events(address, { limit = 25, offset = 0 } = {}) { + const q = `?limit=${limit}&offset=${offset}`; + const list = await this._get(`/addresses/${encodeURIComponent(address)}/events${q}`); + return Array.isArray(list) ? list : []; + } + + async unconfirmedEvents(address) { + const list = await this._get(`/addresses/${encodeURIComponent(address)}/events/unconfirmed`); + return Array.isArray(list) ? list : []; + } + + // Unspent Siacoin outputs. { id, siacoinOutput: {value, address}, maturityHeight } + async siacoinUtxos(address) { + const list = await this._get(`/addresses/${encodeURIComponent(address)}/utxos/siacoin`); + return Array.isArray(list) ? list : []; + } + + // Broadcast a v2 transaction (or a set). explored's POST /txpool/broadcast + // takes { transactions: [v1…], v2Transactions: [v2…] } — we only ever + // send the v2 form (Aegis's tx builder is v2-only). Returns nothing + // on success; a 200 means "accepted into the pool". + async broadcastV2(v2TxOrSet) { + const set = Array.isArray(v2TxOrSet) ? v2TxOrSet : [v2TxOrSet]; + const url = this._base + "/txpool/broadcast"; + const body = JSON.stringify({ transactions: [], v2Transactions: set }); + const r = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + if (!r.ok) { + const errBody = await r.text().catch(() => ""); + throw new Error(`siascan broadcast ${r.status}: ${errBody.slice(0, 250)}`); + } + // explored responds 200 with an empty body on success; nothing to + // parse. Caller derives the txid client-side from the signed tx. + return true; + } + } + + // Compute a per-event delta for the SUBJECT address. explored returns + // rich event structures; we normalise to Aegis's { txid, delta, to, + // confirmations, time } row shape. delta is a signed BigInt-safe string. + // Positive = received, negative = spent. + function normaliseEvents(rawEvents, subjectAddress, tipHeight) { + const out = []; + for (const ev of rawEvents || []) { + const kind = String(ev?.type || ""); + const height = Number(ev?.index?.height || ev?.maturityHeight || 0); + const confirmations = height && tipHeight ? Math.max(0, tipHeight - height + 1) : 0; + // Sum outputs to us minus inputs from us. + let received = 0n, spent = 0n, other = null; + const dat = ev?.data || {}; + const outputs = dat?.siacoinOutputs || dat?.transaction?.siacoinOutputs || []; + const inputs = dat?.siacoinInputs || dat?.transaction?.siacoinInputs || []; + for (const o of outputs) { + const addr = o?.siacoinOutput?.address || o?.address || null; + const val = toBigStr(o?.siacoinOutput?.value || o?.value); + if (addr === subjectAddress) received += BigInt(val); + else if (!other) other = addr; + } + for (const i of inputs) { + const addr = i?.parent?.siacoinOutput?.address || i?.address || null; + const val = toBigStr(i?.parent?.siacoinOutput?.value || i?.value); + if (addr === subjectAddress) spent += BigInt(val); + } + const delta = (received - spent).toString(); + out.push({ + txid: String(ev?.id || ""), + delta, + to: (BigInt(delta) < 0n && other) ? other : null, + from: null, + fee: null, + time: Number(ev?.timestamp || 0), + confirmations, + status: confirmations > 0 ? "confirmed" : "pending", + kind, + }); + } + return out; + } + function toBigStr(x) { + if (typeof x === "string") return x; + if (typeof x === "bigint") return x.toString(); + return String(x || "0"); + } + + return { SiascanClient, normaliseEvents, DEFAULT_BASE }; +}; diff --git a/bundled-addons/aegis/lib/tx.js b/bundled-addons/aegis/lib/tx.js index d903da67..a243c734 100644 --- a/bundled-addons/aegis/lib/tx.js +++ b/bundled-addons/aegis/lib/tx.js @@ -37,7 +37,22 @@ module.exports = function makeTx({ sha256 }) { const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout)); const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE; - const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte); + // OP_RETURN data outputs vary — approximate with 12 + payload bytes to + // keep the fee estimate honest without threading a full byte size through. + const estimateSizeWithData = (nIn, nOut, dataBytes) => estimateSize(nIn, nOut) + (dataBytes ? 12 + dataBytes : 0); + const feeFor = (nIn, nOut, satPerByte, dataBytes = 0) => Math.ceil(estimateSizeWithData(nIn, nOut, dataBytes) * satPerByte); + + // Build a memo protocol OP_RETURN script from a plain UTF-8 string. Layout: + // 0x6a OP_RETURN + // [pushdata] memo bytes (up to 220 to stay under standardness) + // The output's value is always 0 and it's flagged { data: true } so the + // dust check in select() skips it. Callers can pass raw bytes if they + // want to embed a non-UTF8 payload; strings are the common case. + function memoScript(input) { + const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input || 0); + if (bytes.length > 220) throw new Error(`memo too long: ${bytes.length} bytes (max 220)`); + return concat(Uint8Array.from([0x6a]), pushdata(bytes)); + } // inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }] // outputs: [{ value, script(Uint8Array) }] @@ -81,42 +96,51 @@ module.exports = function makeTx({ sha256 }) { return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) }; } - // Largest-first accumulation. `targets` = [{ value, script }]; returns - // { inputs, outputs, fee, change } or throws when funds don't cover it. - // sendMax: spend every UTXO into targets[0] and no change. + // Largest-first accumulation. `targets` = [{ value, script, data? }]: + // data:true — an OP_RETURN memo output; value MUST be 0 and doesn't + // count toward the send amount or the dust check. + // sendMax spends every UTXO into the sole non-data target with no change. + // Data outputs are preserved verbatim in every returned outputs array. function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) { + const dataOuts = targets.filter((t) => t.data); + const spendOuts = targets.filter((t) => !t.data); + const dataBytes = dataOuts.reduce((a, t) => a + (t.script?.length || 0), 0); const sorted = utxos.slice().sort((a, b) => b.value - a.value); const total = sorted.reduce((a, u) => a + u.value, 0); if (sendMax) { - if (targets.length !== 1) throw new Error("send max needs exactly one recipient"); - const fee = feeFor(sorted.length, 1, satPerByte); + if (spendOuts.length !== 1) throw new Error("send max needs exactly one recipient"); + const fee = feeFor(sorted.length, 1 + dataOuts.length, satPerByte, dataBytes); const value = total - fee; if (!sorted.length || value < DUST) throw new Error("balance too small to send"); - return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 }; + return { inputs: sorted, outputs: [{ value, script: spendOuts[0].script }, ...dataOuts], fee, change: 0 }; } - const want = targets.reduce((a, t) => a + t.value, 0); - for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`); + const want = spendOuts.reduce((a, t) => a + t.value, 0); + for (const t of spendOuts) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`); const chosen = []; let sum = 0; for (const u of sorted) { chosen.push(u); sum += u.value; - const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte); + const feeWithChange = feeFor(chosen.length, spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes); if (sum >= want + feeWithChange) { const change = sum - want - feeWithChange; if (change >= DUST) { - return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change }; + return { + inputs: chosen, + outputs: [...spendOuts, { value: change, script: changeScript }, ...dataOuts], + fee: feeWithChange, change, + }; } // Change would be dust: fold it into the fee, one output fewer. const fee = sum - want; - return { inputs: chosen, outputs: targets.slice(), fee, change: 0 }; + return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee, change: 0 }; } - const feeNoChange = feeFor(chosen.length, targets.length, satPerByte); + const feeNoChange = feeFor(chosen.length, spendOuts.length + dataOuts.length, satPerByte, dataBytes); if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) { - return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 }; + return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee: sum - want, change: 0 }; } } - const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total; + const short = want + feeFor(Math.max(1, sorted.length), spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes) - total; throw new Error(`insufficient funds: need about ${short} more sat`); } - return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha }; + return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, memoScript, toHex, fromHex, dsha, concat, pushdata }; }; diff --git a/bundled-addons/aegis/lib/wallet.js b/bundled-addons/aegis/lib/wallet.js index 16407c5d..faa62975 100644 --- a/bundled-addons/aegis/lib/wallet.js +++ b/bundled-addons/aegis/lib/wallet.js @@ -1,6 +1,15 @@ // Wallet state machine on top of an electrum client and a WalletKeys tree: // address discovery (gap limit), balance, history with per-tx deltas, UTXO // set and send construction. Knows nothing about UI or IPC. +// +// 0.7.0: CashTokens read + coin-selection guard. Every UTXO fetched from +// listunspent is enriched with its scriptPubKey and passed through +// cashtokens.decodePrefixedScript. Token UTXOs are tagged { token: {…} } +// and pooled into state.tokenBalances (category → aggregate); they are +// deliberately EXCLUDED from plain-BCH coin selection so no token UTXO +// gets accidentally spent (and its category burned) on a routine send. +const cashtokens = require("./cashtokens.js"); + module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) { const GAP = 20; const HISTORY_LIMIT = 25; @@ -11,7 +20,8 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora watched: new Map(), // scripthash -> entry height: 0, balance: { confirmed: 0, unconfirmed: 0 }, - utxos: [], // { txid, vout, value, height, entry } + utxos: [], // { txid, vout, value, height, entry, token? } + tokenBalances: {}, // { : { fungible: bigint, nfts: [...], utxoIds: [...] } } history: [], // newest first receiveIndex: 0, scanning: false, @@ -70,12 +80,70 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora async function loadUtxos() { const lists = await Promise.all([...state.watched.values()].map(async (e) => { const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); - return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + return (Array.isArray(u) ? u : []).map((x) => ({ + txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e, + })); })); - state.utxos = lists.flat(); - let confirmed = 0, unconfirmed = 0; - for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } - state.balance = { confirmed, unconfirmed }; + const utxos = lists.flat(); + // Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript + // can classify it. getTx() already caches to disk, so a re-scan on a + // wallet with hundreds of UTXOs only fetches new ones. Failures are + // tolerated — an un-classifiable UTXO is treated as bare BCH, which + // is the conservative choice (worst case: user sees BCH value in + // balance but the coin selector still won't pick it if its token + // status matters — it just won't participate in a token send either). + const tokenBalances = {}; + await Promise.all(utxos.map(async (u) => { + try { + const t = await getTx(u.txid); + const out = t.vout[u.vout]; + if (!out || !out.scriptHex) return; + const scriptBytes = tx.fromHex(out.scriptHex); + const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes); + u.scriptHex = out.scriptHex; + u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join(""); + if (token) { + u.token = token; + const cat = token.categoryHex; + if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] }; + if (token.hasAmount) tokenBalances[cat].fungible += token.amount; + if (token.hasNft) { + tokenBalances[cat].nfts.push({ + utxoId: `${u.txid}:${u.vout}`, + commitmentHex: token.commitmentHex, + capability: token.capability, + capabilityLabel: token.capabilityLabel, + }); + } + tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`); + } + } catch (e) { + log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e); + } + })); + state.utxos = utxos; + // Serialize BigInt fungible amounts as decimal strings for the snapshot + // (JSON.stringify chokes on BigInt otherwise). + const serializedBalances = {}; + for (const [cat, bal] of Object.entries(tokenBalances)) { + serializedBalances[cat] = { + fungible: bal.fungible.toString(), + nfts: bal.nfts, + utxoCount: bal.utxoIds.length, + }; + } + state.tokenBalances = serializedBalances; + // Balance number is BCH sat only — token UTXOs still carry a small + // BCH value (dust minimum for the prefix), but treating that as + // spendable would let a routine send burn the token. Track total + // separately as bareBalance so the panel can still show "there's + // BCH sitting in token UTXOs". + let confirmed = 0, unconfirmed = 0, tokenLocked = 0; + for (const u of utxos) { + if (u.token) { tokenLocked += u.value; continue; } + if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; + } + state.balance = { confirmed, unconfirmed, tokenLocked }; } async function getTx(txid) { @@ -197,7 +265,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora } // targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan. - function plan({ targets, feeRate = 1, sendMax = false }) { + // memo: optional string (UTF-8, ≤220 bytes) — attached as an OP_RETURN + // data output. Zero value, no dust check, fee estimate accounts + // for the extra bytes. Passing "" disables the memo. + function plan({ targets, feeRate = 1, sendMax = false, memo = "" }) { const rate = Math.min(10, Math.max(1, Number(feeRate) || 1)); const outs = targets.map((t) => { const a = cashaddr.parseAny(t.to, sha256, keys.prefix); @@ -206,10 +277,25 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]); return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; }); - // Spend confirmed coins first; unconfirmed only when needed. - const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + if (memo) outs.push({ value: 0, script: tx.memoScript(memo), data: true, memo }); + // Spend confirmed coins first; unconfirmed only when needed. Token + // UTXOs are excluded entirely — burning a category by dropping its + // prefix is not a mistake we can undo, so a plain BCH send must + // never pull one. Token sends have their own code path with + // { includeToken: category } later. + const spendable = state.utxos + .filter((u) => !u.token) + .slice() + .sort((a, b) => (b.height > 0) - (a.height > 0)); const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax }); - return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) }; + // recipients only lists spendable (non-data) outputs, keeping the + // panel's summary honest — the memo is surfaced separately as .memo. + const spendable_outs = sel.outputs.filter((o) => !o.data); + return { + ...sel, feeRate: rate, + recipients: spendable_outs.map((o, i) => ({ to: outs[i]?.to, value: o.value })), + memo: memo || null, + }; } async function signAndBroadcast(p) { @@ -232,6 +318,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora height: state.height, history: state.history, utxoCount: state.utxos.length, + // CashTokens balances, keyed by category hex. Empty object when the + // wallet holds no token UTXOs. Serialised BigInts (fungible amounts) + // come across as decimal strings — panel formats via BigInt again. + tokenBalances: state.tokenBalances, scanning: state.scanning, error: state.error, }; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 5c27a21a..da094776 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -37,9 +37,11 @@ .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; } - /* 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; + /* Full-panel sheet — fills most of the sidebar but stops above the + footer so the aegis.x brand + version chip stay visible. Users + opening + should still know which build they're on and be able + to hit the update button. */ + #drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 30px; 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; } @@ -66,9 +68,37 @@ #drop .netgroup[hidden] { display: none; } #drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); } #drop .netchoice:hover { background: rgba(255,255,255,.06); color: var(--ink); } + /* Currency-browse network chip row (0.8.0). Sits above the wallet list + under a coin; clicking a chip switches which subnetwork's wallets + are visible AND persists the choice per chain. */ + #drop .brnetrow { display: flex; gap: 6px; padding: 8px 8px 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); } + #drop .brnet { background: transparent; border: 1px solid var(--line); color: var(--mut); + border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; + display: inline-flex; align-items: center; gap: 4px; } + #drop .brnet:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); } + #drop .brnet.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14); border-color: rgb(from var(--acid, #d6ff3d) r g b / .55); + color: var(--acid, #d6ff3d); font-weight: 600; } + #drop .brnet .hint { color: currentColor; opacity: .65; font-size: 11px; } .ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px; background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; } - #hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; } + /* 0.8.4: hNet became a proper chip on its own row (.netrow) so the + network the wallet is on is easy to spot AND easy to switch. */ + .netrow { display: flex; margin-top: 6px; padding: 0 2px; } + .netchip { background: rgba(255,255,255,.05); border: 1px solid var(--line); + color: var(--mut); border-radius: 999px; padding: 3px 10px 3px 8px; + font: inherit; font-size: 11.5px; cursor: pointer; display: inline-flex; + align-items: center; gap: 6px; line-height: 1.2; } + .netchip:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); } + .netchip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .7; } + /* Currency-picker search + all-coins catalogue (0.8.4). */ + #drop .pickersearch { padding: 8px; border-bottom: 1px solid var(--line); } + #drop .pickersearch input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,.04); + border: 1px solid var(--line); color: var(--ink); border-radius: 7px; + padding: 7px 10px; font: inherit; font-size: 12.5px; outline: none; } + #drop .pickersearch input:focus { border-color: rgb(from var(--acid, #d6ff3d) r g b / .55); } + #drop .catgroup { padding: 4px 6px 2px 10px; color: var(--dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; } + #drop .row.unowned { opacity: .8; } + #drop .row.unowned .v { color: var(--acid, #d6ff3d); font-weight: 600; font-size: 11px; } .fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; } .portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; } .portfolio b { color: var(--ink); font-weight: 600; } @@ -78,6 +108,39 @@ padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; } .chip:hover { border-color: var(--acid); color: var(--acid); } + /* Edit-this-wallet button living inside the label group. Compact and + borderless so it reads as an inline affordance, not a separate + action chip. Fades in on hover of the label row so the header + itself stays visually quiet when the user isn't targeting it. */ + .editchip { background: transparent; border: 0; color: var(--dim); cursor: pointer; + padding: 2px 4px; border-radius: 4px; font: inherit; font-size: 12px; line-height: 1; + opacity: .5; transition: opacity .12s, color .12s; margin-left: 2px; } + .picker:hover .editchip { opacity: 1; } + .editchip:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); } + /* Send/Receive mode toggle (0.8.0). Segmented control at the top of a + tab; picks whether the body shows the normal flow or the consolidate + inline picker. */ + .modetoggle { display: flex; gap: 0; border: 1px solid var(--line); border-radius: 8px; padding: 3px; + margin-bottom: 12px; background: rgba(255,255,255,.03); } + .modetoggle[hidden] { display: none; } + .modetoggle button { flex: 1; background: transparent; border: 0; color: var(--dim); + padding: 6px 8px; font: inherit; font-size: 12.5px; border-radius: 6px; + cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 5px; } + .modetoggle button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16); color: var(--acid, #d6ff3d); font-weight: 600; } + .modetoggle button:hover:not(.on) { color: var(--ink); } + .modetoggle .hint { color: currentColor; opacity: .65; font-size: 11px; } + /* Settings section chip nav (0.8.2). Segmented row that pages between + Security / Session / Wallet / Prices / Sites / About cards without + scrolling through the whole tab. */ + .setsecnav { display: flex; flex-wrap: wrap; gap: 4px; border: 1px solid var(--line); + border-radius: 8px; padding: 3px; margin-bottom: 14px; + background: rgba(255,255,255,.03); } + .setsecnav button { flex: 1 0 auto; min-width: 62px; background: transparent; border: 0; + color: var(--dim); padding: 6px 10px; font: inherit; font-size: 12px; + border-radius: 6px; cursor: pointer; } + .setsecnav button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16); + color: var(--acid, #d6ff3d); font-weight: 600; } + .setsecnav button:hover:not(.on) { color: var(--ink); } nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); } nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; } @@ -262,6 +325,14 @@ .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; } + /* Multi-wallet count pill inside the ticker cell. When the group has + more than one wallet it doubles as the "swap active wallet" trigger + (▾ chevron), independent of the row's own click target which now + selects the current active wallet directly. */ + .wstrip .wcname .wgcount.wgpick { cursor: pointer; } + .wstrip .wcname .wgcount.wgpick:hover { background: rgba(255,255,255,.12); color: var(--acid, #d6ff3d); } + .wstrip .wcname .wgcount .wgchev { color: var(--dim); font-size: 9px; margin-left: 1px; } + .wstrip .wcname .wgcount.wgpick:hover .wgchev { color: var(--acid, #d6ff3d); } .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; @@ -313,21 +384,86 @@ .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; } + /* Inline address-list row (per-coin drilldown). 0.6.35 layout: strictly + columnar so every row's fields line up in the same x positions no + matter how long each label happens to be. The address column is the + only flex one (minmax 0/1fr) — it fills whatever's left after the + fixed cells and truncates with an ellipsis, so a wider sidebar shows + more of the address without the label/balance jumping around. Every + other column has an explicit width — label pill is fixed to 68px so + "a" and "anthem…" occupy the same slot, amount is fixed to 90px so + "0" and "0.000123" right-align identically, actions are fixed to + 46px. Net result: columns look like a table, not a flex mess. */ + .wstrip .warow { display: grid; + grid-template-columns: 16px minmax(0,1fr) 22px 68px 90px 46px; + gap: 6px; align-items: center; padding: 4px 4px; + border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 28px; } .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 .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + /* Copy chip anchored right after the address. Fixed 22px column so the + copy button sits at the same x on every row. */ + .wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer; + padding: 2px 4px; border-radius: 4px; font-size: 11px; line-height: 1; + transition: color .12s; justify-self: start; } + .wstrip .warow .wacopy:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); } + .wstrip .warow .wacopy.copied { color: var(--acid, #d6ff3d); } + /* Label pill: fixed 68px column. Even an empty label renders an + invisible placeholder so the amount column stays put. Long labels + truncate with ellipsis inside the pill (max-width: 100% of column). */ + .wstrip .warow .walabel { font-size: 11px; color: var(--mut); + padding: 1px 6px; border-radius: 999px; + background: rgba(255,255,255,.06); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 100%; box-sizing: border-box; + justify-self: center; } + .wstrip .warow .walabel:empty { visibility: hidden; } + /* Balance shares a single line with everything else — no vertical + amount/fiat stack. Ticker follows the number in a dim tone so the + row reads "0 BCH" at a glance. Right-aligned within the fixed + amount column so short and long numbers line up. */ + .wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; + font-size: 12.5px; color: var(--ink); white-space: nowrap; + display: inline-flex; align-items: baseline; gap: 4px; + justify-self: end; overflow: hidden; } + .wstrip .warow .waamt .watkr { color: var(--dim); font-size: 11px; font-weight: 500; } .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); } + /* Destructive strip action (remove wallet). Stays quiet until hovered so + a row of icons doesn't read as a row of warnings. */ + .wstrip .wact.wactdel:hover { color: var(--danger, #f6768a); background: rgb(from var(--danger, #f6768a) r g b / .12); } + /* Per-address asset list (0.8.8). The count chip expands the row into a + nested list of what that ONE address holds beyond the native coin. */ + .wstrip .waassets { background: rgba(255,255,255,.06); border: 0; color: var(--dim); cursor: pointer; + font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 999px; line-height: 1.5; } + .wstrip .waassets:hover, .wstrip .waassets.on { color: var(--acid, #d6ff3d); background: rgb(from var(--acid, #d6ff3d) r g b / .14); } + .wstrip .waassetlist { padding: 2px 6px 6px 26px; display: flex; flex-direction: column; gap: 2px; } + .wstrip .waasset { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 8px; align-items: baseline; + font-size: 11px; color: var(--mut); padding: 2px 4px; border-radius: 4px; } + .wstrip .waasset:hover { background: rgba(255,255,255,.04); } + .wstrip .waasset .waaname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .wstrip .waasset .waaid { color: var(--dim); font-size: 10px; } + .wstrip .waasset .waaamt { font-variant-numeric: tabular-nums; color: var(--ink); } + /* Coin drilldown header: shows the coin's per-unit price + running + total of the addresses below, so the aggregate context isn't lost + when the user is deep in the per-address view. */ + .wstrip .wcoinsub { display: flex; align-items: baseline; gap: 8px; padding: 2px 4px 5px 4px; + font-size: 11.5px; color: var(--mut); border-bottom: 1px solid var(--line); + margin-bottom: 3px; } + .wstrip .wcoinsub .wprice { color: var(--acid, #d6ff3d); font-weight: 600; font-variant-numeric: tabular-nums; + text-shadow: 0 0 5px rgb(from var(--acid, #d6ff3d) r g b / .30); } + .wstrip .wcoinsub .wsep { color: var(--dim); } + .wstrip .wcoinsub .wtot { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; } + .wstrip .wcoinsub .wtotfiat { color: var(--dim); font-variant-numeric: tabular-nums; } + /* Adapter-error line above the address rows. Shown only when at least + one wallet has a non-null .error — makes silent RPC failures visible + instead of the display quietly rendering 0. */ + .wstrip .wcoinerr { color: #e05a5a; font-size: 11px; padding: 4px 6px; + background: rgba(224,90,90,.08); border-radius: 4px; + margin-bottom: 4px; overflow-wrap: anywhere; } /* 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 @@ -343,10 +479,17 @@ 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; } + /* The form inherits the lock screen's centred alignment — it used to + force text-align:left, which left the setup screen's helper copy + running ragged against a centred title, mark and description. */ + #lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: center; } #lockScreen .lockform input[type=password], - #lockScreen .lockform input[type=text], - #lockScreen .lockform textarea { text-align: center; } + #lockScreen .lockform input[type=text] { text-align: center; } + /* The mnemonic stays left-aligned on purpose: 12/24 words wrap across + several lines, and centring makes them ragged on both edges, which is + exactly the wrong thing when someone is checking a seed word by word. */ + #lockScreen .lockform textarea { text-align: left; } + #lockScreen .lockform .hint { 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; } @@ -396,14 +539,25 @@
Aegis Wallet - + +
- - - + +
+ +
@@ -432,6 +586,15 @@
+ + +
Receiving address
@@ -446,10 +609,30 @@ +
+ +
@@ -825,15 +1069,16 @@ aegis.x - - + + diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 3d1ce1d6..cdb5f425 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -8,6 +8,14 @@ let tab = "receive"; let unit = null; // "big" | "small" — chain-dependent let sendMax = false; let planTimer = null; +// 0.8.0: mode toggles for the Send + Receive tabs. "send"/"receive" is +// the normal flow; "consolidate" swaps the tab body for the inline +// batch-consolidate picker. Session-scoped — resets to normal on reload. +let sendMode = "send"; +let rcvMode = "receive"; +// Cached inline consolidate render tokens — rebuilt on demand, reused +// across paints while the mode is active. +let consolidateInlineHost = null; let lastPlan = null; let settingsFilled = false; // Selected asset for the Send tab. `null` = native coin. Otherwise a @@ -40,6 +48,20 @@ const shortLabel = (s, n) => { const cap = Math.max(1, n || 7); return t.length > cap ? t.slice(0, cap) + "…" : t; }; +// CashAddr / BIP-173 / Cashtokens all prefix the mainnet or testnet name +// before the payload ("bitcoincash:qz…", "bchtest:qp…", "bchreg:qr…"). +// The prefix is the same on every row of a coin drilldown so showing it +// there is redundant noise — strip for display, keep in the tooltip and +// the clipboard so the full canonical form is one hover / one click away. +// Non-BCH addresses (ETH 0x…, TRX T…, base58 SOL) pass through unchanged. +const stripAddrPrefix = (addr) => { + if (!addr) return ""; + const s = String(addr); + const i = s.indexOf(":"); + if (i < 0) return s; + const p = s.slice(0, i).toLowerCase(); + return (p === "bitcoincash" || p === "bchtest" || p === "bchreg") ? s.slice(i + 1) : s; +}; 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: /, ""); @@ -297,7 +319,7 @@ function showTab(name) { tab = name; document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name)); document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; }); - if (name === "settings") { settingsFilled = false; fillSettings(); } + if (name === "settings") { settingsFilled = false; fillSettings(); applySetSec(activeSetSec); } if (name === "send") applyUnitPicker(); // 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 @@ -305,22 +327,69 @@ function showTab(name) { render(); } +// ---- settings section nav (0.8.2) ----------------------------------------- +// Settings grew tall enough (Security, Session, Master password, MultiSig, +// Wallet manage + 6 per-chain cards, Prices, Sites) that scrolling to any +// one was awkward. Segment the tab with a chip row: only one section is +// visible at a time, choice persists in localStorage so users land back +// where they left off. +let activeSetSec = "security"; +try { + const saved = localStorage.getItem("aegis/setSec"); + if (saved) activeSetSec = saved; +} catch (_e) {} +function applySetSec(name) { + activeSetSec = name || "security"; + try { localStorage.setItem("aegis/setSec", activeSetSec); } catch (_e) {} + document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => { + b.classList.toggle("on", b.dataset.setsec === activeSetSec); + }); + // A section can span more than one card (Security holds both the top + // Security card and the MultiSig card lower down), so toggle every + // matching body — hide non-matches. + document.querySelectorAll("[data-setsec-body]").forEach((el) => { + el.hidden = el.dataset.setsecBody !== activeSetSec; + }); +} +document.querySelectorAll("#setsecnav [data-setsec]").forEach((b) => { + b.addEventListener("click", () => applySetSec(b.dataset.setsec)); +}); +applySetSec(activeSetSec); + // ---- wallet picker (two-step add) ------------------------------------------ $("pickerBtn").addEventListener("click", (e) => { - // 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); + // 0.8.0: header is a CURRENCY PICKER. Clicking the wallet name/badge + // opens the browse pane in #drop (coin list → wallet list per coin + // → click a wallet to select it). The ✎ chip on the right opens the + // per-wallet manage modal (rename, path, remove), which is the + // dedicated "edit THIS wallet" affordance — different intent. + if (e.target && e.target.closest("#hAdd")) return; + if (e.target && e.target.closest("#hManage")) { + e.stopPropagation(); + const w = (state?.wallets || []).find((x) => x.id === state?.selectedWalletId); + if (w) openWalletManageModal(w); + return; + } e.stopPropagation(); + pickerTab = "browse"; + const d = $("drop"); + positionDropBelowTabs(d); + d.hidden = false; + fillPicker(); +}); +// 0.8.4: separate netchip row. Click jumps straight to the browse:chain +// view for the current wallet's chain — the network-chip strip at the +// top of that view is what actually switches networks. +$("hNet").addEventListener("click", (e) => { + e.stopPropagation(); + const w = (state?.wallets || []).find((x) => x.id === state?.selectedWalletId); + if (!w) return; + pickerTab = "browse:" + w.chain; + const d = $("drop"); + positionDropBelowTabs(d); + d.hidden = false; + fillPicker(); }); // + 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, @@ -328,14 +397,32 @@ $("pickerBtn").addEventListener("click", (e) => { // outer pickerBtn click doesn't also fire "manage this wallet". $("hAdd").addEventListener("click", (e) => { e.stopPropagation(); - pickerTab = "add"; - const d = $("drop"); d.hidden = false; + // 0.7.1: header + opens the method chooser first (New / Import / + // Connect), then routes into the coin picker for the chosen method. + // Users who want to skip straight to "Add new" from another entry + // point (e.g. openMoreMenu) still set pickerTab = "add" directly. + pickerTab = "method"; + const d = $("drop"); + // 0.7.3: anchor the drop to the BOTTOM of the tabs bar (nav) so it + // opens over the wallet list + main content but leaves the wallet + // header (balance + selected wallet) AND the Receive/Send/History/ + // Settings tabs visible above it. Measured at open time because + // header height varies with content (portfolio line, error banner). + positionDropBelowTabs(d); + d.hidden = false; fillPicker(); }); -$("hMore").addEventListener("click", (e) => { - e.stopPropagation(); - openMoreMenu(); -}); +function positionDropBelowTabs(dropEl) { + try { + const nav = document.querySelector("nav"); + if (!nav) return; + const y = Math.round(nav.getBoundingClientRect().bottom); + if (y > 0) dropEl.style.top = y + "px"; + } catch {} +} +// hMore chip removed in 0.7.2 — the compact method chooser under hAdd +// carries Create / Import / Connect / About, so a second right-corner +// button was redundant. document.addEventListener("click", (e) => { const d = $("drop"); if (d.hidden) return; @@ -352,6 +439,11 @@ document.addEventListener("click", (e) => { // user who opens the picker → picks Import → cancels → reopens returns to // Wallets (the sane default). let pickerTab = "wallets"; +// 0.8.4: coin catalogue search query + one-shot preselected chain for the +// Add pane (set when the browse view routes into "add" for a specific +// unowned coin). +let browseQuery = ""; +let pickerAddChain = null; function fillPicker() { const d = $("drop"); @@ -408,16 +500,250 @@ function fillPicker() { // 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 + // 0.7.1: picker is now a stepped wizard. First "screen" ("method") asks + // HOW the user wants to add a wallet — three cards — before picking a + // coin. Once a method is chosen, the coin picker (or import options) + // appear with a "← Back" chevron so users can revise the method + // without closing the picker. + if (pickerTab === "wallets") pickerTab = "method"; // migrate any stale default + + // 0.8.0: browse mode — coin picker → wallet list. Entered by clicking + // the header. Two sub-states: + // pickerTab = "browse" → coin list (all chains user + // owns wallets for) + // pickerTab = "browse:" → wallet list for that chain, + // with a network-chip row at + // the top for switching. + if (pickerTab === "browse" || pickerTab.startsWith("browse:")) { + const focused = pickerTab.startsWith("browse:") ? pickerTab.slice(7) : null; + // Group user's wallets by chain — the browse view mirrors the strip's + // per-chain grouping but is triggered explicitly by clicking the + // header, and shows richer per-chain summaries (wallet count, total + // native balance, remembered active-network name). + const walletsByChain = new Map(); + for (const w of wallets) { + if (!walletsByChain.has(w.chain)) walletsByChain.set(w.chain, []); + walletsByChain.get(w.chain).push(w); + } + if (!focused) { + // 0.8.4: full catalogue with search. Every supported chain is + // listed, whether the user already has a wallet on it or not. + // Rows for owned coins jump to that coin's wallet list; rows for + // unowned coins jump straight into the Add-wallet flow for that + // coin, so the header picker doubles as a fast on-ramp. + const q = String(browseQuery || "").trim().toLowerCase(); + const matches = (c) => !q + || String(c.chain || "").toLowerCase().includes(q) + || String(c.label || "").toLowerCase().includes(q) + || String(c.ticker || "").toLowerCase().includes(q) + || String(c.short || "").toLowerCase().includes(q); + const ownedRows = []; + const otherRows = []; + for (const c of coins) { + if (!matches(c)) continue; + const ws = walletsByChain.get(c.chain) || []; + if (ws.length > 0) { + const first = ws[0]; + const nets = Array.from(new Set(ws.map((w) => w.network))); + let active = activeNetworkByChain.get(c.chain); + if (!active || !nets.includes(active)) active = nets.includes("mainnet") ? "mainnet" : nets[0]; + const netLbl = networkLabelFor(c.chain, active, active); + const totalUnits = sumGroupUnits(ws.filter((w) => w.network === active)); + const dec = first.decimals || c.decimals || 8; + const bal = fmtBig(totalUnits || 0, dec) + " " + esc(first.ticker || c.ticker || c.chain.toUpperCase()); + const usd = usdOf(c.chain, totalUnits || 0, dec); + const fiat = usd != null ? `
${esc(fmtFiat(usd))}
` : ""; + const activeMark = ws.some((w) => w.id === state?.selectedWalletId) ? "on" : ""; + ownedRows.push(`
+ ${logoSvg(c.logo, 22)} +
+
${esc(c.label)} · ${ws.length} wallet${ws.length === 1 ? "" : "s"}${nets.length > 1 ? ` · ${esc(netLbl)}` : ""}
+
${esc(c.ticker || c.chain)}
+
+
${bal}
${fiat}
+
`); + } else { + otherRows.push(`
+ ${logoSvg(c.logo, 22)} +
+
${esc(c.label)}
+
${esc(c.ticker || c.chain)}
+
+
+ Add
+
`); + } + } + d.innerHTML = ` +
+ Pick a coin + +
+
+
+ ${ownedRows.length ? `
Your wallets
${ownedRows.join("")}` : ""} + ${otherRows.length ? `
Add a new wallet
${otherRows.join("")}` : ""} + ${(!ownedRows.length && !otherRows.length) ? `
No coins match "${esc(q)}".
` : ""} +
`; + d.querySelectorAll("[data-browse-chain]").forEach((row) => row.addEventListener("click", (e) => { + e.stopPropagation(); + pickerTab = "browse:" + row.dataset.browseChain; + fillPicker(); + })); + d.querySelectorAll("[data-browse-add]").forEach((row) => row.addEventListener("click", (e) => { + e.stopPropagation(); + const chain = row.dataset.browseAdd; + // Jump straight into the create-wallet flow for that coin. The + // Add pane keys off `pickerTab = "add"` and reads the target + // chain from a scratch field the coin-picker fills. + pickerTab = "add"; + pickerAddChain = chain; + fillPicker(); + })); + const searchEl = d.querySelector("#pickerSearch"); + if (searchEl) { + searchEl.addEventListener("input", () => { + browseQuery = searchEl.value; + // Preserve caret across re-render. + const caret = searchEl.selectionStart; + fillPicker(); + const s2 = d.querySelector("#pickerSearch"); + if (s2) { s2.focus(); try { s2.setSelectionRange(caret, caret); } catch (_e) {} } + }); + // Autofocus on first render, but not on every re-render — the + // re-focus above handles that. Guard with a data flag. + if (!searchEl.dataset.autof) { searchEl.dataset.autof = "1"; searchEl.focus(); } + } + const closeBtn = d.querySelector("#pickerClose"); + if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; }); + return; + } + // WALLETS: list under one chain, with a network-chip row on top. + const ws = walletsByChain.get(focused) || []; + const nets = Array.from(new Set(ws.map((w) => w.network))); + const ordered = orderNetworks(nets); + let active = activeNetworkByChain.get(focused); + if (!active || !ordered.includes(active)) active = ordered.includes("mainnet") ? "mainnet" : ordered[0]; + const first = ws[0] || {}; + const filtered = ws.filter((w) => w.network === active); + const dec = first.decimals || 8; + const ticker = first.ticker || focused.toUpperCase(); + const netChips = ordered.length > 1 + ? `
${ordered.map((n) => { + const on = n === active ? "on" : ""; + const cnt = ws.filter((w) => w.network === n).length; + const lbl = networkLabelFor(focused, n, n); + return ``; + }).join("")}
` + : ""; + const rows = filtered.map((w) => { + const on = w.id === state?.selectedWalletId ? "on" : ""; + const units = walletBalanceUnits(w); + const bal = fmtBig(units || 0, dec) + " " + esc(ticker); + const usd = usdOf(w.chain, units || 0, dec); + const fiat = usd != null ? `
${esc(fmtFiat(usd))}
` : ""; + const importedTag = w.kind === "imported" ? ` IMPORTED` : ""; + const addrShort = w.address ? `${String(w.address).slice(0, 10)}…${String(w.address).slice(-6)}` : ""; + return `
+ ${logoSvg(w.logo, 22)} +
+
${esc(w.label)}${importedTag}
+
${esc(addrShort)}
+
+
${bal}
${fiat}
+
`; + }).join(""); + d.innerHTML = ` +
+ + + +
+ ${netChips} +
+ ${filtered.length ? rows : `
No ${esc(ticker)} wallet on this network yet.
`} +
`; + d.querySelectorAll("[data-browse-back]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); pickerTab = "browse"; fillPicker(); })); + d.querySelectorAll("[data-browse-net]").forEach((b) => b.addEventListener("click", (e) => { + e.stopPropagation(); + activeNetworkByChain.set(focused, b.dataset.browseNet); + persistActiveNetworks(); + fillPicker(); + })); + d.querySelectorAll("[data-browse-wid]").forEach((row) => row.addEventListener("click", async (e) => { + e.stopPropagation(); + const id = row.dataset.browseWid; + d.hidden = true; + pickerTab = "method"; // Reset so next + opens the add flow, not the wallet list. + try { + if (id !== state?.selectedWalletId) { + state = await S.invoke("selectWallet", { id }); + settingsFilled = false; + render(); + } + } catch (er) { showErr(cleanErr(er)); } + })); + const closeBtn = d.querySelector("#pickerClose"); + if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; }); + return; + } + + if (pickerTab === "method") { + // Compact method chooser. Three "how" options + About sit as short + // one-line rows, so #drop keeps to about a third of the panel and the + // footer (aegis.x brand + version) stays visible below it. Was three + // large cards in 0.7.1; the tall layout was covering the footer. + d.innerHTML = ` +
+ How would you like to add a wallet? + +
+
+
+ + +
Create a new wallet
Derived from your Theseus vault
+
›
+
+
+ ↓ +
Import an existing wallet
BIP39 mnemonic, WIF, or encrypted keystore
+
›
+
+
+ ⚡ +
Connect via WizardConnect${wcCount ? ` ${wcCount} paired` : ""}
Pair a hardware / desktop signer over WC
+
›
+
+
+
+ 🛡 +
About Aegis · aegis.x
Open the wallet's front-door site
+
›
+
+
`; + d.querySelectorAll("[data-method]").forEach((row) => row.addEventListener("click", (e) => { + e.stopPropagation(); + const m = row.dataset.method; + if (m === "about") { d.hidden = true; openUrl("https://aegis.x/"); return; } + pickerTab = m; + fillPicker(); + })); + const closeBtn = d.querySelector("#pickerClose"); + if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; }); + return; + } + + const modeLabel = pickerTab === "add" ? "Create a new wallet" + : pickerTab === "import" ? "Import an existing wallet" + : "Connect via WizardConnect"; d.innerHTML = `
- - - + +
-
Creates a new wallet derived from your Theseus vault. Pick a coin, then a network.
+
Pick a coin, then a network. The wallet is derived from your Theseus vault — nothing to write down.
${coinRows}
@@ -440,11 +766,11 @@ function fillPicker() { ${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. + // Back-chevron routes to the method screen. stopPropagation is critical: + // the click re-renders innerHTML, so 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; @@ -478,6 +804,21 @@ function fillPicker() { group.hidden = !group.hidden; r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾"; })); + // 0.8.4: if the browse view routed here with a preselected chain (user + // clicked "+ Add" on an unowned coin), expand that coin's network + // group AND scroll it into view so the user sees which networks + // exist without a second click. + if (pickerTab === "add" && pickerAddChain) { + const target = pickerAddChain; + pickerAddChain = null; + const row = d.querySelector(`.coinrow[data-coin="${target.replace(/["\\]/g, "")}"]`); + const group = d.querySelector(`#netgroup-${target.replace(/["\\]/g, "")}`); + if (row && group) { + group.hidden = false; + const caret = row.querySelector(".caret"); if (caret) caret.textContent = "▾"; + row.scrollIntoView({ block: "nearest" }); + } + } d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => { const [c, n] = r.dataset.add.split(":"); d.hidden = true; @@ -490,6 +831,91 @@ function fillPicker() { if (impKs) impKs.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openKeystoreImportModal(); }); } +// 0.8.6: in-panel replacement for window.confirm(). The native dialog is +// chrome-owned, so it renders as a Theseus-branded OS box outside the +// sidebar — jarring next to the wallet's own UI, and it can't carry an +// icon or a danger-styled button. Resolves true/false like confirm(), +// so callers just `await` it. +// opts: { title, body, confirmLabel, cancelLabel, danger, icon } +function aegisConfirm(opts) { + const o = opts || {}; + return new Promise((resolve) => { + 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:100000;padding-top:24px"; + overlay.innerHTML = ` +
+
+ ${o.icon || (o.danger ? "⚠" : "🛡")} +
${esc(o.title || "Are you sure?")}
+
+ ${o.body ? `
${o.body}
` : ""} +
+ ${o.alertOnly ? "" : ``} + +
+
`; + document.body.appendChild(overlay); + let done = false; + const finish = (val) => { + if (done) return; + done = true; + document.removeEventListener("keydown", onKey, true); + try { overlay.remove(); } catch {} + resolve(val); + }; + const onKey = (e) => { + if (e.key === "Escape") { e.stopPropagation(); finish(false); } + else if (e.key === "Enter") { e.stopPropagation(); finish(true); } + }; + document.addEventListener("keydown", onKey, true); + overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); }); + overlay.querySelectorAll("[data-ac]").forEach((b) => b.addEventListener("click", (e) => { + e.stopPropagation(); + finish(b.dataset.ac === "yes"); + })); + const yes = overlay.querySelector('[data-ac="yes"]'); + if (yes) yes.focus(); + }); +} + +// Single-button sibling of aegisConfirm, for the error notices that used +// window.alert(). Fire-and-forget: callers don't need the result, so it +// works from sync handlers too. +function aegisAlert(message, opts) { + const o = opts || {}; + return aegisConfirm({ + title: o.title || "Something went wrong", + icon: o.icon || "⚠", + body: esc(String(message == null ? "" : message)), + confirmLabel: o.confirmLabel || "OK", + cancelLabel: null, + alertOnly: true, + }); +} + +// Shared remove-wallet flow, used by the strip's 🗑 buttons and the coin +// drilldown. Confirms first, then unlinks. On-chain funds are untouched — +// this only drops Aegis's record of the wallet. +async function removeWalletWithConfirm(id) { + const w = (state?.wallets || []).find((x) => x.id === id); + if (!w) return false; + if (w.isDefault || w.isLegacy) { + await aegisAlert("This is the default wallet — it holds legacy funds and can't be removed.", { title: "Can't remove", icon: "🔒" }); + return false; + } + const ok = await aegisConfirm({ + title: `Remove "${w.label}"?`, + danger: true, + confirmLabel: "Remove wallet", + body: `On-chain funds stay exactly where they are — this only unlinks the wallet from Aegis.

You can add it back later on the same coin + network to derive the same addresses ${w.kind === "imported" ? "(or re-import it, since this one was imported)" : "from your vault seed"}.`, + }); + if (!ok) return false; + state = await S.invoke("removeWallet", { id }); + settingsFilled = false; + render(); + return true; +} + // 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 @@ -552,7 +978,13 @@ function openWalletManageModal(w) { }); 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; + const ok = await aegisConfirm({ + title: `Remove "${w.label}"?`, + danger: true, + confirmLabel: "Remove wallet", + body: `On-chain funds stay exactly where they are — this only unlinks the wallet from Aegis.

You can add it back later on the same coin + network to derive the same addresses ${w.kind === "imported" ? "(or re-import it, since this one was imported)" : "from your vault seed"}.`, + }); + if (!ok) return; try { state = await S.invoke("removeWallet", { id: w.id }); close(); @@ -863,6 +1295,22 @@ const IMPORT_COIN_CONFIG = { { id: "privB58", label: "Private key (base58)", placeholder: "Phantom / Solflare export" }, ], }, + sc: { + label: "Siacoin", logo: "sc", + // Sia's walletd (v2) uses a 32-byte root seed and an integer index + // (KeyFromSeed layout) — no BIP44 path. Sia Central Lite / walletd + // both accept a 12-word BIP39 mnemonic that PBKDF2's down to the + // root; the raw 32-byte hex is the alternative that walletd's API + // itself takes. defaultPath doubles as the address index the import + // starts on (0 is standard for a fresh import). + networks: [ + { id: "mainnet", label: "Mainnet", defaultPath: "0" }, + ], + formats: [ + { id: "mnemonic", label: "BIP39 mnemonic (12 words)" }, + { id: "seedHex", label: "Seed hex (64 chars)", placeholder: "32-byte root, walletd-compatible" }, + ], + }, }; function openImportModal(initialChain) { @@ -870,7 +1318,53 @@ function openImportModal(initialChain) { 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 = ` + // Check vault lock state up front. Imported wallets that mounted at + // startup can leave overallPhase === "ready" even when the vault is + // still locked (imports skip vault.derive) — that's what makes the + // main panel look unlocked while a fresh "wallet-imports-add" IPC + // fails with "password vault is locked". So we test the vault + // directly here and, if it's locked, surface an unlock form inside + // the modal rather than blindly submitting and showing red text. + const paintUnlockGate = (errText) => { + overlay.innerHTML = ` +
+
+
🔒 Unlock the vault to import
+ +
+
Aegis stores imported key material in Theseus's encrypted vault (wallet-imports.enc). Enter your master password once to unlock it, then Aegis will remember the import form you were filling in.
+
+
Master password
+ +
+
${esc(errText || "")}
+
+ + +
+
`; + overlay.querySelector("#imClose").addEventListener("click", close); + overlay.querySelector("#imCancel").addEventListener("click", close); + const pwInput = overlay.querySelector("#imUnlockPw"); + pwInput.focus(); + const doUnlock = async () => { + const pw = pwInput.value; + const err = overlay.querySelector("#imUnlockMsg"); + if (!pw) { err.textContent = "Password required."; err.hidden = false; return; } + err.hidden = true; + try { + state = await S.invoke("vaultUnlock", { masterPassword: pw }); + // Success — re-paint the modal as the actual import form. + paintImportForm(); + } catch (e) { + err.textContent = cleanErr(e); err.hidden = false; + } + }; + overlay.querySelector("#imUnlockBtn").addEventListener("click", doUnlock); + pwInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); }); + }; + const paintImportForm = () => { + overlay.innerHTML = `
@@ -895,7 +1389,7 @@ function openImportModal(initialChain) {
Mnemonic (12/24 words)
-
Derivation path
+
Derivation path
@@ -924,95 +1418,465 @@ function openImportModal(initialChain) {
`; + overlay.querySelector("#imClose").addEventListener("click", close); + overlay.querySelector("#imCancel").addEventListener("click", close); + + const netGroup = overlay.querySelector("#imNetworkGroup"); + const fmtGroup = overlay.querySelector("#imFormatGroup"); + const rawField = overlay.querySelector("#imRawField"); + const mnField = overlay.querySelector("#imMnemonicField"); + + function paintChain() { + const cfg = IMPORT_COIN_CONFIG[curChain]; + overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22); + netGroup.innerHTML = cfg.networks.map((n, i) => ``).join(""); + fmtGroup.innerHTML = cfg.formats.map((f, i) => ``).join(""); + overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", updatePathDefault)); + overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", updateFormatFields)); + updatePathDefault(true); + updateFormatFields(); + } + + function updatePathDefault(force) { + const cfg = IMPORT_COIN_CONFIG[curChain]; + const netId = overlay.querySelector('input[name="imNet"]:checked')?.value; + const net = cfg.networks.find((n) => n.id === netId) || cfg.networks[0]; + const path = overlay.querySelector("#imPath"); + if (force || !path.value.trim()) path.value = net.defaultPath; + // Sia doesn't have a BIP44 path — the same field carries the u64 + // address index KeyFromSeed derives from. Rename the label + hint + // so users don't paste a bogus m/44'/… into the SC form. + const pathLbl = overlay.querySelector("#imPathLabel"); + const isSia = curChain === "sc"; + if (pathLbl) pathLbl.textContent = isSia ? "Address index" : "Derivation path"; + overlay.querySelector("#imPathHint").textContent = isSia + ? `Sia uses KeyFromSeed(seed, index) — default is ${net.defaultPath} for a fresh import.` + : `Default for ${net.label}: ${net.defaultPath}`; + } + + function updateFormatFields() { + const cfg = IMPORT_COIN_CONFIG[curChain]; + const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; + const f = cfg.formats.find((x) => x.id === fmt) || cfg.formats[0]; + mnField.hidden = fmt !== "mnemonic"; + rawField.hidden = fmt === "mnemonic"; + if (fmt !== "mnemonic") { + overlay.querySelector("#imRawLabel").textContent = f.label; + overlay.querySelector("#imRaw").placeholder = f.placeholder || ""; + overlay.querySelector("#imRaw").value = ""; + } + } + + overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); }); + paintChain(); + + overlay.querySelector("#imGo").addEventListener("click", async () => { + const msg = overlay.querySelector("#imMsg"); msg.hidden = true; + const chain = curChain; + const network = overlay.querySelector('input[name="imNet"]:checked')?.value; + const kind = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; + const label = overlay.querySelector("#imLabel").value.trim(); + const category = overlay.querySelector("#imCategory").value; + if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; } + const payload = { chain, network, label, category }; + if (kind === "mnemonic") { + payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim(); + payload.path = overlay.querySelector("#imPath").value.trim(); + if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; } + // Sia has no BIP44 path — the "path" field carries a u64 address + // index instead. Rename before sending so the addon reads it via + // p.index (mnemonic path field still populates for other chains). + if (chain === "sc") { + payload.index = payload.path || "0"; + delete payload.path; + } + } else if (kind === "wif") { + payload.wif = overlay.querySelector("#imRaw").value.trim(); + if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; } + } else if (kind === "privHex") { + payload.privHex = overlay.querySelector("#imRaw").value.trim(); + if (!payload.privHex) { msg.textContent = "Private key hex required."; msg.hidden = false; return; } + } else if (kind === "privB58") { + payload.privB58 = overlay.querySelector("#imRaw").value.trim(); + if (!payload.privB58) { msg.textContent = "Private key base58 required."; msg.hidden = false; return; } + } else if (kind === "seedHex") { + payload.seedHex = overlay.querySelector("#imRaw").value.trim(); + if (!payload.seedHex) { msg.textContent = "Seed hex required."; msg.hidden = false; return; } + // Same rename as the mnemonic branch — SC's "path" input carries + // the address index. The path field defaults to "0" per config. + if (chain === "sc") { + payload.index = "0"; // Raw-hex form has no path input; use 0. + } + } + try { + state = await S.invoke("importWallet", payload); + close(); + render(); + } catch (e) { + const errText = cleanErr(e); + // Race case: vault got locked between the modal opening and the + // submit hitting Theseus (idle-lock, or user unlocked but the + // imports subsystem didn't get its own credential). Route the + // user through the unlock gate instead of the raw error text. + if (/vault is locked|password vault is locked/i.test(errText)) { + paintUnlockGate("Vault locked while you were filling in the form. Unlock again to save this import."); + return; + } + msg.textContent = errText; msg.hidden = false; + } + }); + }; + + // Bootstrap: attach the overlay first, then pick which face to show. document.body.appendChild(overlay); const close = () => { try { overlay.remove(); } catch {} }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); - overlay.querySelector("#imClose").addEventListener("click", close); - overlay.querySelector("#imCancel").addEventListener("click", close); - - const netGroup = overlay.querySelector("#imNetworkGroup"); - const fmtGroup = overlay.querySelector("#imFormatGroup"); - const rawField = overlay.querySelector("#imRawField"); - const mnField = overlay.querySelector("#imMnemonicField"); - - function paintChain() { - const cfg = IMPORT_COIN_CONFIG[curChain]; - overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22); - netGroup.innerHTML = cfg.networks.map((n, i) => ``).join(""); - fmtGroup.innerHTML = cfg.formats.map((f, i) => ``).join(""); - overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", updatePathDefault)); - overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", updateFormatFields)); - updatePathDefault(true); - updateFormatFields(); - } - - function updatePathDefault(force) { - const cfg = IMPORT_COIN_CONFIG[curChain]; - const netId = overlay.querySelector('input[name="imNet"]:checked')?.value; - const net = cfg.networks.find((n) => n.id === netId) || cfg.networks[0]; - const path = overlay.querySelector("#imPath"); - if (force || !path.value.trim()) path.value = net.defaultPath; - overlay.querySelector("#imPathHint").textContent = `Default for ${net.label}: ${net.defaultPath}`; - } - - function updateFormatFields() { - const cfg = IMPORT_COIN_CONFIG[curChain]; - const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; - const f = cfg.formats.find((x) => x.id === fmt) || cfg.formats[0]; - mnField.hidden = fmt !== "mnemonic"; - rawField.hidden = fmt === "mnemonic"; - if (fmt !== "mnemonic") { - overlay.querySelector("#imRawLabel").textContent = f.label; - overlay.querySelector("#imRaw").placeholder = f.placeholder || ""; - overlay.querySelector("#imRaw").value = ""; - } - } - - overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); }); - paintChain(); - - overlay.querySelector("#imGo").addEventListener("click", async () => { - const msg = overlay.querySelector("#imMsg"); msg.hidden = true; - const chain = curChain; - const network = overlay.querySelector('input[name="imNet"]:checked')?.value; - const kind = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; - const label = overlay.querySelector("#imLabel").value.trim(); - const category = overlay.querySelector("#imCategory").value; - if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; } - const payload = { chain, network, label, category }; - if (kind === "mnemonic") { - payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim(); - payload.path = overlay.querySelector("#imPath").value.trim(); - if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; } - } else if (kind === "wif") { - payload.wif = overlay.querySelector("#imRaw").value.trim(); - if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; } - } else if (kind === "privHex") { - payload.privHex = overlay.querySelector("#imRaw").value.trim(); - if (!payload.privHex) { msg.textContent = "Private key hex required."; msg.hidden = false; return; } - } else if (kind === "privB58") { - payload.privB58 = overlay.querySelector("#imRaw").value.trim(); - if (!payload.privB58) { msg.textContent = "Private key base58 required."; msg.hidden = false; return; } - } - try { - state = await S.invoke("importWallet", payload); - close(); - render(); - } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + S.invoke("vaultStatus").then((st) => { + if (st && st.unlocked) paintImportForm(); + else paintUnlockGate(null); + }).catch(() => { + // If we can't even reach vaultStatus, assume unlocked and let the + // downstream submit surface the real error. + paintImportForm(); }); } + +// ---- Consolidation modal --------------------------------------------------- +// Opens a batch send-max flow from every same-chain/same-network sibling +// into the currently-selected wallet. Preview first, per-source checkboxes, +// then a single PIN gate (if enabled) before the batch fires. +function openConsolidateModal() { + 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 = ` +
+
+
⇢ Consolidate balances
+ +
+
Every wallet you tick is swept via send-max into the currently-selected wallet. Same chain + same network only — nothing crosses networks or coins.
+
+
Destination
+
Loading…
+
+
+ +
+ +
+ + +
+
+
`; + document.body.appendChild(overlay); + const close = () => { try { overlay.remove(); } catch {} }; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + overlay.querySelector("#conClose").addEventListener("click", close); + overlay.querySelector("#conCancel").addEventListener("click", close); + + let preview = null; + const setBusy = (on, t) => { + const btn = overlay.querySelector("#conGo"); + btn.disabled = !!on || !eligibleCount(); + btn.textContent = t || "Consolidate"; + }; + const eligibleCount = () => { + if (!preview) return 0; + return overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked').length; + }; + const paintPreview = () => { + const dest = overlay.querySelector("#conDest"); + dest.textContent = preview.destinationLabel + " — " + shortenAddress(preview.destinationAddress); + const body = overlay.querySelector("#conBody"); + if (!preview.sources.length) { + body.innerHTML = `
No other ${esc(preview.ticker || preview.chain.toUpperCase())} wallets on ${esc(preview.network)}. Add or import one first, then come back.
`; + overlay.querySelector("#conSelectAll").disabled = true; + return; + } + const dec = preview.decimals; + const rows = preview.sources.map((s) => { + const bal = fmtBig(s.balance, dec) + " " + esc(preview.ticker); + const net = s.net != null ? fmtBig(s.net, dec) + " " + esc(preview.ticker) : "—"; + const fee = s.fee != null ? (preview.chain === "bch" ? `${s.fee} sat` : fmtBig(s.fee, dec) + " " + esc(preview.ticker)) : "—"; + const err = s.error ? `
${esc(s.error)}
` : ""; + const check = s.eligible + ? `` + : ``; + const opacity = s.eligible ? "" : "opacity:.55"; + return `
+ +
+
${esc(s.label)}
+
${esc(s.address || "—")}
+
Balance ${bal} · Fee ${fee}
+ ${err} +
+
+
${net}
+
will land
+
+
`; + }).join(""); + // Summary line at the bottom. + body.innerHTML = `
${rows}
+
`; + const refreshSummary = () => { + const eligible = preview.sources.filter((s) => s.eligible); + const checked = new Set(Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked')).map((c) => c.dataset.conwid)); + const sum = (getter) => eligible.filter((s) => checked.has(s.walletId)).reduce((a, s) => a + BigInt(getter(s) || "0"), 0n); + const totalNet = sum((s) => s.net); + const totalFee = sum((s) => s.fee); + const totalBal = sum((s) => s.balance); + overlay.querySelector("#conSummary").innerHTML = ` + ${checked.size} wallet${checked.size === 1 ? "" : "s"} selected · Moving ${esc(fmtBig(totalBal.toString(), dec))} ${esc(preview.ticker)} + (net ${esc(fmtBig(totalNet.toString(), dec))} ${esc(preview.ticker)} after ${esc(fmtBig(totalFee.toString(), dec))} in fees) + `; + overlay.querySelector("#conGo").disabled = checked.size === 0; + }; + overlay.querySelectorAll('input[type="checkbox"][data-conwid]').forEach((cb) => cb.addEventListener("change", () => { + // Uncheck master when any individual comes off. + const master = overlay.querySelector("#conSelectAll"); + const boxes = Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:not(:disabled)')); + master.checked = boxes.length > 0 && boxes.every((b) => b.checked); + refreshSummary(); + })); + overlay.querySelector("#conSelectAll").addEventListener("change", (e) => { + const on = !!e.target.checked; + overlay.querySelectorAll('input[type="checkbox"][data-conwid]:not(:disabled)').forEach((cb) => { cb.checked = on; }); + refreshSummary(); + }); + refreshSummary(); + }; + + const loadPreview = async () => { + try { + preview = await S.invoke("consolidatePreview"); + paintPreview(); + } catch (e) { + overlay.querySelector("#conBody").innerHTML = `
Preview failed: ${esc(cleanErr(e))}
`; + } + }; + + overlay.querySelector("#conGo").addEventListener("click", async () => { + const checked = Array.from(overlay.querySelectorAll('input[type="checkbox"][data-conwid]:checked')).map((c) => c.dataset.conwid); + if (!checked.length) return; + const msg = overlay.querySelector("#conMsg"); msg.hidden = true; + // PIN gate fires ONCE for the whole batch — a batch send-max operation + // is a single user intent. + if (!securityLoaded) await refreshSecurityState(); + if (securityState.requirePinForSending && securityState.hasPin) { + const ok = await verifyPinInteractively(`Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`); + if (!ok) { msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return; } + } + setBusy(true, "Sending…"); + try { + const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); + renderResult(res); + } catch (e) { + msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; + setBusy(false); + } + }); + + const renderResult = (res) => { + const ok = res.results.filter((r) => r.ok); + const bad = res.results.filter((r) => !r.ok); + const explorer = sel()?.explorerTx || ""; + const okRows = ok.map((r) => { + const link = explorer && r.txid ? `${esc(r.txid.slice(0, 16))}…` : (r.txid || ""); + return `
+
${esc(r.label)}
Sent — ${link}
+
✓
+
`; + }).join(""); + const badRows = bad.map((r) => `
+
${esc(r.label)}
${esc(r.error || "unknown error")}
+
⚠
+
`).join(""); + overlay.querySelector("#conIntro").textContent = ok.length + ? `${ok.length} broadcast · ${bad.length} skipped/failed. Balances update as the network confirms.` + : "Nothing broadcast — see per-source errors below."; + overlay.querySelector("#conBody").innerHTML = `
${okRows}${badRows}
`; + overlay.querySelector("#conSelectAll").disabled = true; + overlay.querySelector("#conGo").hidden = true; + overlay.querySelector("#conCancel").textContent = "Done"; + overlay.querySelectorAll("[data-conurl]").forEach((a) => a.addEventListener("click", (e) => { + e.preventDefault(); openUrl(a.dataset.conurl); + })); + }; + + loadPreview(); +} + +function shortenAddress(a) { + const s = String(a || ""); + if (s.length <= 20) return s; + return s.slice(0, 12) + "…" + s.slice(-6); +} + +// 0.8.0: inline consolidate view rendered into the Send/Receive tab body +// when the user flips the mode toggle to Consolidate. Same preview + batch +// mechanics as openConsolidateModal, but without the outer overlay so the +// tab feels like a native alternate mode rather than an interrupting modal. +async function renderConsolidateInline(hostEl) { + if (!hostEl) return; + hostEl.innerHTML = `
Loading…
`; + let preview; + try { preview = await S.invoke("consolidatePreview"); } + catch (e) { hostEl.innerHTML = `
Preview failed: ${esc(cleanErr(e))}
`; return; } + const dec = preview.decimals; + const rows = preview.sources.map((s) => { + const bal = fmtBig(s.balance, dec) + " " + esc(preview.ticker); + const net = s.net != null ? fmtBig(s.net, dec) + " " + esc(preview.ticker) : "—"; + const fee = s.fee != null ? (preview.chain === "bch" ? `${s.fee} sat` : fmtBig(s.fee, dec) + " " + esc(preview.ticker)) : "—"; + const err = s.error ? `
${esc(s.error)}
` : ""; + const check = s.eligible ? `` : ``; + const opacity = s.eligible ? "" : "opacity:.55"; + return `
+ +
+
${esc(s.label)}
+
${esc(s.address || "—")}
+
Balance ${bal} · Fee ${fee}
+ ${err} +
+
+
${net}
+
will land
+
+
`; + }).join(""); + hostEl.innerHTML = ` +
Sweep same-network siblings into ${esc(preview.destinationLabel)} (${esc(shortenAddress(preview.destinationAddress))}).
+
${preview.sources.length ? rows : `
No other wallets to sweep.
`}
+
+ +
+ + +
`; + const eligible = preview.sources.filter((s) => s.eligible); + const refreshSummary = () => { + const checked = new Set(Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:checked')).map((c) => c.dataset.inconwid)); + const sum = (getter) => eligible.filter((s) => checked.has(s.walletId)).reduce((a, s) => a + BigInt(getter(s) || "0"), 0n); + const totalNet = sum((s) => s.net); + const totalFee = sum((s) => s.fee); + const totalBal = sum((s) => s.balance); + hostEl.querySelector("#inconSummary").innerHTML = `${checked.size} selected · Moving ${esc(fmtBig(totalBal.toString(), dec))} ${esc(preview.ticker)} (net ${esc(fmtBig(totalNet.toString(), dec))} after ${esc(fmtBig(totalFee.toString(), dec))} fees)`; + hostEl.querySelector("#inconGo").disabled = checked.size === 0; + }; + hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]').forEach((cb) => cb.addEventListener("change", () => { + const master = hostEl.querySelector("#inconSelectAll"); + const boxes = Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:not(:disabled)')); + master.checked = boxes.length > 0 && boxes.every((b) => b.checked); + refreshSummary(); + })); + hostEl.querySelector("#inconSelectAll").addEventListener("change", (e) => { + const on = !!e.target.checked; + hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:not(:disabled)').forEach((cb) => { cb.checked = on; }); + refreshSummary(); + }); + refreshSummary(); + hostEl.querySelector("#inconGo").addEventListener("click", async () => { + const checked = Array.from(hostEl.querySelectorAll('input[type="checkbox"][data-inconwid]:checked')).map((c) => c.dataset.inconwid); + if (!checked.length) return; + const msg = hostEl.querySelector("#inconMsg"); msg.hidden = true; + if (!securityLoaded) await refreshSecurityState(); + if (securityState.requirePinForSending && securityState.hasPin) { + const ok = await verifyPinInteractively(`Confirm consolidating ${checked.length} wallet${checked.length === 1 ? "" : "s"} with your PIN.`); + if (!ok) { msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return; } + } + const go = hostEl.querySelector("#inconGo"); + go.disabled = true; go.textContent = "Sending…"; + try { + const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); + const okCount = res.results.filter((r) => r.ok).length; + const badCount = res.results.length - okCount; + const explorer = sel()?.explorerTx || ""; + const rowsResult = res.results.map((r) => { + if (r.ok) { + const link = explorer && r.txid ? `${esc(r.txid.slice(0, 16))}…` : (r.txid || ""); + return `
+
${esc(r.label)}
Sent — ${link}
+
✓
+
`; + } + return `
+
${esc(r.label)}
${esc(r.error || "unknown error")}
+
⚠
+
`; + }).join(""); + hostEl.innerHTML = `
${okCount} broadcast · ${badCount} skipped/failed.
+
${rowsResult}
`; + hostEl.querySelectorAll("[data-inconurl]").forEach((a) => a.addEventListener("click", (e) => { e.preventDefault(); openUrl(a.dataset.inconurl); })); + } catch (e) { + msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; + go.disabled = false; go.textContent = "Consolidate"; + } + }); +} + +function paintSendMode() { + const normal = $("sendNormal"); const cons = $("sendConsolidate"); + if (!normal || !cons) return; + const buttons = document.querySelectorAll("[data-send-mode]"); + buttons.forEach((b) => b.classList.toggle("on", b.dataset.sendMode === sendMode)); + normal.hidden = sendMode !== "send"; + cons.hidden = sendMode !== "consolidate"; + if (sendMode === "consolidate") { + const host = $("sendConsolidateInline"); + if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); } + } +} +function paintRcvMode() { + const normal = $("rcvNormal"); const cons = $("rcvConsolidate"); + if (!normal || !cons) return; + const buttons = document.querySelectorAll("[data-rcv-mode]"); + buttons.forEach((b) => b.classList.toggle("on", b.dataset.rcvMode === rcvMode)); + normal.hidden = rcvMode !== "receive"; + cons.hidden = rcvMode !== "consolidate"; + if (rcvMode === "consolidate") { + const host = $("rcvConsolidateInline"); + if (host && consolidateInlineHost !== host) { consolidateInlineHost = host; renderConsolidateInline(host); } + } +} + // Content of the Connect pane in the picker — WizardConnect pairing lives // here so users can paste a wiz:// URI without diving into per-wallet // Settings. If no BCH wallet is ready, we show a gate instead of the form. function renderConnectPane(bchWallets) { const readyBch = bchWallets.filter((w) => w.phase === "ready"); if (!readyBch.length) { + // 0.8.8: the locked branch used to be a dead end — it told the user to + // unlock the vault but gave them nothing to click, and the panel chrome + // stays visible whenever an imported wallet is mounted (those skip the + // vault), so this is reachable without the lock screen ever showing. + // Inline the same unlock form the lock screen uses. + const locked = bchWallets.length > 0; return `
WizardConnect pairs Aegis with a BCH dapp (Cauldron, Moria, or any site built on the SDK).
-
${bchWallets.length ? "Unlock your password vault first — WizardConnect uses your BCH keys to sign." : "Add a BCH wallet first via the Add tab, then come back."}
+ ${locked ? ` +
WizardConnect signs with your BCH keys, so the password vault has to be unlocked first.
+
+ +
+
+ + ` : `
Add a BCH wallet first via the Add tab, then come back.
`}
`; } - const options = readyBch.map((w) => ``).join(""); + // Wallets with no derivable seed (WIF single-key imports) can't pair at + // all, so they're disabled rather than silently failing on Connect. + const pairable = readyBch.filter((w) => !w.wcBlocked); + const options = readyBch.map((w) => ``).join(""); + const blockedNote = (!pairable.length && readyBch.length) + ? `
${esc(readyBch[0].wcBlocked)}
` + : ""; // Flatten all connected dapps (across BCH wallets) into one list — the // user thinks "my dapps", not "dapps per wallet". const rows = []; @@ -1028,7 +1892,8 @@ function renderConnectPane(bchWallets) {
`).join("") : `
No dapps paired yet.
`; return `
-
Paste a wiz:// URI from a BCH dapp's Connect dialog. Aegis will sign every request after your approval.
+
Dapps that support Aegis hand the pairing over with one click. Otherwise open the dapp's Connect dialog and press Scan page, or paste its wiz:// code below. Aegis signs every request after your approval.
+ ${blockedNote}
Sign with
@@ -1036,8 +1901,9 @@ function renderConnectPane(bchWallets) {
-
+
+
Paired dapps
@@ -1046,6 +1912,31 @@ function renderConnectPane(bchWallets) { } function wireConnectPane() { + // Locked-vault branch: unlock in place, then re-render the pane so the + // pairing form replaces the gate without the user reopening the picker. + const unlockBtn = document.getElementById("pkConnectUnlockBtn"); + if (unlockBtn) { + const doUnlock = async () => { + const pwEl = document.getElementById("pkConnectUnlockPw"); + const msg = document.getElementById("pkConnectUnlockMsg"); + msg.hidden = true; + const pw = pwEl.value; + if (!pw) return; + try { + state = await S.invoke("vaultUnlock", { masterPassword: pw }); + pwEl.value = ""; + render(); + fillPicker(); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }; + unlockBtn.addEventListener("click", (e) => { e.stopPropagation(); doUnlock(); }); + const pwEl = document.getElementById("pkConnectUnlockPw"); + if (pwEl) { + pwEl.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") doUnlock(); }); + try { pwEl.focus(); } catch {} + } + return; + } const btn = document.getElementById("pkConnectBtn"); if (!btn) return; btn.addEventListener("click", async () => { const walletId = document.getElementById("pkConnectWallet").value; @@ -1058,6 +1949,41 @@ function wireConnectPane() { fillPicker(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); + const scanBtn = document.getElementById("pkConnectScanBtn"); + if (scanBtn) scanBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const msg = document.getElementById("pkConnectMsg"); msg.hidden = true; + const field = document.getElementById("pkConnectUri"); + const prev = scanBtn.textContent; + scanBtn.textContent = "Scanning…"; scanBtn.disabled = true; + try { + const res = await S.invoke("wcScanPage"); + const uris = res?.uris || []; + if (!uris.length) { + msg.textContent = res?.origin + ? `No wiz:// pairing code found on ${res.origin}. Open the dapp's Connect dialog first, then scan again.` + : "No pairing code found on the open tab."; + msg.hidden = false; + return; + } + // Fill the field rather than pairing outright: the user still picks + // which wallet signs, and still presses Connect. A scan that silently + // paired would be a click with a much larger consequence than the + // button implies. + field.value = uris[0]; + msg.textContent = uris.length > 1 + ? `Found ${uris.length} codes on ${res.origin || "the page"} — filled the first. Press Connect to pair.` + : `Found a pairing code on ${res.origin || "the page"}. Press Connect to pair.`; + msg.classList.remove("err"); + msg.hidden = false; + } catch (err) { + msg.textContent = cleanErr(err); + msg.classList.add("err"); + msg.hidden = false; + } finally { + scanBtn.textContent = prev; scanBtn.disabled = false; + } + }); document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => { const [walletId, connId] = b.dataset.wcpick.split("|"); try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); } @@ -1074,13 +2000,35 @@ function wireConnectPane() { // already opens on the currently-selected wallet's group (auto-expand below). const collapsedGroups = new Set(); -// Per-chain "which network is showing" pointer. Rows are now grouped by -// chain alone (BCH, BTC, ETH, …) and this map picks which subnetwork's -// wallets the row surfaces. Missing entry → pickDefaultNetwork() below -// prefers mainnet when present, falls back to the first wallet's network. -// Session-only; a reload resets to defaults so the strip never quietly -// hides a mainnet balance behind a stale testnet selection. -const activeNetworkByChain = new Map(); +// Per-chain "which network is showing" pointer. Rows grouped by chain +// alone (BCH, BTC, ETH, …); this map picks which subnetwork's wallets +// the row surfaces. Missing entry → pickDefaultNetwork() below prefers +// mainnet when present, falls back to the first wallet's network. +// 0.8.0: persisted to localStorage under aegis/activeNetworks so a user +// who prefers Sepolia on ETH stays on Sepolia across reloads. Fresh +// installs (no persisted entry) still fall back to mainnet — a +// last-selected-when-known, mainnet-otherwise policy. +const activeNetworkByChain = new Map( + (function () { + try { + const raw = localStorage.getItem("aegis/activeNetworks"); + const j = raw ? JSON.parse(raw) : null; + return j && typeof j === "object" ? Object.entries(j) : []; + } catch { return []; } + })(), +); +function persistActiveNetworks() { + try { + const obj = {}; for (const [k, v] of activeNetworkByChain) obj[k] = v; + localStorage.setItem("aegis/activeNetworks", JSON.stringify(obj)); + } catch {} +} + +// 0.7.5: analogous pointer for "which specific wallet is active" within +// a chain+network bucket. Row click uses this to select the right wallet +// instead of always drilling into the address list, and the count pill +// (▾) is what opens the picker to change it. Keyed as ":". +const activeWalletBySubgroup = new Map(); // Legacy per-chain+network key, kept because stripView.groupKey (inline // address view) still uses it, and reorderWallets writes wallet order @@ -1250,18 +2198,44 @@ function renderWalletStrip() { ? `Switch network — ${nets.map((n) => networkLabelFor(chain, n, n)).join(" / ")}` : meta.coinName; - // Single wallet under the active network → click selects it. - // Multiple → click opens the inline addresses list scoped to that - // subnetwork. + // Single wallet under the active network → click selects it. Multi- + // wallet: click selects the "active" wallet for this bucket (defaults + // to the currently-selected one if it's in the group, otherwise the + // first). The wgcount chip becomes a ▾ dropdown trigger that opens + // the inline addresses list — that's how you swap the active wallet. const single = gw.length === 1; - const walletId = single ? gw[0].id : null; - const clickAction = single ? `data-wstripid="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; - const walletsChip = single ? "" : `${gw.length}`; + // Pick the wallet the row will select on click. Precedence: previously + // selected in this bucket → globally selected wallet (if it's in gw) + // → first wallet in the group. Result is what the "active" pointer + // stores AND what the row's data-wstripid points at, so click always + // lands on a valid entry. + let activeWalletId = activeWalletBySubgroup.get(subKey) || null; + if (activeWalletId && !gw.some((w) => w.id === activeWalletId)) activeWalletId = null; + if (!activeWalletId && gw.some((w) => w.id === selId)) activeWalletId = selId; + if (!activeWalletId) activeWalletId = gw[0].id; + activeWalletBySubgroup.set(subKey, activeWalletId); + const walletId = activeWalletId; + const clickAction = `data-wstripid="${esc(walletId)}"`; + // Count chip carries the ▾ to signal the picker; single-wallet rows + // still get no chip. + const walletsChip = single ? "" : `${gw.length} ▾`; - const editAttr = single ? `data-wedit="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; - const setAttr = single ? `data-wsettings="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; + // Actions row: on multi-wallet rows the ✎ / 🗑 target the ACTIVE + // wallet (not "the group") so the buttons still do something specific + // without needing a second click. + // + // 0.8.8: the second button used to be ⚙, which just selected the wallet + // and opened the global Settings tab — the same destination for every + // coin, so it read as a per-coin control that wasn't one. Removing a + // wallet is the action people actually wanted there. + const editAttr = `data-wedit="${esc(walletId)}"`; + const activeW = gw.find((w) => w.id === walletId); + const canRemove = !(activeW?.isDefault || activeW?.isLegacy); + const removeBtn = canRemove + ? `` + : `🔒`; - rows.push(`
+ rows.push(`
@@ -1279,7 +2253,7 @@ function renderWalletStrip() { - + ${removeBtn}
`); } @@ -1297,14 +2271,33 @@ function renderWalletStrip() { el.querySelectorAll(".wrow").forEach((row) => row.addEventListener("click", async (e) => { if (e.target.closest(".wact")) return; if (e.target.closest(".wcname.wswitchable")) return; - if (row.dataset.wstripid) { - const id = row.dataset.wstripid; - if (id === selId) return; - try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); } - catch (er) { showErr(cleanErr(er)); } - } else if (row.dataset.openlist) { + // The count chip is now a picker trigger — clicks there open the + // address list without also firing the row-select. + const pickChip = e.target.closest(".wgpick[data-openlist]"); + if (pickChip) { + e.stopPropagation(); + stripView = { mode: "addresses", groupKey: pickChip.dataset.openlist }; + renderWalletStrip(); + return; + } + // 0.8.8: clicking a coin opens that coin's page (addresses + assets) + // instead of only flipping the selection and leaving the list in place. + // Selecting still happens, so Send/Receive/History follow the coin the + // user just opened — but the strip now navigates, which is what a row + // with a balance and a chevron looks like it should do. + if (row.dataset.openlist) { stripView = { mode: "addresses", groupKey: row.dataset.openlist }; renderWalletStrip(); + return; + } + if (row.dataset.wstripid) { + const id = row.dataset.wstripid; + const key = row.dataset.groupkey || null; + try { + if (id !== selId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; } + if (key) stripView = { mode: "addresses", groupKey: key }; + render(); + } catch (er) { showErr(cleanErr(er)); } } })); // Ticker click on a multi-network chain → pop the network dropdown. @@ -1319,17 +2312,11 @@ function renderWalletStrip() { const w = (state?.wallets || []).find((x) => x.id === b.dataset.wedit); if (w) openWalletManageModal(w); })); - el.querySelectorAll(".wact[data-wsettings]").forEach((b) => b.addEventListener("click", async (e) => { + el.querySelectorAll(".wact[data-wremove]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); - const id = b.dataset.wsettings; - try { - if (id !== state?.selectedWalletId) { - state = await S.invoke("selectWallet", { id }); - settingsFilled = false; - } - showTab("settings"); - render(); - } catch (er) { showErr(cleanErr(er)); } + const id = b.dataset.wremove; + try { await removeWalletWithConfirm(id); } + catch (er) { showErr(cleanErr(er)); } })); el.querySelectorAll(".wact[data-openlist]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); @@ -1406,6 +2393,7 @@ function openNetworkPicker(anchorEl, chain, chainGroup) { closeNetworkPicker(); if (!n || n === active) return; activeNetworkByChain.set(chain, n); + persistActiveNetworks(); renderWalletStrip(); })); netMenuDismiss = (e) => { @@ -1475,10 +2463,57 @@ function wireStripDragDrop(el, chainGroups) { // Inline replacement for the modal address list. Rendered directly into // the wallet strip element when stripView.mode === "addresses". Header // row has a back arrow (returns to the coins summary) and the coin's -// name/logo; body rows show one wallet each with balance + inline ✎ / ⚙. +// Which address rows have their asset list expanded, in the coin drilldown. +// Panel-session only — a drilldown is a transient view, so there's nothing +// worth persisting across restarts. +const expandedAddrAssets = new Set(); + +// Normalise a wallet's assets into one row shape the drilldown can render, +// regardless of which chain family it came from: +// account-model (TRC20 / SPL) → w.tokens: [{mint, symbol, name, decimals, balance}] +// BCH CashTokens → w.tokenBalances: { : {fungible, nfts[]} } +// Returns [{ id, symbol, name, amount }] with amount already formatted. +function assetsForWallet(w) { + const out = []; + for (const t of (w.tokens || [])) { + const dec = Number(t.decimals) || 0; + out.push({ + id: String(t.mint || ""), + symbol: t.symbol || "?", + name: t.name || "", + // Unknown contracts have no decimals — show the raw integer rather + // than a number invented from an assumed scale. + amount: t.known === false ? `${t.balance} raw` : fmtTokenAmount(t.balance, dec), + }); + } + const tb = w.tokenBalances || {}; + for (const cat of Object.keys(tb)) { + const b = tb[cat] || {}; + const fungible = String(b.fungible || "0"); + const nftCount = Array.isArray(b.nfts) ? b.nfts.length : 0; + const parts = []; + // Decimals live in BCMR, which the drilldown doesn't fetch — show the + // raw fungible amount and let the Tokens card do the named rendering. + if (fungible !== "0") parts.push(fungible); + if (nftCount) parts.push(`${nftCount} NFT${nftCount === 1 ? "" : "s"}`); + if (!parts.length) continue; + out.push({ id: cat, symbol: "CashToken", name: "", amount: parts.join(" · ") }); + } + return out; +} + +// name/logo; body rows show one wallet each with balance + inline ✎ / 🗑. function renderInlineCoinList(el, groupKey, group) { const { meta, wallets: gw } = group; const selId = state?.selectedWalletId; + const chain = gw[0]?.chain; + const decimals = gw[0]?.decimals || 8; + const unitPrice = priceFor(chain); + const priceTxt = unitPrice != null ? fmtFiat(unitPrice) : "—"; + const totalUnits = sumGroupUnits(gw); + const totalNative = fmtBig(totalUnits || 0, decimals); + const totalUsd = usdOf(chain, totalUnits || 0, decimals); + const totalFiat = totalUsd != null ? fmtFiat(totalUsd) : ""; const rows = gw.map((w) => { const on = w.id === selId ? "on" : ""; const units = walletBalanceUnits(w); @@ -1487,27 +2522,59 @@ function renderInlineCoinList(el, groupKey, group) { const bal = fmtBig(units || 0, w.decimals); const usd = usdOf(w.chain, units || 0, w.decimals); const fiat = usd != null ? fmtFiat(usd) : ""; - // Address shown as short-head / short-tail, mono. Kept trimmer than - // before so the row width holds the balance column comfortably. - const addr = w.address ? `${String(w.address).slice(0, 8)}…${String(w.address).slice(-5)}` : ""; - // Labels get truncated to ~7 characters here — the full label lives - // in the tooltip and stays available via the Rename button. Anything - // longer would push the balance column off-screen on tight panels. - const shortName = shortLabel(w.label, 7); - return `
+ // Address is the row's primary identifier — mono, ellipsised in + // whatever flex space is left after the fixed cells. The BCH + // "bitcoincash:" / "bchtest:" / "bchreg:" prefix is stripped for the + // in-row display (it's identical on every row of a drilldown and + // burns 8-9 chars of a fixed column), but the tooltip and clipboard + // carry the full canonical form so the truncation is display-only. + const fullAddr = w.address ? String(w.address) : ""; + const displayAddr = stripAddrPrefix(fullAddr); + // Label preview capped at ~8 visible chars in JS; the CSS pill's + // fixed 68px column handles final ellipsis for oddball wide glyphs. + const shortName = shortLabel(w.label || "", 8); + // Fiat is dropped from the row to keep everything on one line; the + // aggregate coin fiat still shows in the drilldown header above. + // 0.8.8: per-address assets. Each row can expand to show what THIS + // address holds beyond the native coin — TRC20/SPL via `tokens`, BCH + // CashTokens via `tokenBalances`. Rows with nothing extra get no + // chevron so the list stays quiet for plain wallets. + const assets = assetsForWallet(w); + const expanded = expandedAddrAssets.has(w.id); + const assetChip = assets.length + ? `` + : ""; + const canRemoveRow = !(w.isDefault || w.isLegacy); + const rowRemove = canRemoveRow + ? `` + : `🔒`; + const assetRows = expanded && assets.length + ? `
${assets.map((a) => ` +
+ ${esc(a.symbol)}${a.name ? ` ${esc(a.name)}` : ""} + ${esc(a.id.slice(0, 8))}…${esc(a.id.slice(-6))} + ${esc(a.amount)} +
`).join("")}
` + : ""; + return `
${logoSvg(meta.logo, 14)} - - ${esc(shortName)} - ${addr ? `${esc(addr)}` : ""} + ${esc(displayAddr || "—")} + ${fullAddr ? `` : ``} + ${esc(shortName)} + ${esc(bal)}${esc(meta.ticker)} + + ${assetChip} + + ${rowRemove} - - ${esc(bal)} - ${fiat ? `${esc(fiat)}` : ""} - - - -
`; +
${assetRows}`; }).join(""); + // Surface any adapter errors from the wallets in this group. If a fetch + // is failing (RPC unreachable, CORS block, rate limit) the display would + // silently show 0 without this — which is exactly what "why does my + // funded wallet still say 0" feels like from the user side. + const errs = gw.filter((w) => w.error).map((w) => `${shortLabel(w.label || w.address || "?", 6)}: ${w.error}`); + const errLine = errs.length ? `
⚠ ${esc(errs.join(" · "))}
` : ""; el.innerHTML = `
@@ -1515,33 +2582,95 @@ function renderInlineCoinList(el, groupKey, group) {
${logoSvg(meta.logo, 16)} ${esc(meta.coinName)}· ${gw.length} address${gw.length === 1 ? "" : "es"} +
+
+ ${esc(priceTxt)}/ ${esc(meta.ticker)} + · + ${esc(totalNative)} ${esc(meta.ticker)} + ${totalFiat ? `(${esc(totalFiat)})` : ""} +
+ ${errLine} ${rows}`; const back = () => { stripView = { mode: "coins", groupKey: null }; renderWalletStrip(); }; el.querySelector("#stripBack").addEventListener("click", back); el.querySelector("#stripBackX").addEventListener("click", back); + // Manual refresh: force every wallet under this coin+network to + // re-poll now. Handy when a testnet faucet just delivered or a + // mainnet transfer is expected to have landed. + const refreshBtn = el.querySelector("#stripRefresh"); + if (refreshBtn) refreshBtn.addEventListener("click", async () => { + if (refreshBtn.dataset.spinning === "1") return; + refreshBtn.dataset.spinning = "1"; + const prev = refreshBtn.textContent; + refreshBtn.textContent = "…"; + try { + const r = await S.invoke("refreshChain", { chain: gw[0]?.chain, network: gw[0]?.network }); + const failed = (r?.results || []).filter((x) => !x.ok); + if (failed.length) refreshBtn.title = "Refresh failed: " + failed.map((f) => f.error).join("; "); + else refreshBtn.title = "Refresh balances now"; + } catch (e) { + refreshBtn.title = "Refresh failed: " + (e?.message || e); + } finally { + refreshBtn.textContent = prev; + delete refreshBtn.dataset.spinning; + } + }); el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => { if (e.target.closest(".wact")) return; + if (e.target.closest(".wacopy")) return; const id = row.dataset.listpick; try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); } catch (er) { showErr(cleanErr(er)); } })); + // Address copy chip. Uses navigator.clipboard when available (the addon + // panel runs under file:// but Electron gives it clipboard access), and + // falls back to a textarea+execCommand for older stacks. Visual "copied" + // flash lasts ~1s so the user sees the click landed. + el.querySelectorAll(".wacopy[data-lpcopy]").forEach((b) => b.addEventListener("click", async (e) => { + e.stopPropagation(); + const addr = b.dataset.lpcopy || ""; + if (!addr) return; + try { + if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(addr); + else { + const ta = document.createElement("textarea"); + ta.value = addr; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.select(); + try { document.execCommand("copy"); } finally { ta.remove(); } + } + const prev = b.textContent; + b.classList.add("copied"); b.textContent = "✓"; + setTimeout(() => { b.classList.remove("copied"); b.textContent = prev; }, 1000); + } catch {} + })); el.querySelectorAll("[data-lpedit]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit); if (w) openWalletManageModal(w); })); - el.querySelectorAll("[data-lpset]").forEach((b) => b.addEventListener("click", async (e) => { + el.querySelectorAll("[data-lpremove]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); - const id = b.dataset.lpset; try { - if (id !== state?.selectedWalletId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; } - stripView = { mode: "coins", groupKey: null }; - showTab("settings"); - render(); + const gone = await removeWalletWithConfirm(b.dataset.lpremove); + // Removing the last address under this coin leaves the drilldown + // pointing at an empty group — fall back to the coin list. + if (gone) { + const left = (state?.wallets || []).filter((w) => w.id !== b.dataset.lpremove && group.wallets.some((g) => g.id === w.id)); + if (!left.length) stripView = { mode: "coins", groupKey: null }; + renderWalletStrip(); + } } catch (er) { showErr(cleanErr(er)); } })); + // Per-address asset list toggle. + el.querySelectorAll("[data-lpassets]").forEach((b) => b.addEventListener("click", (e) => { + e.stopPropagation(); + const id = b.dataset.lpassets; + if (expandedAddrAssets.has(id)) expandedAddrAssets.delete(id); + else expandedAddrAssets.add(id); + renderWalletStrip(); + })); el.querySelector("#stripAddMore").addEventListener("click", async () => { // Add another wallet of the same coin+network directly, without // opening the picker sheet — the user is already inside this coin's @@ -1799,9 +2928,16 @@ function render() { // $("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22); $("hLabel").textContent = s?.label || "Aegis Wallet"; - $("hNet").innerHTML = s?.meta - ? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}` - : ""; + // 0.8.4: hNet is a chip on its own row. Show only the network name + // (coin name is already in hLabel above); hide the row when we don't + // have any wallet-context to describe yet. + if (s?.meta) { + $("hNet").innerHTML = `${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}`; + $("hNetRow").hidden = false; + } else { + $("hNet").innerHTML = ""; + $("hNetRow").hidden = true; + } if (showLock) { renderLockScreen(phase); } @@ -1868,7 +3004,30 @@ function render() { renderTokens(); applyUnitPicker(); $("feeField").hidden = chain() !== "bch"; + // OP_RETURN memo is BCH-only (added 0.6.36). Every other chain hides + // the field completely so the Send tab stays consistent. + const memoEl = $("memoField"); if (memoEl) memoEl.hidden = chain() !== "bch"; renderHistory(); + // 0.8.0: consolidate is a MODE TOGGLE on Send + Receive, not a chip. + // Show the toggle when there's at least one same-network sibling; the + // count sits inside the button label. paintSendMode() / paintRcvMode() + // swap the body between normal and consolidate views. + const cur = sel(); + const others = (state?.wallets || []).filter((w) => + cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId, + ); + const showToggle = others.length > 0; + for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvModeToggle", "rcvConsolidateCount"]]) { + const wrap = $(wrapId); if (!wrap) continue; + wrap.hidden = !showToggle; + if (showToggle) { + const c = $(cntId); if (c) c.textContent = `${others.length}`; + } + } + // Reset to normal mode when the toggle disappears (no siblings left). + if (!showToggle) { sendMode = "send"; rcvMode = "receive"; } + paintSendMode(); + paintRcvMode(); } // Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N @@ -1903,24 +3062,102 @@ function renderPortfolio() { function renderTokens() { const s = sel(); - const tokens = (chain() === "sol" && s?.tokens) || []; const card = $("tokensCard"); - card.hidden = tokens.length === 0; - if (!tokens.length) return; const el = $("tokensList"); - el.innerHTML = tokens.map((t) => { - const dec = Number(t.decimals) || 0; - const bal = fmtTokenAmount(t.balance, dec); - return `
-
${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + '' : ""}
${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}
-
${esc(bal)}
- -
`; - }).join(""); - el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => { - sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) }; - showTab("send"); - })); + // SOL wallets: SPL tokens with a Send button (existing flow). + if (chain() === "sol") { + const tokens = s?.tokens || []; + card.hidden = tokens.length === 0; + const hintEl = $("tokensHint"); + if (hintEl) hintEl.textContent = "SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown."; + if (!tokens.length) return; + el.innerHTML = tokens.map((t) => { + const dec = Number(t.decimals) || 0; + const bal = fmtTokenAmount(t.balance, dec); + return `
+
${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + '' : ""}
${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}
+
${esc(bal)}
+ +
`; + }).join(""); + el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => { + sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) }; + showTab("send"); + })); + return; + } + // BCH wallets: CashTokens. Read-only display in 0.7.0 (spend ships in + // 0.7.1). Categories come pre-serialised from the wallet (fungible is + // a decimal string; NFTs are per-UTXO). Names/symbols/decimals/icons + // come from BCMR, fetched async; the first paint uses raw category hex. + if (chain() === "bch") { + const balances = s?.tokenBalances || {}; + const cats = Object.keys(balances); + card.hidden = cats.length === 0; + const hintEl = $("tokensHint"); + if (hintEl) hintEl.textContent = "CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings."; + if (!cats.length) return; + const draw = (metaMap) => { + el.innerHTML = cats.map((cat) => { + const b = balances[cat]; + const meta = metaMap?.[cat] || null; + const decimals = meta && Number.isFinite(meta.decimals) ? meta.decimals : 0; + const fungibleRaw = String(b.fungible || "0"); + const showFungible = fungibleRaw !== "0"; + const nftCount = Array.isArray(b.nfts) ? b.nfts.length : 0; + const name = meta?.name || meta?.symbol || null; + const symbol = meta?.symbol || ""; + const icon = meta?.iconUri || ""; + const iconHtml = icon ? `` : ""; + const catShort = cat.slice(0, 10) + "…" + cat.slice(-6); + const fungibleTxt = showFungible ? fmtTokenAmount(fungibleRaw, decimals) + (symbol ? " " + symbol : "") : ""; + const nftTxt = nftCount ? `${nftCount} NFT${nftCount === 1 ? "" : "s"}` : ""; + const amountLine = [fungibleTxt, nftTxt].filter(Boolean).join(" · ") || "—"; + const nameLine = name ? `${iconHtml}${esc(name)}${symbol && name !== symbol ? ` ${esc(symbol)}` : ""}` : `${iconHtml}${esc(catShort)}`; + return `
+
${nameLine}
${esc(catShort)}
+
${esc(amountLine)}
+
`; + }).join(""); + }; + // Paint immediately with whatever we know synchronously so the row + // set doesn't wait for the network. Then run the async lookup and + // redraw with names + icons. + draw({}); + S.invoke("tokenMetadata", { categories: cats }).then((res) => { + if (chain() !== "bch") return; // user switched away mid-fetch + draw(res || {}); + }).catch(() => {}); + return; + } + // TRX wallets: TRC20 balances. Read-only — Aegis has no TRC20 spend + // path yet, so there's no Send button here (unlike SPL above). + if (chain() === "trx") { + const tokens = s?.tokens || []; + card.hidden = tokens.length === 0; + const hintEl = $("tokensHint"); + if (hintEl) hintEl.textContent = "TRC20 tokens held by this wallet. Read-only in this build — use the explorer link to move them."; + if (!tokens.length) return; + el.innerHTML = tokens.map((t) => { + const dec = Number(t.decimals) || 0; + const short = String(t.mint || ""); + const addrLine = `${esc(short.slice(0, 10))}…${esc(short.slice(-6))}`; + // Unknown contracts have no decimals, so a raw integer would be a + // misleading "balance". Say so instead of inventing a number. + const title = t.known + ? `${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + "" : ""}` + : `Unknown token`; + const amount = t.known + ? esc(fmtTokenAmount(t.balance, dec)) + : `${esc(t.balance)} raw`; + return `
+
${title}
${addrLine}
+
${amount}
+
`; + }).join(""); + return; + } + card.hidden = true; } // Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. function fmtTokenAmount(rawStr, decimals) { @@ -2024,17 +3261,35 @@ function renderHistory() { const list = s?.history || []; const el = $("txlist"); if (!list.length) { el.innerHTML = `
${s?.scanning ? "Syncing…" : "No transactions yet."}
`; return; } + const dec = s?.decimals ?? 8; el.innerHTML = list.map((t) => { - const inc = t.delta >= 0; + // `delta` arrives in three shapes now: a number (UTXO chains), a decimal + // STRING (ETH — 18 decimals of wei overflows a JS number, so it must + // stay exact), or null (Solana, where the signature feed carries no + // amount and fetching one per tx would be 25 extra round trips a poll). + // Treating null as 0 would render "+ —", claiming a receive we cannot + // actually verify, so unknown amounts get their own neutral branch. + const known = t.delta != null; + const neg = known && String(t.delta).trim().startsWith("-"); + const inc = known ? !neg : null; const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; - const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); - const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); - const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); - const delta = Math.abs(t.delta || 0); + const who = inc === null ? "" : inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); + const what = (inc === null ? (t.kind || "Transaction") : inc ? "Received" : "Sent") + (who ? " " + who : ""); + const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") + : (t.status === "failed" ? "failed" : t.status === "pending" ? "pending" : "unconfirmed"); + // Strip the sign as text rather than via Math.abs so a big-decimal + // string keeps every digit. + const magnitude = known ? String(t.delta).trim().replace(/^[-+]/, "") : ""; + const isZero = known && /^0*$/.test(magnitude); + const amountHtml = !known ? "—" + : isZero ? "—" + : `${inc ? "+" : "−"}${esc(fmtBig(magnitude, dec))}`; + const icon = inc === null ? "·" : inc ? "↓" : "↑"; + const iconCls = inc === null ? "" : inc ? "in" : "out"; return `
-
${inc ? "↓" : "↑"}
+
${icon}
${esc(what)}
-
${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}
+
${amountHtml}
${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}
${esc(conf)}
`; @@ -2102,6 +3357,25 @@ $("sendMax").addEventListener("click", () => { schedulePlan(); }); $("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); }); +if ($("sendMemo")) $("sendMemo").addEventListener("input", () => schedulePlan()); +// 0.7.7 legacy chips — hidden in 0.8.0 but kept for graceful transition; +// clicking still opens the standalone modal for anyone with muscle memory. +if ($("consolidateChip")) $("consolidateChip").addEventListener("click", () => openConsolidateModal()); +if ($("consolidateChipRcv")) $("consolidateChipRcv").addEventListener("click", () => openConsolidateModal()); +// 0.8.0 mode-toggle wiring. One class="modetoggle" element per tab — +// clicking a segment flips the mode variable and repaints the body via +// paintSendMode / paintRcvMode. Freshly-rendered inline hosts get their +// consolidate view populated on first switch. +document.querySelectorAll("[data-send-mode]").forEach((b) => b.addEventListener("click", () => { + sendMode = b.dataset.sendMode; + consolidateInlineHost = null; // force re-render on next switch + paintSendMode(); +})); +document.querySelectorAll("[data-rcv-mode]").forEach((b) => b.addEventListener("click", () => { + rcvMode = b.dataset.rcvMode; + consolidateInlineHost = null; + paintRcvMode(); +})); ["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; if (id === "sendAmt") updateSendFiatPreview(); @@ -2140,7 +3414,8 @@ async function updatePlan() { return; } const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; - const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax }); + const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : ""; + const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax, memo }); lastPlan = p; $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); @@ -2169,14 +3444,16 @@ $("sendBtn").addEventListener("click", async () => { try { const isToken = sendAsset && lastPlan._token; const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; + const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : ""; const r = isToken ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) - : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax }); + : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax, memo }); msg.className = "msg ok"; msg.innerHTML = `Sent. ${esc(r.txid.slice(0, 16))}…`; msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); msg.hidden = false; $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; + if ($("sendMemo")) $("sendMemo").value = ""; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; lastPlan = null; } catch (e) { @@ -2385,14 +3662,20 @@ $("gsPinChange") && $("gsPinChange").addEventListener("click", async () => { await handlePinSet(true); }); $("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => { - if (!confirm("Remove the quick-access PIN? You'll have to type the master password on every unlock again.")) return; + const ok = await aegisConfirm({ + title: "Remove the quick-access PIN?", + danger: true, + confirmLabel: "Remove PIN", + body: "You'll have to type the master password on every unlock again.", + }); + if (!ok) return; try { await S.invoke("pinBlobClear"); // Also disable the send-time PIN policy — it depends on having a PIN. await S.invoke("securitySet", { requirePinForSending: false }); await refreshSecurityState(); renderGeneralSecurity(); - } catch (e) { alert("Could not remove PIN: " + cleanErr(e)); } + } catch (e) { aegisAlert("Could not remove PIN: " + cleanErr(e)); } }); $("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => { const on = $("gsRequirePin").checked; @@ -2401,7 +3684,7 @@ $("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => { renderGeneralSecurity(); } catch (e) { $("gsRequirePin").checked = !on; - alert("Could not save setting: " + cleanErr(e)); + aegisAlert("Could not save setting: " + cleanErr(e)); } }); $("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => { @@ -2433,7 +3716,7 @@ $("gsLockOnClose") && $("gsLockOnClose").addEventListener("change", async () => bindIdleAutoLock(); } catch (e) { $("gsLockOnClose").checked = !on; - alert("Could not save setting: " + cleanErr(e)); + aegisAlert("Could not save setting: " + cleanErr(e)); } }); $("gsIdleMinutes") && $("gsIdleMinutes").addEventListener("change", async () => { @@ -2442,10 +3725,16 @@ $("gsIdleMinutes") && $("gsIdleMinutes").addEventListener("change", async () => sessionState = await S.invoke("sessionConfigSet", { idleMinutes: mins }); renderSessionSettings(); bindIdleAutoLock(); - } catch (e) { alert("Could not save idle timeout: " + cleanErr(e)); } + } catch (e) { aegisAlert("Could not save idle timeout: " + cleanErr(e)); } }); $("gsSignOut") && $("gsSignOut").addEventListener("click", async () => { - if (!confirm("Sign out of Aegis? The vault will re-lock and you'll need the master password (or PIN) to open it again.")) return; + const ok = await aegisConfirm({ + title: "Sign out of Aegis?", + icon: "🔒", + confirmLabel: "Sign out", + body: "The vault will re-lock and you'll need the master password (or PIN) to open it again.", + }); + if (!ok) return; try { state = await S.invoke("vaultLock"); stripView = { mode: "coins", groupKey: null }; @@ -2453,7 +3742,7 @@ $("gsSignOut") && $("gsSignOut").addEventListener("click", async () => { // Session blob was cleared server-side; refresh our cached view. sessionState = await S.invoke("sessionStatus"); renderSessionSettings(); - } catch (e) { alert("Could not sign out: " + cleanErr(e)); } + } catch (e) { aegisAlert("Could not sign out: " + cleanErr(e)); } }); // Setting or changing a PIN needs the master password to encrypt against. @@ -2484,7 +3773,7 @@ async function handlePinSet(replacing) { await refreshSecurityState(); renderGeneralSecurity(); } catch (e) { - alert("Could not save PIN: " + cleanErr(e)); + aegisAlert("Could not save PIN: " + cleanErr(e)); } finally { // Drop the buffered password sooner rather than later — we only kept // it around to enroll a PIN without a re-prompt. @@ -2539,7 +3828,7 @@ function promptMasterPassword({ title, subtitle }) { async function verifyPinInteractively(subtitle) { const remain = await pinLockoutRemainingMs(); if (remain > 0) { - alert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`); + aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`); return false; } return new Promise((resolve) => { @@ -2596,32 +3885,46 @@ function renderPricesSetting() { if (!toggle) return; toggle.checked = !!p?.enabled; $("refreshPrices").hidden = !p?.enabled; - // Populate the oracle dropdown once per state snapshot. Sources include a - // label + origin so users see WHERE each request goes before choosing. - const src = $("pricesSource"); - const sources = Array.isArray(p?.sources) && p.sources.length ? p.sources : []; - if (src && sources.length) { - const key = sources.map((s) => s.id).join("|"); - if (src.dataset.key !== key) { - src.dataset.key = key; - src.innerHTML = sources.map((s) => ``).join(""); - } - src.value = p?.source || sources[0].id; - const cur = sources.find((s) => s.id === src.value) || sources[0]; - const hint = $("pricesSourceHint"); - if (hint) hint.textContent = cur?.coversAll ? "Covers every supported coin in a single request." : "Covers a subset of coins (BCH, BTC, ETH, SOL, TRX)."; - } const st = $("pricesStatus"); - if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; } - if (p.loading) { st.textContent = "Fetching…"; return; } - if (p.error) { st.textContent = "Error: " + p.error; return; } - if (p.fetchedAt) { - const secs = Math.round((Date.now() - p.fetchedAt) / 1000); - const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`; - st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`; - return; + const sourcesEl = $("pricesSources"); + const paintStatus = () => { + if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; } + if (p.loading) { st.textContent = "Fetching…"; return; } + if (p.error) { st.textContent = "Error: " + p.error; return; } + if (p.fetchedAt) { + const secs = Math.round((Date.now() - p.fetchedAt) / 1000); + const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`; + st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`; + return; + } + st.textContent = "Enabled — first fetch pending."; + }; + paintStatus(); + // Per-source status: name → up/down + last fetch age. Renders even when + // disabled so users can see WHICH oracles will be polled once they flip + // the switch. On a down source we surface the error text. + if (sourcesEl) { + const sources = Array.isArray(p?.sources) ? p.sources : []; + const status = p?.sourceStatus || {}; + if (!sources.length) { sourcesEl.innerHTML = ""; } + else { + const rows = sources.map((s) => { + const st = status[s.id]; + let tag = `idle`; + if (st) { + if (st.ok) { + const covers = Object.keys(st.prices || {}).length; + const age = Math.round((Date.now() - (st.at || Date.now())) / 1000); + tag = `✓ ${covers} coin${covers === 1 ? "" : "s"}${age > 5 ? ` · ${age < 60 ? age + "s" : Math.round(age / 60) + "m"}` : ""}`; + } else { + tag = `⚠ down`; + } + } + return `
${esc(s.label)} · ${esc(s.origin)}${tag}
`; + }); + sourcesEl.innerHTML = rows.join(""); + } } - st.textContent = "Enabled — first fetch pending."; } async function renderSites() { let perms = {}; @@ -2742,7 +4045,13 @@ $("renameBtn").addEventListener("click", async () => { }); $("removeBtn").addEventListener("click", async () => { const s = sel(); if (!s || s.isLegacy) return; - if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) return; + const ok = await aegisConfirm({ + title: `Remove "${s.label}"?`, + danger: true, + confirmLabel: "Remove wallet", + body: "The on-chain address stays exactly where it is; the wallet is only unlinked from Aegis.

You can add it back later by creating a new wallet on the same coin + network.", + }); + if (!ok) return; try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); @@ -2923,11 +4232,10 @@ $("pricesToggle").addEventListener("change", async () => { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); -$("pricesSource").addEventListener("change", async () => { - const source = $("pricesSource").value; - try { state = await S.invoke("setPricesSource", { source }); renderPricesSetting(); render(); } - catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } -}); +// pricesSource select is a hidden legacy element in 0.6.36+ — the picker +// was removed when pricing moved to multi-source majority-rule. No change +// handler needed; kept the DOM node so panel.js code that reads .value +// doesn't NPE mid-migration. $("refreshPrices").addEventListener("click", async () => { try { await S.invoke("refreshPrices"); @@ -2976,15 +4284,26 @@ function cmpSemver(a, b) { let footerLastCheckManual = false; function paintFooterUpdate() { - const el = $("brandUpdate"); if (!el) return; - if (!footerCurrentVer || !footerLatestKnown) { el.hidden = true; return; } + const el = $("brandUpdate"); + const verEl = $("brandVer"); + if (!el) return; + // 0.8.1: the update chip and version marker share the same slot at the + // bottom-right. When a newer build is available the chip takes over the + // slot and the raw "v0.8.0" marker hides; when the check flashes "up to + // date" the chip briefly steals it back; otherwise the version marker + // is the resting state and the chip stays hidden. showVer/hideVer are + // no-ops when brandVer isn't in the DOM so the render is idempotent. + const showVer = () => { if (verEl) verEl.hidden = false; }; + const hideVer = () => { if (verEl) verEl.hidden = true; }; + if (!footerCurrentVer || !footerLatestKnown) { el.hidden = true; showVer(); return; } if (cmpSemver(footerLatestKnown, footerCurrentVer) > 0) { el.hidden = false; el.className = "brandupd"; - el.textContent = "↑ Update to v" + footerLatestKnown; - el.title = "Aegis v" + footerLatestKnown + " is available — click to apply"; + el.textContent = "↑ v" + footerLatestKnown; + el.title = "Aegis v" + footerLatestKnown + " is available — click to install"; el.style.cursor = "pointer"; el.onclick = () => triggerFooterUpdate(); + hideVer(); return; } // At-or-past latest: silent on auto-check (unobtrusive), transient @@ -2996,13 +4315,13 @@ function paintFooterUpdate() { el.title = "Aegis v" + footerCurrentVer + " is the latest"; el.style.cursor = "default"; el.onclick = null; + hideVer(); setTimeout(() => { - // Only clear if we're still in the "up to date" state — an update - // that arrives during the flash window keeps the newer message. - if (el.classList.contains("brandok")) el.hidden = true; + if (el.classList.contains("brandok")) { el.hidden = true; showVer(); } }, 2200); } else { el.hidden = true; + showVer(); } } @@ -3051,6 +4370,13 @@ async function checkFooterUpdate(opts = {}) { // an older Theseus that lacks the panel-driven update hooks. async function triggerFooterUpdate() { const el = $("brandUpdate"); if (!el) return; + const verEl = $("brandVer"); + // While the chip is doing something, the raw version marker stays + // hidden — the chip owns the slot end-to-end for the whole transaction + // so the user never sees "v0.8.0 ↑ v0.8.1" simultaneously in the + // corner. Returned to the version marker only after the flash timers + // clear (below). + if (verEl) verEl.hidden = true; const setChip = (text, klass, title, handler) => { el.hidden = false; el.className = "brandupd" + (klass ? " " + klass : ""); @@ -3059,6 +4385,7 @@ async function triggerFooterUpdate() { el.style.cursor = handler ? "pointer" : "default"; el.onclick = handler || null; }; + const restoreVer = () => { el.hidden = true; if (verEl) verEl.hidden = false; }; try { setChip("Staging update…", "brandwait", "Downloading + verifying the signed payload", null); const r = await S.invoke("requestUpdate", { step: "stage" }); @@ -3075,21 +4402,24 @@ async function triggerFooterUpdate() { }); return; } - // Server responded but nothing to stage — surface the reason briefly. const msg = r?.status === "up-to-date" ? "✓ Already up to date" : r?.status ? "⚠ " + r.status : "⚠ Update failed"; setChip(msg, r?.status === "up-to-date" ? "brandok" : "branderr", r?.detail || "", null); - setTimeout(() => { if (el.classList.contains("brandok") || el.classList.contains("branderr")) el.hidden = true; }, 2500); + setTimeout(() => { if (el.classList.contains("brandok") || el.classList.contains("branderr")) restoreVer(); }, 2500); } catch (e) { console.warn("update trigger failed:", e?.message || e); setChip("⚠ Update failed", "branderr", String(e?.message || e), null); - setTimeout(() => { if (el.classList.contains("branderr")) el.hidden = true; }, 2500); + setTimeout(() => { if (el.classList.contains("branderr")) restoreVer(); }, 2500); } } (function wireFooter() { const link = $("brandLink"); if (!link) return; link.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); }); + const a1 = $("aboutOpenAegisSite"); + if (a1) a1.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); }); + const a2 = $("aboutOpenSilentmodeSite"); + if (a2) a2.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://silentmode.st/"); }); // Version comes from the addon manifest; if the state message carries it // we surface it, otherwise the slot stays empty. S.invoke("aegisVersion").then((v) => { diff --git a/bundled-addons/aegis/wallet-inject.js b/bundled-addons/aegis/wallet-inject.js index ae4d1f2b..0287e5c5 100644 --- a/bundled-addons/aegis/wallet-inject.js +++ b/bundled-addons/aegis/wallet-inject.js @@ -141,6 +141,36 @@ const tronLink = { theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb); theseus.contextBridge.exposeInMainWorld("tronLink", tronLink); +// -------- WizardConnect bridge (window.wizardconnect), everywhere ----------- +// +// WizardConnect is a Nostr-relay pairing protocol built for CROSS-device use: +// the dapp calls initiateDappRelay(), gets a wiz:// uri, and renders it as a +// QR for a phone wallet to scan. On the same device that QR round-trip is +// pure friction — the dapp and Aegis are in the same browser. +// +// The SDK has no in-page wallet discovery, so this is Silent Mode's own +// surface. A dapp keeps its existing initiateDappRelay() call and simply +// hands us the uri it already generated: +// +// const { uri } = initiateDappRelay(onStatus); +// if (window.wizardconnect) await window.wizardconnect.connect(uri); +// else renderQr(uri); // unchanged fallback +// +// connect() resolves once the user approves the pairing in Aegis and the +// key exchange completes, and rejects if they decline. Nothing is paired +// without an explicit approval, and we never read the page to find a uri — +// the dapp hands it to us. +const wizardconnect = { + isAegis: true, + version: "1.0.0", + // Present so a dapp can tell "wallet is installed" from "wallet is + // installed but has no BCH wallet ready to pair with" before it decides + // whether to fall back to a QR. + isReady: () => call("wcPageReady"), + connect: (uri) => call("wcConnectFromPage", { uri: String(uri ?? "") }), +}; +theseus.contextBridge.exposeInMainWorld("wizardconnect", wizardconnect); + // -------- Main-world bridges (window.ethereum, window.solana) --------------- // // EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet @@ -491,6 +521,23 @@ const mainWorldSource = `(function () { announce(); window.addEventListener("eip6963:requestProvider", announce); } catch {} + + // Same announce/request handshake for WizardConnect. The WC SDK defines + // no discovery mechanism at all, so we borrow EIP-6963's shape: a dapp + // that wants to support several WC wallets dispatches + // "wizardconnect:requestProvider" and collects the announcements instead + // of reaching for window.wizardconnect and finding whoever won the race. + // window.wizardconnect stays for the simple single-wallet case. + try { + const wcInfo = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" }; + const announceWc = () => { + const provider = window.wizardconnect; + if (!provider) return; + window.dispatchEvent(new CustomEvent("wizardconnect:announceProvider", { detail: Object.freeze({ info: wcInfo, provider }) })); + }; + announceWc(); + window.addEventListener("wizardconnect:requestProvider", announceWc); + } catch {} })();`; // Actually push the script into the main world. Doing this at diff --git a/main.js b/main.js index b52b10c2..95eb1337 100644 --- a/main.js +++ b/main.js @@ -2122,6 +2122,59 @@ function initAddons() { // and-stitch pass; kept for a later revision. // region — run the caller-supplied overlay source in the tab, wait // for a rect (or null = cancel), then capturePage(rect). + // Back scan-page. The extraction runs IN the page and returns only the + // matched URIs — the add-on never sees the DOM. We look at anchor hrefs, + // visible text, and the handful of attributes a dapp realistically + // stashes a pairing code in (data-uri, value, title), then dedupe. + // + // WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which + // is often the ONLY thing in the DOM when a dapp renders a QR, so the + // matcher accepts the percent-encoded spelling too and decodes it. + scanTabForUris: async ({ scheme, limit }, addonId) => { + const t = activeTab(); + if (!t) throw new Error("no active tab"); + if (t.addonId || t.settings) throw new Error("open the dapp's tab first, then scan"); + const wc = t.view.webContents; + const origin = pageOriginOf(wc.getURL()); + // The regex SOURCES are built here and shipped as JSON. Assembling + // them inside the injected string instead means hand-escaping + // backslashes and quotes through two levels of literal, which is both + // easy to get wrong and unreviewable. JSON.stringify does it exactly. + // `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so + // it cannot carry regex metacharacters. + const plainSrc = `\\b${scheme}://[^\\s"'<>]{4,2048}`; + // Percent-encoded QR spelling: WIZ://%3FP%3D… + const qrSrc = `\\b${scheme}://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~$+-])+`; + const arg = JSON.stringify({ plainSrc, qrSrc, limit }); + const found = await wc.executeJavaScript(`(() => { + const { plainSrc, qrSrc, limit } = ${arg}; + const out = new Set(); + const plain = new RegExp(plainSrc, "gi"); + const qr = new RegExp(qrSrc, "gi"); + const push = (s) => { + if (!s || out.size >= limit) return; + let v = String(s).trim(); + if (v.includes("%3F") || v.includes("%3f")) { try { v = decodeURIComponent(v); } catch {} } + if (v.length <= 2048) out.add(v); + }; + const scan = (s) => { + if (!s) return; + for (const m of String(s).matchAll(plain)) push(m[0]); + for (const m of String(s).matchAll(qr)) push(m[0]); + }; + for (const a of document.querySelectorAll("a[href]")) scan(a.getAttribute("href")); + for (const el of document.querySelectorAll("[data-uri],[data-wc-uri],[value],[title]")) { + scan(el.getAttribute("data-uri")); scan(el.getAttribute("data-wc-uri")); + scan(el.getAttribute("value")); scan(el.getAttribute("title")); + } + for (const el of document.querySelectorAll("input,textarea")) scan(el.value); + scan(document.body ? document.body.innerText : ""); + return [...out].slice(0, limit); + })()`, true); + const uris = Array.isArray(found) ? found.filter((s) => typeof s === "string") : []; + console.log(`[addons] ${addonId} scanned ${origin || "tab"} for ${scheme}:// — ${uris.length} match(es)`); + return { origin, uris }; + }, captureTab: async (opts, addonId) => { // Prefer the currently-active tab, BUT if that's an add-on-owned page // (e.g. the screenshot editor is already up when the user re-picks a @@ -3062,6 +3115,13 @@ function createTab(initial, opts = {}) { } return navigateTab(id, target + rest); } + // A wiz:// click never navigates — it hands the pairing URI to the + // wallet and leaves the dapp exactly where it is. + if (parsed.protocol === "wiz:") { + e.preventDefault(); + routeWizUri(u, pageOriginOf(wc.getURL()), tab.id); + return; + } if (parsed.protocol === "bns:") return; if (isBnsHost(parsed.hostname)) { // Only intercept cross-origin navigations. Same-origin (a form submit @@ -3103,6 +3163,12 @@ function createTab(initial, opts = {}) { }); // Links that open a new tab: target="_blank", window.open, Ctrl/middle-click. wc.setWindowOpenHandler(({ url, disposition }) => { + // target="_blank" on a wiz:// link lands here rather than will-navigate. + // Route it to the wallet instead of opening a tab on an unloadable URL. + if (url && /^wiz:/i.test(url)) { + routeWizUri(url, pageOriginOf(wc.getURL()), tab.id); + return { action: "deny" }; + } const installId = installLinkId(url); if (installId) { let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {} @@ -4132,6 +4198,30 @@ function pageOriginOf(url) { return u.protocol && u.host ? `${u.protocol}//${u.host}` : null; } catch { return null; } } +// wiz:// — WizardConnect pairing links. +// +// WizardConnect is a cross-device protocol: a dapp renders its pairing URI +// as a QR for a phone wallet to scan. On the same device that means copying +// a wiz:// string out of one tab and pasting it into the wallet by hand. +// When a dapp renders the URI as a link instead, we can route the click +// straight to the wallet — no copying, and no change required on the dapp +// side beyond making it an anchor, so this works for third-party dapps that +// will never adopt a Silent Mode API. +// +// The wallet still shows its own approval before anything is paired; all +// this does is carry the URI across, tagged with the origin that offered it +// so the approval can name the real site. +function routeWizUri(uri, origin, tabId) { + if (!addonHost) return false; + const u = String(uri || ""); + if (!/^wiz:/i.test(u) || u.length > 4096) return false; + if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false; + addonHost + .dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId }) + .catch((err) => console.log("[wiz] pairing failed:", err?.message || err)); + return true; +} + ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { const tab = tabForSender(e.sender); if (!tab || !addonHost) throw new Error("not a page");