From cb7ca53b01720513df400a69f31d6ddb221b00ee Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 20:37:28 +0200 Subject: [PATCH 01/10] =?UTF-8?q?chore(aegis):=200.8.2=20=E2=80=94=20secti?= =?UTF-8?q?oned=20Settings=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings grew tall enough that the user had to scroll past a dozen cards to reach Prices, Sites or About. Split it into six named sections (Security, Session, Wallet, Prices, Sites, About) with a chip nav row at the top; only one section is visible at a time and the choice persists across restarts. Adds an About card that names the wallet, the aegis.x front-door site and the silentmode.st umbrella, so support triage has a one-click way to reach either from within the panel. Includes the accumulated 0.7.x-0.8.1 wallet work that was already shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan integration, consolidate, currency picker, footer update chip). --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/index.js | 253 +++- bundled-addons/aegis/lib/bcmr.js | 146 ++ bundled-addons/aegis/lib/cashaddr.js | 38 +- bundled-addons/aegis/lib/cashtokens.js | 206 +++ .../aegis/lib/chain-bch-imported.js | 192 ++- bundled-addons/aegis/lib/chain-bch.js | 6 +- bundled-addons/aegis/lib/chain-sia.js | 132 +- .../aegis/lib/chain-utxo-imported.js | 4 +- bundled-addons/aegis/lib/import-derive.js | 43 +- bundled-addons/aegis/lib/keys.js | 54 +- bundled-addons/aegis/lib/prices.js | 215 ++- bundled-addons/aegis/lib/sia/siascan.js | 156 ++ bundled-addons/aegis/lib/tx.js | 56 +- bundled-addons/aegis/lib/wallet.js | 110 +- bundled-addons/aegis/panel.html | 269 +++- bundled-addons/aegis/panel.js | 1302 ++++++++++++++--- 17 files changed, 2833 insertions(+), 351 deletions(-) create mode 100644 bundled-addons/aegis/lib/bcmr.js create mode 100644 bundled-addons/aegis/lib/cashtokens.js create mode 100644 bundled-addons/aegis/lib/sia/siascan.js diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 40f751a6..52ab7b61 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.6.31", + "version": "0.8.2", "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", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index e3bed81b..a7a7eeb7 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,6 +506,7 @@ 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); } else if (entry.chain === "btc" || entry.chain === "dgb") { @@ -518,6 +520,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}"`); } @@ -1024,6 +1052,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 +1135,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 +1346,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()); @@ -1700,6 +1898,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 +2495,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..0d12cab5 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,7 +23,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, const IMPORTED_BCH_NETWORKS = { mainnet: { - id: "mainnet", label: "Mainnet", prefix: "bitcoincash", + id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80, explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", explorerAddr: "https://blockchair.com/bitcoin-cash/address/", defaultServers: [ @@ -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..046ddf44 100644 --- a/bundled-addons/aegis/lib/chain-bch.js +++ b/bundled-addons/aegis/lib/chain-bch.js @@ -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-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..9e91f58f 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,150 @@ 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(); + const usd = Number(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 +248,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 +281,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..8355579e 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,6 +68,17 @@ #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; } @@ -78,6 +91,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 +308,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 +367,71 @@ .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); } + /* 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 @@ -397,11 +501,17 @@ Aegis Wallet + +
- - - + +
@@ -432,6 +542,15 @@
+ + +
Receiving address
@@ -446,10 +565,30 @@ +
+ +
@@ -825,15 +1025,16 @@ aegis.x - - + + diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 3d1ce1d6..f88cec7d 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,56 @@ 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(); }); // + 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 +384,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; @@ -408,16 +482,198 @@ 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) { + // COINS: one row per chain user has a wallet for. + const rows = []; + for (const [chain, ws] of walletsByChain) { + const first = ws[0]; + const nets = Array.from(new Set(ws.map((w) => w.network))); + let active = activeNetworkByChain.get(chain); + if (!active || !nets.includes(active)) active = nets.includes("mainnet") ? "mainnet" : nets[0]; + const netLbl = networkLabelFor(chain, active, active); + const totalUnits = sumGroupUnits(ws.filter((w) => w.network === active)); + const dec = first.decimals || 8; + const bal = fmtBig(totalUnits || 0, dec) + " " + esc(first.ticker || chain.toUpperCase()); + const usd = usdOf(chain, totalUnits || 0, dec); + const fiat = usd != null ? `
${esc(fmtFiat(usd))}
` : ""; + const activeMark = ws.some((w) => w.id === state?.selectedWalletId) ? "on" : ""; + rows.push(`
+ ${logoSvg(first.logo, 22)} +
+
${esc(first.coinLabel || chain.toUpperCase())} · ${ws.length} wallet${ws.length === 1 ? "" : "s"}${nets.length > 1 ? ` · ${esc(netLbl)}` : ""}
+
${esc(chain)}
+
+
${bal}
${fiat}
+
`); + } + d.innerHTML = ` +
+ Pick a coin + +
+
+ ${rows.length ? rows.join("") : `
No wallets yet. Use + to create one.
`} +
`; + d.querySelectorAll("[data-browse-chain]").forEach((row) => row.addEventListener("click", (e) => { + e.stopPropagation(); + pickerTab = "browse:" + row.dataset.browseChain; + fillPicker(); + })); + 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 +696,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; @@ -863,6 +1119,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 +1142,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 +1213,7 @@ function openImportModal(initialChain) {
Mnemonic (12/24 words)
-
Derivation path
+
Derivation path
@@ -924,83 +1242,434 @@ 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. @@ -1074,13 +1743,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,16 +1941,33 @@ 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. + const editAttr = `data-wedit="${esc(walletId)}"`; + const setAttr = `data-wsettings="${esc(walletId)}"`; rows.push(`
@@ -1297,6 +2005,15 @@ 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; + // 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; + } if (row.dataset.wstripid) { const id = row.dataset.wstripid; if (id === selId) return; @@ -1406,6 +2123,7 @@ function openNetworkPicker(anchorEl, chain, chainGroup) { closeNetworkPicker(); if (!n || n === active) return; activeNetworkByChain.set(chain, n); + persistActiveNetworks(); renderWalletStrip(); })); netMenuDismiss = (e) => { @@ -1479,6 +2197,14 @@ function wireStripDragDrop(el, chainGroups) { 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 +2213,37 @@ 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. + return `
${logoSvg(meta.logo, 14)} - - ${esc(shortName)} - ${addr ? `${esc(addr)}` : ""} + ${esc(displayAddr || "—")} + ${fullAddr ? `` : ``} + ${esc(shortName)} + ${esc(bal)}${esc(meta.ticker)} + + + - - ${esc(bal)} - ${fiat ? `${esc(fiat)}` : ""} - - -
`; }).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,18 +2251,69 @@ 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); @@ -1868,7 +2655,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 +2713,75 @@ 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; + } + card.hidden = true; } // Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. function fmtTokenAmount(rawStr, decimals) { @@ -2102,6 +2963,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 +3020,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 +3050,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) { @@ -2596,32 +3479,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 = {}; @@ -2923,11 +3820,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 +3872,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 +3903,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 +3958,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 +3973,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 +3990,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) => { From b32b353db7ddce22dd940318d63d2ce18f8437d4 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 20:44:44 +0200 Subject: [PATCH 02/10] =?UTF-8?q?chore(aegis):=200.8.3=20=E2=80=94=20defau?= =?UTF-8?q?lt=20BCH=20explorer=20switches=20to=20bchexplorer.cash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockchair's BCH explorer is slow and ad-heavy; bchexplorer.cash is the Bitcoin Cash community's own instance, faster on tx pages and with a proper mempool view. Same /tx/ + /address/ path scheme (address takes the bitcoincash: prefix as-is), so no other code has to change. Chipnet explorer stays on chipnet.imaginary.cash — bchexplorer.cash is mainnet-only. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/lib/chain-bch-imported.js | 4 ++-- bundled-addons/aegis/lib/chain-bch.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 52ab7b61..9cb2f7fc 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.2", + "version": "0.8.3", "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", diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index 0d12cab5..82557b1d 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -24,8 +24,8 @@ module.exports = function makeImportedBchAdapter({ const IMPORTED_BCH_NETWORKS = { mainnet: { id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80, - 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", diff --git a/bundled-addons/aegis/lib/chain-bch.js b/bundled-addons/aegis/lib/chain-bch.js index 046ddf44..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", From e03c7281166ef10c1e5bc14071732abe3d40be62 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 21:19:36 +0200 Subject: [PATCH 03/10] =?UTF-8?q?chore(aegis):=200.8.4=20=E2=80=94=20netwo?= =?UTF-8?q?rk=20chip=20on=20its=20own=20row=20+=20coin-catalogue=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on 0.8.0's currency picker after a testing pass: - The network label was a tiny gray suffix next to the wallet name in the header, so "which network am I on" wasn't obvious at a glance. Now it's a separate row of one clickable chip under the coin ticker; clicking jumps into the browse:chain view where the network strip actually switches network. - Clicking the header opened a "Pick a coin" pane that only listed coins the user already had a wallet for — a first-time user with a single BCH wallet would see one row and no way to add more. Now it shows every supported coin behind a search box; owned coins jump to the wallet list, unowned coins jump straight into the create-wallet flow with that coin's network group pre-expanded. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.html | 26 +++++- bundled-addons/aegis/panel.js | 144 ++++++++++++++++++++++++++------ 3 files changed, 143 insertions(+), 29 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 9cb2f7fc..f4caa1c7 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.3", + "version": "0.8.4", "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", diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 8355579e..2671fcdc 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -81,7 +81,24 @@ #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; } @@ -500,7 +517,6 @@
Aegis Wallet - @@ -514,6 +530,12 @@
+ +
diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index f88cec7d..38b5b55e 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -378,6 +378,19 @@ $("pickerBtn").addEventListener("click", (e) => { 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, // openMoreMenu for Import/Connect/About. Each stopsPropagation so the @@ -426,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"); @@ -508,42 +526,94 @@ function fillPicker() { walletsByChain.get(w.chain).push(w); } if (!focused) { - // COINS: one row per chain user has a wallet for. - const rows = []; - for (const [chain, ws] of walletsByChain) { - const first = ws[0]; - const nets = Array.from(new Set(ws.map((w) => w.network))); - let active = activeNetworkByChain.get(chain); - if (!active || !nets.includes(active)) active = nets.includes("mainnet") ? "mainnet" : nets[0]; - const netLbl = networkLabelFor(chain, active, active); - const totalUnits = sumGroupUnits(ws.filter((w) => w.network === active)); - const dec = first.decimals || 8; - const bal = fmtBig(totalUnits || 0, dec) + " " + esc(first.ticker || chain.toUpperCase()); - const usd = usdOf(chain, totalUnits || 0, dec); - const fiat = usd != null ? `
${esc(fmtFiat(usd))}
` : ""; - const activeMark = ws.some((w) => w.id === state?.selectedWalletId) ? "on" : ""; - rows.push(`
- ${logoSvg(first.logo, 22)} -
-
${esc(first.coinLabel || chain.toUpperCase())} · ${ws.length} wallet${ws.length === 1 ? "" : "s"}${nets.length > 1 ? ` · ${esc(netLbl)}` : ""}
-
${esc(chain)}
-
-
${bal}
${fiat}
-
`); + // 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
+
- ${rows.length ? rows.join("") : `
No wallets yet. Use + to create one.
`} + ${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; @@ -734,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; @@ -2586,9 +2671,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); } From f3fb31e65169b42e28616e04db64cd6157249cec Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 21:31:43 +0200 Subject: [PATCH 04/10] =?UTF-8?q?chore(aegis):=200.8.5=20=E2=80=94=20Coin-?= =?UTF-8?q?Spectrum=20price=20parser=20reads=20body.asset.price=5Fusd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Coin-Spectrum poll silently returned an empty map because we were reading body.price_usd (top-level) while the API wraps its data under body.asset. Every chain's Number(undefined) came back NaN, so Settings › Prices showed "✓ 0 coins" and majority-vote reconciliation had one fewer source than intended. Now reads body.asset.price_usd (with a top-level fallback in case the API is ever flattened). --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/lib/prices.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index f4caa1c7..ac3ccce7 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.4", + "version": "0.8.5", "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", diff --git a/bundled-addons/aegis/lib/prices.js b/bundled-addons/aegis/lib/prices.js index 9e91f58f..91c049d5 100644 --- a/bundled-addons/aegis/lib/prices.js +++ b/bundled-addons/aegis/lib/prices.js @@ -111,7 +111,11 @@ const SOURCES = { 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(); - const usd = Number(body?.price_usd); + // 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 */ } })); From 30fbe73d21068e77273f350259ee4e6e8f5a4ce6 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 21:44:03 +0200 Subject: [PATCH 05/10] =?UTF-8?q?chore(aegis):=200.8.6=20=E2=80=94=20in-pa?= =?UTF-8?q?nel=20confirm/alert=20instead=20of=20native=20dialogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit window.confirm/alert render as chrome-owned, Theseus-branded OS boxes outside the sidebar, which breaks the illusion that Aegis is one coherent surface — and they can't carry an icon, a danger-styled button, or formatted copy. Adds aegisConfirm() / aegisAlert(): the same overlay shell the manage and import modals already use, resolving like confirm() so callers just await it. Escape cancels, Enter confirms, click-outside cancels. Swapped at all four confirm sites (remove wallet from the picker, remove wallet from Settings, remove PIN, sign out) and all seven alert sites. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.js | 108 ++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index ac3ccce7..37c555c5 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.5", + "version": "0.8.6", "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", diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 38b5b55e..8feed09c 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -831,6 +831,68 @@ 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, + }); +} + // 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 @@ -893,7 +955,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(); @@ -3360,14 +3428,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; @@ -3376,7 +3450,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", () => { @@ -3408,7 +3482,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 () => { @@ -3417,10 +3491,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 }; @@ -3428,7 +3508,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. @@ -3459,7 +3539,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. @@ -3514,7 +3594,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) => { @@ -3731,7 +3811,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; } }); From 8785ecc7cf1ef175958b4270b80230e7d1fd0df2 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 21:52:54 +0200 Subject: [PATCH 06/10] =?UTF-8?q?chore(aegis):=200.8.7=20=E2=80=94=20impor?= =?UTF-8?q?ted=20Tron=20wallets=20get=20history=20+=20TRC20=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imported Tron wallets pointed at api.nileex.io, which only serves the /wallet/* JSON-RPC family and 404s all of /v1/. That REST family is where transaction history and the trc20 balance map live, so an imported Nile wallet showed a native balance and nothing else. The built-in Tron adapter was already on nile.trongrid.io, which is why only imports were affected. Switches the imported Nile endpoint to nile.trongrid.io and fills in the two features that were never implemented for imported account- model wallets: - History via /v1/accounts//transactions, with the signed delta computed by comparing owner_address against the wallet's own address in 41-hex form (the feed returns hex regardless of visible:true). - TRC20 balances via /v1/accounts/, joined against token_info harvested from recent trc20 transfers to recover symbol + decimals. Both are best-effort so a chain with no keyless feed can't blank a wallet whose balance fetch succeeded. Contracts with no registry entry render as "Unknown token" with a raw amount rather than a number invented from assumed decimals, and named tokens sort above them so airdrop spam can't bury real holdings. --- bundled-addons/aegis/addon.json | 2 +- .../aegis/lib/chain-generic-imported.js | 110 +++++++++++++++++- bundled-addons/aegis/panel.js | 27 +++++ 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 37c555c5..a309ea62 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.6", + "version": "0.8.7", "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", diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index c60a3681..c267a42b 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -10,6 +10,24 @@ 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 ""; } + } + const CHAIN_CFGS = { eth: { ticker: "ETH", decimals: 18, @@ -30,7 +48,11 @@ module.exports = function makeGenericImportedAdapter() { 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" }, + // 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 }) { @@ -40,6 +62,74 @@ module.exports = function makeGenericImportedAdapter() { 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); + return { + mint: contract, + symbol: ti?.symbol || "?", + name: ti?.name || "", + decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, + known: !!ti, + balance, + }; + }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")); + }, }, sol: { ticker: "SOL", decimals: 9, @@ -72,6 +162,7 @@ module.exports = function makeGenericImportedAdapter() { this._state = { balance: { confirmed: "0", unconfirmed: "0" }, history: [], + tokens: [], scanning: false, error: null, }; @@ -95,6 +186,7 @@ module.exports = function makeGenericImportedAdapter() { 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, @@ -109,9 +201,23 @@ module.exports = function makeGenericImportedAdapter() { async refresh() { this._state.scanning = true; this._emit(); + const opts = { rpc: this._net.rpc, address: this._address }; try { - const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address }); + // 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); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 8feed09c..b8a52f0e 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -2941,6 +2941,33 @@ function renderTokens() { }).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. From 1b74b10fc5860159e3c26741883d762353a1fc5d Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 23:08:16 +0200 Subject: [PATCH 07/10] =?UTF-8?q?chore(aegis):=200.8.8=20=E2=80=94=20coin?= =?UTF-8?q?=20drilldown,=20per-address=20assets,=20WizardConnect=20from=20?= =?UTF-8?q?the=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet strip: - Clicking a coin opens that coin's page (addresses, price, totals, back and close) instead of only flipping the selection and leaving the list sitting there. The page already existed but was reachable only via the small count chip. - The per-coin second action was a gear that 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. It is now Remove, behind a confirm, with the default/legacy wallet showing a lock instead since it gates legacy funds. - Each address in the drilldown can expand to show what THAT address holds: TRC20/SPL via the adapter's tokens, BCH CashTokens via tokenBalances. walletSummary now carries both per wallet, so the view no longer has to borrow the selected wallet's assets. WizardConnect — the Connect pane was effectively unusable: - The locked-vault branch told the user to unlock and gave them nothing to click. It is reachable without the lock screen ever appearing, because a mounted imported wallet makes overallPhase read "ready". It now carries the same unlock form the lock screen uses. - Imported BCH wallets were never registered with the WC manager — startForWallet ran only in the vault-derived mount branch. They mount as ready, so they appeared in the "Sign with" picker and then failed on pair. They now register from their stored seed. WC derives a child key tree, so single-key (WIF) imports genuinely cannot pair; those are disabled in the picker with the reason, rather than failing on click. - Adds window.wizardconnect so a dapp can hand over the wiz:// URI it already generated instead of making the user copy it between tabs. The protocol is Nostr-relay pairing designed for phone-scans-QR, and the SDK has no in-page discovery at all, so this is our own surface: connect() + isReady(), plus a wizardconnect:announceProvider event shaped like EIP-6963 so several WC wallets can coexist. Pairing always goes through the approval modal; the URI is validated before any UI shows, and the wallet never reads the page to find one. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/index.js | 114 ++++++++++++++ bundled-addons/aegis/panel.html | 15 ++ bundled-addons/aegis/panel.js | 219 ++++++++++++++++++++++---- bundled-addons/aegis/wallet-inject.js | 47 ++++++ 5 files changed, 363 insertions(+), 34 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index a309ea62..ce07ff29 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.7", + "version": "0.8.8", "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", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index a7a7eeb7..c6ca833e 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -509,6 +509,40 @@ async function mountWallet(entry) { 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, @@ -675,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); } @@ -749,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); @@ -765,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, }; } @@ -1531,6 +1585,66 @@ function registerPanelMessages(api) { return fullState(); }); + // ---- 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 diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 2671fcdc..0c7a6a80 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -432,6 +432,21 @@ .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. */ diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index b8a52f0e..d4fa9faf 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -893,6 +893,29 @@ function aegisAlert(message, opts) { }); } +// 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 @@ -1829,12 +1852,31 @@ function paintRcvMode() { 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 = []; @@ -1850,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 directly can hand the pairing over with one click — no copying. Otherwise paste a wiz:// URI from the dapp's Connect dialog. Aegis signs every request after your approval.
+ ${blockedNote}
Sign with
@@ -1868,6 +1911,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; @@ -2116,13 +2184,22 @@ function renderWalletStrip() { // still get no chip. const walletsChip = single ? "" : `${gw.length} ▾`; - // Actions row: on multi-wallet rows the ✎ / ⚙ target the ACTIVE + // 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 setAttr = `data-wsettings="${esc(walletId)}"`; + const activeW = gw.find((w) => w.id === walletId); + const canRemove = !(activeW?.isDefault || activeW?.isLegacy); + const removeBtn = canRemove + ? `` + : `🔒`; - rows.push(`
+ rows.push(`
@@ -2140,7 +2217,7 @@ function renderWalletStrip() { - + ${removeBtn}
`); } @@ -2167,14 +2244,24 @@ function renderWalletStrip() { renderWalletStrip(); 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) { + // 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. @@ -2189,17 +2276,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(); @@ -2346,7 +2427,46 @@ 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; @@ -2379,17 +2499,39 @@ function renderInlineCoinList(el, groupKey, group) { 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(displayAddr || "—")} ${fullAddr ? `` : ``} ${esc(shortName)} ${esc(bal)}${esc(meta.ticker)} - + + ${assetChip} - + ${rowRemove} -
`; +
${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 @@ -2472,16 +2614,27 @@ function renderInlineCoinList(el, groupKey, group) { 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 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 From 64bc26ecf6b94e1d4c4b0c9530b740d5b3b902b3 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 23 Sep 2026 00:05:14 +0200 Subject: [PATCH 08/10] =?UTF-8?q?chore(aegis):=200.8.9=20=E2=80=94=20ETH/S?= =?UTF-8?q?OL=20imported=20history=20+=20tokens,=20centred=20setup=20scree?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the parity work 0.8.7 started for Tron. Imported ETH and SOL wallets showed a native balance and nothing else, because the JSON-RPC endpoints they poll have no history or token concept at all. - ETH history + ERC-20 balances via Blockscout, which needs no API key (Etherscan V2 does). Mainnet RPC moves off eth.llamarpc.com, which was answering 525 with an HTML error page — that parsed as a JSON error and showed as a 0 balance. - SOL history via getSignaturesForAddress and SPL balances via getTokenAccountsByOwner, both keyless on the public RPC. Three things the live testing turned up: - A Blockscout mempool entry is {result:"pending", status:null}. Reading that as "not ok, therefore failed" showed pending sends as failures. Now carries a distinct pending state through to the row. - History `delta` is now a number, a decimal string, or null. ETH wei needs the string (18 decimals overflows a JS number, and Math.abs was silently rounding it); Solana's signature feed carries no amount at all, and null >= 0 is true, so unknown amounts were rendering as a "+" that claimed a receive we cannot verify. Unknown now renders as a neutral row instead. - A real address came back with 855 ERC-20s and 3078 SPL mints, nearly all airdrop spam, some with blank, zero-width or bidi-override symbols that render as an empty row borrowing trust from its neighbours. Token text is sanitised and lists are capped at 50, sorted so named tokens survive the cap. Also: the first-run setup screen forced text-align:left on the form, so its helper copy ran ragged under a centred mark, title and description. The form now inherits the centred alignment; the mnemonic box stays left-aligned on purpose, since centring wrapped seed words makes them harder to check. --- bundled-addons/aegis/addon.json | 2 +- .../aegis/lib/chain-generic-imported.js | 632 +++++++++++------- bundled-addons/aegis/panel.html | 13 +- bundled-addons/aegis/panel.js | 32 +- 4 files changed, 425 insertions(+), 254 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index ce07ff29..7c8edf0f 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.8", + "version": "0.8.9", "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", diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index c267a42b..b52419c3 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -1,243 +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() { - - // 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 ""; } - } - - 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.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); - return { - mint: contract, - symbol: ti?.symbol || "?", - name: ti?.name || "", - decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, - known: !!ti, - balance, - }; - }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")); - }, - }, - 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: [], - 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 }; - 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 }; -}; +// 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/panel.html b/bundled-addons/aegis/panel.html index 0c7a6a80..da094776 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -479,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; } diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index d4fa9faf..6df30791 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -3225,17 +3225,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)}
`; From 8174fccba0f1e386fd9fdcd612a0fab63be06918 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 23 Sep 2026 00:16:49 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(theseus+aegis):=20WizardConnect=20au?= =?UTF-8?q?to-detection=20=E2=80=94=20wiz://=20links=20+=20page=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button. --- addons-host.js | 1350 ++++++++++++++++--------------- bundled-addons/aegis/addon.json | 4 +- bundled-addons/aegis/index.js | 14 + bundled-addons/aegis/panel.js | 40 +- main.js | 90 +++ 5 files changed, 834 insertions(+), 664 deletions(-) diff --git a/addons-host.js b/addons-host.js index 465d28ff..2db024f0 100644 --- a/addons-host.js +++ b/addons-host.js @@ -1,660 +1,690 @@ -// Theseus add-on framework — loader + API surface. -// -// Add-ons live in /addons// 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 -// /addons-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", -]); - -// 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, - }; - } - // `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, 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 }) { - 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; - // 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, - 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}`); - }, - // 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, - }; - }, - // Cleanly relaunch Theseus. Used by the plug-in card's "apply - // update" chip to activate a staged build without asking the user - // to hunt for the app menu. - restartApp: () => { - if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); - this._restartApp(); - }, - // Resolves once the browser chrome has painted (immediately if it - // already has). Put expensive dependency loading behind this so it - // never competes with the first frame at launch. - 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(), - }; - } - getSidebarPanels() { - const out = []; - for (const active of this._active.values()) out.push(...active.sidebarPanels); - 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, - items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), - }); - } - 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 /addons// 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 +// /addons-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", +]); + +// 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, + }; + } + // `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, 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 }) { + 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; + // 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, + 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}`); + }, + // 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, + }; + }, + // Cleanly relaunch Theseus. Used by the plug-in card's "apply + // update" chip to activate a staged build without asking the user + // to hunt for the app menu. + restartApp: () => { + if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); + this._restartApp(); + }, + // Resolves once the browser chrome has painted (immediately if it + // already has). Put expensive dependency loading behind this so it + // never competes with the first frame at launch. + 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(), + }; + } + getSidebarPanels() { + const out = []; + for (const active of this._active.values()) out.push(...active.sidebarPanels); + 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, + items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), + }); + } + 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 7c8edf0f..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.8.9", + "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 c6ca833e..002e684c 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1585,6 +1585,20 @@ 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 diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 6df30791..cdb5f425 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1892,7 +1892,7 @@ function renderConnectPane(bchWallets) {
`).join("") : `
No dapps paired yet.
`; return `
-
Dapps that support Aegis directly can hand the pairing over with one click — no copying. Otherwise paste a wiz:// URI from the dapp's Connect dialog. Aegis signs 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
@@ -1901,8 +1901,9 @@ function renderConnectPane(bchWallets) {
-
+
+
Paired dapps
@@ -1948,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(); } diff --git a/main.js b/main.js index 5c4c8a2c..439a51ed 100644 --- a/main.js +++ b/main.js @@ -1860,6 +1860,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 @@ -2692,6 +2745,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 @@ -2733,6 +2793,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" }; + } if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" }); return { action: "deny" }; }); @@ -3417,6 +3483,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"); From 17cac62b228fdcc05c090a4343abf387d544249c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 23 Sep 2026 00:42:22 +0200 Subject: [PATCH 10/10] chore(theseus): 0.3.56 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78746569..a9c817ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.55", + "version": "0.3.56", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", "main": "main.js",