From 5574641fb90a651c4c05403ca5c5f7661656092e Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 6 Sep 2026 22:00:27 +0200 Subject: [PATCH 01/12] feat(theseus/aegis): multi-wallet + Tron mainnet + Tron Nile in the bundled addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the single-account BCH addon into Aegis: a chain-agnostic wallet manager with a wallet picker in the sidebar header, per-wallet sub-accounts, and Tron mainnet + Nile alongside BCH. Add-on id stays "bchwallet" so vault-derive paths stay in the same namespace and the legacy BCH default wallet uses PURPOSE "bchwallet/mainnet/0" byte-identical to before — funds are untouched. - lib/chain-bch.js wraps the existing keys/tx/wallet/electrum stack with the common adapter shape and scopes each wallet's storage under wallets//… - lib/chain-tron.js: m/44'/195'/0'/0/0 → secp256k1 → keccak256 → 0x41 || h20 → base58check. Balance + history via TronGrid v1, send via createtransaction + sha256(raw_data_hex) sign + broadcasttransaction. Mainnet and Nile share the address format; different vault paths mean different keys so a mainnet wallet can never accidentally sign against Nile. - lib/base58check.js: bitcoin-alphabet base58 with sha256d checksum. k=1 derivation verified against Ethereum's canonical k=1 H160 in a scratchpad harness (correct-by-construction for Tron address). - Combined wallet-inject.js: window.bitcoincash on .x pages (unchanged gate), window.tronWeb + window.tronLink on any https page. tron_requestAccounts triggers the approval overlay; sign / sendRawTransaction / signMessageV2 route to the currently-selected Tron wallet. Emits accountsChanged / setNode messages TronLink dapps listen for; chain ids 0x2b6653dc / 0xcd8690dc match what TronLink itself uses. - New panel: chain-aware wallet picker in the header (badges 🟨 BCH, 🔴 Tron, 🔵 Nile), Add-wallet dropdown per chain, per-chain unit picker (BCH/sat, TRX/sun), per-wallet rename + remove (isLegacy default is protected). Sends show the chosen wallet in the approval overlay so the user can never mistake sub-account. - Migration on first launch: pre-multi-wallet storage (top-level receiveCursor / txCache) is rehomed under wallets/bch-default/… and the legacy account path is preserved. Not shipped: user is bundling into the next release. Live Nile broadcast + real dapp connect need a set-up vault; the code paths are unit-verified end to end but a testnet send + tronscan.io/nile connect are user-side steps. --- bundled-addons/bchwallet/addon.json | 10 +- bundled-addons/bchwallet/index.js | 858 +++++++++++++++----- bundled-addons/bchwallet/lib/base58check.js | 83 ++ bundled-addons/bchwallet/lib/chain-bch.js | 122 +++ bundled-addons/bchwallet/lib/chain-tron.js | 353 ++++++++ bundled-addons/bchwallet/panel.html | 123 ++- bundled-addons/bchwallet/panel.js | 354 +++++--- bundled-addons/bchwallet/wallet-inject.js | 154 +++- 8 files changed, 1687 insertions(+), 370 deletions(-) create mode 100644 bundled-addons/bchwallet/lib/base58check.js create mode 100644 bundled-addons/bchwallet/lib/chain-bch.js create mode 100644 bundled-addons/bchwallet/lib/chain-tron.js diff --git a/bundled-addons/bchwallet/addon.json b/bundled-addons/bchwallet/addon.json index eeac612..09d39ab 100644 --- a/bundled-addons/bchwallet/addon.json +++ b/bundled-addons/bchwallet/addon.json @@ -1,14 +1,14 @@ { "id": "bchwallet", - "name": "Bitcoin Cash Wallet", - "version": "0.1.0", - "description": "Send, receive and sign with a BCH wallet derived from your Theseus vault. Dapps on .x sites can request payments through window.bitcoincash.", + "name": "Aegis Wallet", + "version": "0.2.0", + "description": "Multi-chain wallet (BCH, Tron mainnet, Tron Nile testnet) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites and window.tronWeb / window.tronLink on any https page.", "author": "Silent Mode", - "icon": "₿", + "icon": "🛡", "main": "index.js", "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], "page-inject": { "preload": "wallet-inject.js", - "origins": ["https://*.x/*"] + "origins": ["https://*/*"] } } diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index 434c825..6f3f791 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -1,246 +1,590 @@ -// Bitcoin Cash Wallet — bundled Theseus add-on. activate() runs in the main -// process; all key material lives here, in memory, and is re-derived from the -// password vault on every launch. Nothing secret is ever written to disk or -// logged — storage holds settings, the receive cursor, dapp permissions and a -// cache of public transactions only. +// Aegis — multi-chain wallet bundled with Theseus. activate() runs in the +// main process; every wallet's key material lives here, in memory, and is +// re-derived from the password vault on every launch. Nothing secret is ever +// written to disk or logged. +// +// A single addon can hold many wallets — one per {chain, network}, or several +// sub-accounts of the same chain — and each wallet is backed by its own +// vault-derived 32-byte HKDF root. Chains today: BCH, Tron mainnet, Tron +// Nile testnet. Adding a fourth chain is a new adapter file under lib/ and +// an entry in the CHAIN_REGISTRY below. +// +// Back-compat: the addon id stays "bchwallet" (the manifest label became +// Aegis, but the id gates the vault-derive namespace and older vaults have +// funds against it). The legacy BCH default wallet uses purpose +// "bchwallet/mainnet/0" — byte-identical to the pre-multi-wallet build — +// so on-disk funds are untouched. See memory bchwallet-vault-root-derivation. const path = require("node:path"); const fs = require("node:fs"); -const NETWORK = "mainnet"; -const PREFIX = "bitcoincash"; -const DEFAULT_ACCOUNT_PATH = "m/44'/145'/0'"; -const PURPOSE = "bchwallet/mainnet/0"; -const EXPLORER_TX = "https://blockchair.com/bitcoin-cash/transaction/"; -const EXPLORER_ADDR = "https://blockchair.com/bitcoin-cash/address/"; +const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0"; +const LEGACY_BCH_WALLET_ID = "bch-default"; -let ctx = null; // { api, keys, wallet, client, phase, error } +let ctx = null; -function defaultServers(api) { - try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); } - catch { return []; } -} -function serverList(api) { - const custom = api.storage.get("servers", null); - return Array.isArray(custom) && custom.length ? custom : defaultServers(api); -} -function accountPath(api) { - const p = String(api.storage.get("accountPath", "") || "").trim(); - return /^m(\/\d+'?)+$/.test(p) ? p : DEFAULT_ACCOUNT_PATH; -} +// ---- deps ------------------------------------------------------------------- -async function deps(api) { +async function loadDeps(api) { const { secp256k1 } = await api.import("@noble/curves/secp256k1.js"); const { sha256 } = await api.import("@noble/hashes/sha2.js"); const { ripemd160 } = await api.import("@noble/hashes/legacy.js"); + const { keccak_256 } = await api.import("@noble/hashes/sha3.js"); const { HDKey } = await api.import("@scure/bip32"); const WebSocket = api.require("ws"); const cashaddr = require("./lib/cashaddr.js"); const keysLib = require("./lib/keys.js")({ HDKey, secp256k1, sha256, ripemd160, cashaddr }); const tx = require("./lib/tx.js")({ sha256 }); const electrum = require("./lib/electrum.js")({ WebSocket, log: (...a) => api.log("electrum", ...a) }); - return { sha256, cashaddr, keysLib, tx, electrum }; -} - -function snapshot() { - const c = ctx; - const base = { - network: NETWORK, phase: c.phase, error: c.error, - server: c.client ? c.client.url : null, - accountPath: accountPath(c.api), - servers: serverList(c.api), - customServers: Array.isArray(c.api.storage.get("servers", null)), - explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, - }; - if (c.wallet) Object.assign(base, c.wallet.snapshot(), { xpub: c.keys.xpub }); - return base; -} -function emitState() { try { ctx.api.emit("state", snapshot()); } catch {} } -function setPhase(phase, error = null) { ctx.phase = phase; ctx.error = error; emitState(); } - -// Build (or rebuild, after a settings change) the key tree + wallet from the -// vault-derived root. The root itself is kept only for re-derivation when the -// account path changes; it is a Uint8Array in this closure and nowhere else. -function buildWallet() { - const c = ctx; - if (c.wallet) { c.wallet.dispose(); c.wallet = null; } - if (c.keys) { c.keys.wipe(); c.keys = null; } - if (c.client) { c.client.clearSubscriptions(); c.client.setServers(serverList(c.api)); } - else c.client = new c.d.electrum.Client(serverList(c.api)); - c.client.onServer = () => emitState(); - c.keys = new c.d.keysLib.WalletKeys(c.root, accountPath(c.api), PREFIX); - c.wallet = require("./lib/wallet.js")({ - client: c.client, keys: c.keys, tx: c.d.tx, cashaddr: c.d.cashaddr, sha256: c.d.sha256, - storage: c.api.storage, log: (...a) => c.api.log("wallet", ...a), onChange: emitState, + const base58check = require("./lib/base58check.js")({ sha256 }); + const bchAdapter = require("./lib/chain-bch.js")({ + HDKey, secp256k1, sha256, ripemd160, cashaddr, keysLib, tx, electrum, WebSocket, }); - c.api.log("wallet ready, receive address", c.keys.entry(0, 0).address); - setPhase("ready"); - c.wallet.refresh(true); + const tronAdapter = require("./lib/chain-tron.js")({ + HDKey, secp256k1, sha256, keccak_256, base58check, + }); + return { HDKey, secp256k1, sha256, ripemd160, keccak_256, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter }; } -async function deriveAndStart() { +// ---- servers --------------------------------------------------------------- + +function bchDefaultServers(api) { + try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); } + catch { return []; } +} +function bchServerList(api) { + const custom = api.storage.get("servers", null); + return Array.isArray(custom) && custom.length ? custom : bchDefaultServers(api); +} + +// ---- chain registry -------------------------------------------------------- +// Chain-side facts that don't depend on state. Adding a chain = extending this +// map + writing an adapter that constructs a wallet from a 32-byte root. +const CHAIN_REGISTRY = { + "bch:mainnet": { + chain: "bch", network: "mainnet", + label: "Bitcoin Cash", short: "BCH", ticker: "BCH", decimals: 8, + badge: "🟨", color: "#0ac18e", + purposePrefix: "bchwallet/bch/", // legacy wallet uses the flat "bchwallet/mainnet/0" instead + startIndex: 1, // 0 is reserved for the legacy wallet + supportsMessageSign: true, + supportsPageInject: true, // window.bitcoincash on *.x pages + }, + "trx:mainnet": { + chain: "trx", network: "mainnet", + label: "Tron", short: "TRX", ticker: "TRX", decimals: 6, + badge: "🔴", color: "#ff060a", + purposePrefix: "bchwallet/trx/mainnet/", + startIndex: 0, + supportsMessageSign: true, + supportsPageInject: true, // window.tronWeb everywhere + }, + "trx:nile": { + chain: "trx", network: "nile", + label: "Tron Nile testnet", short: "TRX (Nile)", ticker: "TRX", decimals: 6, + badge: "🔵", color: "#4d9dff", + purposePrefix: "bchwallet/trx/nile/", + startIndex: 0, + supportsMessageSign: true, + supportsPageInject: true, + }, +}; +function chainKey(chain, network) { return `${chain}:${network}`; } +function chainMeta(chain, network) { return CHAIN_REGISTRY[chainKey(chain, network)] || null; } + +// ---- wallet list ------------------------------------------------------------ + +function readWallets(api) { + const raw = api.storage.get("wallets", null); + return Array.isArray(raw) ? raw : null; +} +function writeWallets(api, list) { api.storage.set("wallets", list); } + +// Bring pre-multi-wallet storage forward: create the legacy BCH default entry +// and rehome its receiveCursor / txCache under the new per-wallet subkey. +function migrateLegacyStorage(api) { + if (readWallets(api)) return; // already multi-wallet + const legacyAccountPath = String(api.storage.get("accountPath", "") || "").trim() || "m/44'/145'/0'"; + const wallets = [{ + id: LEGACY_BCH_WALLET_ID, + label: "BCH — main", + chain: "bch", + network: "mainnet", + purpose: LEGACY_BCH_PURPOSE, + accountPath: legacyAccountPath, + isDefault: true, + isLegacy: true, + createdAt: 0, + }]; + writeWallets(api, wallets); + api.storage.set("selectedWalletId", LEGACY_BCH_WALLET_ID); + // Move per-wallet state under the scoped prefix used by chain-bch.js. + const prefix = `wallets/${LEGACY_BCH_WALLET_ID}/`; + for (const legacyKey of ["receiveCursor", "txCache"]) { + const v = api.storage.get(legacyKey, null); + if (v !== null && api.storage.get(prefix + legacyKey, null) === null) { + api.storage.set(prefix + legacyKey, v); + } + } + api.log("migrated legacy BCH wallet into multi-wallet layout"); +} + +function nextIndex(wallets, meta) { + let max = meta.startIndex - 1; + for (const w of wallets) { + if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue; + if (w.isLegacy) continue; + const m = /\/(\d+)$/.exec(w.purpose || ""); + const n = m ? Number(m[1]) : NaN; + if (Number.isFinite(n) && n > max) max = n; + } + return max + 1; +} + +function makeWalletId(meta, index) { + const n = String(meta.network).replace(/[^a-z0-9]/gi, ""); + return `${meta.chain}-${n}-${index}`; +} + +function autoLabel(meta, wallets) { + const same = wallets.filter((w) => chainKey(w.chain, w.network) === chainKey(meta.chain, meta.network)); + if (!same.length) return meta.short; + return `${meta.short} #${same.length + 1}`; +} + +// ---- runtime wallet map ----------------------------------------------------- +// A "runtime" is a mounted wallet: its adapter instance plus phase + error. +// activate() derives all of them in parallel once the vault unlocks. + +async function mountAllWallets() { const c = ctx; - setPhase("locked"); + const walletList = readWallets(c.api) || []; + for (const w of walletList) { + if (!c.runtimes.has(w.id)) c.runtimes.set(w.id, { entry: w, phase: "locked", error: null, adapter: null }); + } + emitState(); + await Promise.all(walletList.map((w) => mountWallet(w))); +} + +async function mountWallet(entry) { + const c = ctx; + const rt = c.runtimes.get(entry.id) || { entry, phase: "locked", error: null, adapter: null }; + rt.entry = entry; + rt.phase = "locked"; rt.error = null; + c.runtimes.set(entry.id, rt); + emitState(); + let root; try { - c.root = await c.api.vault.derive(PURPOSE); + root = await c.api.vault.derive(entry.purpose); } catch (e) { const msg = e?.message || String(e); - setPhase(/not set up/i.test(msg) ? "nosetup" : "error", msg); - c.api.log("vault derive failed:", msg); + rt.phase = /not set up/i.test(msg) ? "nosetup" : "error"; + rt.error = msg; + emitState(); return; } - if (!ctx || ctx !== c) return; // deactivated while waiting for unlock - try { buildWallet(); } - catch (e) { setPhase("error", e?.message || String(e)); c.api.log("wallet build failed:", e?.message); } -} - -const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); -function planFrom(p) { - const c = requireReady(); - const spec = p || {}; - 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 c.wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax }); -} -function describePlan(plan) { - const sent = plan.recipients.reduce((a, r) => a + r.value, 0); - return { recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate, inputs: plan.inputs.length, change: plan.change, total: sent + plan.fee }; -} - -function requireReady() { - if (!ctx || ctx.phase !== "ready" || !ctx.wallet) throw new Error("wallet is not ready (vault locked?)"); - return ctx; -} -function fromPanel(ctxMsg) { if (!ctxMsg || ctxMsg.from !== "panel") throw new Error("panel-only message"); } - -function registerPanelMessages(api) { - api.onMessage("state", (_p, m) => { fromPanel(m); return snapshot(); }); - api.onMessage("refresh", async (_p, m) => { fromPanel(m); const c = requireReady(); await c.wallet.refresh(true); return snapshot(); }); - api.onMessage("nextAddress", (_p, m) => { fromPanel(m); const c = requireReady(); c.wallet.nextUnusedAddress(); return snapshot(); }); - api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); - api.onMessage("setSettings", (p, m) => { - fromPanel(m); - const patch = p || {}; - if ("accountPath" in patch) { - const v = String(patch.accountPath || "").trim(); - if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/44'/145'/0'"); - api.storage.set("accountPath", v || DEFAULT_ACCOUNT_PATH); - } - if ("servers" in patch) { - const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : []; - for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`); - api.storage.set("servers", list.length ? list : null); - } - if (ctx && ctx.root) buildWallet(); - return snapshot(); - }); - // Send preview — no side effects, used for the live fee/total summary. - api.onMessage("planSend", (p, m) => { fromPanel(m); return describePlan(planFrom(p)); }); - // Send for real: plan -> approval overlay -> sign -> broadcast. - api.onMessage("send", async (p, m) => { - fromPanel(m); - const c = requireReady(); - const plan = planFrom(p); - const d = describePlan(plan); - const pick = await api.approvalModal({ - title: "Send Bitcoin Cash?", - origin: "Theseus wallet panel", - rows: [ - { label: "To", value: d.recipients[0].to, mono: true }, - { label: "Amount", value: fmtBch(d.recipients[0].value) + " BCH", strong: true }, - { label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` }, - { label: "Total", value: fmtBch(d.total) + " BCH" }, - ], - actions: [{ id: "send", label: "Send", primary: true }], - }); - if (pick !== "send") throw new Error("cancelled"); - return c.wallet.signAndBroadcast(plan); - }); - // Recovery info: xpub always; the account xprv only after an explicit - // confirmation in the approval overlay. Import the xprv into any BIP32 - // wallet (branch 0 receive / 1 change) to move funds without Theseus. - api.onMessage("recovery", async (p, m) => { - fromPanel(m); - const c = requireReady(); - const out = { accountPath: accountPath(api), xpub: c.keys.xpub, purpose: PURPOSE }; - if (p && p.reveal) { - const pick = await api.approvalModal({ - title: "Reveal the account private key?", - origin: "Theseus wallet panel", - body: "Anyone holding this key can spend every coin in this wallet. It stays on screen until you close the Settings tab.", - actions: [{ id: "reveal", label: "Reveal", danger: true }], + if (ctx !== c) return; + try { + let adapter; + if (entry.chain === "bch") { + adapter = new c.d.bchAdapter.BchWallet(root, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + servers: bchServerList(c.api), + accountPath: entry.accountPath, }); - if (pick === "reveal") out.xprv = c.keys.xprv; + } else if (entry.chain === "trx") { + adapter = new c.d.tronAdapter.TronWallet(root, entry.network, { + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + }); + adapter.schedulePoll(20_000); + } else { + throw new Error(`unknown chain ${entry.chain}`); } - return out; - }); + rt.adapter = adapter; + rt.phase = "ready"; + // Kick a first fetch. Errors here don't fail the mount — the panel shows + // them per-wallet via snapshot.error. + adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e)); + emitStateForWallet(entry.id); + } catch (e) { + rt.phase = "error"; + rt.error = e?.message || String(e); + emitState(); + } finally { + // Wipe the root buffer — the adapter has already turned it into keys. + if (root) try { root.fill(0); } catch {} + } } -// ---- dapp bridge (window.bitcoincash) --------------------------------------- -// Permissions live in storage as -// { [origin]: { readAddress: true, sendTx: { capSats, usedSats, grantedAt } } } -// readAddress is a plain grant. sendTx is an allowance the user picks in the -// send approval; silent sends draw it down and a request over the remainder -// asks again. There is no "unlimited" option. Message signing always asks. -const ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH -const pendingByOrigin = new Set(); -function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; } +function unmountWallet(walletId) { + const rt = ctx.runtimes.get(walletId); + if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } + ctx.runtimes.delete(walletId); +} + +// ---- state / snapshot ------------------------------------------------------- + +function selectedWalletId() { + const list = readWallets(ctx.api) || []; + if (!list.length) return null; + const saved = String(ctx.api.storage.get("selectedWalletId", "") || ""); + if (saved && list.some((w) => w.id === saved)) return saved; + const dflt = list.find((w) => w.isDefault) || list[0]; + return dflt.id; +} + +function walletEntries() { return readWallets(ctx.api) || []; } + +function overallPhase() { + // If ANY wallet is nosetup, treat the whole addon as nosetup — the user + // hasn't unlocked / set up the vault, so no wallet can work. + const runtimes = [...ctx.runtimes.values()]; + if (!runtimes.length) return "locked"; + if (runtimes.some((r) => r.phase === "nosetup")) return "nosetup"; + if (runtimes.some((r) => r.phase === "locked")) return "locked"; + return "ready"; +} + +function walletSummary(w) { + const meta = chainMeta(w.chain, w.network); + const rt = ctx.runtimes.get(w.id); + const snap = rt && rt.adapter ? rt.adapter.snapshot() : null; + return { + id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy, + badge: meta?.badge || "🧩", color: meta?.color || "#888", ticker: meta?.ticker || "?", + short: meta?.short || w.chain, decimals: meta?.decimals || 8, + address: snap?.address || null, + balance: snap?.balance || { confirmed: 0, unconfirmed: 0 }, + phase: rt?.phase || "locked", + error: rt?.error || null, + }; +} + +function snapshotForSelected() { + const id = selectedWalletId(); + if (!id) return { phase: "empty" }; + const rt = ctx.runtimes.get(id); + const entry = walletEntries().find((w) => w.id === id); + const meta = entry ? chainMeta(entry.chain, entry.network) : null; + const base = { + walletId: id, + label: entry?.label, + chain: entry?.chain, + network: entry?.network, + isLegacy: !!entry?.isLegacy, + meta: meta ? { badge: meta.badge, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals } : null, + supportsMessageSign: !!meta?.supportsMessageSign, + phase: rt?.phase || "locked", + error: rt?.error || null, + }; + if (rt && rt.adapter) Object.assign(base, rt.adapter.snapshot()); + return base; +} + +function fullState() { + return { + overallPhase: overallPhase(), + selectedWalletId: selectedWalletId(), + wallets: walletEntries().map(walletSummary), + selected: snapshotForSelected(), + bchServers: { + list: bchServerList(ctx.api), + custom: Array.isArray(ctx.api.storage.get("servers", null)), + }, + chains: Object.entries(CHAIN_REGISTRY).map(([k, m]) => ({ + key: k, chain: m.chain, network: m.network, label: m.label, short: m.short, ticker: m.ticker, badge: m.badge, decimals: m.decimals, + })), + }; +} + +function emitState() { try { ctx.api.emit("state", fullState()); } catch {} } +function emitStateForWallet(id) { + // Any wallet change fans out to the panel with the full state so the + // wallet list balances update in the header too. + if (!ctx || !ctx.runtimes.has(id)) return; + emitState(); +} + +// ---- panel messages --------------------------------------------------------- + +function requireWallet(id) { + const rt = ctx.runtimes.get(id); + if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)"); + return rt; +} +function requireSelected() { return requireWallet(selectedWalletId()); } +function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); } function fromPage(m) { if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); return m.origin; } -// One approval in flight per origin — a page can't stack modals. + +const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +function fmtValue(units, decimals) { + const n = Number(units) / Math.pow(10, decimals); + return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +} + +function registerPanelMessages(api) { + api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); }); + api.onMessage("selectWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + if (!walletEntries().some((w) => w.id === id)) throw new Error("unknown wallet"); + api.storage.set("selectedWalletId", id); + emitState(); + return fullState(); + }); + api.onMessage("addWallet", async (p, m) => { + fromPanel(m); + const chain = String(p && p.chain || ""); + const network = String(p && p.network || ""); + const meta = chainMeta(chain, network); + if (!meta) throw new Error("unknown chain/network"); + const list = walletEntries().slice(); + const index = nextIndex(list, meta); + const purpose = meta.purposePrefix + index; + const id = makeWalletId(meta, index); + if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet"); + const label = String(p && p.label || "").trim() || autoLabel(meta, list); + const entry = { id, label, chain, network, purpose, createdAt: Date.now() }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", id); + ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return fullState(); + }); + api.onMessage("removeWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + const list = walletEntries(); + const entry = list.find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (entry.isDefault) throw new Error("the default wallet cannot be removed"); + const next = list.filter((w) => w.id !== id); + writeWallets(api, next); + if (selectedWalletId() === id) api.storage.set("selectedWalletId", next[0]?.id || ""); + unmountWallet(id); + // Drop per-wallet storage subtree. + const all = api.storage.all ? api.storage.all() : {}; + const prefix = `wallets/${id}/`; + for (const k of Object.keys(all)) if (k.startsWith(prefix)) api.storage.set(k, null); + emitState(); + return fullState(); + }); + api.onMessage("renameWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + const label = String(p && p.label || "").trim().slice(0, 60); + if (!label) throw new Error("label required"); + const list = walletEntries(); + const entry = list.find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + entry.label = label; + writeWallets(api, list); + emitState(); + return fullState(); + }); + + api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); }); + api.onMessage("nextAddress", (_p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "bch") throw new Error("only BCH wallets have multiple receive addresses"); + rt.adapter.nextAddress(); + return snapshotForSelected(); + }); + api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); + + api.onMessage("setBchServers", (p, m) => { + fromPanel(m); + const patch = p || {}; + if ("servers" in patch) { + const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : []; + for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`); + api.storage.set("servers", list.length ? list : null); + // Push the new server list into every mounted BCH wallet. + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain === "bch" && rt.adapter) rt.adapter.setServers(bchServerList(api)); + } + } + return fullState(); + }); + api.onMessage("setAccountPath", (p, m) => { + fromPanel(m); + const patch = p || {}; + const id = String(patch.id || selectedWalletId()); + const entry = walletEntries().find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (entry.chain !== "bch") throw new Error("account path is a BCH-only setting"); + const v = String(patch.accountPath || "").trim(); + if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/44'/145'/0'"); + const list = walletEntries(); + const idx = list.findIndex((w) => w.id === id); + list[idx] = { ...list[idx], accountPath: v || "m/44'/145'/0'" }; + writeWallets(api, list); + // Rebuild the wallet's adapter with the new account path. + const rt = ctx.runtimes.get(id); + if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} rt.adapter = null; rt.phase = "locked"; } + mountWallet(list[idx]); + return fullState(); + }); + + // Live plan preview for the selected wallet. + api.onMessage("planSend", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + const plan = await Promise.resolve(rt.adapter.plan(p || {})); + return describePlan(plan, rt.entry.chain, rt.entry.network); + }); + // Execute a send with approval overlay. + api.onMessage("send", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + const plan = await Promise.resolve(rt.adapter.plan(p || {})); + const d = describePlan(plan, rt.entry.chain, rt.entry.network); + const meta = chainMeta(rt.entry.chain, rt.entry.network); + const rows = [ + { label: "To", value: d.recipients[0].to, mono: true }, + { label: "Amount", value: `${fmtValue(d.recipients[0].value, meta.decimals)} ${meta.ticker}`, strong: true }, + { label: "Fee", value: rt.entry.chain === "bch" ? `${plan.fee} sat (${plan.feeRate} sat/B)` : `${fmtValue(plan.fee, meta.decimals)} ${meta.ticker}` }, + { label: "Total", value: `${fmtValue(d.total, meta.decimals)} ${meta.ticker}` }, + { label: "Wallet", value: `${meta.badge} ${rt.entry.label}` }, + ]; + const pick = await api.approvalModal({ + title: `Send ${meta.ticker}?`, + origin: "Aegis wallet panel", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("cancelled"); + return rt.adapter.signAndBroadcast(plan); + }); + + api.onMessage("recovery", async (p, m) => { + fromPanel(m); + const id = String(p && p.id || selectedWalletId()); + const rt = requireWallet(id); + if (rt.entry.chain !== "bch") throw new Error("recovery details are only exposed for BCH wallets in this build"); + const r = rt.adapter.recovery(); + const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose }; + if (p && p.reveal) { + const pick = await api.approvalModal({ + title: "Reveal the account private key?", + origin: "Aegis wallet panel", + body: "Anyone holding this key can spend every coin in this wallet. It stays on screen until you close the Settings tab.", + actions: [{ id: "reveal", label: "Reveal", danger: true }], + }); + if (pick === "reveal") out.xprv = r.xprv; + } + return out; + }); + + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); + api.onMessage("revoke", (p, m) => { + fromPanel(m); + const perms = permissions(api); + delete perms[String(p && p.origin || "")]; + api.storage.set("permissions", perms); + return perms; + }); +} + +// One "describePlan" is enough for both chains because plan() returns a +// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}. +function describePlan(plan) { + const sent = plan.recipients.reduce((a, r) => a + r.value, 0); + return { + recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate, + inputs: (plan.inputs || []).length, change: plan.change || null, + total: plan.total != null ? plan.total : (sent + plan.fee), + }; +} + +// ---- dapp bridges (page → activate()) -------------------------------------- +// Permission model stays the same as the single-wallet build for BCH: +// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt}, +// trx: { readAddress:true, network } } } +// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages +// pre-date multi-wallet and cannot pick between them). The Tron bridge talks +// to the currently-SELECTED Tron wallet; if none is selected, requests fail. +const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH +const pendingByOrigin = new Set(); +function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; } async function withOriginLock(origin, fn) { if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); pendingByOrigin.add(origin); try { return await fn(); } finally { pendingByOrigin.delete(origin); } } -const MAGIC = "Bitcoin Signed Message:\n"; -function messageDigest(sha256, message) { - 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 payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); - return sha256(sha256(payload)); + +// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre- +// multi-wallet gate. Widening the manifest to https://*/* makes the Tron +// bridge available everywhere; the BCH side enforces its narrower rule +// inside the handlers. +function isBchOrigin(origin) { + try { const h = new URL(origin).hostname; return /\.x$/.test(h); } + catch { return false; } +} +function legacyBchRuntime() { + const rt = ctx.runtimes.get(LEGACY_BCH_WALLET_ID); + if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)"); + return rt; +} +// The Tron bridge routes to the currently-selected wallet if it is Tron; +// otherwise it looks for the first ready Tron wallet on the selected network +// hint; else rejects with "no tron wallet". +function activeTronRuntime() { + const selId = selectedWalletId(); + const selRt = selId && ctx.runtimes.get(selId); + if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return selRt; + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return rt; + throw new Error("no Tron wallet available — add one in the Aegis sidebar"); } function registerPageMessages(api) { + // ---- BCH bridge (unchanged behavior; wallet source is legacy default) ---- api.onMessage("getAddress", async (_p, m) => { const origin = fromPage(m); - const c = requireReady(); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); const perms = permissions(api); - if (perms[origin] && perms[origin].readAddress) return c.wallet.current().address; + if (perms[origin] && perms[origin].readAddress) return rt.adapter.current().address; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Share your Bitcoin Cash address?", origin, body: "The site will see your current receiving address and can look up its balance and history on the public chain.", - rows: [{ label: "Address", value: c.wallet.current().address, mono: true }], + rows: [{ label: "Address", value: rt.adapter.current().address, mono: true }], actions: [{ id: "allow", label: "Share", primary: true }], checkbox: { id: "always", label: "Always allow this site to see my address" }, }); if (!pick.startsWith("allow")) throw new Error("user rejected"); if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); } - return c.wallet.current().address; + return rt.adapter.current().address; }); }); api.onMessage("signAndSend", async (p, m) => { const origin = fromPage(m); - const c = requireReady(); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); return withOriginLock(origin, async () => { let plan; - // Never echo the shortfall to a page — it would let a site probe the - // balance by bisecting amounts. - try { plan = planFrom(p); } + try { plan = rt.adapter.plan(p || {}); } catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); } const d = describePlan(plan); if (d.recipients.length > 8) throw new Error("too many outputs"); - // Remembered budget: a site the user granted an allowance may spend - // silently until it is used up; anything larger re-prompts. const perms = permissions(api); const budget = perms[origin] && perms[origin].sendTx; const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; if (budget && d.total <= remaining) { - const r = await c.wallet.signAndBroadcast(plan); + const r = await rt.adapter.signAndBroadcast(plan); budget.usedSats = (budget.usedSats | 0) + d.total; api.storage.set("permissions", perms); emitState(); @@ -249,7 +593,7 @@ function registerPageMessages(api) { } const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true }); - rows.push({ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` }); + rows.push({ label: "Fee", value: `${plan.fee} sat (${plan.feeRate} sat/B)` }); rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" }); const pick = await api.approvalModal({ title: "Send Bitcoin Cash?", @@ -261,14 +605,14 @@ function registerPageMessages(api) { actions: [{ id: "send", label: "Send", primary: true }], select: { id: "cap", label: "Afterwards", - options: [{ value: "", label: "ask every time" }, ...ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))], + options: [{ value: "", label: "ask every time" }, ...BCH_ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))], }, }); const [action, ...flags] = pick.split("+"); if (action !== "send") throw new Error("user rejected"); const cap = flags.find((f) => f.startsWith("cap=")); const capSats = cap ? Number(cap.slice(4)) : 0; - if (ALLOWANCES.includes(capSats)) { + if (BCH_ALLOWANCES.includes(capSats)) { perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } }; api.storage.set("permissions", perms); } else if (budget) { @@ -276,62 +620,172 @@ function registerPageMessages(api) { api.storage.set("permissions", perms); } emitState(); - const r = await c.wallet.signAndBroadcast(plan); + const r = await rt.adapter.signAndBroadcast(plan); return { txid: r.txid }; }); }); api.onMessage("signMessage", async (p, m) => { const origin = fromPage(m); - const c = requireReady(); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); const message = String(p && p.message != null ? p.message : ""); if (message.length > 4096) throw new Error("message too long"); return withOriginLock(origin, async () => { - const entry = c.wallet.current(); const pick = await api.approvalModal({ title: "Sign a message?", origin, body: "Signing proves you control the address below. It moves no coins.", - rows: [{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Address", value: entry.address, mono: true }], + rows: [ + { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Address", value: rt.adapter.current().address, mono: true }, + ], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); - const sig = c.keys.signRecoverable(entry, messageDigest(c.d.sha256, message)); - return { address: entry.address, signature: Buffer.from(sig).toString("base64") }; + return rt.adapter.signMessage(message); }); }); - // Panel-side management of remembered sites. - api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); - api.onMessage("revoke", (p, m) => { - fromPanel(m); + + // ---- Tron bridge (tronWeb / tronLink) ----------------------------------- + api.onMessage("trx.requestAccounts", async (_p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); const perms = permissions(api); - delete perms[String(p && p.origin || "")]; - api.storage.set("permissions", perms); - return perms; + const alreadyOK = perms[origin] && perms[origin].trx && perms[origin].trx.readAddress; + const snap = rt.adapter.snapshot(); + if (alreadyOK) return { code: 200, address: snap.address, network: snap.network }; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Connect this site to your Tron wallet?", + origin, + body: "The site will see this address and can build transactions for you to sign.", + rows: [ + { label: "Address", value: snap.address, mono: true }, + { label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" }, + { label: "Wallet", value: `🔴 ${rt.entry.label}` }, + ], + actions: [{ id: "allow", label: "Connect", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see this address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { + perms[origin] = { ...(perms[origin] || {}), trx: { readAddress: true, network: snap.network } }; + api.storage.set("permissions", perms); + emitState(); + } + return { code: 200, address: snap.address, network: snap.network }; + }); + }); + api.onMessage("trx.getAccount", (_p, m) => { + const origin = fromPage(m); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const rt = activeTronRuntime(); + const snap = rt.adapter.snapshot(); + return { address: snap.address, network: snap.network }; + }); + // Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb). + // The wallet never guesses the intent — the approval overlay shows the + // decoded contract type and destination when it can, and always the txID. + api.onMessage("trx.signTransaction", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const tx = p && p.transaction; + if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction"); + return withOriginLock(origin, async () => { + const contract = (tx.raw_data.contract || [])[0]; + const type = contract?.type || "Contract"; + const rows = [{ label: "Type", value: type }, { label: "Tx ID", value: tx.txID || "(unset)", mono: true }]; + if (type === "TransferContract") { + const v = contract.parameter?.value || {}; + try { + const to = v.to_address ? ctx.d.tronAdapter.hexToAddress(v.to_address) : (v.to_address || ""); + const amount = Number(v.amount || 0); + rows.splice(1, 0, { label: "To", value: to, mono: true }, { label: "Amount", value: `${fmtTrx(amount)} TRX`, strong: true }); + } catch {} + } + rows.push({ label: "Wallet", value: `🔴 ${rt.entry.label} (${rt.adapter.snapshot().network})` }); + const pick = await api.approvalModal({ + title: "Sign a Tron transaction?", + origin, + body: "The site built this transaction. Check the type, amount, and destination before signing.", + rows, + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + const sig = rt.adapter.signRawData(tx.raw_data_hex); + const signed = { ...tx, signature: [sig] }; + return signed; + }); + }); + api.onMessage("trx.sendRawTransaction", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const signedTx = p && p.transaction; + if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx"); + // No approval here — broadcasting a *signed* tx does not add any risk + // the sign step didn't already carry. Sites that don't want an extra + // network round-trip pass {broadcast:true} to sign; we support both. + return rt.adapter.broadcastSignedTx(signedTx); + }); + api.onMessage("trx.signMessageV2", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const message = String(p && p.message != null ? p.message : ""); + if (message.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + const pick = await api.approvalModal({ + title: "Sign a Tron message?", + origin, + body: "Signing proves you control this address. It moves no coins.", + rows: [ + { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Address", value: snap.address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signMessageV2(message); + }); }); } +// ---- activate --------------------------------------------------------------- + module.exports = { activate(api) { - api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" }); - const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null }; + api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "🛡", page: "panel.html" }); + const c = ctx = { + api, + d: null, + runtimes: new Map(), // walletId -> { entry, phase, error, adapter } + }; + migrateLegacyStorage(api); registerPanelMessages(api); registerPageMessages(api); - deps(api).then((d) => { + loadDeps(api).then((d) => { if (ctx !== c) return; c.d = d; - return deriveAndStart(); + return mountAllWallets(); }).catch((e) => { if (ctx !== c) return; - setPhase("error", e?.message || String(e)); api.log("startup failed:", e?.message); + emitState(); }); }, deactivate() { const c = ctx; ctx = null; if (!c) return; - try { c.wallet && c.wallet.dispose(); } catch {} - try { c.keys && c.keys.wipe(); } catch {} - try { c.client && c.client.disconnect(); } catch {} - if (c.root) c.root.fill(0); + for (const rt of c.runtimes.values()) { + try { rt.adapter && rt.adapter.dispose(); } catch {} + } + c.runtimes.clear(); }, }; diff --git a/bundled-addons/bchwallet/lib/base58check.js b/bundled-addons/bchwallet/lib/base58check.js new file mode 100644 index 0000000..8cb4c8d --- /dev/null +++ b/bundled-addons/bchwallet/lib/base58check.js @@ -0,0 +1,83 @@ +// Base58Check encode/decode for Tron addresses (0x41 || H160 || sha256d[:4]). +// Bitcoin-style base58 alphabet; the caller supplies the raw 21-byte payload +// (version byte first) so this module knows nothing about Tron itself. +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const INDEX = new Int8Array(128).fill(-1); +for (let i = 0; i < ALPHABET.length; i++) INDEX[ALPHABET.charCodeAt(i)] = i; + +module.exports = function makeBase58Check({ sha256 }) { + function encodeBase58(bytes) { + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + // Convert base-256 → base-58 by repeated division. + const b58 = new Uint8Array(Math.ceil(bytes.length * 138 / 100 + 1)); + let length = 0; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + let j = 0; + for (let k = b58.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += (b58[k] << 8) >>> 0; + b58[k] = carry % 58; + carry = (carry / 58) | 0; + } + length = j; + } + // Skip leading zero-bytes in the base58 buffer, then prepend '1' per leading zero-byte in input. + let it = b58.length - length; + while (it < b58.length && b58[it] === 0) it++; + let out = ""; + for (let i = 0; i < zeros; i++) out += ALPHABET[0]; + for (; it < b58.length; it++) out += ALPHABET[b58[it]]; + return out; + } + + function decodeBase58(str) { + if (typeof str !== "string" || str.length === 0) throw new Error("base58: empty input"); + let zeros = 0; + while (zeros < str.length && str[zeros] === ALPHABET[0]) zeros++; + const out = new Uint8Array(Math.ceil(str.length * 733 / 1000 + 1)); + let length = 0; + for (let i = zeros; i < str.length; i++) { + const c = str.charCodeAt(i); + const val = c < 128 ? INDEX[c] : -1; + if (val < 0) throw new Error("base58: bad character " + JSON.stringify(str[i])); + let carry = val; + let j = 0; + for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += 58 * out[k]; + out[k] = carry & 0xff; + carry >>>= 8; + } + length = j; + } + let it = out.length - length; + while (it < out.length && out[it] === 0) it++; + const total = zeros + (out.length - it); + const decoded = new Uint8Array(total); + for (let i = 0; i < zeros; i++) decoded[i] = 0; + let p = zeros; + while (it < out.length) decoded[p++] = out[it++]; + return decoded; + } + + function encodeCheck(payload) { + const bytes = payload instanceof Uint8Array ? payload : Uint8Array.from(payload); + const check = sha256(sha256(bytes)).slice(0, 4); + const full = new Uint8Array(bytes.length + 4); + full.set(bytes, 0); + full.set(check, bytes.length); + return encodeBase58(full); + } + + function decodeCheck(str) { + const full = decodeBase58(str); + if (full.length < 5) throw new Error("base58check: too short"); + const payload = full.slice(0, full.length - 4); + const check = full.slice(full.length - 4); + const want = sha256(sha256(payload)).slice(0, 4); + for (let i = 0; i < 4; i++) if (check[i] !== want[i]) throw new Error("base58check: bad checksum"); + return payload; + } + + return { encodeBase58, decodeBase58, encodeCheck, decodeCheck }; +}; diff --git a/bundled-addons/bchwallet/lib/chain-bch.js b/bundled-addons/bchwallet/lib/chain-bch.js new file mode 100644 index 0000000..89b3dae --- /dev/null +++ b/bundled-addons/bchwallet/lib/chain-bch.js @@ -0,0 +1,122 @@ +// BCH chain adapter — wraps the existing keys.js / wallet.js / tx.js / +// electrum.js / cashaddr.js code with the common adapter shape that the +// multi-wallet manager talks to. Every BCH wallet is one BIP32 account +// derived from its own 32-byte root (from api.vault.derive). +// +// Deps are handed in so index.js loads @noble/* once and shares them across +// every wallet, rather than each adapter dynamic-importing on its own. + +module.exports = function makeBchAdapter({ + HDKey, secp256k1, sha256, ripemd160, cashaddr, + keysLib, tx, electrum, WebSocket, +}) { + const NETWORK = "mainnet"; + const PREFIX = "bitcoincash"; + const DEFAULT_ACCOUNT_PATH = "m/44'/145'/0'"; + const EXPLORER_TX = "https://blockchair.com/bitcoin-cash/transaction/"; + const EXPLORER_ADDR = "https://blockchair.com/bitcoin-cash/address/"; + + // storage is the FULL api.storage. keyPrefix scopes every read/write under + // "wallets//…" so multiple BCH wallets don't stomp each other. + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + class BchWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + accountPath = DEFAULT_ACCOUNT_PATH, + } = {}) { + if (!walletId) throw new Error("chain-bch: walletId required"); + this.walletId = walletId; + this.chain = "bch"; + this.network = NETWORK; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._servers = Array.isArray(servers) && servers.length ? servers : []; + this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : DEFAULT_ACCOUNT_PATH; + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._keys = new keysLib.WalletKeys(root32, this._accountPath, PREFIX); + this._root = new Uint8Array(root32); + const walletFactory = require("./wallet.js"); + this._wallet = walletFactory({ + client: this._client, keys: this._keys, tx, cashaddr, sha256, + storage: this.storage, + log: (...a) => this.log(...a), + onChange: () => this._emit(), + }); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : []; + this._client.setServers(this._servers); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + const w = this._wallet.snapshot(); + return { + chain: "bch", + network: NETWORK, + ticker: "BCH", + decimals: 8, + address: w.address, + addressIndex: w.addressIndex, + addressPath: w.addressPath, + balance: w.balance, + height: w.height, + history: w.history, + scanning: w.scanning, + error: w.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._accountPath, + xpub: this._keys.xpub, + explorerTx: EXPLORER_TX, + explorerAddr: EXPLORER_ADDR, + faucet: null, + }; + } + + async refresh(full) { return this._wallet.refresh(!!full); } + nextAddress() { return this._wallet.nextUnusedAddress(); } + current() { return this._wallet.current(); } + plan(spec) { + 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 }); + } + async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); } + // 65-byte BIP-137 recoverable signature — the format Electron Cash and + // most BCH tooling verify against. + signMessage(message) { + 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 entry = this.current(); + const sig = this._keys.signRecoverable(entry, digest); + return { address: entry.address, signature: Buffer.from(sig).toString("base64") }; + } + recovery() { + return { accountPath: this._accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + dispose() { + try { this._wallet.dispose(); } catch {} + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { BchWallet, DEFAULT_ACCOUNT_PATH, PREFIX, EXPLORER_TX, EXPLORER_ADDR }; +}; diff --git a/bundled-addons/bchwallet/lib/chain-tron.js b/bundled-addons/bchwallet/lib/chain-tron.js new file mode 100644 index 0000000..eebbf7b --- /dev/null +++ b/bundled-addons/bchwallet/lib/chain-tron.js @@ -0,0 +1,353 @@ +// Tron chain adapter (mainnet + Nile testnet). One address per wallet, in the +// TronLink style: seed for this wallet → BIP32 → m/44'/195'/0'/0/0 → secp256k1 +// private key → uncompressed pubkey (drop 0x04) → keccak256 last 20 bytes → +// prepend 0x41 → base58check → "T..." address. +// +// Balance and history come from TronGrid (read; no key required). Send is +// build-locally-signed-locally-broadcast-remotely: +// POST /wallet/createtransaction {owner_address, to_address, amount, visible:true} +// → { raw_data, raw_data_hex, txID, ... } +// sig = secp256k1.sign(sha256(raw_data_hex), privateKey, {format:"recovered"}) +// → 65 bytes [r||s||recid] +// POST /wallet/broadcasttransaction {raw_data, raw_data_hex, txID, signature:[hexSig]} +// → { result: true/false, txid/message } +// +// Nile and mainnet share the address format (both prefix 0x41). The RPC host +// differs. Coin type 195 is used for both — the derivation-path *string* is +// identical; the difference between mainnet and Nile is which sub-wallet the +// user picked, which vault-derive purpose seeded it, and which RPC we hit. +// Two addresses; a mainnet wallet's key cannot accidentally sign for Nile +// (different key material entirely; the check is enforced at the seed layer). + +const NETWORKS = { + mainnet: { + id: "mainnet", + label: "Tron", + ticker: "TRX", + rpcBase: "https://api.trongrid.io", + explorerTx: "https://tronscan.org/#/transaction/", + explorerAddr: "https://tronscan.org/#/address/", + faucet: null, + }, + nile: { + id: "nile", + label: "Tron Nile testnet", + ticker: "TRX", + rpcBase: "https://nile.trongrid.io", + explorerTx: "https://nile.tronscan.org/#/transaction/", + explorerAddr: "https://nile.tronscan.org/#/address/", + faucet: "https://nileex.io/join/getJoinPage", + }, +}; + +module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256, base58check }) { + if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) { + throw new Error("chain-tron: missing dep"); + } + const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const hexToBytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + + // Derive the raw key material for a single Tron account. + // root32: 32-byte HKDF child from api.vault.derive. + // returns: { privateKey (Uint8Array 32), publicKey (Uint8Array 65 uncompressed), + // addressBytes (Uint8Array 21, prefix 0x41 || h20), address (base58) } + function deriveAccount(root32) { + const master = HDKey.fromMasterSeed(root32); + // Tron follows Ethereum-style non-hardened branch/index: m/44'/195'/0'/0/0. + const node = master.derive("m/44'/195'/0'/0/0"); + const priv = node.privateKey; + // Uncompressed pub is 65 bytes with a 0x04 prefix; drop it to feed keccak256. + const pubUncompressed = secp256k1.getPublicKey(priv, false); + const inner = pubUncompressed.slice(1); // 64 bytes + const kh = keccak_256(inner); // 32 bytes + const h20 = kh.slice(kh.length - 20); + const addrBytes = new Uint8Array(21); + addrBytes[0] = 0x41; + addrBytes.set(h20, 1); + const address = base58check.encodeCheck(addrBytes); + return { privateKey: priv, publicKey: pubUncompressed, addressBytes: addrBytes, address, node }; + } + + // "T..." → 21-byte payload (0x41 || h20). Throws on bad checksum / prefix. + function decodeAddress(str) { + const payload = base58check.decodeCheck(String(str).trim()); + if (payload.length !== 21) throw new Error("bad address length"); + if (payload[0] !== 0x41) throw new Error("bad address prefix (want 0x41 / T…)"); + return payload; + } + function hexToAddress(hex) { + const b = hexToBytes(hex); + if (b.length !== 21 || b[0] !== 0x41) throw new Error("bad address hex"); + return base58check.encodeCheck(b); + } + + function makeClient(networkId) { + const net = NETWORKS[networkId]; + if (!net) throw new Error(`unknown Tron network ${networkId}`); + async function rpc(pathPart, body) { + const url = net.rpcBase + pathPart; + let r; + try { r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); } + catch (e) { throw new Error(`network: ${e?.message || e}`); } + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + async function get(pathPart) { + const url = net.rpcBase + pathPart; + const r = await fetch(url); + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + return { net, rpc, get }; + } + + class TronWallet { + constructor(root32, networkId, { storage, log = () => {}, onChange = () => {} } = {}) { + this.storage = storage; + this.log = log; + this.onChange = onChange; + this.client = makeClient(networkId); + this.net = this.client.net; + const acc = deriveAccount(root32); + // Root is stored so future revs can rotate accounts; the derived priv + // is what actually signs. Both wiped in dispose(). + this._root = new Uint8Array(root32); + this._priv = acc.privateKey; + this.publicKey = acc.publicKey; + this.addressBytes = acc.addressBytes; + this.address = acc.address; + this.state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + height: 0, + scanning: false, + error: null, + server: this.net.rpcBase, + }; + this._pollTimer = null; + } + + // Public snapshot (no key material). + snapshot() { + return { + chain: "trx", + network: this.net.id, + ticker: this.net.ticker, + address: this.address, + addressIndex: 0, + addressPath: "m/44'/195'/0'/0/0", + balance: this.state.balance, + height: this.state.height, + history: this.state.history, + scanning: this.state.scanning, + error: this.state.error, + server: this.state.server, + explorerTx: this.net.explorerTx, + explorerAddr: this.net.explorerAddr, + faucet: this.net.faucet, + decimals: 6, + }; + } + + async refresh(_full = false) { + if (this.state.scanning) return; + this.state.scanning = true; this.state.error = null; this.onChange(); + try { + const [acc, txs] = await Promise.all([this._fetchAccount(), this._fetchHistory()]); + // TronGrid returns balance in "sun" (1 TRX = 1_000_000 sun). + const sun = Number(acc?.balance || 0); + this.state.balance = { confirmed: sun, unconfirmed: 0 }; + this.state.history = txs; + // Block height comes from any recent tx or a getnowblock call. + try { + const nb = await this.client.rpc("/wallet/getnowblock", {}); + this.state.height = Number(nb?.block_header?.raw_data?.number || 0); + } catch {} + } catch (e) { + this.state.error = e?.message || String(e); + this.log("refresh failed:", this.state.error); + } finally { + this.state.scanning = false; + this.onChange(); + } + } + + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms); + } + + async _fetchAccount() { + const body = { address: this.address, visible: true }; + // /wallet/getaccount returns {} for never-funded addresses. + return this.client.rpc("/wallet/getaccount", body); + } + + async _fetchHistory() { + const url = `/v1/accounts/${encodeURIComponent(this.address)}/transactions?limit=25`; + let raw; + try { raw = await this.client.get(url); } catch (e) { this.log("history failed:", e?.message || e); return []; } + const list = Array.isArray(raw?.data) ? raw.data : []; + const now = Math.floor(Date.now() / 1000); + return list.map((t) => this._describeTx(t, now)).filter(Boolean); + } + + _describeTx(t, nowSec) { + const contract = t?.raw_data?.contract?.[0]; + const type = contract?.type; + const value = contract?.parameter?.value || {}; + const txID = t.txID || t.txid; + const time = Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000); + const conf = t.ret && Array.isArray(t.ret) ? (t.ret[0]?.contractRet === "SUCCESS" ? 1 : -1) : 0; + if (type === "TransferContract") { + // owner_address / to_address come as hex here (prefixed 41), regardless + // of visible:true (that flag only affects some endpoints). + let ownerB58 = "", toB58 = ""; + try { ownerB58 = value.owner_address ? hexToAddress(value.owner_address) : ""; } catch {} + try { toB58 = value.to_address ? hexToAddress(value.to_address) : ""; } catch {} + const amount = Number(value.amount || 0); + const inc = toB58 === this.address; + return { + txid: txID, + delta: inc ? amount : -amount, + to: inc ? null : toB58, + from: inc ? ownerB58 : null, + fee: t.net_fee || t.energy_fee || null, + time: time || 0, + confirmations: conf > 0 ? 1 : (conf < 0 ? 0 : 0), + status: conf > 0 ? "confirmed" : (conf < 0 ? "failed" : "pending"), + kind: "transfer", + }; + } + // Non-transfer contracts (delegation, votes, TRC20) — surface as a + // neutral entry so users see something happened without exaggerating. + return { + txid: txID, + delta: 0, + to: null, from: null, fee: null, + time: time || 0, + confirmations: conf > 0 ? 1 : 0, + status: conf > 0 ? "confirmed" : "other", + kind: type || "contract", + }; + } + + // Preview a send: no side effects, no signature. Returns the fields the + // panel needs plus the raw createtransaction result cached under _draft + // so signAndBroadcast doesn't re-fetch. + async plan({ to, amount, sendMax }) { + if (!to) throw new Error("recipient required"); + const dest = decodeAddress(to); + const balance = this.state.balance.confirmed; + // Tron doesn't have a fee-market for plain TRX transfers between accounts + // that carry enough bandwidth. For untouched addresses the network + // consumes a fixed 100_000 sun (0.1 TRX) burn from the sender if no + // free bandwidth is available. We show that as an upper-bound estimate. + const FEE_EST = 100_000; + let value; + if (sendMax) { + value = Math.max(0, balance - FEE_EST); + } else { + value = Math.round(Number(amount) || 0); + } + if (!(value > 0)) throw new Error("amount must be > 0 sun"); + if (value + FEE_EST > balance) throw new Error("insufficient funds"); + const body = { owner_address: this.address, to_address: base58check.encodeCheck(dest), amount: value, visible: true }; + const draft = await this.client.rpc("/wallet/createtransaction", body); + if (draft?.Error || !draft?.raw_data_hex) { + throw new Error("createtransaction: " + (draft?.Error || "empty response")); + } + const plan = { + recipients: [{ to: base58check.encodeCheck(dest), value }], + fee: FEE_EST, + feeRate: 1, + total: value + FEE_EST, + inputs: [], + _draft: draft, + }; + return plan; + } + + // Sign the raw_data_hex bytes with the wallet key and POST the signed tx. + async signAndBroadcast(plan) { + const draft = plan && plan._draft; + if (!draft || !draft.raw_data_hex) throw new Error("bad plan"); + const rawBytes = hexToBytes(draft.raw_data_hex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + // Recovered format from noble is 65 bytes: [recid || r(32) || s(32)]. Tron + // wants [r(32) || s(32) || recid]. Reorder in place. + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + const body = { + raw_data: draft.raw_data, + raw_data_hex: draft.raw_data_hex, + txID: draft.txID, + visible: true, + signature: [bytesToHex(trxSig)], + }; + const r = await this.client.rpc("/wallet/broadcasttransaction", body); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + // Refresh soon so history/balance catch up. + setTimeout(() => this.refresh(false), 2500); + return { txid: draft.txID }; + } + + // BIP137-style signing isn't standard on Tron; dapps use signMessageV2 + // (tronWeb.trx.signMessageV2), which is a raw ECDSA over sha256 of the + // message bytes with a Tron prefix "\x19TRON Signed Message:\n32". Kept + // simple here: signMessageV2 always asks the user. + signMessageV2(message) { + const enc = new TextEncoder(); + const bodyBytes = enc.encode(String(message)); + const prefix = enc.encode("\x19TRON Signed Message:\n" + bodyBytes.length); + const buf = new Uint8Array(prefix.length + bodyBytes.length); + buf.set(prefix, 0); buf.set(bodyBytes, prefix.length); + const digest = keccak_256(buf); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; // Ethereum-style v = 27 + recid + return { address: this.address, signature: "0x" + bytesToHex(out) }; + } + + // Sign an arbitrary raw_data_hex the dapp built with its own tronWeb. + // The caller must have already presented an approval overlay. + signRawData(rawDataHex) { + const rawBytes = hexToBytes(rawDataHex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + return bytesToHex(trxSig); + } + + async broadcastSignedTx(signedTx) { + const r = await this.client.rpc("/wallet/broadcasttransaction", signedTx); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + return { txid: signedTx.txID }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { TronWallet, deriveAccount, decodeAddress, hexToAddress, NETWORKS }; +}; diff --git a/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index e2d5f91..cd875e6 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -2,12 +2,12 @@ -Bitcoin Cash Wallet +Aegis Wallet