diff --git a/addons-host.js b/addons-host.js index 658c4bc..e8d180e 100644 --- a/addons-host.js +++ b/addons-host.js @@ -154,7 +154,7 @@ function validateManifest(raw, folderName) { // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // of the app queries via `getActive()` / `getInstalled()`. class AddonHost { - constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, captureTab, saveCapture }) { + constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) { this.addonsDir = addonsDir; this.dataDir = dataDir; this.isDisabled = isDisabled || (() => false); @@ -164,6 +164,15 @@ class AddonHost { // Capability hooks injected by main. Each is (args..., addonId) so main // can log/gate per add-on. Missing hook = capability unavailable. this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; + // vaultImports: main-process shim {list, add, remove, signer} that owns + // wallet-imports.enc. Same trust tier as vaultDerive — an add-on that + // holds vault-derive can also see imports (design §3.2 co-tenancy). + this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null; + // vaultLifecycle: main-process shim {status, setup, unlock, lock} so the + // wallet add-on can drive vault setup/unlock without redirecting users + // to Settings > Passwords. Same "vault-derive" capability gate. + this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" + ? arguments[0].vaultLifecycle : null; this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; // Add-ons live outside the app's node_modules tree, so a bare require() @@ -177,6 +186,10 @@ class AddonHost { // open-tab: opens one of the add-on's own HTML files as a full Theseus tab. // Signature: (addonId, relPath, queryString) => Promise. this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null; + // openSettings: opens Theseus's Settings tab, optionally scrolled to a + // named section (e.g. "passwords"). Uses the same IPC route the picker + // uses for "Search settings…". Signature: (section?: string) => void. + this._openSettings = typeof openSettings === "function" ? openSettings : null; // Session-proxy hook — injected by main so add-ons can swap the default // session's proxy rules (e.g. a "route everything through my VPS" add-on). // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise @@ -195,6 +208,42 @@ class AddonHost { } } + // api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same + // "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from + // its own gate instead of redirecting users to Settings > Passwords. + _makeLifecycleApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); + }; + return { + status: async () => { requireCap(); return this._vaultLifecycle.status(); }, + unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, + setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, + lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, + }; + } + + // api.vault.imports namespace factory. Gated by the "vault-derive" cap + // because the two surfaces sit at the same trust tier (design §3.2). If + // main didn't wire the vaultImports shim, calls throw a clear error. + _makeImportsApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); + }; + return { + list: async () => { requireCap(); return this._vaultImports.list(); }, + add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); }, + remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); }, + signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); }, + }; + } + discoverAndActivate() { this.ensureDirs(); this._deactivateAll(); @@ -374,6 +423,15 @@ class AddonHost { } return this._openAddonTab(manifest.id, s, qs); }, + // Open Theseus's Settings tab, optionally scrolled to a named section + // (validated against a known list in main). No capability needed — + // it's the same thing the user could do from the ⋮ menu, just a + // one-click shortcut so add-ons can point users at the right place + // (e.g. Aegis's "Set up vault" gate → Passwords). + openSettings: (section) => { + if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); + this._openSettings(typeof section === "string" ? section : ""); + }, // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, // injected page bridges) call into the add-on with a message name + // one JSON payload; the handler's return value goes back as the @@ -413,6 +471,8 @@ class AddonHost { } return this._vaultDerive(p, manifest.id); }, + imports: this._makeImportsApi(manifest), + lifecycle: this._makeLifecycleApi(manifest), }, // approval-modal: ask the user. Resolves to the chosen action id, or // "cancel" (Escape / mask click / window closed). With `checkbox` set diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 013c125..8abd5d7 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.4.4", + "version": "0.6.1", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "author": "Silent Mode", "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 4bc92fb..c29491e 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -28,6 +28,7 @@ async function loadDeps(api) { const { secp256k1 } = await api.import("@noble/curves/secp256k1.js"); const { ed25519 } = await api.import("@noble/curves/ed25519.js"); const { sha256 } = await api.import("@noble/hashes/sha2.js"); + const { hkdf } = await api.import("@noble/hashes/hkdf.js"); const { ripemd160 } = await api.import("@noble/hashes/legacy.js"); const { keccak_256 } = await api.import("@noble/hashes/sha3.js"); const { blake2b } = await api.import("@noble/hashes/blake2.js"); @@ -57,25 +58,52 @@ async function loadDeps(api) { // bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via // api.require from the Theseus dependency tree. const { pathToFileURL } = require("node:url"); - const dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); - const dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); + // DGB's bundled ESM packages import their peer deps by bare specifier + // ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in + // userData/addons/, Node's ESM resolver can't reach Theseus's node_modules + // from that path — so the import throws. Wrap it: DGB just becomes + // unavailable, the rest of Aegis keeps working. + let dgbCore = null, dgbPsbt = null, dgbAdapter = null; + try { + dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); + dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); + } catch (e) { + api.log("dgb unavailable:", e?.message || e); + } const bitcoinjs = api.require("bitcoinjs-lib"); const { BIP32Factory } = api.require("bip32"); const { ECPairFactory } = api.require("ecpair"); const ecc = api.require("@bitcoinerlab/secp256k1"); - const dgbAdapter = require("./lib/chain-dgb.js")({ - dgbCore, dgbPsbt, bitcoinjs, - bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, - sha256, electrum, - }); + if (dgbCore && dgbPsbt) { + dgbAdapter = require("./lib/chain-dgb.js")({ + dgbCore, dgbPsbt, bitcoinjs, + bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, + sha256, electrum, + }); + } const btcAdapter = require("./lib/chain-btc.js")({ bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, sha256, electrum, }); - return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, + const bip39 = api.require("bip39"); + // Imported BCH — a lean read-only adapter for wallets whose key material + // lives in Theseus's wallet-imports.enc. Same electrum/cashaddr surface as + // 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, + }); + // WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the + // §4d combined-work requirement (dynamic linkage + license notice + source + // availability) is met — package sources ship with npm. + const wcCore = await api.import("@wizardconnect/core"); + const wcWallet = await api.import("@wizardconnect/wallet"); + const libauth = await api.import("@bitauth/libauth"); + return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, - dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 }; + importedBchAdapter, bip39, + dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, + wcCore, wcWallet, libauth }; } // ---- servers --------------------------------------------------------------- @@ -443,6 +471,30 @@ async function mountWallet(entry) { rt.phase = "locked"; rt.error = null; c.runtimes.set(entry.id, rt); emitState(); + + // Imported wallets skip the vault-derive/HKDF path entirely: their signer + // material is in wallet-imports.enc, and the runtime here is read-only so + // it doesn't need the material until spend support ships (M.1b). + if (entry.kind === "imported") { + try { + const adapter = new c.d.importedBchAdapter.ImportedBchWallet({ + walletId: entry.id, storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + network: entry.network, + cashaddr: entry.importedCashaddr, + servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, + }); + rt.adapter = adapter; rt.phase = "ready"; + 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(); + } + return; + } + let root; try { root = await c.api.vault.derive(entry.purpose); @@ -490,6 +542,7 @@ async function mountWallet(entry) { }); if (walletdUrl) adapter.startPolling(); } else if (entry.chain === "dgb") { + if (!c.d.dgbAdapter) throw new Error("DGB unavailable (bundled ESM couldn't resolve peer deps)"); adapter = new c.d.dgbAdapter.DgbWallet(root, { walletId: entry.id, storage: c.api.storage, @@ -537,6 +590,12 @@ async function mountWallet(entry) { // 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)); + if (entry.chain === "bch" && c.wc) { + c.wc.startForWallet({ + walletId: entry.id, label: entry.label, + root32: root, accountPath: entry.accountPath || "m/44'/145'/0'", + }).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e)); + } emitStateForWallet(entry.id); } catch (e) { rt.phase = "error"; @@ -551,9 +610,50 @@ async function mountWallet(entry) { function unmountWallet(walletId) { const rt = ctx.runtimes.get(walletId); if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } + if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} } ctx.runtimes.delete(walletId); } +// ---- import derive helpers (client-side, cashaddr only) -------------------- +// The pasted material never leaves this process — main-side stores it once +// via api.vault.imports.add. These helpers only turn (seed+path) or WIF into +// a P2PKH cashaddr, which is safe to send back to the panel. + +function deriveCashaddrFromSeed(seedHex, path, prefix) { + const d = ctx.d; + const seed = new Uint8Array(seedHex.length / 2); + for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16); + const root = d.HDKey.fromMasterSeed(seed); + const node = root.derive(path); + const h160 = d.ripemd160(d.sha256(node.publicKey)); + return d.cashaddr.encode(prefix, 0, h160); +} +function deriveCashaddrFromWif(wif, prefix) { + const d = ctx.d; + // WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01]) + const raw = d.base58check.decode(wif); + if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`); + // First byte is version (network); we allow any — BCH mainnet uses 0x80, + // testnet 0xEF. Both round-trip through the same address derivation below. + const priv = raw.slice(1, 33); + const compressed = raw.length === 34; // trailing 0x01 marker + const pub = d.secp256k1.getPublicKey(priv, compressed); + const h160 = d.ripemd160(d.sha256(pub)); + return d.cashaddr.encode(prefix, 0, h160); +} + +function buildWcApprovalBody(payload) { + const req = payload?.request || {}; + const tx = req.transaction || {}; + const dappName = tx.userPrompt || "unknown dapp"; + const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?"; + const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp."; + const walletName = payload?.label || payload?.walletId || ""; + return "
" + dappName + " requests a BCH transaction signature.
" + + "
Wallet: " + walletName + " · " + inputCount + " input(s).
" + + "
" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.
"; +} + // ---- state / snapshot ------------------------------------------------------- function selectedWalletId() { @@ -629,6 +729,8 @@ function fullState() { custom: Array.isArray(ctx.api.storage.get("servers", null)), }, coins: coinsForPanel(), + prices: ctx.priceFeed ? ctx.priceFeed.snapshot() : { enabled: false, prices: {} }, + wc: ctx.wc ? ctx.wc.snapshot() : {}, }; } @@ -718,6 +820,99 @@ function registerPanelMessages(api) { await mountWallet(entry); return fullState(); }); + // Vault lifecycle from inside the wallet panel — no more redirecting the + // user to Settings > Passwords. After a successful unlock/setup we remount + // every wallet: the vault-derive route is now available. + api.onMessage("vaultSetup", async (p, m) => { + fromPanel(m); + const pw = String(p && p.masterPassword || ""); + const seedSource = p && p.seedSource; + await api.vault.lifecycle.setup(pw, seedSource); + await mountAllWallets(); + return fullState(); + }); + api.onMessage("vaultUnlock", async (p, m) => { + fromPanel(m); + const pw = String(p && p.masterPassword || ""); + await api.vault.lifecycle.unlock(pw); + await mountAllWallets(); + return fullState(); + }); + api.onMessage("vaultStatus", async (_p, m) => { + fromPanel(m); + return api.vault.lifecycle.status(); + }); + + // ---- wallet import (M.1 of DESIGN-wallet-multi-account-amendment.md) ---- + // Accepts either a BIP39 mnemonic (12/24 words) + BIP44 path, OR a raw WIF. + // Derives the P2PKH cashaddr client-side, stores the signer material via + // api.vault.imports.add (main-process holds it), then adds a slim Aegis + // wallet entry with kind=imported pointing at the returned importId. + api.onMessage("importWallet", async (p, m) => { + fromPanel(m); + const chain = String(p && p.chain || "bch"); + if (chain !== "bch") throw new Error("only BCH imports are supported in this build"); + const network = String(p && p.network || "chipnet"); + if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`); + const label = String(p && p.label || "").trim(); + if (!label) throw new Error("label required"); + const category = String(p && p.category || "operational").trim(); + const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; + const source = String(p && p.source || "manual-paste"); + + let kind, seedHex, path, wif, cashaddrStr; + if (p && p.wif) { + kind = "wif"; + wif = String(p.wif).trim(); + cashaddrStr = deriveCashaddrFromWif(wif, prefix); + } else if (p && p.mnemonic) { + kind = "seed"; + const words = String(p.mnemonic).trim().split(/\s+/).length; + if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) { + throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`); + } + if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) { + throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)"); + } + const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim()); + seedHex = Buffer.from(seed).toString("hex"); + path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); + } else if (p && p.seedHex) { + kind = "seed"; + seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, ""); + if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex"); + path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); + } else { + throw new Error("supply mnemonic, seedHex, or wif"); + } + + const spec = { kind, cashaddr: cashaddrStr, label, category, source }; + if (kind === "seed") { spec.seed = seedHex; spec.path = path; } + else { spec.wif = wif; } + const { id: importId } = await api.vault.imports.add(spec); + + // Persist as an Aegis wallet entry with kind=imported. Uses a distinct + // id prefix so it's obvious in storage that this row references the + // imports file rather than a vault-derive purpose. + const list = walletEntries().slice(); + const walletId = `bch-imported-${importId}`; + if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id"); + const entry = { + id: walletId, label, chain: "bch", network, + kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category, + createdAt: Date.now(), + }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", walletId); + ctx.runtimes.set(walletId, { 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 || ""); @@ -759,6 +954,9 @@ function registerPanelMessages(api) { return snapshotForSelected(); }); api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); + // Panel gate uses this to jump to Settings › Passwords when the vault is + // locked / not yet created — one-click bridge to Theseus's built-in UI. + api.onMessage("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; }); api.onMessage("setBchServers", (p, m) => { fromPanel(m); @@ -927,6 +1125,37 @@ function registerPanelMessages(api) { return out; }); + // Opt-in USD prices. Persist the choice so restart doesn't silently + // disable it, and kick a fetch immediately when switched on. + api.onMessage("setPricesEnabled", async (p, m) => { + fromPanel(m); + const on = !!(p && p.enabled); + api.storage.set("pricesEnabled", on); + if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on); + return fullState(); + }); + api.onMessage("refreshPrices", async (_p, m) => { + fromPanel(m); + if (ctx.priceFeed) await ctx.priceFeed.refresh(); + return fullState(); + }); + + // WizardConnect: pair a wiz:// URI with a specific BCH wallet. + api.onMessage("wcConnect", async (p, m) => { + fromPanel(m); + if (!ctx.wc) throw new Error("WizardConnect not ready"); + const walletId = String(p && p.walletId || ""); + const uri = String(p && p.uri || ""); + await ctx.wc.connectUri(walletId, uri); + return fullState(); + }); + api.onMessage("wcDisconnect", async (p, m) => { + fromPanel(m); + if (!ctx.wc) throw new Error("WizardConnect not ready"); + await ctx.wc.disconnect(String(p && p.walletId || ""), String(p && p.connId || "")); + return fullState(); + }); + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); @@ -1632,13 +1861,43 @@ module.exports = { api, d: null, runtimes: new Map(), // walletId -> { entry, phase, error, adapter } + priceFeed: require("./lib/prices.js")({ + log: (...a) => api.log("prices", ...a), + onChange: () => emitState(), + }), + wc: null, // WizardConnect manager, initialised when deps load }; migrateLegacyStorage(api); registerPanelMessages(api); registerPageMessages(api); + // Restore the user's opt-in choice from storage. Off by default so a + // fresh install never hits CoinGecko without asking. + if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {}); loadDeps(api).then((d) => { if (ctx !== c) return; c.d = d; + // Start the WizardConnect manager once deps are ready. Per-wallet + // adapters get spun up in mountWallet() as each BCH wallet becomes + // available (only BCH today — hdwalletv1 is BCH-scoped). + c.wc = require("./lib/wc.js")({ + HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf, + WalletConnectionManager: d.wcWallet.WalletConnectionManager, + wcCore: d.wcCore, libauth: d.libauth, + log: (...a) => api.log("wc", ...a), + api, + // Bridge sign approvals through the addon's approval-modal capability. + approvalRequest: async (payload) => { + const dappName = payload.request?.transaction?.userPrompt || "dapp"; + const pick = await api.approvalModal({ + title: `Sign transaction for ${dappName}`, + body: buildWcApprovalBody(payload), + approve: "Sign", + reject: "Reject", + }); + return { approved: pick === "approve" }; + }, + }); + c.wc.onStateChange(() => emitState()); return mountAllWallets(); }).catch((e) => { if (ctx !== c) return; @@ -1649,6 +1908,7 @@ module.exports = { deactivate() { const c = ctx; ctx = null; if (!c) return; + try { c.priceFeed && c.priceFeed.dispose(); } catch {} for (const rt of c.runtimes.values()) { try { rt.adapter && rt.adapter.dispose(); } catch {} } diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js new file mode 100644 index 0000000..13dac81 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -0,0 +1,146 @@ +// 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. +// +// M.1a scope: read-only (balance + history over Electrum). planSend/send +// throw with a clear message until M.1b lands the sign path. + +module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) { + + // 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()); + const hash160 = (b) => ripemd160(sha256(b)); + + const IMPORTED_BCH_NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", prefix: "bitcoincash", + explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", + explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + defaultServers: [ + "wss://bch.imaginary.cash:50004", + "wss://cashnode.bch.ninja:50004", + "wss://electroncash.dk:50004", + "wss://fulcrum.jettscythe.xyz:50004", + ], + }, + chipnet: { + id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", + explorerTx: "https://chipnet.imaginary.cash/tx/", + explorerAddr: "https://chipnet.imaginary.cash/address/", + defaultServers: [ + "wss://chipnet.imaginary.cash:50004", + "wss://chipnet.bch.ninja:50004", + ], + faucet: "https://tbch.googol.cash/", + }, + }; + + // 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); + if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`); + return hash; + } + + class ImportedBchWallet { + constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) { + 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; + this.chain = "bch"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this._address = address; + this._h160 = h160OfCashaddr(address); + this._script = p2pkhScript(this._h160); + this._scripthash = scripthashOf(this._script); + this._scriptHex = toHex(this._script); + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + height: 0, + scanning: false, + error: null, + }; + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + return { + chain: "bch", + network: this._net.id, + ticker: "BCH", + decimals: 8, + address: this._address, + addressIndex: 0, + addressPath: null, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + imported: true, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + 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]); + this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; + if (full) { + const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]); + this._state.history = (hist || []).slice(-50).map((h) => ({ + txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, + })); + } + this._state.error = null; + } catch (e) { + this._state.error = e?.message || String(e); + } finally { + this._state.scanning = false; + this._emit(); + } + } + + nextAddress() { return { address: this._address, index: 0 }; } + current() { return { address: this._address, index: 0, branch: 0, path: null, 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."); } + + recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; } + + dispose() { try { this._client.disconnect(); } catch {} } + } + + return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/prices.js b/bundled-addons/aegis/lib/prices.js new file mode 100644 index 0000000..cf015b3 --- /dev/null +++ b/bundled-addons/aegis/lib/prices.js @@ -0,0 +1,98 @@ +// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price +// endpoint, one request covers the lot. Opt-in via Settings so a +// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open. +// +// Cache is in-memory (returned by fullState() → panel). The addon polls +// every 5 min while enabled; each fetch is cheap (~200 B response) and +// the free tier tolerates one call/5 min per client easily. +// +// Trade-off named in the settings copy: CoinGecko sees the browser's IP +// + a User-Agent every poll. Not seed-linked, not address-linked, but a +// data point. Off by default. + +const COIN_GECKO_IDS = { + bch: "bitcoin-cash", + btc: "bitcoin", + trx: "tron", + eth: "ethereum", + sol: "solana", + sc: "siacoin", + dgb: "digibyte", +}; + +const ENDPOINT = "https://api.coingecko.com/api/v3/simple/price"; +const POLL_MS = 5 * 60 * 1000; + +module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { + const state = { + enabled: false, + prices: {}, // { : usd (number) } + fetchedAt: null, + error: null, + loading: false, + }; + let timer = null; + + async function fetchOnce() { + if (!state.enabled) return; + state.loading = true; state.error = null; onChange(); + try { + const ids = Object.values(COIN_GECKO_IDS).join(","); + const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`; + const r = await fetch(url); + if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`); + const body = await r.json(); + const next = {}; + for (const [chain, cgId] of Object.entries(COIN_GECKO_IDS)) { + const usd = body?.[cgId]?.usd; + if (typeof usd === "number") next[chain] = usd; + } + state.prices = next; + state.fetchedAt = Date.now(); + state.error = null; + } catch (e) { + state.error = e?.message || String(e); + log("price fetch failed:", state.error); + } finally { + state.loading = false; + onChange(); + } + } + + function schedule() { + clearTimeout(timer); + if (!state.enabled) return; + timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); + } + + return { + // Snapshot for the panel: only what the UI needs. + snapshot() { + return { + enabled: state.enabled, + prices: state.prices, + fetchedAt: state.fetchedAt, + error: state.error, + loading: state.loading, + }; + }, + // Turn the feed on/off. Enabling triggers an immediate fetch so the + // panel doesn't wait 5 minutes 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; + clearTimeout(timer); + if (changed) onChange(); + return; + } + onChange(); + await fetchOnce(); + schedule(); + }, + // Force-refresh — bound to a manual "refresh" button in the panel. + refresh() { return fetchOnce(); }, + dispose() { clearTimeout(timer); state.enabled = false; }, + }; +}; diff --git a/bundled-addons/aegis/lib/wc-sign.js b/bundled-addons/aegis/lib/wc-sign.js new file mode 100644 index 0000000..5872afc --- /dev/null +++ b/bundled-addons/aegis/lib/wc-sign.js @@ -0,0 +1,95 @@ +// WizardConnect transaction signing for Aegis. +// +// The dapp hands us a full BCH transaction plus its source outputs. Per the +// WC protocol, we must sign every input with SIGHASH_ALL | FORKID | UTXOS. +// Any other sighash flag combination MUST be rejected (protocol/security). +// +// This module supports P2PKH inputs only. Contract inputs (a source output +// carrying a `contract` field) are rejected with a clear error — they need +// script-aware signing that Aegis's BCH runtime doesn't do today. + +// SIGHASH byte required for this protocol: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS +// = 0x01 | 0x40 | 0x20 = 0x61. +const REQUIRED_SIGHASH = 0x61; + +function toHex(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0"); return s; } +function fromHex(h) { + const s = String(h || "").replace(/^0x/i, ""); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16); + return out; +} + +function ensureTransaction(txOrHex, libauth) { + if (typeof txOrHex === "string") { + const dec = libauth.decodeTransactionCommon + ? libauth.decodeTransactionCommon(fromHex(txOrHex)) + : libauth.decodeTransaction(fromHex(txOrHex)); + if (typeof dec === "string") throw new Error(`wc-sign: bad tx hex — ${dec}`); + return dec; + } + return txOrHex; +} + +async function signTx({ request, account, branches, libauth, secp256k1 }) { + const { + generateSigningSerializationBCH, + hash256, encodeTransaction, + } = libauth; + + const tx = ensureTransaction(request.transaction, libauth); + const sourceOutputs = (request.sourceOutputs || []).map((o, i) => { + if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); + return { + lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode), + valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis), + }; + }); + if (sourceOutputs.length !== tx.inputs.length) { + throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`); + } + + const inputPathMap = new Map(); // inputIndex -> { branch, addressIndex } + for (const [inputIndex, pathName, addressIndex] of (request.inputPaths || [])) { + inputPathMap.set(Number(inputIndex), { pathName: String(pathName), addressIndex: Number(addressIndex) }); + } + + const signedInputs = tx.inputs.map((inp, i) => ({ ...inp })); + + for (let i = 0; i < tx.inputs.length; i++) { + const hint = inputPathMap.get(i); + if (!hint) throw new Error(`wc-sign: no path for input ${i}`); + const branch = branches[hint.pathName]; + if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`); + const node = branch.deriveChild(hint.addressIndex); + + const preimage = generateSigningSerializationBCH({ + inputIndex: i, + signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]), + sourceOutputs, + transaction: { ...tx, inputs: signedInputs }, + }); + const digest = hash256(preimage); + const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" }); + // signature || sighashType byte + const sigWithHash = new Uint8Array(sig.length + 1); + sigWithHash.set(sig, 0); sigWithHash[sig.length] = REQUIRED_SIGHASH; + + // P2PKH unlocking: + const pushSig = new Uint8Array(1 + sigWithHash.length); + pushSig[0] = sigWithHash.length; + pushSig.set(sigWithHash, 1); + const pushPk = new Uint8Array(1 + node.publicKey.length); + pushPk[0] = node.publicKey.length; + pushPk.set(node.publicKey, 1); + + const unlocking = new Uint8Array(pushSig.length + pushPk.length); + unlocking.set(pushSig, 0); unlocking.set(pushPk, pushSig.length); + signedInputs[i].unlockingBytecode = unlocking; + } + + const encoded = encodeTransaction({ ...tx, inputs: signedInputs }); + return { signedTransaction: toHex(encoded) }; +} + +module.exports = { signTx, REQUIRED_SIGHASH }; diff --git a/bundled-addons/aegis/lib/wc.js b/bundled-addons/aegis/lib/wc.js new file mode 100644 index 0000000..41c03ef --- /dev/null +++ b/bundled-addons/aegis/lib/wc.js @@ -0,0 +1,196 @@ +// WizardConnect wallet-side bridge for Aegis. +// +// Aegis's BCH runtime acts as a WizardConnect wallet: sites we build (dapps) +// pair via a wiz:// URI, get xpubs for BCH derivation paths, and send us +// sign requests that we route through the existing approval-modal capability. +// +// LGPL boundary: @wizardconnect/{core,wallet} are dynamic-linked via +// api.import(); we do not statically embed them. Their sources live at +// https://github.com/whiterun-labs/wizardconnect (also on npm) and their +// LICENSE / copyright headers are shipped by npm inside the package. +// +// Docs: https://docs.riftenlabs.com/wizardconnect/ + +const WC_PATH_RECEIVE = "receive"; // m/44'/145'/0'/0 +const WC_PATH_CHANGE = "change"; // m/44'/145'/0'/1 +const WC_PATH_CAULDRON = "defi"; // m/44'/145'/0'/7 (BCH DEX ecosystem) + +const WALLET_ICON = "data:image/svg+xml;utf8," + encodeURIComponent( + ` + + + + ` +); + +module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) { + + // ---- WalletAdapter -------------------------------------------------------- + // + // Bound to one BCH runtime. Uses its 32-byte root to reproduce the account + // HDKey and to derive per-URI relay identities via HKDF, so reconnecting + // yields the same Nostr identity (dapp recognises us on reload). + function makeAdapter({ root32, accountPath, walletId, label }) { + const account = HDKey.fromMasterSeed(root32).derive(accountPath); + const branches = new Map(); + const branchFor = (childIndex) => { + let b = branches.get(childIndex); + if (!b) { b = account.deriveChild(childIndex); branches.set(childIndex, b); } + return b; + }; + // WC path enum → BCH child index (identity mapping today; enum members + // hold the numeric child index directly — see docs/protocol. + const childOf = (path) => Number(path); + + return { + walletName: label ? `Aegis · ${label}` : "Aegis", + walletIcon: WALLET_ICON, + + // Stable identity per pairing URI. HKDF salt binds it to this wallet's + // root, info binds it to the URI, so: + // - reconnecting to the same URI = same Nostr identity + // - two different URIs = uncorrelatable identities (privacy) + getRelayPrivateKey(uri) { + const salt = new TextEncoder().encode("aegis/wc/relay/v1"); + const info = new TextEncoder().encode(uri); + // 32 bytes for a Nostr secp256k1 private key. + return hkdf(sha256, root32, salt, info, 32); + }, + + getPublicKey(path, index) { + const branch = branchFor(childOf(path)); + const node = branch.deriveChild(Number(index)); + return node.publicKey; // 33 bytes compressed + }, + + getXpub(path) { + return branchFor(childOf(path)).publicExtendedKey; + }, + + // Sign a transaction the dapp has already assembled. See signTx.js for + // the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth + // preimage + secp256k1 der/lowS signatures). + async signTransaction(request) { + // Route through approval-modal first — the user always sees what + // they're signing before any private key touches the request. + if (!approvalRequest) throw new Error("no approval channel"); + const decision = await approvalRequest({ + kind: "wc-sign", + walletId, label, + request, + }); + if (!decision?.approved) throw new Error("cancelled"); + const { signTx } = require("./wc-sign.js"); + return signTx({ + request, + account, + branches: { receive: branchFor(0), change: branchFor(1), defi: branchFor(7) }, + libauth, secp256k1, + }); + }, + }; + } + + // ---- connection tracker -------------------------------------------------- + + // One manager per BCH wallet. We keep them in a per-walletId map so the + // panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc. + const managers = new Map(); // walletId -> WalletConnectionManager + const uris = new Map(); // walletId -> Set (persisted) + const listeners = new Set(); // () => void — panel resubscribes on state change + + function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} } + + function persist(walletId) { + const list = [...(uris.get(walletId) || new Set())]; + api.storage.set(`wc/${walletId}/uris`, list); + } + + async function startForWallet({ walletId, label, root32, accountPath }) { + if (managers.has(walletId)) return managers.get(walletId); + const adapter = makeAdapter({ root32, accountPath, walletId, label }); + const mgr = new WalletConnectionManager(adapter); + managers.set(walletId, mgr); + + mgr.on("connectionsChanged", fireStateChange); + mgr.on("connectionStatusChanged", fireStateChange); + mgr.on("remoteDisconnect", (connId, reason) => { + log(`wc[${walletId}] remote disconnect ${connId}: ${reason}`); + fireStateChange(); + }); + mgr.on("pendingSignRequest", async ({ connectionId, request }) => { + try { + const { signedTransaction } = await adapter.signTransaction(request); + await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction); + } catch (e) { + log(`wc[${walletId}] sign failed:`, e?.message || e); + try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {} + } + }); + + // Restore persisted pairings. + uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || [])); + for (const uri of uris.get(walletId)) { + try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); } + } + return mgr; + } + + function stopForWallet(walletId) { + const mgr = managers.get(walletId); if (!mgr) return; + try { mgr.disconnectAll?.(); } catch {} + managers.delete(walletId); + uris.delete(walletId); + } + + async function connectUri(walletId, uri) { + const mgr = managers.get(walletId); + if (!mgr) throw new Error("wc: wallet not ready"); + const trimmed = String(uri || "").trim(); + if (!/^wiz:\/\//i.test(trimmed)) throw new Error("wc: URI must start with wiz://"); + const id = mgr.connect(trimmed); + const set = uris.get(walletId) || new Set(); + set.add(trimmed); + uris.set(walletId, set); + persist(walletId); + fireStateChange(); + return id; + } + + async function disconnect(walletId, connId) { + const mgr = managers.get(walletId); if (!mgr) return; + try { await mgr.disconnect(connId); } catch {} + // Trim the persisted URI so the next start doesn't re-add it. + const conn = [...(mgr.connections?.values?.() || [])].find((c) => c.id === connId); + if (conn?.uri) { + const set = uris.get(walletId); if (set) { set.delete(conn.uri); persist(walletId); } + } + fireStateChange(); + } + + function snapshot() { + const out = {}; + for (const [walletId, mgr] of managers) { + const list = [...(mgr.connections?.values?.() || [])].map((c) => ({ + id: c.id, + uri: c.uri, + dappName: c.dappName || null, + dappIcon: c.dappIcon || null, + status: c.status?.kind || String(c.status || "unknown"), + connectedAt: c.connectedAt || null, + })); + out[walletId] = list; + } + return out; + } + + function onStateChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } + + function cleanErrForDapp(e) { + const m = String(e?.message || e); + if (m === "cancelled") return "user rejected"; + return m.replace(/\n[\s\S]*$/, "").slice(0, 200); + } + + return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange }; +}; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 44d477e..ec790a2 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -46,6 +46,7 @@ #drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #drop .row .m .s, #drop .coinrow .m .s { color: var(--dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #drop .row .v { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; } + #drop .row .v .fs { color: var(--dim); font-size: 11px; margin-top: 2px; } #drop .row.on { background: rgb(from var(--acid) r g b / .10); } #drop .coinrow .caret { color: var(--dim); font-size: 11px; } #drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; } @@ -57,6 +58,10 @@ .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; } + .fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; } + .portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; } + .portfolio b { color: var(--ink); font-weight: 600; } + .portfolio[hidden] { display: none; } .picker-actions { display: flex; align-items: center; gap: 6px; } .chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px; padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1; @@ -82,12 +87,14 @@ .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; } .qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; } canvas { image-rendering: pixelated; } - input[type=text], input[type=number], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel); + input[type=text], input[type=number], input[type=password], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel); border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; } input:focus, textarea:focus { border-color: rgb(from var(--acid) r g b / .5); } textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; } .field { margin-bottom: 12px; } .hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; } + .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12.5px; color: var(--ink); } + .switch input { accent-color: var(--brand, #d6ff3d); width: 15px; height: 15px; margin: 0; } .amt { display: flex; gap: 6px; } .amt input { flex: 1; } .unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; } @@ -136,11 +143,15 @@
-
+
+ + +
connecting…
+