feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
This commit is contained in:
parent
dcab5c10ec
commit
de576935c1
12 changed files with 1372 additions and 13 deletions
|
|
@ -99,7 +99,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 }) {
|
||||
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab }) {
|
||||
this.addonsDir = addonsDir;
|
||||
this.dataDir = dataDir;
|
||||
this.isDisabled = isDisabled || (() => false);
|
||||
|
|
@ -115,6 +115,10 @@ class AddonHost {
|
|||
// from their folder can't see Theseus's deps (ws, @noble/*, …). Main
|
||||
// hands us its own require so add-ons can share the bundled tree.
|
||||
this._hostRequire = typeof hostRequire === "function" ? hostRequire : null;
|
||||
// ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's
|
||||
// Node; hostImport resolves them from the app tree and import()s them.
|
||||
this._hostImport = typeof hostImport === "function" ? hostImport : null;
|
||||
this._openTab = typeof openTab === "function" ? openTab : null;
|
||||
// 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<void>
|
||||
|
|
@ -148,7 +152,11 @@ class AddonHost {
|
|||
this._activateOne(manifest, folder);
|
||||
} catch (e) {
|
||||
this.log(`failed to load ${dirent.name}: ${e?.message || e}`);
|
||||
this._installed.push({ manifest: null, folder, error: String(e?.message || e) });
|
||||
// A manifest that parsed but whose activate() threw was already
|
||||
// pushed above — replace it rather than listing the add-on twice.
|
||||
const i = this._installed.findIndex((x) => x.folder === folder);
|
||||
const entry = { manifest: null, folder, error: String(e?.message || e) };
|
||||
if (i >= 0) this._installed[i] = entry; else this._installed.push(entry);
|
||||
}
|
||||
}
|
||||
return this.snapshot();
|
||||
|
|
@ -266,6 +274,19 @@ class AddonHost {
|
|||
if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`);
|
||||
return this._hostRequire(name);
|
||||
},
|
||||
// Same, for ES-module-only packages: resolves to a Promise of the
|
||||
// module namespace.
|
||||
import: async (name) => {
|
||||
if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`);
|
||||
return this._hostImport(name);
|
||||
},
|
||||
// Open a URL in a new Theseus tab (http/https only).
|
||||
openTab: (url) => {
|
||||
const u = String(url || "");
|
||||
if (!/^https?:\/\//i.test(u)) throw new Error("openTab: http(s) URLs only");
|
||||
if (!this._openTab) throw new Error("openTab unavailable (host not wired)");
|
||||
this._openTab(u, manifest.id);
|
||||
},
|
||||
// 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
|
||||
|
|
|
|||
9
bundled-addons/bchwallet/electrum-servers.json
Normal file
9
bundled-addons/bchwallet/electrum-servers.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[
|
||||
"wss://bch.imaginary.cash:50004",
|
||||
"wss://electrum.imaginary.cash:50004",
|
||||
"wss://fulcrum.jettscythe.xyz:50004",
|
||||
"wss://bch.loping.net:50004",
|
||||
"wss://bch.soul-dev.com:50004",
|
||||
"wss://cashnode.bch.ninja:50004",
|
||||
"wss://blackie.c3-soft.com:50004"
|
||||
]
|
||||
|
|
@ -1,9 +1,166 @@
|
|||
// 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.
|
||||
// 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.
|
||||
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/";
|
||||
|
||||
let ctx = null; // { api, keys, wallet, client, phase, error }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function deps(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 { 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,
|
||||
});
|
||||
c.api.log("wallet ready, receive address", c.keys.entry(0, 0).address);
|
||||
setPhase("ready");
|
||||
c.wallet.refresh(true);
|
||||
}
|
||||
|
||||
async function deriveAndStart() {
|
||||
const c = ctx;
|
||||
setPhase("locked");
|
||||
try {
|
||||
c.root = await c.api.vault.derive(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);
|
||||
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); }
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
// 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 (pick === "reveal") out.xprv = c.keys.xprv;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activate(api) {
|
||||
api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" });
|
||||
api.log("wallet panel registered");
|
||||
const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null };
|
||||
registerPanelMessages(api);
|
||||
deps(api).then((d) => {
|
||||
if (ctx !== c) return;
|
||||
c.d = d;
|
||||
return deriveAndStart();
|
||||
}).catch((e) => {
|
||||
if (ctx !== c) return;
|
||||
setPhase("error", e?.message || String(e));
|
||||
api.log("startup failed:", e?.message);
|
||||
});
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
102
bundled-addons/bchwallet/lib/cashaddr.js
Normal file
102
bundled-addons/bchwallet/lib/cashaddr.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// CashAddr (BCH address format) encode/decode + legacy Base58Check decode, so
|
||||
// the send form accepts whatever the user pastes but the UI only ever shows
|
||||
// cashaddr. Pure JS, no deps — the polymod is tiny.
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const CHARSET_REV = Object.fromEntries([...CHARSET].map((c, i) => [c, i]));
|
||||
|
||||
function polymod(values) {
|
||||
let c = 1n;
|
||||
for (const d of values) {
|
||||
const c0 = c >> 35n;
|
||||
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
|
||||
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
|
||||
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
|
||||
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
|
||||
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
|
||||
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
|
||||
}
|
||||
return c ^ 1n;
|
||||
}
|
||||
const prefixExpand = (prefix) => [...prefix].map((c) => c.charCodeAt(0) & 0x1f).concat([0]);
|
||||
|
||||
function convertBits(data, from, to, pad) {
|
||||
let acc = 0, bits = 0; const out = [];
|
||||
const maxv = (1 << to) - 1;
|
||||
for (const v of data) {
|
||||
acc = (acc << from) | v; bits += from;
|
||||
while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); }
|
||||
}
|
||||
if (pad) { if (bits > 0) out.push((acc << (to - bits)) & maxv); }
|
||||
else if (bits >= from || ((acc << (to - bits)) & maxv)) throw new Error("cashaddr: bad padding");
|
||||
return out;
|
||||
}
|
||||
|
||||
// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit).
|
||||
function encode(prefix, type, hash) {
|
||||
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
|
||||
const versionByte = (type << 3) | 0; // size bits 000 = 160
|
||||
const payload = convertBits([versionByte, ...hash], 8, 5, true);
|
||||
const mod = polymod([...prefixExpand(prefix), ...payload, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const checksum = [];
|
||||
for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn));
|
||||
return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join("");
|
||||
}
|
||||
|
||||
// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
|
||||
function decode(address, defaultPrefix = "bitcoincash") {
|
||||
const raw = String(address).trim();
|
||||
if (raw !== raw.toLowerCase() && raw !== raw.toUpperCase()) throw new Error("cashaddr: mixed case");
|
||||
const s = raw.toLowerCase();
|
||||
const i = s.lastIndexOf(":");
|
||||
const prefix = i >= 0 ? s.slice(0, i) : defaultPrefix;
|
||||
const payloadStr = i >= 0 ? s.slice(i + 1) : s;
|
||||
if (!/^[a-z0-9]+$/.test(prefix) || payloadStr.length < 8) throw new Error("cashaddr: malformed");
|
||||
const values = [...payloadStr].map((c) => {
|
||||
if (!(c in CHARSET_REV)) throw new Error("cashaddr: bad character");
|
||||
return CHARSET_REV[c];
|
||||
});
|
||||
if (polymod([...prefixExpand(prefix), ...values]) !== 0n) throw new Error("cashaddr: bad checksum");
|
||||
const data = convertBits(values.slice(0, -8), 5, 8, false);
|
||||
const versionByte = data[0];
|
||||
const type = (versionByte >> 3) & 0x0f;
|
||||
const size = [20, 24, 28, 32, 40, 48, 56, 64][versionByte & 0x07];
|
||||
const hash = Uint8Array.from(data.slice(1));
|
||||
if (hash.length !== size) throw new Error("cashaddr: hash size mismatch");
|
||||
return { prefix, type, hash };
|
||||
}
|
||||
|
||||
// Legacy Base58Check (1... / 3... on mainnet). sha256 is injected so this
|
||||
// file stays dependency-free.
|
||||
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
function decodeLegacy(address, sha256) {
|
||||
const s = String(address).trim();
|
||||
let n = 0n;
|
||||
for (const c of s) {
|
||||
const v = B58.indexOf(c);
|
||||
if (v < 0) throw new Error("base58: bad character");
|
||||
n = n * 58n + BigInt(v);
|
||||
}
|
||||
const bytes = [];
|
||||
while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; }
|
||||
for (const c of s) { if (c !== "1") break; bytes.unshift(0); }
|
||||
if (bytes.length !== 25) throw new Error("base58: wrong length");
|
||||
const body = Uint8Array.from(bytes.slice(0, 21));
|
||||
const check = sha256(sha256(body)).slice(0, 4);
|
||||
for (let k = 0; k < 4; k++) if (check[k] !== bytes[21 + k]) throw new Error("base58: bad checksum");
|
||||
const type = body[0] === 0x00 ? 0 : body[0] === 0x05 ? 1 : null;
|
||||
if (type == null) throw new Error("base58: not a mainnet address");
|
||||
return { prefix: "bitcoincash", type, hash: body.slice(1) };
|
||||
}
|
||||
|
||||
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other
|
||||
// prefixes so a chipnet address can never be paid on mainnet by accident.
|
||||
function parseAny(input, sha256, prefix = "bitcoincash") {
|
||||
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
|
||||
if (!s) throw new Error("empty address");
|
||||
const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix);
|
||||
if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`);
|
||||
if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type");
|
||||
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) };
|
||||
}
|
||||
|
||||
module.exports = { encode, decode, decodeLegacy, parseAny };
|
||||
131
bundled-addons/bchwallet/lib/electrum.js
Normal file
131
bundled-addons/bchwallet/lib/electrum.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Electrum (Fulcrum) JSON-RPC over WebSocket for the wallet. One live
|
||||
// connection at a time, chosen by walking the server list in order; the
|
||||
// caller gets a stable `call()` that reconnects transparently on the next
|
||||
// request after a drop. Notifications (headers / scripthash subscriptions)
|
||||
// fan out to `onNotify`.
|
||||
module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||
const CALL_TIMEOUT_MS = 20000;
|
||||
|
||||
class Connection {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.buf = "";
|
||||
this.closed = false;
|
||||
this.onNotify = null;
|
||||
this.onClose = null;
|
||||
}
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url);
|
||||
this.ws = ws;
|
||||
const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } };
|
||||
ws.on("open", async () => {
|
||||
try { await this.call("server.version", ["theseus-bchwallet", "1.4"]); resolve(this); }
|
||||
catch (e) { fail(e); this.close(); }
|
||||
});
|
||||
ws.on("error", fail);
|
||||
ws.on("message", (d) => this._onData(String(d)));
|
||||
ws.on("close", () => {
|
||||
this.closed = true;
|
||||
for (const p of this.pending.values()) p.reject(new Error("electrum connection closed"));
|
||||
this.pending.clear();
|
||||
if (this.onClose) this.onClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
_onData(chunk) {
|
||||
this.buf += chunk;
|
||||
let nl;
|
||||
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
||||
const line = this.buf.slice(0, nl).trim();
|
||||
this.buf = this.buf.slice(nl + 1);
|
||||
if (line) this._handleLine(line);
|
||||
}
|
||||
const rest = this.buf.trim();
|
||||
if (rest) { try { JSON.parse(rest); this._handleLine(rest); this.buf = ""; } catch {} }
|
||||
}
|
||||
_handleLine(line) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch { return; }
|
||||
if (msg.id != null && this.pending.has(msg.id)) {
|
||||
const p = this.pending.get(msg.id);
|
||||
this.pending.delete(msg.id);
|
||||
clearTimeout(p.timer);
|
||||
if (msg.error) p.reject(new Error(typeof msg.error === "object" ? (msg.error.message || JSON.stringify(msg.error)) : String(msg.error)));
|
||||
else p.resolve(msg.result);
|
||||
} else if (msg.method && this.onNotify) {
|
||||
this.onNotify(msg.method, msg.params || []);
|
||||
}
|
||||
}
|
||||
call(method, params = []) {
|
||||
if (this.closed) return Promise.reject(new Error("electrum connection closed"));
|
||||
const id = ++this.id;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
|
||||
}, CALL_TIMEOUT_MS);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
try { this.ws.send(JSON.stringify({ id, method, params }) + "\n"); }
|
||||
catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); }
|
||||
});
|
||||
}
|
||||
close() { this.closed = true; try { this.ws.close(); } catch {} }
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(servers) {
|
||||
this.servers = servers.slice();
|
||||
this.conn = null;
|
||||
this.connecting = null;
|
||||
this.subscriptions = new Map(); // method+key -> params (replayed on reconnect)
|
||||
this.onNotify = null;
|
||||
this.onServer = null; // (url|null) connection state for the UI
|
||||
}
|
||||
setServers(servers) {
|
||||
this.servers = servers.slice();
|
||||
this.disconnect();
|
||||
}
|
||||
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
|
||||
async _ensure() {
|
||||
if (this.conn && !this.conn.closed) return this.conn;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = (async () => {
|
||||
let lastErr;
|
||||
for (const url of this.servers) {
|
||||
try {
|
||||
const c = await new Connection(url).connect();
|
||||
c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); };
|
||||
c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } };
|
||||
this.conn = c;
|
||||
log("connected", url);
|
||||
if (this.onServer) this.onServer(url);
|
||||
// Re-arm subscriptions so a reconnect keeps the live feed.
|
||||
for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {});
|
||||
return c;
|
||||
} catch (e) { lastErr = e; log("failed", url, e?.message); }
|
||||
}
|
||||
throw lastErr || new Error("no electrum server reachable");
|
||||
})();
|
||||
try { return await this.connecting; }
|
||||
finally { this.connecting = null; }
|
||||
}
|
||||
async call(method, params = []) {
|
||||
const c = await this._ensure();
|
||||
return c.call(method, params);
|
||||
}
|
||||
// Remember a subscription so it survives reconnects.
|
||||
async subscribe(method, params = []) {
|
||||
this.subscriptions.set(method + ":" + JSON.stringify(params), [method, params]);
|
||||
return this.call(method, params);
|
||||
}
|
||||
clearSubscriptions() { this.subscriptions.clear(); }
|
||||
disconnect() {
|
||||
if (this.conn) { const c = this.conn; this.conn = null; c.close(); }
|
||||
if (this.onServer) this.onServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
return { Client };
|
||||
};
|
||||
66
bundled-addons/bchwallet/lib/keys.js
Normal file
66
bundled-addons/bchwallet/lib/keys.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// HD key tree for the wallet. Root = 32 bytes from api.vault.derive treated as
|
||||
// a BIP32 master seed; account = m/44'/145'/0' (BCH, SLIP-44). Branch 0 is
|
||||
// receive, branch 1 is change. Private keys never leave this module except
|
||||
// through sign() / signRecoverable() for a specific entry.
|
||||
module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashaddr }) {
|
||||
const hash160 = (b) => ripemd160(sha256(b));
|
||||
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
|
||||
const p2shScript = (h160) => Uint8Array.from([0xa9, 0x14, ...h160, 0x87]);
|
||||
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
// electrum scripthash: sha256(script), byte-reversed, hex.
|
||||
const scripthash = (script) => toHex(sha256(script).slice().reverse());
|
||||
|
||||
class WalletKeys {
|
||||
constructor(root32, accountPath, prefix) {
|
||||
this.prefix = prefix;
|
||||
this.accountPath = accountPath;
|
||||
this._account = HDKey.fromMasterSeed(root32).derive(accountPath);
|
||||
this._branch = [this._account.deriveChild(0), this._account.deriveChild(1)];
|
||||
this._cache = new Map(); // "branch/index" -> entry
|
||||
}
|
||||
get xpub() { return this._account.publicExtendedKey; }
|
||||
// Revealed only on explicit user action in Settings (show recovery info).
|
||||
get xprv() { return this._account.privateExtendedKey; }
|
||||
entry(branch, index) {
|
||||
const k = branch + "/" + index;
|
||||
let e = this._cache.get(k);
|
||||
if (!e) {
|
||||
const node = this._branch[branch].deriveChild(index);
|
||||
const h160 = hash160(node.publicKey);
|
||||
const script = p2pkhScript(h160);
|
||||
e = {
|
||||
branch, index, path: this.accountPath + "/" + branch + "/" + index,
|
||||
publicKey: node.publicKey, h160, script, scriptHex: toHex(script),
|
||||
scripthash: scripthash(script),
|
||||
address: cashaddr.encode(this.prefix, 0, h160),
|
||||
_node: node,
|
||||
};
|
||||
this._cache.set(k, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
findByScriptHex(scriptHex) {
|
||||
for (const e of this._cache.values()) if (e.scriptHex === scriptHex) return e;
|
||||
return null;
|
||||
}
|
||||
// ECDSA over a 32-byte digest, DER-encoded, low-S (BCH consensus rule).
|
||||
sign(entry, digest32) {
|
||||
return secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "der" });
|
||||
}
|
||||
// 65-byte BIP-137 signature: [27 + recid + 4 (compressed)] || r || s.
|
||||
signRecoverable(entry, digest32) {
|
||||
const sig = secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "recovered" });
|
||||
const out = new Uint8Array(65);
|
||||
out[0] = 27 + sig[0] + 4;
|
||||
out.set(sig.subarray(1), 1);
|
||||
return out;
|
||||
}
|
||||
wipe() {
|
||||
for (const e of this._cache.values()) { try { e._node.wipePrivateData(); } catch {} }
|
||||
this._cache.clear();
|
||||
for (const b of this._branch) { try { b.wipePrivateData(); } catch {} }
|
||||
try { this._account.wipePrivateData(); } catch {}
|
||||
}
|
||||
}
|
||||
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex };
|
||||
};
|
||||
122
bundled-addons/bchwallet/lib/tx.js
Normal file
122
bundled-addons/bchwallet/lib/tx.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Transaction building for P2PKH spends on Bitcoin Cash: serialization, the
|
||||
// BIP143-style replay-protected sighash (SIGHASH_ALL | FORKID), coin
|
||||
// selection and fee estimation. Signing itself is delegated to WalletKeys so
|
||||
// private keys stay in one module.
|
||||
module.exports = function makeTx({ sha256 }) {
|
||||
const SIGHASH_ALL_FORKID = 0x41;
|
||||
const DUST = 546;
|
||||
const P2PKH_INPUT_SIZE = 149; // outpoint 36 + len 1 + sig push 74 + pubkey push 34 + sequence 4
|
||||
const P2PKH_OUTPUT_SIZE = 34;
|
||||
const OVERHEAD = 10; // version 4 + in/out counts 2 + locktime 4
|
||||
|
||||
const dsha = (b) => sha256(sha256(b));
|
||||
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
const fromHex = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16));
|
||||
const concat = (...parts) => {
|
||||
const n = parts.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let o = 0;
|
||||
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||
return out;
|
||||
};
|
||||
const u32le = (n) => Uint8Array.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
const u64le = (n) => {
|
||||
let v = BigInt(n); const out = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return out;
|
||||
};
|
||||
const varint = (n) => {
|
||||
if (n < 0xfd) return Uint8Array.from([n]);
|
||||
if (n <= 0xffff) return Uint8Array.from([0xfd, n & 0xff, n >> 8]);
|
||||
return concat(Uint8Array.from([0xfe]), u32le(n));
|
||||
};
|
||||
const varbytes = (b) => concat(varint(b.length), b);
|
||||
const pushdata = (b) => {
|
||||
if (b.length < 0x4c) return concat(Uint8Array.from([b.length]), b);
|
||||
return concat(Uint8Array.from([0x4c, b.length]), b);
|
||||
};
|
||||
const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
|
||||
|
||||
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE;
|
||||
const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte);
|
||||
|
||||
// inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
|
||||
// outputs: [{ value, script(Uint8Array) }]
|
||||
function serialize(tx) {
|
||||
return concat(
|
||||
u32le(tx.version ?? 2),
|
||||
varint(tx.inputs.length),
|
||||
...tx.inputs.map((i) => concat(outpoint(i), varbytes(i.unlocking || new Uint8Array(0)), u32le(i.sequence ?? 0xffffffff))),
|
||||
varint(tx.outputs.length),
|
||||
...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))),
|
||||
u32le(tx.locktime ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
function sighash(tx, index) {
|
||||
const inp = tx.inputs[index];
|
||||
const hashPrevouts = dsha(concat(...tx.inputs.map(outpoint)));
|
||||
const hashSequence = dsha(concat(...tx.inputs.map((i) => u32le(i.sequence ?? 0xffffffff))));
|
||||
const hashOutputs = dsha(concat(...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script)))));
|
||||
const preimage = concat(
|
||||
u32le(tx.version ?? 2),
|
||||
hashPrevouts, hashSequence,
|
||||
outpoint(inp),
|
||||
varbytes(inp.script),
|
||||
u64le(inp.value),
|
||||
u32le(inp.sequence ?? 0xffffffff),
|
||||
hashOutputs,
|
||||
u32le(tx.locktime ?? 0),
|
||||
u32le(SIGHASH_ALL_FORKID),
|
||||
);
|
||||
return dsha(preimage);
|
||||
}
|
||||
|
||||
// signer(input, index, digest) -> { sig: DER bytes, publicKey }
|
||||
function sign(tx, signer) {
|
||||
tx.inputs.forEach((inp, i) => {
|
||||
const { sig, publicKey } = signer(inp, i, sighash(tx, i));
|
||||
inp.unlocking = concat(pushdata(concat(sig, Uint8Array.from([SIGHASH_ALL_FORKID]))), pushdata(publicKey));
|
||||
});
|
||||
const raw = serialize(tx);
|
||||
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
|
||||
}
|
||||
|
||||
// Largest-first accumulation. `targets` = [{ value, script }]; returns
|
||||
// { inputs, outputs, fee, change } or throws when funds don't cover it.
|
||||
// sendMax: spend every UTXO into targets[0] and no change.
|
||||
function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) {
|
||||
const sorted = utxos.slice().sort((a, b) => b.value - a.value);
|
||||
const total = sorted.reduce((a, u) => a + u.value, 0);
|
||||
if (sendMax) {
|
||||
if (targets.length !== 1) throw new Error("send max needs exactly one recipient");
|
||||
const fee = feeFor(sorted.length, 1, satPerByte);
|
||||
const value = total - fee;
|
||||
if (!sorted.length || value < DUST) throw new Error("balance too small to send");
|
||||
return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 };
|
||||
}
|
||||
const want = targets.reduce((a, t) => a + t.value, 0);
|
||||
for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
|
||||
const chosen = []; let sum = 0;
|
||||
for (const u of sorted) {
|
||||
chosen.push(u); sum += u.value;
|
||||
const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte);
|
||||
if (sum >= want + feeWithChange) {
|
||||
const change = sum - want - feeWithChange;
|
||||
if (change >= DUST) {
|
||||
return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change };
|
||||
}
|
||||
// Change would be dust: fold it into the fee, one output fewer.
|
||||
const fee = sum - want;
|
||||
return { inputs: chosen, outputs: targets.slice(), fee, change: 0 };
|
||||
}
|
||||
const feeNoChange = feeFor(chosen.length, targets.length, satPerByte);
|
||||
if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) {
|
||||
return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 };
|
||||
}
|
||||
}
|
||||
const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total;
|
||||
throw new Error(`insufficient funds: need about ${short} more sat`);
|
||||
}
|
||||
|
||||
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha };
|
||||
};
|
||||
243
bundled-addons/bchwallet/lib/wallet.js
Normal file
243
bundled-addons/bchwallet/lib/wallet.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Wallet state machine on top of an electrum client and a WalletKeys tree:
|
||||
// address discovery (gap limit), balance, history with per-tx deltas, UTXO
|
||||
// set and send construction. Knows nothing about UI or IPC.
|
||||
module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
|
||||
const GAP = 20;
|
||||
const HISTORY_LIMIT = 25;
|
||||
const sats = (bch) => Math.round(Number(bch) * 1e8);
|
||||
|
||||
const state = {
|
||||
used: new Set(), // "branch/index" with history
|
||||
watched: new Map(), // scripthash -> entry
|
||||
height: 0,
|
||||
balance: { confirmed: 0, unconfirmed: 0 },
|
||||
utxos: [], // { txid, vout, value, height, entry }
|
||||
history: [], // newest first
|
||||
receiveIndex: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
};
|
||||
// Verbose transactions are public chain data; caching them on disk saves a
|
||||
// round of fetches on every launch.
|
||||
const txCache = storage.get("txCache", {}) || {};
|
||||
let refreshTimer = null;
|
||||
let subscribedHeaders = false;
|
||||
|
||||
function key(e) { return e.branch + "/" + e.index; }
|
||||
function watch(e) { if (!state.watched.has(e.scripthash)) state.watched.set(e.scripthash, e); }
|
||||
|
||||
async function historyOf(e) {
|
||||
const h = await client.call("blockchain.scripthash.get_history", [e.scripthash]);
|
||||
return Array.isArray(h) ? h : [];
|
||||
}
|
||||
|
||||
// Walk both branches until GAP consecutive unused indexes, always covering
|
||||
// the user's chosen receive cursor so its lookahead stays subscribed.
|
||||
async function scan() {
|
||||
const cursor = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
for (const branch of [0, 1]) {
|
||||
let gap = 0, i = 0;
|
||||
const minIndex = branch === 0 ? cursor + 1 : 0;
|
||||
while (gap < GAP || i < minIndex + GAP) {
|
||||
const batch = [];
|
||||
for (let k = 0; k < 10; k++) batch.push(keys.entry(branch, i + k));
|
||||
const results = await Promise.all(batch.map(historyOf));
|
||||
for (let k = 0; k < batch.length; k++) {
|
||||
const e = batch[k]; watch(e);
|
||||
if (results[k].length) { state.used.add(key(e)); gap = 0; } else gap++;
|
||||
i++;
|
||||
if (gap >= GAP && i >= minIndex + GAP) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Current receive address: first unused at or after the cursor.
|
||||
let r = cursor;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
state.receiveIndex = r;
|
||||
watch(keys.entry(0, r));
|
||||
}
|
||||
|
||||
async function subscribeAll() {
|
||||
if (!subscribedHeaders) {
|
||||
subscribedHeaders = true;
|
||||
const tip = await client.subscribe("blockchain.headers.subscribe", []);
|
||||
if (tip && tip.height) state.height = tip.height;
|
||||
}
|
||||
await Promise.all([...state.watched.values()].map((e) =>
|
||||
client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {})));
|
||||
}
|
||||
|
||||
async function loadUtxos() {
|
||||
const lists = await Promise.all([...state.watched.values()].map(async (e) => {
|
||||
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]);
|
||||
return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e }));
|
||||
}));
|
||||
state.utxos = lists.flat();
|
||||
let confirmed = 0, unconfirmed = 0;
|
||||
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
|
||||
state.balance = { confirmed, unconfirmed };
|
||||
}
|
||||
|
||||
async function getTx(txid) {
|
||||
const c = txCache[txid];
|
||||
if (c && c.confirmations > 0) return c;
|
||||
const raw = await client.call("blockchain.transaction.get", [txid, true]);
|
||||
const slim = {
|
||||
txid,
|
||||
confirmations: raw.confirmations || 0,
|
||||
time: raw.blocktime || raw.time || 0,
|
||||
vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })),
|
||||
vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })),
|
||||
size: raw.size || 0,
|
||||
};
|
||||
txCache[txid] = slim;
|
||||
return slim;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const entries = [...state.watched.values()].filter((e) => state.used.has(key(e)));
|
||||
const merged = new Map();
|
||||
const lists = await Promise.all(entries.map(historyOf));
|
||||
for (const list of lists) for (const h of list) {
|
||||
const prev = merged.get(h.tx_hash);
|
||||
if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height });
|
||||
}
|
||||
const ordered = [...merged.values()].sort((a, b) => {
|
||||
const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity;
|
||||
return hb - ha;
|
||||
}).slice(0, HISTORY_LIMIT);
|
||||
const ours = new Set([...state.watched.values()].map((e) => e.scriptHex));
|
||||
const out = [];
|
||||
for (const h of ordered) {
|
||||
const t = await getTx(h.txid);
|
||||
let received = 0, spent = 0, inputsTotal = 0, outputsTotal = 0, allInputsOurs = true;
|
||||
for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; }
|
||||
for (const i of t.vin) {
|
||||
if (!i.txid) continue; // coinbase
|
||||
const p = await getTx(i.txid);
|
||||
const po = p.vout[i.vout];
|
||||
if (!po) continue;
|
||||
inputsTotal += po.value;
|
||||
if (ours.has(po.scriptHex)) spent += po.value; else allInputsOurs = false;
|
||||
}
|
||||
const delta = received - spent;
|
||||
let to = null;
|
||||
if (delta < 0) {
|
||||
const ext = t.vout.find((o) => !ours.has(o.scriptHex));
|
||||
if (ext && ext.scriptHex) to = scriptToAddress(ext.scriptHex);
|
||||
}
|
||||
out.push({
|
||||
txid: t.txid, height: h.height, confirmations: t.confirmations, time: t.time,
|
||||
delta, fee: allInputsOurs && inputsTotal ? inputsTotal - outputsTotal : null, to,
|
||||
});
|
||||
}
|
||||
state.history = out;
|
||||
storage.set("txCache", txCache);
|
||||
}
|
||||
|
||||
function scriptToAddress(scriptHex) {
|
||||
try {
|
||||
if (/^76a914[0-9a-f]{40}88ac$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 0, tx.fromHex(scriptHex.slice(6, 46)));
|
||||
if (/^a914[0-9a-f]{40}87$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 1, tx.fromHex(scriptHex.slice(4, 44)));
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refresh(full = false) {
|
||||
if (state.scanning) return;
|
||||
state.scanning = true; state.error = null; onChange();
|
||||
try {
|
||||
if (full || !state.watched.size) await scan();
|
||||
else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has("0/" + r)) r++; state.receiveIndex = r; watch(keys.entry(0, r)); }
|
||||
await loadUtxos();
|
||||
await loadHistory();
|
||||
await subscribeAll();
|
||||
// A tx that just landed can mark the current receive address used.
|
||||
for (const u of state.utxos) state.used.add(key(u.entry));
|
||||
let r = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
if (r !== state.receiveIndex) { state.receiveIndex = r; watch(keys.entry(0, r)); }
|
||||
} catch (e) {
|
||||
state.error = e?.message || String(e);
|
||||
log("refresh failed:", state.error);
|
||||
} finally {
|
||||
state.scanning = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
function scheduleRefresh(ms = 800) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => refresh(false), ms);
|
||||
}
|
||||
|
||||
client.onNotify = (method, params) => {
|
||||
if (method === "blockchain.headers.subscribe") {
|
||||
const h = params && params[0] && params[0].height;
|
||||
if (h) { state.height = h; scheduleRefresh(1500); }
|
||||
} else if (method === "blockchain.scripthash.subscribe") {
|
||||
scheduleRefresh(800);
|
||||
}
|
||||
};
|
||||
|
||||
function nextUnusedAddress() {
|
||||
let r = state.receiveIndex + 1;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
storage.set("receiveCursor", r);
|
||||
state.receiveIndex = r;
|
||||
watch(keys.entry(0, r));
|
||||
client.subscribe("blockchain.scripthash.subscribe", [keys.entry(0, r).scripthash]).catch(() => {});
|
||||
onChange();
|
||||
return current();
|
||||
}
|
||||
function current() { return keys.entry(0, state.receiveIndex); }
|
||||
function changeEntry() {
|
||||
let i = 0;
|
||||
while (state.used.has("1/" + i)) i++;
|
||||
return keys.entry(1, i);
|
||||
}
|
||||
|
||||
// targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan.
|
||||
function plan({ targets, feeRate = 1, sendMax = false }) {
|
||||
const rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
|
||||
const outs = targets.map((t) => {
|
||||
const a = cashaddr.parseAny(t.to, sha256, keys.prefix);
|
||||
const script = a.type === 0
|
||||
? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac])
|
||||
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
|
||||
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
|
||||
});
|
||||
// Spend confirmed coins first; unconfirmed only when needed.
|
||||
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
||||
const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax });
|
||||
return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) };
|
||||
}
|
||||
|
||||
async function signAndBroadcast(p) {
|
||||
const t = { inputs: p.inputs.map((u) => ({ ...u, script: u.entry.script })), outputs: p.outputs };
|
||||
const signed = tx.sign(t, (inp, _i, digest) => ({ sig: keys.sign(inp.entry, digest), publicKey: inp.entry.publicKey }));
|
||||
const txid = await client.call("blockchain.transaction.broadcast", [signed.hex]);
|
||||
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
|
||||
log("broadcast", txid);
|
||||
scheduleRefresh(1200);
|
||||
return { txid, hex: signed.hex, fee: p.fee };
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const cur = current();
|
||||
return {
|
||||
address: cur.address,
|
||||
addressIndex: state.receiveIndex,
|
||||
addressPath: cur.path,
|
||||
balance: state.balance,
|
||||
height: state.height,
|
||||
history: state.history,
|
||||
utxoCount: state.utxos.length,
|
||||
scanning: state.scanning,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
function dispose() { clearTimeout(refreshTimer); }
|
||||
|
||||
return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, dispose, state };
|
||||
};
|
||||
|
|
@ -5,25 +5,155 @@
|
|||
<title>Bitcoin Cash Wallet</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; }
|
||||
--bg:#0e131c; --panel:#141a24; --card:#0f1621; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; --ok:#5ad38a;
|
||||
--bch:#0ac18e; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --card:#f1f4fa; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
header { padding: 12px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
header .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
main { flex: 1; padding: 14px 16px; color: var(--mut); }
|
||||
header .t .coin { color: var(--bch); font-size: 16px; }
|
||||
.net { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 11.5px; white-space: nowrap; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); }
|
||||
.dot.on { background: var(--ok); } .dot.busy { background: #e0b341; }
|
||||
.bal { margin-top: 8px; font-variant-numeric: tabular-nums; }
|
||||
.bal .big { font-size: 22px; font-weight: 650; letter-spacing: .2px; }
|
||||
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
|
||||
.bal .sub { color: var(--dim); font-size: 11.5px; }
|
||||
nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
|
||||
nav button.on { color: var(--acid); border-bottom-color: var(--acid); }
|
||||
nav button:hover { color: var(--ink); }
|
||||
main { flex: 1; overflow: auto; padding: 14px; }
|
||||
section[hidden] { display: none; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||
.mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
|
||||
.lbl { color: var(--dim); font-size: 11.5px; margin-bottom: 4px; }
|
||||
.btn { padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink);
|
||||
cursor: pointer; font: inherit; font-size: 12.5px; }
|
||||
.btn:hover { border-color: rgba(214,255,61,.4); }
|
||||
.btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
|
||||
.btn.primary:disabled { opacity: .45; cursor: default; }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.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);
|
||||
border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; }
|
||||
input:focus, textarea:focus { border-color: rgba(214,255,61,.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; }
|
||||
.amt { display: flex; gap: 6px; }
|
||||
.amt input { flex: 1; }
|
||||
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
|
||||
.unit button { border: 0; background: var(--panel); color: var(--mut); padding: 0 10px; cursor: pointer; font: inherit; font-size: 12px; }
|
||||
.unit button.on { background: rgba(214,255,61,.14); color: var(--acid); }
|
||||
.fee { display: flex; align-items: center; gap: 10px; }
|
||||
.fee input[type=range] { flex: 1; accent-color: var(--acid); }
|
||||
.fee .v { color: var(--mut); font-size: 12px; min-width: 70px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.summary { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12.5px; margin-top: 10px; }
|
||||
.summary .k { color: var(--dim); } .summary .v { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.msg { margin-top: 10px; font-size: 12.5px; padding: 8px 10px; border-radius: 7px; }
|
||||
.msg.err { background: rgba(246,118,138,.12); color: var(--danger); }
|
||||
.msg.ok { background: rgba(90,211,138,.12); color: var(--ok); }
|
||||
.msg a { color: inherit; }
|
||||
.tx { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 10px; padding: 9px 4px; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.tx:hover { background: rgba(255,255,255,.03); }
|
||||
.tx .ic { font-size: 15px; line-height: 1.3; }
|
||||
.tx .ic.in { color: var(--ok); } .tx .ic.out { color: var(--danger); }
|
||||
.tx .what { font-size: 12.5px; }
|
||||
.tx .when { color: var(--dim); font-size: 11.5px; grid-column: 2; }
|
||||
.tx .amt2 { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12.5px; }
|
||||
.tx .amt2.in { color: var(--ok); }
|
||||
.tx .conf { grid-column: 3; text-align: right; color: var(--dim); font-size: 11px; }
|
||||
.tx .conf.pending { color: #e0b341; }
|
||||
.empty { color: var(--dim); text-align: center; padding: 30px 10px; font-size: 12.5px; }
|
||||
.gate { padding: 40px 18px; text-align: center; color: var(--mut); }
|
||||
.gate .big { font-size: 28px; margin-bottom: 8px; }
|
||||
.gate b { color: var(--ink); }
|
||||
.kv { margin-top: 10px; }
|
||||
.kv .lbl { margin-top: 8px; }
|
||||
a.link { color: var(--acid); text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="t"><span>₿</span><span>Bitcoin Cash Wallet</span></div></header>
|
||||
<main>Loading…</main>
|
||||
<header>
|
||||
<div class="row">
|
||||
<div class="t"><span class="coin">₿</span><span>Bitcoin Cash</span></div>
|
||||
<div class="net"><span class="dot" id="dot"></span><span id="netlbl">connecting…</span></div>
|
||||
</div>
|
||||
<div class="bal">
|
||||
<div class="big"><span id="balBch">—</span><small>BCH</small></div>
|
||||
<div class="sub" id="balSub">mainnet</div>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<button data-tab="receive" class="on">Receive</button>
|
||||
<button data-tab="send">Send</button>
|
||||
<button data-tab="history">History</button>
|
||||
<button data-tab="settings">Settings</button>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="gate" class="gate" hidden></div>
|
||||
<div id="tabs">
|
||||
<section id="tab-receive">
|
||||
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
|
||||
<div class="card">
|
||||
<div class="lbl">Receiving address <span id="addrMeta"></span></div>
|
||||
<div class="mono" id="addr">—</div>
|
||||
<div class="actions">
|
||||
<button class="btn sm" id="copyAddr">Copy</button>
|
||||
<button class="btn sm" id="nextAddr">Next unused address</button>
|
||||
<button class="btn sm" id="viewAddr">Explorer</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tab-send" hidden>
|
||||
<div class="empty">Sending arrives in the next step.</div>
|
||||
</section>
|
||||
<section id="tab-history" hidden>
|
||||
<div id="txlist"></div>
|
||||
</section>
|
||||
<section id="tab-settings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">Derivation path (account)</div>
|
||||
<input type="text" id="setPath" spellcheck="false" placeholder="m/44'/145'/0'">
|
||||
<div class="hint">Changing this switches to a different set of addresses under the same vault root.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Electrum servers (one per line, tried in order)</div>
|
||||
<textarea id="setServers" spellcheck="false"></textarea>
|
||||
<div class="hint" id="serverHint"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applySettings">Apply</button>
|
||||
<button class="btn" id="resetServers">Reset servers</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="purpose"></span>. The same vault seed on another machine recreates this wallet.</div>
|
||||
<div class="kv" id="recovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="showXpub">Show account xpub</button>
|
||||
<button class="btn danger" id="showXprv">Show account private key</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg err" id="settingsMsg" hidden></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="qr.js"></script>
|
||||
<script src="panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
158
bundled-addons/bchwallet/panel.js
Normal file
158
bundled-addons/bchwallet/panel.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// Wallet panel. All state comes from the add-on's activate() context via
|
||||
// window.silentmode.invoke / on; this file only renders and collects input.
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const S = window.silentmode;
|
||||
let state = null;
|
||||
let tab = "receive";
|
||||
|
||||
// 8 decimals, trailing zeros trimmed, never fewer than two: 0.00, 0.001, 1.23456789
|
||||
function fmtBch(sats) {
|
||||
let s = (Number(sats || 0) / 1e8).toFixed(8).replace(/0+$/, "");
|
||||
if (s.endsWith(".")) s += "00"; else if (/\.\d$/.test(s)) s += "0";
|
||||
return s;
|
||||
}
|
||||
const fmtSats = (sats) => Number(sats || 0).toLocaleString("en-US");
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
const hostOf = (url) => { try { return new URL(url).host; } catch { return url; } };
|
||||
const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {});
|
||||
|
||||
// ---- tabs ------------------------------------------------------------------
|
||||
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||||
function showTab(name) {
|
||||
tab = name;
|
||||
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
|
||||
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
|
||||
if (name === "settings") fillSettings();
|
||||
}
|
||||
|
||||
// ---- render ----------------------------------------------------------------
|
||||
function render() {
|
||||
if (!state) return;
|
||||
const ready = state.phase === "ready";
|
||||
const gate = $("gate");
|
||||
$("tabs").hidden = !ready;
|
||||
gate.hidden = ready;
|
||||
if (!ready) {
|
||||
const copy = {
|
||||
locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings › Passwords. The wallet keys derive from the vault seed, so there is nothing separate to unlock."],
|
||||
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings › Passwords › Set up. Use a recovery phrase there and this wallet can be recreated from it on any machine."],
|
||||
error: ["⚠", "The wallet could not start.", state.error || ""],
|
||||
}[state.phase] || ["…", "Starting…", ""];
|
||||
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>`;
|
||||
}
|
||||
// header
|
||||
const dot = $("dot");
|
||||
dot.className = "dot " + (state.server ? (state.scanning ? "busy" : "on") : "");
|
||||
$("netlbl").textContent = state.server ? hostOf(state.server) + (state.scanning ? " · syncing" : "") : (ready ? "connecting…" : "mainnet");
|
||||
if (ready) {
|
||||
const total = (state.balance?.confirmed || 0) + (state.balance?.unconfirmed || 0);
|
||||
$("balBch").textContent = fmtBch(total);
|
||||
const parts = [fmtSats(total) + " sat"];
|
||||
if (state.balance?.unconfirmed) parts.push(fmtBch(state.balance.unconfirmed) + " unconfirmed");
|
||||
if (state.height) parts.push("block " + fmtSats(state.height));
|
||||
$("balSub").textContent = parts.join(" · ");
|
||||
} else { $("balBch").textContent = "—"; $("balSub").textContent = "mainnet"; }
|
||||
if (!ready) return;
|
||||
// receive
|
||||
if (state.address && $("addr").textContent !== state.address) {
|
||||
$("addr").textContent = state.address;
|
||||
drawQr("bitcoincash:" + state.address.replace(/^bitcoincash:/, ""));
|
||||
}
|
||||
$("addrMeta").textContent = `· #${state.addressIndex} · ${state.addressPath || ""}`;
|
||||
// history
|
||||
renderHistory();
|
||||
if (state.error) { $("balSub").textContent = state.error; }
|
||||
}
|
||||
|
||||
function drawQr(text) {
|
||||
const cv = $("qr");
|
||||
const g = cv.getContext("2d");
|
||||
let q;
|
||||
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
|
||||
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
|
||||
const px = (q.size + 2) * scale;
|
||||
cv.width = cv.height = px;
|
||||
cv.style.width = cv.style.height = px + "px";
|
||||
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
|
||||
g.fillStyle = "#000";
|
||||
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const list = state.history || [];
|
||||
const el = $("txlist");
|
||||
if (!list.length) { el.innerHTML = `<div class="empty">${state.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; }
|
||||
el.innerHTML = list.map((t) => {
|
||||
const inc = t.delta >= 0;
|
||||
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
|
||||
const what = inc ? "Received" : ("Sent" + (t.to ? " to " + esc(t.to.replace(/^bitcoincash:/, "").slice(0, 12)) + "…" : ""));
|
||||
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : "unconfirmed";
|
||||
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
|
||||
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div>
|
||||
<div class="what">${what}</div>
|
||||
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : "−"}${fmtBch(Math.abs(t.delta))}</div>
|
||||
<div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSats(t.fee) + " sat" : ""}</div>
|
||||
<div class="conf ${t.confirmations > 0 ? "" : "pending"}">${conf}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(state.explorerTx + row.dataset.txid)));
|
||||
}
|
||||
|
||||
// ---- receive actions -------------------------------------------------------
|
||||
$("copyAddr").addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText(state.address); flash($("copyAddr"), "Copied"); } catch {}
|
||||
});
|
||||
$("nextAddr").addEventListener("click", async () => {
|
||||
try { state = await S.invoke("nextAddress"); render(); } catch (e) { flash($("nextAddr"), "Failed"); }
|
||||
});
|
||||
$("viewAddr").addEventListener("click", () => openUrl(state.explorerAddr + state.address));
|
||||
function flash(btn, text) {
|
||||
const old = btn.textContent; btn.textContent = text;
|
||||
setTimeout(() => { btn.textContent = old; }, 1200);
|
||||
}
|
||||
|
||||
// ---- settings --------------------------------------------------------------
|
||||
let settingsFilled = false;
|
||||
function fillSettings() {
|
||||
if (!state) return;
|
||||
if (!settingsFilled) {
|
||||
$("setPath").value = state.accountPath || "";
|
||||
$("setServers").value = (state.servers || []).join("\n");
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("serverHint").textContent = (state.customServers ? "Custom list." : "Bundled defaults.") + (state.server ? " Connected to " + hostOf(state.server) + "." : " Not connected.");
|
||||
$("purpose").textContent = "silentmode/addons/bchwallet/mainnet/0";
|
||||
}
|
||||
$("applySettings").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean);
|
||||
state = await S.invoke("setSettings", { accountPath: $("setPath").value, servers: state.customServers || servers.join() !== (state.servers || []).join() ? servers : undefined });
|
||||
settingsFilled = false; fillSettings(); render();
|
||||
flash($("applySettings"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("resetServers").addEventListener("click", async () => {
|
||||
try { state = await S.invoke("setSettings", { servers: [] }); settingsFilled = false; fillSettings(); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
|
||||
});
|
||||
$("showXpub").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", {}); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showXprv").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); }
|
||||
});
|
||||
function recoveryHtml(r) {
|
||||
let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`;
|
||||
if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`;
|
||||
return h;
|
||||
}
|
||||
const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, "");
|
||||
// Wipe a revealed key when the user leaves the Settings tab.
|
||||
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => { if (b.dataset.tab !== "settings") $("recovery").innerHTML = ""; }));
|
||||
|
||||
// ---- boot ------------------------------------------------------------------
|
||||
S.on("state", (s) => { state = s; render(); });
|
||||
(async () => {
|
||||
try { state = await S.invoke("state"); render(); }
|
||||
catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
|
||||
})();
|
||||
218
bundled-addons/bchwallet/qr.js
Normal file
218
bundled-addons/bchwallet/qr.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// Minimal QR encoder for the receive tab: byte mode, versions 1-10, error
|
||||
// correction M (falls back to L when M won't fit). Returns a boolean matrix.
|
||||
// Loaded as a plain script in panel.html and as a CommonJS module in tests.
|
||||
(function (root) {
|
||||
const EC_LEVELS = { L: 1, M: 0 };
|
||||
// Per version: [totalCodewords, {L:[ecPerBlock, [[blocks, dataCw], ...]], M:[...]}]
|
||||
const TABLE = {
|
||||
1: [26, { L: [7, [[1, 19]]], M: [10, [[1, 16]]] }],
|
||||
2: [44, { L: [10, [[1, 34]]], M: [16, [[1, 28]]] }],
|
||||
3: [70, { L: [15, [[1, 55]]], M: [26, [[1, 44]]] }],
|
||||
4: [100, { L: [20, [[1, 80]]], M: [18, [[2, 32]]] }],
|
||||
5: [134, { L: [26, [[1, 108]]], M: [24, [[2, 43]]] }],
|
||||
6: [172, { L: [18, [[2, 68]]], M: [16, [[4, 27]]] }],
|
||||
7: [196, { L: [20, [[2, 78]]], M: [18, [[4, 31]]] }],
|
||||
8: [242, { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]] }],
|
||||
9: [292, { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]] }],
|
||||
10: [346, { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]] }],
|
||||
};
|
||||
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
||||
const REMAINDER = { 1: 0, 2: 7, 3: 7, 4: 7, 5: 7, 6: 7, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
|
||||
// GF(256) with the QR polynomial 0x11d.
|
||||
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
||||
(function () {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
const gmul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0;
|
||||
function generator(n) {
|
||||
let g = [1];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = new Array(g.length + 1).fill(0);
|
||||
for (let j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= gmul(g[j], EXP[i]); }
|
||||
g = next;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function rsEncode(data, nEc) {
|
||||
const g = generator(nEc);
|
||||
const out = new Uint8Array(data.length + nEc);
|
||||
out.set(data);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = out[i];
|
||||
if (c) for (let j = 1; j < g.length; j++) out[i + j] ^= gmul(g[j], c);
|
||||
}
|
||||
return out.slice(data.length);
|
||||
}
|
||||
|
||||
function dataCapacity(v, ec) { return TABLE[v][1][ec][1].reduce((a, [b, d]) => a + b * d, 0); }
|
||||
function pickVersion(len) {
|
||||
for (const ec of ["M", "L"]) {
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
const bitsNeeded = 4 + (v <= 9 ? 8 : 16) + len * 8;
|
||||
if (bitsNeeded <= dataCapacity(v, ec) * 8) return { v, ec };
|
||||
}
|
||||
}
|
||||
throw new Error("qr: text too long");
|
||||
}
|
||||
|
||||
function encodeData(bytes, v, ec) {
|
||||
const cap = dataCapacity(v, ec);
|
||||
const bits = [];
|
||||
const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
||||
push(0b0100, 4);
|
||||
push(bytes.length, v <= 9 ? 8 : 16);
|
||||
for (const b of bytes) push(b, 8);
|
||||
for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0);
|
||||
while (bits.length % 8) bits.push(0);
|
||||
const data = new Uint8Array(cap);
|
||||
for (let i = 0; i < bits.length / 8; i++) data[i] = parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2);
|
||||
for (let i = bits.length / 8, k = 0; i < cap; i++, k++) data[i] = k % 2 ? 0x11 : 0xec;
|
||||
return data;
|
||||
}
|
||||
|
||||
function interleave(data, v, ec) {
|
||||
const [nEc, groups] = TABLE[v][1][ec];
|
||||
const blocks = []; let o = 0;
|
||||
for (const [count, size] of groups) for (let i = 0; i < count; i++) { blocks.push(data.slice(o, o + size)); o += size; }
|
||||
const ecBlocks = blocks.map((b) => rsEncode(b, nEc));
|
||||
const out = [];
|
||||
const maxLen = Math.max(...blocks.map((b) => b.length));
|
||||
for (let i = 0; i < maxLen; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||
for (let i = 0; i < nEc; i++) for (const b of ecBlocks) out.push(b[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remainder of value·x^degree modulo the generator poly (BCH error correction
|
||||
// for the format / version fields).
|
||||
function bch(value, poly, degree) {
|
||||
let v = value << degree;
|
||||
for (let i = 31 - Math.clz32(v); i >= degree; i--) if ((v >> i) & 1) v ^= poly << (i - degree);
|
||||
return v;
|
||||
}
|
||||
const formatBits = (ec, mask) => { const d = (EC_LEVELS[ec] << 3) | mask; return ((d << 10) | bch(d, 0x537, 10)) ^ 0x5412; };
|
||||
const versionBits = (v) => (v << 12) | bch(v, 0x1f25, 12);
|
||||
|
||||
function build(text) {
|
||||
const bytes = typeof text === "string" ? new TextEncoder().encode(text) : Uint8Array.from(text);
|
||||
const { v, ec } = pickVersion(bytes.length);
|
||||
const size = v * 4 + 17;
|
||||
const codewords = interleave(encodeData(bytes, v, ec), v, ec);
|
||||
const grid = Array.from({ length: size }, () => new Uint8Array(size)); // 1 dark, 0 light
|
||||
const fixed = Array.from({ length: size }, () => new Uint8Array(size)); // function pattern / reserved
|
||||
const set = (r, c, val) => { grid[r][c] = val ? 1 : 0; fixed[r][c] = 1; };
|
||||
const finder = (r0, c0) => {
|
||||
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
|
||||
const rr = r0 + r, cc = c0 + c;
|
||||
if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
|
||||
const inner = r >= 0 && r <= 6 && c >= 0 && c <= 6;
|
||||
const dark = inner && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
||||
set(rr, cc, dark);
|
||||
}
|
||||
};
|
||||
finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
|
||||
for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||
const al = ALIGN[v], last = al.length - 1;
|
||||
al.forEach((r, ri) => al.forEach((c, ci) => {
|
||||
// The three corners that would sit on a finder pattern are omitted.
|
||||
if ((ri === 0 && ci === 0) || (ri === 0 && ci === last) || (ri === last && ci === 0)) return;
|
||||
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
||||
set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
|
||||
}
|
||||
}));
|
||||
set(size - 8, 8, 1); // dark module
|
||||
// Reserve format areas (filled per mask below) and version areas.
|
||||
for (let i = 0; i < 9; i++) { fixed[8][i] = 1; fixed[i][8] = 1; }
|
||||
for (let i = 0; i < 8; i++) { fixed[8][size - 1 - i] = 1; fixed[size - 1 - i][8] = 1; }
|
||||
if (v >= 7) {
|
||||
const vb = versionBits(v);
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const bit = (vb >> i) & 1;
|
||||
set(Math.floor(i / 3), size - 11 + (i % 3), bit);
|
||||
set(size - 11 + (i % 3), Math.floor(i / 3), bit);
|
||||
}
|
||||
}
|
||||
// Zigzag data placement.
|
||||
const bits = [];
|
||||
for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1);
|
||||
for (let i = 0; i < REMAINDER[v]; i++) bits.push(0);
|
||||
let bi = 0, up = true;
|
||||
for (let col = size - 1; col > 0; col -= 2) {
|
||||
if (col === 6) col--;
|
||||
for (let k = 0; k < size; k++) {
|
||||
const r = up ? size - 1 - k : k;
|
||||
for (const c of [col, col - 1]) {
|
||||
if (fixed[r][c]) continue;
|
||||
grid[r][c] = bi < bits.length ? bits[bi] : 0;
|
||||
bi++;
|
||||
}
|
||||
}
|
||||
up = !up;
|
||||
}
|
||||
// Try every mask, keep the lowest penalty.
|
||||
const MASKS = [
|
||||
(i, j) => (i + j) % 2 === 0, (i) => i % 2 === 0, (_i, j) => j % 3 === 0, (i, j) => (i + j) % 3 === 0,
|
||||
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0, (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
||||
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0, (i, j) => (((i + j) % 2) + ((i * j) % 3)) % 2 === 0,
|
||||
];
|
||||
let best = null;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
const g = grid.map((row) => Uint8Array.from(row));
|
||||
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!fixed[r][c] && MASKS[m](r, c)) g[r][c] ^= 1;
|
||||
const fb = formatBits(ec, m);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const bit = (fb >> i) & 1;
|
||||
// Copy 1: down column 8 (bits 0-7, skipping the timing row), then
|
||||
// left along row 8 (bits 8-14). Copy 2: right end of row 8, bottom
|
||||
// of column 8.
|
||||
if (i < 6) g[i][8] = bit; else if (i === 6) g[7][8] = bit; else if (i === 7) g[8][8] = bit;
|
||||
else if (i === 8) g[8][7] = bit; else g[8][14 - i] = bit;
|
||||
if (i < 8) g[8][size - 1 - i] = bit; else g[size - 15 + i][8] = bit;
|
||||
}
|
||||
const p = penalty(g, size);
|
||||
if (!best || p < best.p) best = { g, p, m };
|
||||
}
|
||||
return { size, version: v, ec, mask: best.m, modules: best.g.map((row) => Array.from(row, (x) => !!x)) };
|
||||
}
|
||||
|
||||
function penalty(g, n) {
|
||||
let p = 0;
|
||||
const runs = (get) => {
|
||||
for (let a = 0; a < n; a++) {
|
||||
let run = 1;
|
||||
for (let b = 1; b <= n; b++) {
|
||||
if (b < n && get(a, b) === get(a, b - 1)) run++;
|
||||
else { if (run >= 5) p += 3 + run - 5; run = 1; }
|
||||
}
|
||||
}
|
||||
};
|
||||
runs((a, b) => g[a][b]); runs((a, b) => g[b][a]);
|
||||
for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) {
|
||||
const s = g[r][c] + g[r][c + 1] + g[r + 1][c] + g[r + 1][c + 1];
|
||||
if (s === 0 || s === 4) p += 3;
|
||||
}
|
||||
const pat = [1, 0, 1, 1, 1, 0, 1];
|
||||
const finderLike = (get, a) => {
|
||||
for (let b = 0; b <= n - 7; b++) {
|
||||
let ok = true;
|
||||
for (let k = 0; k < 7; k++) if (get(a, b + k) !== pat[k]) { ok = false; break; }
|
||||
if (!ok) continue;
|
||||
const before = b >= 4 && [0, 1, 2, 3].every((k) => get(a, b - 1 - k) === 0);
|
||||
const after = b + 10 < n && [0, 1, 2, 3].every((k) => get(a, b + 7 + k) === 0);
|
||||
if (before || after) p += 40;
|
||||
}
|
||||
};
|
||||
for (let a = 0; a < n; a++) { finderLike((x, y) => g[x][y], a); finderLike((x, y) => g[y][x], a); }
|
||||
let dark = 0;
|
||||
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += g[r][c];
|
||||
const pct = (dark * 100) / (n * n);
|
||||
p += Math.floor(Math.abs(pct - 50) / 5) * 10;
|
||||
return p;
|
||||
}
|
||||
|
||||
const api = { build };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.QR = api;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||
2
main.js
2
main.js
|
|
@ -1361,6 +1361,8 @@ function initAddons() {
|
|||
try { sidebar.webContents.send("addon-event", msg, payload); } catch {}
|
||||
},
|
||||
hostRequire: (name) => require(name),
|
||||
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
||||
openTab: (url) => { if (win) createTab(url); },
|
||||
});
|
||||
addonHost.discoverAndActivate();
|
||||
const snap = addonHost.snapshot();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue