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) => {